Skip to content

Commit 5333aad

Browse files
committed
k8s-ui: shared IssuesView — grouped live-issue triage queue
The presentation sibling to the Checks queue (ChecksView): one row per grouped issue (subject + category), severity rail + pill, single-open accordion expanding to the diagnosis (reason/message + pod crash context) plus the subject and affected-member deep-links. Consumed by BOTH OSS single-cluster and the hub fleet view (the host wires resourceHref / onResourceClick / clusterLabel) so the two surfaces can't diverge. Reuses the established shared atoms (ClusterName, EmptyState) and the EXACT Checks severity hues (critical=red, warning=amber = Checks medium) so the two queues read as one product. Identity (IssueResourceRef + resourceKey) matches the Checks contract + audit.ResourceKey; named IssueResourceRef to avoid colliding with the core single-cluster ResourceRef (same reason Checks uses CheckResourceRef). Faceting stays the host page's job (FleetPageShell), so there are no in-component filter chips. Types mirror radar's grouped Issue (internal/issues.GroupIssues).
1 parent d91f53b commit 5333aad

5 files changed

Lines changed: 585 additions & 0 deletions

File tree

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import { useMemo, useState, type ReactNode } from 'react';
2+
import { ChevronRight, CircleCheck, ExternalLink } from 'lucide-react';
3+
import { ClusterName, EmptyState } from '../ui';
4+
import {
5+
ISSUE_SEVERITY_BADGE_CLASS,
6+
ISSUE_SEVERITY_LABEL,
7+
ISSUE_SEVERITY_RAIL_CLASS,
8+
categoryLabel,
9+
groupBadgeClass,
10+
groupLabel,
11+
} from './severity';
12+
import {
13+
ISSUE_SEVERITY_RANK,
14+
memberRef,
15+
subjectRef,
16+
type Issue,
17+
type IssueAffected,
18+
type IssueResourceRef,
19+
} from './types';
20+
21+
export interface IssuesViewProps {
22+
/** Grouped live issues — one row per subject+category. Typically flattened
23+
* across the fleet by the host (the hub) or a single cluster (OSS). */
24+
issues: Issue[];
25+
/** True when at least one source returned issue data — distinguishes "clean"
26+
* from "nothing connected / everything errored". */
27+
anyData: boolean;
28+
/** Resolve a deep-link href for a resource (host-specific routing). Omit to
29+
* render non-link text. */
30+
resourceHref?: (ref: IssueResourceRef) => string;
31+
/** In-app resource navigation. When set, resource lines call this (no reload)
32+
* instead of following resourceHref — OSS opens its own drawer this way.
33+
* Takes precedence over resourceHref. */
34+
onResourceClick?: (ref: IssueResourceRef) => void;
35+
/** Display label for an issue's source cluster. Omit (or return falsy) to
36+
* hide the cluster line — e.g. single-cluster OSS. */
37+
clusterLabel?: (issue: Issue) => string | undefined;
38+
/** Empty-state CTA shown when there's no data. */
39+
emptyAction?: ReactNode;
40+
}
41+
42+
// The queue list. Filtering/faceting is the host page's job (FleetPageShell on
43+
// the hub, a thin wrapper in OSS) — this renders the rows + the healthy /
44+
// no-data terminal states only.
45+
export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction }: IssuesViewProps) {
46+
// Single-open accordion: opening a row collapses the previous one, so the
47+
// queue stays scannable and you never lose your place to a wall of expansions.
48+
const [openId, setOpenId] = useState<string | null>(null);
49+
50+
const sorted = useMemo(() => {
51+
// Worst-first: severity, then most-recent, then name. Mirrors the server's
52+
// ordering so the queue is stable across refetches.
53+
return [...issues].sort((a, b) => {
54+
const r = ISSUE_SEVERITY_RANK[b.severity] - ISSUE_SEVERITY_RANK[a.severity];
55+
if (r !== 0) return r;
56+
const la = a.last_seen ?? '';
57+
const lb = b.last_seen ?? '';
58+
if (la !== lb) return lb.localeCompare(la);
59+
return a.name.localeCompare(b.name);
60+
});
61+
}, [issues]);
62+
63+
if (sorted.length === 0) {
64+
return anyData ? (
65+
<EmptyState
66+
tone="healthy"
67+
variant="card"
68+
icon={CircleCheck}
69+
headline="Nothing broken right now"
70+
body="No active issues across the selected scope."
71+
/>
72+
) : (
73+
<EmptyState headline="No issue data yet" body="Connect a cluster to populate the issue queue." action={emptyAction} />
74+
);
75+
}
76+
77+
return (
78+
<ol className="flex flex-col gap-1.5">
79+
{sorted.map((issue) => {
80+
const rowKey = `${issue.cluster_id ?? ''}:${issue.id}:${issue.category}`;
81+
return (
82+
<IssueRow
83+
key={rowKey}
84+
issue={issue}
85+
clusterLabel={clusterLabel}
86+
open={openId === rowKey}
87+
onToggle={() => setOpenId((cur) => (cur === rowKey ? null : rowKey))}
88+
resourceHref={resourceHref}
89+
onResourceClick={onResourceClick}
90+
/>
91+
);
92+
})}
93+
</ol>
94+
);
95+
}
96+
97+
function IssueRow({
98+
issue,
99+
clusterLabel,
100+
open,
101+
onToggle,
102+
resourceHref,
103+
onResourceClick,
104+
}: {
105+
issue: Issue;
106+
clusterLabel?: (issue: Issue) => string | undefined;
107+
open: boolean;
108+
onToggle: () => void;
109+
resourceHref?: (ref: IssueResourceRef) => string;
110+
onResourceClick?: (ref: IssueResourceRef) => void;
111+
}) {
112+
const cluster = clusterLabel?.(issue);
113+
const affected = affectedSummary(issue.affected);
114+
115+
return (
116+
<li className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
117+
{/* The whole header is the single toggle target — chevron is just the
118+
open/closed indicator, not a separate action. Deep-links live in the
119+
expanded body (a link nested in a button would be invalid). */}
120+
<div
121+
role="button"
122+
tabIndex={0}
123+
aria-expanded={open}
124+
onClick={onToggle}
125+
onKeyDown={(e) => {
126+
if (e.target !== e.currentTarget) return;
127+
if (e.key === 'Enter' || e.key === ' ') {
128+
e.preventDefault();
129+
onToggle();
130+
}
131+
}}
132+
className={`group flex cursor-pointer items-center gap-3 border-l-2 py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${ISSUE_SEVERITY_RAIL_CLASS[issue.severity]}`}
133+
>
134+
<ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
135+
136+
<div className="flex min-w-0 flex-1 flex-col gap-1">
137+
<div className="flex items-center gap-2">
138+
<span className="truncate text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
139+
<span className={`badge-sm shrink-0 text-[10px] ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
140+
</div>
141+
<div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
142+
<span className="shrink-0 font-mono uppercase tracking-wide">{issue.kind}</span>
143+
<span className="min-w-0 truncate font-medium text-theme-text-secondary">
144+
{issue.namespace ? `${issue.namespace} / ` : ''}
145+
{issue.name}
146+
</span>
147+
{cluster ? (
148+
<>
149+
<span aria-hidden>·</span>
150+
<span className="max-w-[160px] shrink-0 truncate">
151+
<ClusterName name={cluster} />
152+
</span>
153+
</>
154+
) : null}
155+
{affected ? (
156+
<>
157+
<span aria-hidden>·</span>
158+
<span className="shrink-0 tabular-nums">{affected}</span>
159+
</>
160+
) : null}
161+
</div>
162+
</div>
163+
164+
<span className={`badge-sm shrink-0 text-[10px] font-semibold ${ISSUE_SEVERITY_BADGE_CLASS[issue.severity]}`}>
165+
{ISSUE_SEVERITY_LABEL[issue.severity]}
166+
</span>
167+
</div>
168+
169+
<div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
170+
{/* Kept mounted (not `open &&`) so the grid-rows transition animates the
171+
collapse too; inert when closed so SR + tab skip the clipped content. */}
172+
<div className="overflow-hidden" inert={!open || undefined}>
173+
<div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
174+
<div className="flex flex-col gap-4">
175+
<Diagnosis issue={issue} />
176+
<div className="border-t border-theme-border/70 pt-3">
177+
<AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
178+
</div>
179+
</div>
180+
</div>
181+
</div>
182+
</div>
183+
</li>
184+
);
185+
}
186+
187+
// What's-wrong block: the specific detector reason + message, plus pod crash
188+
// context when present (the "chronic vs acute" signal).
189+
function Diagnosis({ issue }: { issue: Issue }) {
190+
const crash =
191+
issue.restart_count || issue.last_terminated_reason
192+
? [issue.restart_count ? `${issue.restart_count} restarts` : null, issue.last_terminated_reason ? `last exit: ${issue.last_terminated_reason}` : null]
193+
.filter(Boolean)
194+
.join(' · ')
195+
: null;
196+
return (
197+
<section className="flex flex-col gap-1">
198+
<h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
199+
<p className="text-sm leading-relaxed text-theme-text-primary">
200+
<span className="font-medium">{issue.reason}</span>
201+
{issue.message ? <span className="text-theme-text-secondary">{issue.message}</span> : null}
202+
</p>
203+
{crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
204+
</section>
205+
);
206+
}
207+
208+
function AffectedResources({
209+
issue,
210+
resourceHref,
211+
onResourceClick,
212+
}: {
213+
issue: Issue;
214+
resourceHref?: (ref: IssueResourceRef) => string;
215+
onResourceClick?: (ref: IssueResourceRef) => void;
216+
}) {
217+
const members = issue.members ?? [];
218+
const total = issue.count ?? members.length + 1;
219+
return (
220+
<section className="flex flex-col gap-1.5">
221+
{/* The subject (the grouped thing — e.g. the Deployment) is always the
222+
first deep-link; members (the folded pods) follow. */}
223+
<ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} />
224+
{members.length > 0 && (
225+
<>
226+
<h4 className="mt-1.5 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
227+
Affected resources <span className="tabular-nums">({total})</span>
228+
</h4>
229+
<ul className="flex flex-col gap-px">
230+
{members.map((m, i) => (
231+
<ResourceLine
232+
key={`${m.group}/${m.kind}/${m.namespace}/${m.name}#${i}`}
233+
refForLink={memberRef(issue, m)}
234+
resourceHref={resourceHref}
235+
onResourceClick={onResourceClick}
236+
/>
237+
))}
238+
</ul>
239+
{issue.members_truncated && (
240+
<p className="mt-0.5 text-xs text-theme-text-tertiary">
241+
Showing {members.length} of {total} — open the subject to see the rest.
242+
</p>
243+
)}
244+
</>
245+
)}
246+
</section>
247+
);
248+
}
249+
250+
function ResourceLine({
251+
label,
252+
refForLink,
253+
resourceHref,
254+
onResourceClick,
255+
}: {
256+
label?: string;
257+
refForLink: IssueResourceRef;
258+
resourceHref?: (ref: IssueResourceRef) => string;
259+
onResourceClick?: (ref: IssueResourceRef) => void;
260+
}) {
261+
const r = refForLink;
262+
const linkable = !!(onResourceClick || resourceHref);
263+
const body = (
264+
<>
265+
{label ? <span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{label}</span> : null}
266+
<span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
267+
<span className={`min-w-0 truncate font-medium ${linkable ? 'text-[var(--color-radar-accent)]' : 'text-theme-text-primary'}`}>
268+
{r.namespace ? `${r.namespace} / ` : ''}
269+
{r.name}
270+
</span>
271+
{linkable && <ExternalLink className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
272+
</>
273+
);
274+
const cls = 'group/r flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60';
275+
return (
276+
<li>
277+
{onResourceClick ? (
278+
<button type="button" onClick={() => onResourceClick(r)} className={cls}>
279+
{body}
280+
</button>
281+
) : resourceHref ? (
282+
<a href={resourceHref(r)} className={cls}>
283+
{body}
284+
</a>
285+
) : (
286+
<span className="flex items-center gap-2 rounded-md px-2 py-1 text-sm">{body}</span>
287+
)}
288+
</li>
289+
);
290+
}
291+
292+
// "3 pods · 1 service" from the affected rollup; null when there's no fan-out
293+
// (single-resource issue — the subject line already says everything).
294+
function affectedSummary(a?: IssueAffected): string | null {
295+
if (!a) return null;
296+
const parts: string[] = [];
297+
const add = (n: number | undefined, singular: string, plural: string) => {
298+
if (n && n > 0) parts.push(`${n} ${n === 1 ? singular : plural}`);
299+
};
300+
add(a.pods, 'pod', 'pods');
301+
add(a.workloads, 'workload', 'workloads');
302+
add(a.services, 'service', 'services');
303+
add(a.pvcs, 'PVC', 'PVCs');
304+
add(a.nodes, 'node', 'nodes');
305+
return parts.length > 0 ? parts.join(' · ') : null;
306+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Explicit exports (not `export *`) so the generic identity helpers
2+
// (resourceKey/resourceRefKey) stay module-internal and don't collide at the
3+
// top-level barrel with the Checks queue's identically-named helpers when both
4+
// land. Issue-prefixed public names are safe to surface.
5+
export { IssuesView } from './IssuesView';
6+
export type { IssuesViewProps } from './IssuesView';
7+
export {
8+
ISSUE_SEVERITIES,
9+
ISSUE_SEVERITY_RANK,
10+
isIssueSeverity,
11+
subjectRef,
12+
memberRef,
13+
} from './types';
14+
export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef } from './types';
15+
export {
16+
ISSUE_SEVERITY_LABEL,
17+
ISSUE_SEVERITY_BADGE_CLASS,
18+
ISSUE_SEVERITY_FILL_CLASS,
19+
ISSUE_SEVERITY_TEXT_CLASS,
20+
ISSUE_SEVERITY_RAIL_CLASS,
21+
groupBadgeClass,
22+
categoryLabel,
23+
groupLabel,
24+
} from './severity';

0 commit comments

Comments
 (0)