@skyhook-io/k8s-ui 1.7.6 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/package.json +6 -1
  2. package/src/components/charts/PrometheusChartsView.tsx +233 -0
  3. package/src/components/charts/index.ts +13 -0
  4. package/src/components/checks/ChecksView.tsx +5 -1
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +4 -4
  6. package/src/components/gitops/GitOpsTableView.tsx +203 -6
  7. package/src/components/gitops/index.ts +1 -0
  8. package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -3
  9. package/src/components/issues/IssuesView.tsx +352 -0
  10. package/src/components/issues/index.ts +24 -0
  11. package/src/components/issues/issues.test.ts +100 -0
  12. package/src/components/issues/severity.ts +125 -0
  13. package/src/components/issues/types.ts +194 -0
  14. package/src/components/resources/ResourcesView.tsx +27 -25
  15. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +47 -3
  16. package/src/components/resources/renderers/CAPIKubeadmControlPlaneRenderer.tsx +1 -1
  17. package/src/components/resources/renderers/CAPIMachineDeploymentRenderer.tsx +1 -1
  18. package/src/components/resources/renderers/CAPIMachineSetRenderer.tsx +1 -1
  19. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +1 -1
  20. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  21. package/src/components/resources/renderers/CrossplanePackageRenderer.tsx +5 -5
  22. package/src/components/resources/renderers/CrossplaneProviderConfigRenderer.tsx +1 -1
  23. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +1 -1
  24. package/src/components/resources/renderers/PodRenderer.test.tsx +53 -0
  25. package/src/components/resources/renderers/PodRenderer.tsx +7 -1
  26. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +1 -1
  27. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +41 -0
  28. package/src/components/resources/renderers/WorkloadRenderer.tsx +49 -7
  29. package/src/components/resources/renderers/XRDRenderer.tsx +2 -2
  30. package/src/components/resources/resource-utils-argo.ts +18 -0
  31. package/src/components/shared/DetailShell.tsx +107 -0
  32. package/src/components/shared/ManagedByChip.tsx +149 -16
  33. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  34. package/src/components/shared/ResourceRendererDispatch.tsx +13 -1
  35. package/src/components/shared/index.ts +2 -1
  36. package/src/components/timeline/TimelineSwimlanes.tsx +1320 -0
  37. package/src/components/timeline/index.ts +1 -0
  38. package/src/components/topology/K8sResourceNode.tsx +60 -60
  39. package/src/components/topology/TopologyFilterSidebar.tsx +25 -3
  40. package/src/components/topology/TopologyGraph.tsx +168 -52
  41. package/src/components/topology/TopologySearch.tsx +17 -8
  42. package/src/components/topology/topology-search-match.test.ts +1 -1
  43. package/src/components/topology/topology.css +18 -0
  44. package/src/components/ui/RowActionMenu.tsx +149 -0
  45. package/src/components/ui/Tooltip.tsx +37 -2
  46. package/src/components/ui/index.ts +2 -0
  47. package/src/components/workload/WorkloadView.tsx +118 -112
  48. package/src/index.ts +5 -0
  49. package/src/theme/components.css +16 -0
  50. package/src/types/core.ts +3 -2
  51. package/src/utils/env-from.ts +3 -0
  52. package/src/utils/git-provider-urls.test.ts +348 -0
  53. package/src/utils/git-provider-urls.ts +142 -0
  54. package/src/utils/index.ts +2 -0
  55. package/src/utils/replica-scalers.ts +9 -0
  56. package/src/components/resources/resources-search-sidebar-hint.test.ts +0 -85
@@ -0,0 +1,352 @@
1
+ import { useMemo, useState, type ReactNode } from 'react';
2
+ import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
3
+ import { ClusterName, EmptyState } from '../ui';
4
+ import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
5
+ import {
6
+ ISSUE_SEVERITY_BADGE_CLASS,
7
+ ISSUE_SEVERITY_LABEL,
8
+ ISSUE_SEVERITY_RAIL_CLASS,
9
+ categoryLabel,
10
+ groupBadgeClass,
11
+ groupLabel,
12
+ } from './severity';
13
+ import {
14
+ compareIssues,
15
+ issueMessageParts,
16
+ memberRef,
17
+ subjectRef,
18
+ type Issue,
19
+ type IssueAffected,
20
+ type IssueResourceRef,
21
+ } from './types';
22
+
23
+ export interface IssuesViewProps {
24
+ /** Grouped live issues — one row per subject+category. Typically flattened
25
+ * across the fleet by the host (the hub) or a single cluster (OSS). */
26
+ issues: Issue[];
27
+ /** True when at least one source returned issue data — distinguishes "clean"
28
+ * from "nothing connected / everything errored". */
29
+ anyData: boolean;
30
+ /** Resolve a deep-link href for a resource (host-specific routing). Omit to
31
+ * render non-link text. */
32
+ resourceHref?: (ref: IssueResourceRef) => string;
33
+ /** In-app resource navigation. When set, resource lines call this (no reload)
34
+ * instead of following resourceHref — OSS opens its own drawer this way.
35
+ * Takes precedence over resourceHref. */
36
+ onResourceClick?: (ref: IssueResourceRef) => void;
37
+ /** Display label for an issue's source cluster. Omit (or return falsy) to
38
+ * hide the cluster line — e.g. single-cluster OSS. */
39
+ clusterLabel?: (issue: Issue) => string | undefined;
40
+ /** Empty-state CTA shown when there's no data. */
41
+ emptyAction?: ReactNode;
42
+ }
43
+
44
+ // The queue list. Filtering/faceting is the host page's job (FleetPageShell on
45
+ // the hub, a thin wrapper in OSS) — this renders the rows + the healthy /
46
+ // no-data terminal states only.
47
+ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction }: IssuesViewProps) {
48
+ // Single-open accordion: opening a row collapses the previous one, so the
49
+ // queue stays scannable and you never lose your place to a wall of expansions.
50
+ const [openId, setOpenId] = useState<string | null>(null);
51
+
52
+ // Stable order keyed on severity → onset → identity (see compareIssues), so
53
+ // the queue doesn't reshuffle under the host's auto-refresh.
54
+ const sorted = useMemo(() => [...issues].sort(compareIssues), [issues]);
55
+
56
+ if (sorted.length === 0) {
57
+ return anyData ? (
58
+ <EmptyState
59
+ tone="healthy"
60
+ variant="card"
61
+ icon={CircleCheck}
62
+ headline="Nothing broken right now"
63
+ body="No active issues across the selected scope."
64
+ />
65
+ ) : (
66
+ <EmptyState headline="No issue data yet" body="Connect a cluster to populate the issue queue." action={emptyAction} />
67
+ );
68
+ }
69
+
70
+ return (
71
+ <ol className="flex flex-col gap-1.5">
72
+ {sorted.map((issue) => {
73
+ // Stable identity for the React key + open-accordion state, so a row
74
+ // survives auto-refresh in place. cluster_id scopes the id across the
75
+ // fleet (the hub renders issues from many clusters in one list).
76
+ const rowKey = `${issue.cluster_id ?? ''}:${issue.id}`;
77
+ return (
78
+ <IssueRow
79
+ key={rowKey}
80
+ issue={issue}
81
+ clusterLabel={clusterLabel}
82
+ open={openId === rowKey}
83
+ onToggle={() => setOpenId((cur) => (cur === rowKey ? null : rowKey))}
84
+ resourceHref={resourceHref}
85
+ onResourceClick={onResourceClick}
86
+ />
87
+ );
88
+ })}
89
+ </ol>
90
+ );
91
+ }
92
+
93
+ function IssueRow({
94
+ issue,
95
+ clusterLabel,
96
+ open,
97
+ onToggle,
98
+ resourceHref,
99
+ onResourceClick,
100
+ }: {
101
+ issue: Issue;
102
+ clusterLabel?: (issue: Issue) => string | undefined;
103
+ open: boolean;
104
+ onToggle: () => void;
105
+ resourceHref?: (ref: IssueResourceRef) => string;
106
+ onResourceClick?: (ref: IssueResourceRef) => void;
107
+ }) {
108
+ const cluster = clusterLabel?.(issue);
109
+ const affected = affectedSummary(issue.affected);
110
+ const { headline } = issueMessageParts(issue);
111
+
112
+ return (
113
+ <li className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
114
+ {/* The whole header is the single toggle target — chevron is just the
115
+ open/closed indicator, not a separate action. Deep-links live in the
116
+ expanded body (a link nested in a button would be invalid). */}
117
+ <div
118
+ role="button"
119
+ tabIndex={0}
120
+ aria-expanded={open}
121
+ onClick={onToggle}
122
+ onKeyDown={(e) => {
123
+ if (e.target !== e.currentTarget) return;
124
+ if (e.key === 'Enter' || e.key === ' ') {
125
+ e.preventDefault();
126
+ onToggle();
127
+ }
128
+ }}
129
+ 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]}`}
130
+ >
131
+ <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
132
+
133
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
134
+ <div className="flex min-w-0 items-baseline gap-2">
135
+ <span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
136
+ <span className={`badge-sm shrink-0 self-center text-[10px] ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
137
+ {/* The detector reason/message rides the title row so the most
138
+ useful triage signal is visible without expanding — it fills
139
+ the otherwise-empty band between the title and the severity
140
+ badge. Full text (plus crash context) stays in the body. */}
141
+ {issue.reason ? (
142
+ <span className="min-w-0 flex-1 truncate text-xs text-theme-text-tertiary">
143
+ <span className="font-medium text-theme-text-secondary">{issue.reason}</span>
144
+ {headline ? <span> — {headline}</span> : null}
145
+ </span>
146
+ ) : null}
147
+ </div>
148
+ <div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
149
+ <span className="shrink-0 font-mono uppercase tracking-wide">{issue.kind}</span>
150
+ <span className="min-w-0 truncate font-medium text-theme-text-secondary">
151
+ {issue.namespace ? `${issue.namespace} / ` : ''}
152
+ {issue.name}
153
+ </span>
154
+ {cluster ? (
155
+ <>
156
+ <span aria-hidden>·</span>
157
+ <span className="max-w-[160px] shrink-0 truncate">
158
+ <ClusterName name={cluster} />
159
+ </span>
160
+ </>
161
+ ) : null}
162
+ {affected ? (
163
+ <>
164
+ <span aria-hidden>·</span>
165
+ <span className="shrink-0 tabular-nums">{affected}</span>
166
+ </>
167
+ ) : null}
168
+ </div>
169
+ </div>
170
+
171
+ {/* Onset age (first_seen, fixed for the issue's life) is the chronic-vs-
172
+ acute signal — "broken 2m" reads very differently from "broken 5d".
173
+ Keyed on first_seen, not last_seen, so it doesn't churn on refresh. */}
174
+ {issue.first_seen ? (
175
+ <time
176
+ dateTime={issue.first_seen}
177
+ title={ageTitle(issue)}
178
+ className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
179
+ >
180
+ <Clock className="h-3 w-3" aria-hidden />
181
+ {formatCompactAge(issue.first_seen)}
182
+ </time>
183
+ ) : null}
184
+
185
+ <span className={`badge-sm shrink-0 text-[10px] font-semibold ${ISSUE_SEVERITY_BADGE_CLASS[issue.severity]}`}>
186
+ {ISSUE_SEVERITY_LABEL[issue.severity]}
187
+ </span>
188
+ </div>
189
+
190
+ <div className="grid transition-[grid-template-rows] duration-200 ease-out" style={{ gridTemplateRows: open ? '1fr' : '0fr' }}>
191
+ {/* Kept mounted (not `open &&`) so the grid-rows transition animates the
192
+ collapse too; inert when closed so SR + tab skip the clipped content. */}
193
+ <div className="overflow-hidden" inert={!open || undefined}>
194
+ <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
195
+ <div className="flex flex-col gap-4">
196
+ <Diagnosis issue={issue} />
197
+ <div className="border-t border-theme-border/70 pt-3">
198
+ <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} />
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </li>
205
+ );
206
+ }
207
+
208
+ // What's-wrong block: the specific detector reason + message, plus pod crash
209
+ // context when present (the "chronic vs acute" signal).
210
+ function Diagnosis({ issue }: { issue: Issue }) {
211
+ const crash =
212
+ issue.restart_count || issue.last_terminated_reason
213
+ ? [issue.restart_count ? `${issue.restart_count} restart${issue.restart_count === 1 ? '' : 's'}` : null, issue.last_terminated_reason ? `last exit: ${issue.last_terminated_reason}` : null]
214
+ .filter(Boolean)
215
+ .join(' · ')
216
+ : null;
217
+ const { headline, detail } = issueMessageParts(issue);
218
+ return (
219
+ <section className="flex flex-col gap-1">
220
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
221
+ <p className="text-sm leading-relaxed text-theme-text-primary">
222
+ <span className="font-medium">{issue.reason}</span>
223
+ {headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
224
+ </p>
225
+ {/* Raw source string kept as secondary detail only when we showed a
226
+ normalized headline above (e.g. the verbose containerd image-pull
227
+ error) — so the precise message is never lost, just de-emphasized. */}
228
+ {detail ? <p className="break-words font-mono text-xs leading-relaxed text-theme-text-tertiary">{detail}</p> : null}
229
+ {crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
230
+ {issue.first_seen ? (
231
+ <p className="text-xs text-theme-text-tertiary tabular-nums">
232
+ Started {formatRelativeAgeTime(issue.first_seen)}
233
+ {issue.last_seen ? ` · last seen ${formatRelativeAgeTime(issue.last_seen)}` : ''}
234
+ </p>
235
+ ) : null}
236
+ </section>
237
+ );
238
+ }
239
+
240
+ // Native-tooltip detail for the collapsed-row age chip: absolute onset + last-seen
241
+ // freshness, the two facts the compact "2h" hides.
242
+ function ageTitle(issue: Issue): string {
243
+ const parts: string[] = [];
244
+ if (issue.first_seen) parts.push(`Started ${new Date(issue.first_seen).toLocaleString()}`);
245
+ if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
246
+ return parts.join('\n');
247
+ }
248
+
249
+ function AffectedResources({
250
+ issue,
251
+ resourceHref,
252
+ onResourceClick,
253
+ }: {
254
+ issue: Issue;
255
+ resourceHref?: (ref: IssueResourceRef) => string;
256
+ onResourceClick?: (ref: IssueResourceRef) => void;
257
+ }) {
258
+ const members = issue.members ?? [];
259
+ // count is the backend fan-out size (members, subject excluded — see
260
+ // grouping.go); fall back to the inline member count, not +1.
261
+ const total = issue.count ?? members.length;
262
+ return (
263
+ <section className="flex flex-col gap-1.5">
264
+ {/* The subject (the grouped thing — e.g. the Deployment) is always the
265
+ first deep-link; members (the folded pods) follow. ResourceLine emits
266
+ an <li>, so it needs a list parent of its own. */}
267
+ <ul className="flex flex-col gap-px">
268
+ <ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} />
269
+ </ul>
270
+ {members.length > 0 && (
271
+ <>
272
+ <h4 className="mt-1.5 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
273
+ Affected resources <span className="tabular-nums">({total})</span>
274
+ </h4>
275
+ <ul className="flex flex-col gap-px">
276
+ {members.map((m, i) => (
277
+ <ResourceLine
278
+ key={`${m.group}/${m.kind}/${m.namespace}/${m.name}#${i}`}
279
+ refForLink={memberRef(issue, m)}
280
+ resourceHref={resourceHref}
281
+ onResourceClick={onResourceClick}
282
+ />
283
+ ))}
284
+ </ul>
285
+ {issue.members_truncated && (
286
+ <p className="mt-0.5 text-xs text-theme-text-tertiary">
287
+ Showing {members.length} of {total} — open the subject to see the rest.
288
+ </p>
289
+ )}
290
+ </>
291
+ )}
292
+ </section>
293
+ );
294
+ }
295
+
296
+ function ResourceLine({
297
+ label,
298
+ refForLink,
299
+ resourceHref,
300
+ onResourceClick,
301
+ }: {
302
+ label?: string;
303
+ refForLink: IssueResourceRef;
304
+ resourceHref?: (ref: IssueResourceRef) => string;
305
+ onResourceClick?: (ref: IssueResourceRef) => void;
306
+ }) {
307
+ const r = refForLink;
308
+ const linkable = !!(onResourceClick || resourceHref);
309
+ const body = (
310
+ <>
311
+ {label ? <span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{label}</span> : null}
312
+ <span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
313
+ <span className={`min-w-0 truncate font-medium ${linkable ? 'text-[var(--color-radar-accent)]' : 'text-theme-text-primary'}`}>
314
+ {r.namespace ? `${r.namespace} / ` : ''}
315
+ {r.name}
316
+ </span>
317
+ {linkable && <ExternalLink className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
318
+ </>
319
+ );
320
+ 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';
321
+ return (
322
+ <li>
323
+ {onResourceClick ? (
324
+ <button type="button" onClick={() => onResourceClick(r)} className={cls}>
325
+ {body}
326
+ </button>
327
+ ) : resourceHref ? (
328
+ <a href={resourceHref(r)} className={cls}>
329
+ {body}
330
+ </a>
331
+ ) : (
332
+ <span className="flex items-center gap-2 rounded-md px-2 py-1 text-sm">{body}</span>
333
+ )}
334
+ </li>
335
+ );
336
+ }
337
+
338
+ // "3 pods · 1 service" from the affected rollup; null when there's no fan-out
339
+ // (single-resource issue — the subject line already says everything).
340
+ function affectedSummary(a?: IssueAffected): string | null {
341
+ if (!a) return null;
342
+ const parts: string[] = [];
343
+ const add = (n: number | undefined, singular: string, plural: string) => {
344
+ if (n && n > 0) parts.push(`${n} ${n === 1 ? singular : plural}`);
345
+ };
346
+ add(a.pods, 'pod', 'pods');
347
+ add(a.workloads, 'workload', 'workloads');
348
+ add(a.services, 'service', 'services');
349
+ add(a.pvcs, 'PVC', 'PVCs');
350
+ add(a.nodes, 'node', 'nodes');
351
+ return parts.length > 0 ? parts.join(' · ') : null;
352
+ }
@@ -0,0 +1,24 @@
1
+ // Explicit exports (not `export *`) so the generic identity helpers stay
2
+ // module-internal and don't collide at the top-level barrel with the Checks
3
+ // queue's identically-named helpers when both land. Issue-prefixed public
4
+ // 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,0 +1,100 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { compareIssues, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
3
+ import { categoryLabel, groupLabel, groupBadgeClass } from './severity'
4
+
5
+ const base: Issue = {
6
+ id: 'id-0',
7
+ severity: 'warning',
8
+ source: 'problem',
9
+ category: 'crashloop',
10
+ category_group: 'runtime',
11
+ grouping_scope: 'workload',
12
+ kind: 'Deployment',
13
+ name: 'app',
14
+ reason: 'CrashLoopBackOff',
15
+ }
16
+ const mk = (o: Partial<Issue>): Issue => ({ ...base, ...o })
17
+
18
+ describe('compareIssues', () => {
19
+ it('orders critical before warning regardless of onset', () => {
20
+ const warn = mk({ id: 'w', severity: 'warning', first_seen: '2026-05-01T00:00:00Z' }) // newer
21
+ const crit = mk({ id: 'c', severity: 'critical', first_seen: '2026-01-01T00:00:00Z' }) // older
22
+ expect([warn, crit].sort(compareIssues).map((i) => i.id)).toEqual(['c', 'w'])
23
+ })
24
+
25
+ it('breaks same-severity ties by first_seen DESC (newest onset first)', () => {
26
+ const older = mk({ id: 'o', first_seen: '2026-01-01T00:00:00Z' })
27
+ const newer = mk({ id: 'n', first_seen: '2026-05-01T00:00:00Z' })
28
+ expect([older, newer].sort(compareIssues).map((i) => i.id)).toEqual(['n', 'o'])
29
+ })
30
+
31
+ it('does NOT reshuffle same-severity rows when only last_seen changes (anti-churn)', () => {
32
+ // Two same-severity rows, same onset — order is the deterministic name tiebreak.
33
+ const a = mk({ id: 'id-a', name: 'a', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-01T00:00:00Z' })
34
+ const b = mk({ id: 'id-b', name: 'b', first_seen: '2026-01-01T00:00:00Z', last_seen: '2026-05-30T00:00:00Z' })
35
+ const before = [a, b].sort(compareIssues).map((i) => i.id)
36
+ expect(before).toEqual(['id-a', 'id-b'])
37
+ // A refetch bumps a's last_seen to "now". Sorting on last_seen would flip the
38
+ // order; keying on first_seen + identity must NOT — this is the whole point
39
+ // of the onset-based sort.
40
+ const aRefetched = mk({ ...a, last_seen: '2026-06-01T00:00:00Z' })
41
+ const after = [aRefetched, b].sort(compareIssues).map((i) => i.id)
42
+ expect(after).toEqual(before)
43
+ })
44
+ })
45
+
46
+ describe('category/group label fallbacks', () => {
47
+ it('returns the mapped label, else humanizes (server-added category needs no frontend deploy)', () => {
48
+ expect(categoryLabel('crashloop')).toBe('Crash loop')
49
+ expect(categoryLabel('some_new_future_category')).toBe('Some new future category')
50
+ })
51
+ it('humanizes an unmapped group', () => {
52
+ expect(groupLabel('runtime')).toBe('Runtime')
53
+ expect(groupLabel('some_future_group')).toBe('Some future group')
54
+ })
55
+ it('groupBadgeClass falls back to a non-empty neutral class for an unknown group', () => {
56
+ expect(groupBadgeClass('totally_unknown_group')).toBeTruthy()
57
+ })
58
+ })
59
+
60
+ describe('subjectRef / memberRef', () => {
61
+ it('subjectRef defaults empty group/namespace and threads cluster_id', () => {
62
+ const issue = mk({ cluster_id: 'cl_1', kind: 'Deployment', name: 'web' }) // no group/namespace
63
+ expect(subjectRef(issue)).toEqual({ cluster_id: 'cl_1', group: '', kind: 'Deployment', namespace: '', name: 'web' })
64
+ })
65
+ it('memberRef threads the issue cluster_id onto a member', () => {
66
+ const issue = mk({ cluster_id: 'cl_2' })
67
+ const member = { group: 'apps', kind: 'Pod', namespace: 'ns', name: 'p1' }
68
+ expect(memberRef(issue, member)).toEqual({ ...member, cluster_id: 'cl_2' })
69
+ })
70
+ })
71
+
72
+ describe('image-pull message normalization', () => {
73
+ const notFound =
74
+ 'Back-off pulling image "reg.io/team/api:v2": ErrImagePull: rpc error: code = NotFound desc = failed to pull and unpack image "reg.io/team/api:v2": failed to resolve reference "reg.io/team/api:v2": "reg.io/team/api:v2": not found'
75
+
76
+ it('extracts cause + single image ref from the verbose CRI string', () => {
77
+ expect(normalizeImagePullMessage(notFound)).toBe('Image not found: reg.io/team/api:v2')
78
+ })
79
+ it('classifies the common failure modes', () => {
80
+ expect(normalizeImagePullMessage('pull access denied for image "x:1", repository does not exist or may require authorization')).toBe('Not authorized to pull image: x:1')
81
+ expect(normalizeImagePullMessage('failed to pull image "x:1": dial tcp: lookup reg.io: no such host')).toBe('Registry unreachable: x:1')
82
+ expect(normalizeImagePullMessage('toomanyrequests: rate limit exceeded for image "x:1"')).toBe('Registry rate-limited: x:1')
83
+ })
84
+ it('returns null for shapes it does not recognize (caller keeps raw)', () => {
85
+ expect(normalizeImagePullMessage('some novel kubelet error')).toBeNull()
86
+ expect(normalizeImagePullMessage('')).toBeNull()
87
+ })
88
+
89
+ it('issueMessageParts normalizes image-pull headline and keeps raw as detail', () => {
90
+ const parts = issueMessageParts(mk({ category: 'image_pull_failed', reason: 'ImagePullBackOff', message: notFound }))
91
+ expect(parts.headline).toBe('Image not found: reg.io/team/api:v2')
92
+ expect(parts.detail).toBe(notFound)
93
+ })
94
+ it('does NOT mislabel a non-image "not found" message (gating)', () => {
95
+ // missing_config_ref carries 'secret "x" not found' — must stay verbatim, no detail split.
96
+ const parts = issueMessageParts(mk({ category: 'missing_config_ref', reason: 'Missing Secret', message: 'secret "project-infra" not found' }))
97
+ expect(parts.headline).toBe('secret "project-infra" not found')
98
+ expect(parts.detail).toBe('')
99
+ })
100
+ })
@@ -0,0 +1,125 @@
1
+ import type { IssueSeverity } from './types';
2
+
3
+ // Visual language for the 2-tier Issues severity, deliberately reusing the
4
+ // SAME class strings as the Checks queue (components/checks/severity.ts):
5
+ // critical = red (= Checks `critical`), warning = amber (= Checks `medium`).
6
+ // Issues and Checks are different severity axes, but the queues must read as
7
+ // one product — sharing the exact hues makes the rails/pills pixel-identical.
8
+ //
9
+ // Class strings are literal so each consuming app's Tailwind @source scan emits
10
+ // them.
11
+
12
+ export const ISSUE_SEVERITY_LABEL: Record<IssueSeverity, string> = {
13
+ critical: 'Critical',
14
+ warning: 'Warning',
15
+ };
16
+
17
+ // Pill badge — the loud, explicit severity signal on a row.
18
+ export const ISSUE_SEVERITY_BADGE_CLASS: Record<IssueSeverity, string> = {
19
+ critical: 'bg-red-50 text-red-700 ring-1 ring-red-200 dark:bg-red-950/50 dark:text-red-300 dark:ring-red-900',
20
+ warning: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-950/50 dark:text-amber-300 dark:ring-amber-900',
21
+ };
22
+
23
+ // Solid fill — dots + the proportional distribution bar segments.
24
+ export const ISSUE_SEVERITY_FILL_CLASS: Record<IssueSeverity, string> = {
25
+ critical: 'bg-red-500',
26
+ warning: 'bg-amber-500',
27
+ };
28
+
29
+ export const ISSUE_SEVERITY_TEXT_CLASS: Record<IssueSeverity, string> = {
30
+ critical: 'text-red-600 dark:text-red-400',
31
+ warning: 'text-amber-600 dark:text-amber-400',
32
+ };
33
+
34
+ // Left accent rail on a queue row — the scan-down severity cue.
35
+ export const ISSUE_SEVERITY_RAIL_CLASS: Record<IssueSeverity, string> = {
36
+ critical: 'border-l-red-500 hover:bg-red-50/40 dark:hover:bg-red-950/20',
37
+ warning: 'border-l-amber-500 hover:bg-amber-50/30 dark:hover:bg-amber-950/15',
38
+ };
39
+
40
+ // Category-group accent — the quiet classification tag (severity is the loud
41
+ // one). One hue per group; unknown/unmapped falls back to a neutral theme tag.
42
+ const GROUP_BADGE_CLASS: Record<string, string> = {
43
+ scheduling: 'bg-violet-50 text-violet-700 ring-1 ring-violet-200 dark:bg-violet-950/40 dark:text-violet-300 dark:ring-violet-900',
44
+ startup: 'bg-sky-50 text-sky-700 ring-1 ring-sky-200 dark:bg-sky-950/40 dark:text-sky-300 dark:ring-sky-900',
45
+ runtime: 'bg-rose-50 text-rose-700 ring-1 ring-rose-200 dark:bg-rose-950/40 dark:text-rose-300 dark:ring-rose-900',
46
+ configuration: 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 dark:bg-teal-950/40 dark:text-teal-300 dark:ring-teal-900',
47
+ networking: 'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-950/40 dark:text-indigo-300 dark:ring-indigo-900',
48
+ storage: 'bg-cyan-50 text-cyan-700 ring-1 ring-cyan-200 dark:bg-cyan-950/40 dark:text-cyan-300 dark:ring-cyan-900',
49
+ scaling: 'bg-fuchsia-50 text-fuchsia-700 ring-1 ring-fuchsia-200 dark:bg-fuchsia-950/40 dark:text-fuchsia-300 dark:ring-fuchsia-900',
50
+ security: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900',
51
+ control_plane: 'bg-slate-100 text-slate-600 ring-1 ring-slate-200 dark:bg-slate-800/60 dark:text-slate-300 dark:ring-slate-700',
52
+ };
53
+
54
+ export function groupBadgeClass(group: string): string {
55
+ return GROUP_BADGE_CLASS[group] ?? 'bg-theme-elevated text-theme-text-secondary ring-1 ring-theme-border';
56
+ }
57
+
58
+ // Display labels. The server emits raw snake_case category/group enums (so a
59
+ // new category needs no frontend deploy to APPEAR); the UI humanizes for
60
+ // display, falling back to title-cased snake_case for anything unmapped.
61
+ const CATEGORY_LABEL: Record<string, string> = {
62
+ unschedulable: 'Unschedulable',
63
+ quota_exceeded: 'Quota exceeded',
64
+ admission_webhook_blocking: 'Admission blocked',
65
+ image_pull_failed: 'Image pull failed',
66
+ container_waiting: 'Container waiting',
67
+ init_container_failed: 'Init container failed',
68
+ crashloop: 'Crash loop',
69
+ oom_killed: 'OOM killed',
70
+ liveness_probe_failed: 'Liveness probe failing',
71
+ readiness_failed: 'Readiness failing',
72
+ workload_degraded: 'Workload degraded',
73
+ high_restart: 'High restart count',
74
+ missing_config_ref: 'Missing reference',
75
+ pdb_blocks_evictions: 'PDB blocks evictions',
76
+ service_no_endpoints: 'No endpoints',
77
+ ingress_backend_missing: 'Ingress backend missing',
78
+ dns_failure: 'DNS failure',
79
+ network_policy_block: 'Network policy block',
80
+ pvc_pending: 'PVC pending',
81
+ pvc_lost: 'PVC lost',
82
+ volume_mount_failed: 'Volume mount failed',
83
+ volume_access_mode_conflict: 'Volume access conflict',
84
+ job_failed: 'Job failed',
85
+ cronjob_failed: 'CronJob failed',
86
+ rollout_stalled: 'Rollout stalled',
87
+ hpa_limited_or_failed: 'HPA limited',
88
+ rbac_forbidden: 'RBAC forbidden',
89
+ certificate_not_ready: 'Certificate not ready',
90
+ pod_security_violation: 'Pod Security violation',
91
+ node_not_ready: 'Node not ready',
92
+ operator_condition_failed: 'Controller condition',
93
+ gitops_sync_failed: 'GitOps sync failed',
94
+ webhook_backend_down: 'Webhook backend down',
95
+ control_plane_not_ready: 'Control plane not ready',
96
+ machine_not_ready: 'Machine not ready',
97
+ unknown: 'Unknown',
98
+ };
99
+
100
+ const GROUP_LABEL: Record<string, string> = {
101
+ scheduling: 'Scheduling',
102
+ startup: 'Startup',
103
+ runtime: 'Runtime',
104
+ configuration: 'Configuration',
105
+ networking: 'Networking',
106
+ storage: 'Storage',
107
+ scaling: 'Scaling',
108
+ security: 'Security',
109
+ control_plane: 'Control plane',
110
+ unknown: 'Unknown',
111
+ };
112
+
113
+ function humanize(raw: string): string {
114
+ if (!raw) return '';
115
+ const spaced = raw.replace(/_/g, ' ');
116
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
117
+ }
118
+
119
+ export function categoryLabel(category: string): string {
120
+ return CATEGORY_LABEL[category] ?? humanize(category);
121
+ }
122
+
123
+ export function groupLabel(group: string): string {
124
+ return GROUP_LABEL[group] ?? humanize(group);
125
+ }