@skyhook-io/k8s-ui 1.8.9 → 1.8.10

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 (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/ApplicationsList.tsx +4 -1
  3. package/src/components/applications/ApplicationsView.tsx +40 -9
  4. package/src/components/checks/ChecksView.tsx +80 -49
  5. package/src/components/checks/severity.ts +29 -39
  6. package/src/components/gitops/GitOpsTableView.tsx +11 -56
  7. package/src/components/issues/IssuesView.tsx +186 -123
  8. package/src/components/issues/ResourceIssuesSection.test.tsx +39 -0
  9. package/src/components/issues/ResourceIssuesSection.tsx +21 -3
  10. package/src/components/issues/index.ts +0 -2
  11. package/src/components/issues/issues.test.ts +45 -4
  12. package/src/components/issues/severity.ts +27 -32
  13. package/src/components/resources/ResourcesSidebar.test.tsx +36 -0
  14. package/src/components/resources/ResourcesSidebar.tsx +24 -6
  15. package/src/components/resources/ResourcesView.tsx +10 -2
  16. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  17. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  18. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  19. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  20. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  21. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  23. package/src/components/ui/BoardSkeleton.tsx +47 -0
  24. package/src/components/ui/CardSection.tsx +117 -0
  25. package/src/components/ui/Toast.tsx +1 -1
  26. package/src/components/ui/index.ts +2 -0
  27. package/src/components/ui/severity-tone.ts +56 -0
  28. package/src/theme/variables.css +14 -0
  29. package/src/utils/api-resources.test.ts +54 -0
  30. package/src/utils/api-resources.ts +68 -1
  31. package/tsconfig.json +2 -2
@@ -1,12 +1,15 @@
1
1
  import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
2
- import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
3
- import { ClusterName, EmptyState } from '../ui';
2
+ import { AlertOctagon, AlertTriangle, ArrowRight, ChevronRight, CircleCheck, Clock, ExternalLink, Layers, Terminal, Workflow } from 'lucide-react';
3
+ import { CardBody, CardSection, ClusterName, EmptyState, KIND_CHIP_CLASS, TerminalBlock } from '../ui';
4
4
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
5
5
  import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
6
6
  import {
7
7
  ISSUE_SEVERITY_BADGE_CLASS,
8
+ ISSUE_SEVERITY_HEADER_BAND_CLASS,
8
9
  ISSUE_SEVERITY_LABEL,
9
10
  ISSUE_SEVERITY_RAIL_CLASS,
11
+ ISSUE_SEVERITY_SOLID_CLASS,
12
+ ISSUE_SEVERITY_TEXT_CLASS,
10
13
  categoryLabel,
11
14
  groupBadgeClass,
12
15
  groupLabel,
@@ -19,8 +22,25 @@ import {
19
22
  type Issue,
20
23
  type IssueAffected,
21
24
  type IssueResourceRef,
25
+ type IssueSeverity,
22
26
  } from './types';
23
27
 
28
+ // Leading severity glyph — the at-a-glance severity cue on every row: critical =
29
+ // octagon (stop), warning = triangle.
30
+ const ISSUE_SEVERITY_ICON: Record<IssueSeverity, ComponentType<{ className?: string }>> = {
31
+ critical: AlertOctagon,
32
+ warning: AlertTriangle,
33
+ };
34
+
35
+ // An out-of-contract severity the backend might emit is coerced to this tier
36
+ // once, up front (normalizeIssueSeverity), so the icon AND every color map
37
+ // (text/rail/band/pill) resolve together — a raw miss would crash the icon and
38
+ // silently drop the tint everywhere else.
39
+ const ISSUE_SEVERITY_FALLBACK: IssueSeverity = 'warning';
40
+ // Object.hasOwn (not `in`) so inherited keys like "toString" don't slip past.
41
+ const normalizeIssueSeverity = (s: IssueSeverity): IssueSeverity =>
42
+ Object.hasOwn(ISSUE_SEVERITY_ICON, s) ? s : ISSUE_SEVERITY_FALLBACK;
43
+
24
44
  export interface IssuesViewProps {
25
45
  /** Grouped live issues — one row per subject+category. Typically flattened
26
46
  * across the fleet by the host (the hub) or a single cluster (OSS). */
@@ -132,6 +152,8 @@ export function IssueRow({
132
152
  const { headline } = issueMessageParts(issue);
133
153
  const [renderDetails, setRenderDetails] = useState(open);
134
154
  const Container = as;
155
+ const severity = normalizeIssueSeverity(issue.severity);
156
+ const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
135
157
  const slotCtx = { issue, open };
136
158
 
137
159
  useEffect(() => {
@@ -148,15 +170,23 @@ export function IssueRow({
148
170
  return (
149
171
  <Container
150
172
  className={[
151
- 'overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm',
173
+ 'overflow-hidden rounded-xl border bg-theme-surface transition-[border-color,box-shadow] duration-200',
174
+ // The open card lifts via elevation — heavier shadow + a bright
175
+ // emphasis edge that clearly separates it from sibling cards. Severity
176
+ // stays rationed to the band + pill; separation is depth, not more color.
177
+ // ring-1 widens the edge to 2px without the layout shift a border-2
178
+ // swap would cause on expand.
179
+ open ? 'border-[var(--border-emphasis)] ring-1 ring-[var(--border-emphasis)] shadow-theme-md' : 'border-theme-border shadow-theme-sm',
152
180
  dimmed ? 'opacity-60' : '',
153
181
  className ?? '',
154
182
  ].filter(Boolean).join(' ')}
155
183
  style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 72px' }}
156
184
  >
157
- {/* The whole header is the single toggle target — chevron is just the
158
- open/closed indicator, not a separate action. Deep-links live in the
159
- expanded body (a link nested in a button would be invalid). */}
185
+ {/* The whole header is the single toggle target. The leading severity
186
+ icon is the at-a-glance cue; a trailing chevron shows open/closed.
187
+ Deep-links live in the expanded body (a link nested in a button would
188
+ be invalid). Collapsed: neutral row + rail. Expanded: severity-tinted
189
+ band + solid pill — the tint is a focus signal, not per-row alarm. */}
160
190
  <div
161
191
  role="button"
162
192
  tabIndex={0}
@@ -169,28 +199,32 @@ export function IssueRow({
169
199
  onToggle();
170
200
  }
171
201
  }}
172
- 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]}`}
202
+ className={`group flex cursor-pointer items-center gap-3 border-l-[3px] py-3 pl-3 pr-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-radar-accent)]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
173
203
  >
174
- <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
204
+ <SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
175
205
 
176
206
  <div className="flex min-w-0 flex-1 flex-col gap-1">
177
207
  <div className="flex min-w-0 items-baseline gap-2">
178
208
  <span className="shrink-0 text-sm font-medium text-theme-text-primary">{categoryLabel(issue.category)}</span>
179
- <span className={`badge-sm shrink-0 self-center text-[10px] ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
209
+ <span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
180
210
  {renderBadges?.(slotCtx)}
181
- {/* The detector reason/message rides the title row so the most
182
- useful triage signal is visible without expanding — it fills
183
- the otherwise-empty band between the title and the severity
184
- badge. Full text (plus crash context) stays in the body. */}
211
+ {/* The detector reason rides the title row while COLLAPSED so the
212
+ key triage signal shows without expanding. When open, the full
213
+ cause lives in the WHAT'S WRONG section below, so it fades out
214
+ here (stays mounted — it's the flex-1 filler, so unmounting
215
+ wouldn't reflow anything, but fading avoids the pop). */}
185
216
  {issue.reason ? (
186
- <span className="min-w-0 flex-1 truncate text-xs text-theme-text-tertiary">
217
+ <span
218
+ aria-hidden={open || undefined}
219
+ className={`min-w-0 flex-1 truncate text-xs text-theme-text-tertiary transition-opacity duration-200 ${open ? 'opacity-0' : 'opacity-100'}`}
220
+ >
187
221
  <span className="font-medium text-theme-text-secondary">{issue.reason}</span>
188
222
  {headline ? <span> — {headline}</span> : null}
189
223
  </span>
190
224
  ) : null}
191
225
  </div>
192
226
  <div className="flex min-w-0 items-center gap-1.5 text-xs text-theme-text-tertiary">
193
- <span className="shrink-0 font-mono uppercase tracking-wide">{issue.kind}</span>
227
+ <span className={KIND_CHIP_CLASS}>{issue.kind}</span>
194
228
  <span className="min-w-0 truncate font-medium text-theme-text-secondary">
195
229
  {issue.namespace ? `${issue.namespace} / ` : ''}
196
230
  {issue.name}
@@ -224,27 +258,28 @@ export function IssueRow({
224
258
  </div>
225
259
  </div>
226
260
 
227
- {/* Age chip: chronic-vs-acute signal. When issue_timing is
228
- started_at_resource_creation, swap the raw age for "since deploy" —
229
- the raw age is misleading (it reads as urgency, but this issue has
230
- been present since the resource was first created). */}
231
- {issue.first_seen ? (
232
- <time
233
- dateTime={issue.first_seen}
234
- title={ageTitle(issue)}
235
- className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
236
- >
237
- <Clock className="h-3 w-3" aria-hidden />
238
- {issue.issue_timing === 'started_at_resource_creation'
239
- ? 'since deploy'
240
- : formatCompactAge(issue.first_seen)}
241
- </time>
242
- ) : null}
243
-
244
- <span className={`badge-sm shrink-0 text-[10px] font-semibold ${ISSUE_SEVERITY_BADGE_CLASS[issue.severity]}`}>
245
- {ISSUE_SEVERITY_LABEL[issue.severity]}
246
- </span>
247
- {renderActions?.(slotCtx)}
261
+ {/* Right cluster — severity pill THEN age, vertically centered as a group. */}
262
+ <div className="flex shrink-0 items-center gap-3">
263
+ <span className={`badge-sm shrink-0 px-2.5 py-0.5 text-xs font-semibold ${open ? ISSUE_SEVERITY_SOLID_CLASS[severity] : ISSUE_SEVERITY_BADGE_CLASS[severity]}`}>
264
+ {ISSUE_SEVERITY_LABEL[severity]}
265
+ </span>
266
+ {/* Age chip: keep recency visible; the deployment-start signal rides as a secondary tag. */}
267
+ {issue.first_seen ? (
268
+ <time
269
+ dateTime={issue.first_seen}
270
+ title={ageTitle(issue)}
271
+ className="flex shrink-0 items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
272
+ >
273
+ <Clock className="h-3 w-3" aria-hidden />
274
+ {formatCompactAge(issue.first_seen)}
275
+ {issue.issue_timing === 'started_at_resource_creation' ? (
276
+ <span className="badge-sm ml-1 text-[10px] text-theme-text-secondary">since deploy</span>
277
+ ) : null}
278
+ </time>
279
+ ) : null}
280
+ {renderActions?.(slotCtx)}
281
+ <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
282
+ </div>
248
283
  </div>
249
284
 
250
285
  {renderDetails ? (
@@ -257,8 +292,10 @@ export function IssueRow({
257
292
  }}
258
293
  >
259
294
  <div className="overflow-hidden">
260
- <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
261
- <div className="flex flex-col gap-4">
295
+ {/* Body sits on the card surface (not a recessed grey panel) so its
296
+ text keeps enough contrast. */}
297
+ <div className="border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4">
298
+ <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
262
299
  <Diagnosis issue={issue} />
263
300
  {issue.incident_parent ? (
264
301
  <section className="flex flex-col gap-1">
@@ -276,9 +313,7 @@ export function IssueRow({
276
313
  </section>
277
314
  ) : null}
278
315
  <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
279
- <div className="border-t border-theme-border/70 pt-3">
280
- <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
281
- </div>
316
+ <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
282
317
  </div>
283
318
  </div>
284
319
  </div>
@@ -288,8 +323,9 @@ export function IssueRow({
288
323
  );
289
324
  }
290
325
 
291
- // What's-wrong block: the specific detector reason + message, plus pod crash
292
- // context when present (the "chronic vs acute" signal).
326
+ // Diagnosis: WHAT'S WRONG (amber) → NEXT STEP (emerald) → RAW ERROR (monochrome
327
+ // terminal block). Status/timing facts share one muted meta line under the
328
+ // diagnosis so the body reads as three beats rather than a stack of one-liners.
293
329
  function Diagnosis({ issue }: { issue: Issue }) {
294
330
  const crash =
295
331
  issue.restart_count || issue.last_terminated_reason
@@ -298,71 +334,85 @@ function Diagnosis({ issue }: { issue: Issue }) {
298
334
  .join(' · ')
299
335
  : null;
300
336
  const { headline, detail } = issueMessageParts(issue);
301
- // When the issue carries a parsed plain-English cause, lead with it. The raw
302
- // detector message is kept below as de-emphasized detail.
337
+ // Raw detector text → terminal block. Two independent sources: the explicit
338
+ // raw_message (or, for a derived cause, the original message it came from) and
339
+ // the precise kubelet/containerd `detail` a short image-pull headline replaced
340
+ // — both surface when distinct, so setting raw_message never hides the CRI
341
+ // detail. Each is kept only when it adds something the prose above doesn't
342
+ // already show, compared against what's actually rendered (the cause when
343
+ // present, else headline+detail) rather than a string that may never appear.
303
344
  const visibleMessage = [headline, detail].filter(Boolean).join(' ');
304
- const rawMessage = issue.raw_message ?? (issue.cause ? issue.message ?? '' : '');
305
- const shouldShowRawMessage = issue.cause
306
- ? Boolean(rawMessage)
307
- : Boolean(issue.raw_message && issue.raw_message !== visibleMessage);
345
+ const shownText = issue.cause ?? visibleMessage;
346
+ const rawError = [
347
+ ...new Set(
348
+ [issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
349
+ (s): s is string => !!s && s !== shownText,
350
+ ),
351
+ ),
352
+ ].join('\n');
353
+
354
+ // One muted line — stuck/retry · crash · timing · change. A retrying-but-not-
355
+ // yet-stuck operation leads with "Retrying"; once it's declared stuck, "Stuck"
356
+ // takes over.
357
+ const meta: string[] = [];
358
+ if (issue.stuck) meta.push('Stuck');
359
+ else if (issue.operation_retry_count) meta.push('Retrying');
360
+ if (issue.operation_retry_count) meta.push(`retried ${issue.operation_retry_count}×`);
361
+ if (crash) meta.push(crash);
362
+ if (issue.first_seen) {
363
+ meta.push(
364
+ issue.issue_timing === 'started_at_resource_creation'
365
+ ? 'present since deployment'
366
+ : issue.issue_timing === 'started_after_resource_was_healthy'
367
+ ? `started ${formatRelativeAgeTime(issue.first_seen)} · was previously healthy`
368
+ : `started ${formatRelativeAgeTime(issue.first_seen)}`,
369
+ );
370
+ if (issue.last_seen && issue.issue_timing !== 'started_at_resource_creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
371
+ }
372
+ if (issue.change_context) meta.push(changeContextText(issue.change_context));
373
+
374
+ const hasNextStep = !!issue.action || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
375
+
308
376
  return (
309
- <section className="flex flex-col gap-1">
310
- <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
311
- {issue.cause ? (
312
- <p className="text-sm leading-relaxed text-theme-text-primary">{issue.cause}</p>
313
- ) : (
314
- <p className="text-sm leading-relaxed text-theme-text-primary">
315
- <span className="font-medium">{issue.reason}</span>
316
- {headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
317
- </p>
318
- )}
319
- {/* For non-cause issues whose message was normalized to a short headline
320
- (e.g. image-pull), keep the precise raw kubelet/containerd detail. The
321
- cause branch shows its own raw block below. */}
322
- {!issue.cause && detail ? (
323
- <p className="break-words font-mono text-xs leading-relaxed text-theme-text-tertiary">{detail}</p>
324
- ) : null}
325
- {issue.action ? (
326
- <p className="text-sm leading-relaxed text-theme-text-secondary">
327
- <span className="font-medium text-theme-text-primary">Next step: </span>
328
- {issue.action}
329
- </p>
330
- ) : null}
331
- {issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
332
- <p className="text-xs text-theme-text-tertiary">
333
- Suggested fix: create namespace <code className="rounded bg-theme-elevated px-1 font-mono">{issue.remediation_target}</code> — apply it from the GitOps detail page.
334
- </p>
335
- ) : null}
336
- {issue.stuck || issue.operation_retry_count ? (
337
- <p className="text-xs text-theme-text-tertiary tabular-nums">
338
- {issue.stuck ? 'Stuck' : 'Retrying'}
339
- {issue.operation_retry_count ? ` · retried ${issue.operation_retry_count}×` : ''}
340
- </p>
341
- ) : null}
342
- {/* Raw detector message, de-emphasized so precise controller/kubelet text
343
- remains available without leading the diagnosis. */}
344
- {shouldShowRawMessage ? (
345
- <p className="break-words font-mono text-[11px] leading-relaxed text-theme-text-tertiary">{rawMessage}</p>
346
- ) : null}
347
- {crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
348
- {issue.change_context ? (
349
- <p className="text-xs text-theme-text-tertiary">
350
- {changeContextText(issue.change_context)}
351
- </p>
377
+ <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
378
+ {/* When a parsed cause replaces the detector text, the terse reason code
379
+ (CrashLoopBackOff, FailedMount…) rides the eyebrow — it's the greppable
380
+ identifier operators anchor on, and the collapsed header drops it while
381
+ open. The non-cause branch already leads with it in the prose. */}
382
+ <CardSection
383
+ icon={AlertTriangle}
384
+ label="What's wrong"
385
+ tone="warn"
386
+ labelExtra={issue.cause && issue.reason ? `· ${issue.reason}` : undefined}
387
+ >
388
+ {issue.cause ? (
389
+ <p className="text-sm leading-relaxed text-theme-text-primary">{issue.cause}</p>
390
+ ) : (
391
+ <p className="text-sm leading-relaxed text-theme-text-primary">
392
+ <span className="font-medium">{issue.reason}</span>
393
+ {headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
394
+ </p>
395
+ )}
396
+ {meta.length > 0 ? <p className="text-xs leading-relaxed text-theme-text-tertiary tabular-nums">{meta.join(' · ')}</p> : null}
397
+ </CardSection>
398
+
399
+ {hasNextStep ? (
400
+ <CardSection icon={ArrowRight} label="Next step" tone="fix">
401
+ {issue.action ? <CardBody>{issue.action}</CardBody> : null}
402
+ {issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
403
+ <p className="text-xs text-theme-text-tertiary">
404
+ Suggested fix: create namespace <code className="inline-code">{issue.remediation_target}</code> — apply it from the GitOps detail page.
405
+ </p>
406
+ ) : null}
407
+ </CardSection>
352
408
  ) : null}
353
- {issue.first_seen ? (
354
- <p className="text-xs text-theme-text-tertiary tabular-nums">
355
- {issue.issue_timing === 'started_at_resource_creation'
356
- ? 'Present since deployment'
357
- : issue.issue_timing === 'started_after_resource_was_healthy'
358
- ? `Started ${formatRelativeAgeTime(issue.first_seen)} · was previously healthy`
359
- : `Started ${formatRelativeAgeTime(issue.first_seen)}`}
360
- {issue.last_seen && issue.issue_timing !== 'started_at_resource_creation'
361
- ? ` · last seen ${formatRelativeAgeTime(issue.last_seen)}`
362
- : ''}
363
- </p>
409
+
410
+ {rawError ? (
411
+ <CardSection icon={Terminal} label="Raw error">
412
+ <TerminalBlock>{rawError}</TerminalBlock>
413
+ </CardSection>
364
414
  ) : null}
365
- </section>
415
+ </div>
366
416
  );
367
417
  }
368
418
 
@@ -387,11 +437,11 @@ function DiagnosticContext({
387
437
  if (!ctx || facts.length === 0) return null;
388
438
 
389
439
  return (
390
- <section className="flex flex-col gap-2">
391
- <div className="flex items-center gap-2">
392
- <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Context</h4>
393
- {ctx.role ? <span className="badge-sm text-[10px] text-theme-text-secondary">{diagnosticRoleLabel(ctx.role)}</span> : null}
394
- </div>
440
+ <CardSection
441
+ icon={Workflow}
442
+ label="Context"
443
+ labelExtra={ctx.role ? diagnosticRoleLabel(ctx.role) : undefined}
444
+ >
395
445
  <ul className="flex flex-col gap-2">
396
446
  {facts.map((fact, idx) => (
397
447
  <li key={`${fact.type}-${idx}`} className="flex flex-col gap-1.5 rounded-md border border-theme-border/70 px-2.5 py-2">
@@ -438,7 +488,7 @@ function DiagnosticContext({
438
488
  </li>
439
489
  ))}
440
490
  </ul>
441
- </section>
491
+ </CardSection>
442
492
  );
443
493
  }
444
494
 
@@ -472,18 +522,15 @@ function AffectedResources({
472
522
  // grouping.go); fall back to the inline member count, not +1.
473
523
  const total = issue.count ?? members.length;
474
524
  return (
475
- <section className="flex flex-col gap-1.5">
525
+ <section className="flex flex-col gap-3">
476
526
  {/* The subject (the grouped thing — e.g. the Deployment) is always the
477
527
  first deep-link; members (the folded pods) follow. ResourceLine emits
478
528
  an <li>, so it needs a list parent of its own. */}
479
529
  <ul className="flex flex-col gap-px">
480
- <ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
530
+ <ResourceLine label="Subject" compact refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
481
531
  </ul>
482
532
  {members.length > 0 && (
483
- <>
484
- <h4 className="mt-1.5 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
485
- Affected resources <span className="tabular-nums">({total})</span>
486
- </h4>
533
+ <CardSection icon={Layers} label="Affected resources" labelExtra={`· ${total}`}>
487
534
  <ul className="flex flex-col gap-px">
488
535
  {members.map((m, i) => (
489
536
  <ResourceLine
@@ -496,11 +543,11 @@ function AffectedResources({
496
543
  ))}
497
544
  </ul>
498
545
  {issue.members_truncated && (
499
- <p className="mt-0.5 text-xs text-theme-text-tertiary">
546
+ <p className="text-xs text-theme-text-tertiary">
500
547
  Showing {members.length} of {total} — open the subject to see the rest.
501
548
  </p>
502
549
  )}
503
- </>
550
+ </CardSection>
504
551
  )}
505
552
  </section>
506
553
  );
@@ -513,6 +560,7 @@ function ResourceLine({
513
560
  resourceHref,
514
561
  onResourceClick,
515
562
  ResourceLinkIcon,
563
+ compact,
516
564
  }: {
517
565
  label?: string;
518
566
  refForLink: IssueResourceRef;
@@ -520,14 +568,23 @@ function ResourceLine({
520
568
  resourceHref?: (ref: IssueResourceRef) => string;
521
569
  onResourceClick?: (ref: IssueResourceRef) => void;
522
570
  ResourceLinkIcon: ComponentType<{ className?: string }>;
571
+ // Footer variant (the Subject row): renders the kind as a grey mono chip and
572
+ // a slightly bolder name, distinguishing the subject from the affected-members
573
+ // list below it.
574
+ compact?: boolean;
523
575
  }) {
524
576
  const r = refForLink;
525
577
  const linkable = !!(onResourceClick || resourceHref);
526
578
  const body = (
527
579
  <>
528
- {label ? <span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{label}</span> : null}
529
- <span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
530
- <span className={`min-w-0 truncate font-medium ${linkable ? 'text-[var(--color-radar-accent)]' : 'text-theme-text-primary'}`}>
580
+ {label ? <span className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.06em] text-theme-text-tertiary">{label}</span> : null}
581
+ {/* Footer (compact): the kind is a grey mono chip; member rows keep plain mono. */}
582
+ {compact ? (
583
+ <span className={KIND_CHIP_CLASS}>{r.kind}</span>
584
+ ) : (
585
+ <span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
586
+ )}
587
+ <span className={`min-w-0 truncate text-sm ${linkable ? `${compact ? 'font-semibold' : 'font-medium'} text-[var(--color-radar-accent)]` : 'font-medium text-theme-text-primary'}`}>
531
588
  {r.namespace ? `${r.namespace} / ` : ''}
532
589
  {r.name}
533
590
  </span>
@@ -537,7 +594,13 @@ function ResourceLine({
537
594
  {linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
538
595
  </>
539
596
  );
540
- 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';
597
+ // compact = Subject row; default = the affected-members list. Both get the
598
+ // padded, rounded hover hit-box so the Subject reads as clickable like the
599
+ // members below it. items-baseline so the label + kind chip sit on the same
600
+ // baseline as the larger resource name.
601
+ const cls = compact
602
+ ? 'group/r flex w-full items-baseline gap-2 rounded-md px-2 py-1 text-left transition-colors hover:bg-theme-hover/60'
603
+ : 'group/r flex w-full items-baseline gap-2 rounded-md px-2 py-1 text-left text-sm transition-colors hover:bg-theme-hover/60';
541
604
  return (
542
605
  <li>
543
606
  {onResourceClick ? (
@@ -549,7 +612,7 @@ function ResourceLine({
549
612
  {body}
550
613
  </a>
551
614
  ) : (
552
- <span className="flex items-center gap-2 rounded-md px-2 py-1 text-sm">{body}</span>
615
+ <span className={compact ? 'flex items-baseline gap-2 rounded-md px-2 py-1' : 'flex items-baseline gap-2 rounded-md px-2 py-1 text-sm'}>{body}</span>
553
616
  )}
554
617
  </li>
555
618
  );
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { ResourceIssuesSection } from './ResourceIssuesSection'
4
+ import type { Issue } from './types'
5
+
6
+ const issue: Issue = {
7
+ id: 'pvc-root',
8
+ severity: 'critical',
9
+ source: 'missing_ref',
10
+ category: 'pvc_pending',
11
+ category_group: 'storage',
12
+ grouping_scope: 'pvc',
13
+ kind: 'PersistentVolumeClaim',
14
+ namespace: 'demo',
15
+ name: 'data',
16
+ reason: 'StorageClassMissing',
17
+ cause: 'PVC demo/data references a StorageClass that does not exist.',
18
+ diagnostic_context: {
19
+ role: 'candidate',
20
+ facts: [
21
+ {
22
+ type: 'pvc_blast_radius',
23
+ confidence: 'high',
24
+ message: 'Blocks pods that mount this claim.',
25
+ },
26
+ ],
27
+ },
28
+ }
29
+
30
+ describe('ResourceIssuesSection', () => {
31
+ it('keeps drawer context facts but omits the role badge', () => {
32
+ const html = renderToString(<ResourceIssuesSection issues={[issue]} />)
33
+
34
+ expect(html).toContain('Context')
35
+ expect(html).toContain('Blocked pods')
36
+ expect(html).toContain('high<!-- --> confidence')
37
+ expect(html).not.toContain('Possible cause')
38
+ })
39
+ })
@@ -3,7 +3,7 @@ import { Section } from '../ui/drawer-components'
3
3
  import { Badge } from '../ui/Badge'
4
4
  import type { Issue, IssueResourceRef } from './types'
5
5
  import { categoryLabel } from './severity'
6
- import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle } from './diagnostic'
6
+ import { diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic'
7
7
 
8
8
  /**
9
9
  * ResourceIssuesSection — the compact "Operational Issues" block for the resource
@@ -14,7 +14,7 @@ import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle } from './dia
14
14
  *
15
15
  * Header mirrors the queue: the plain `categoryLabel` is the operator-facing
16
16
  * headline and the raw `reason` rides alongside as a muted signal (so the K8s
17
- * jargon is available but not the lead). Body is intentionally just the plain
17
+ * jargon is available but not the lead). Body is primarily the plain
18
18
  * `cause` (which names the offending object) + the `Next step` — the diagnosis
19
19
  * and the fix. The raw `message`/evidence stays in the queue + MCP where the
20
20
  * locator detail is wanted; inline it mostly restated the cause or the category,
@@ -34,6 +34,7 @@ export function ResourceIssuesSection({
34
34
  <Section title={`Operational Issues (${issues.length})`} icon={AlertTriangle} defaultExpanded>
35
35
  <div className="space-y-3">
36
36
  {issues.map((issue) => {
37
+ const parent = issue.incident_parent
37
38
  return (
38
39
  <div key={issue.id} className="card-inner">
39
40
  <div className="mb-1 flex min-w-0 items-baseline gap-2">
@@ -65,6 +66,24 @@ export function ResourceIssuesSection({
65
66
  <code className="rounded bg-theme-elevated px-1 font-mono">{issue.remediation_target}</code> — apply it from the GitOps detail page.
66
67
  </p>
67
68
  ) : null}
69
+ {parent ? (
70
+ <p className="mt-1 text-xs text-theme-text-tertiary">
71
+ {incidentParentLabel(parent.fact_type, parent.confidence)}:{' '}
72
+ {onResourceClick ? (
73
+ <button
74
+ type="button"
75
+ onClick={() => onResourceClick(parent.ref)}
76
+ className="group inline-flex items-center gap-1 text-left font-mono hover:text-theme-text-secondary"
77
+ title={confidenceTitle(parent.confidence ?? '')}
78
+ >
79
+ {parent.ref.kind} / {parent.ref.name}
80
+ <ExternalLink className="h-3 w-3 opacity-0 group-hover:opacity-100" />
81
+ </button>
82
+ ) : (
83
+ <span className="font-mono">{parent.ref.kind} / {parent.ref.name}</span>
84
+ )}
85
+ </p>
86
+ ) : null}
68
87
  <CausalContext issue={issue} onResourceClick={onResourceClick} />
69
88
  </div>
70
89
  )
@@ -89,7 +108,6 @@ function CausalContext({ issue, onResourceClick }: { issue: Issue; onResourceCli
89
108
  <div className="mt-2 border-t border-theme-border/60 pt-2">
90
109
  <div className="mb-1 flex items-center gap-2">
91
110
  <span className="text-[10px] font-semibold uppercase tracking-wide text-theme-text-tertiary">Context</span>
92
- {ctx.role ? <span className="badge-sm text-[10px] text-theme-text-secondary">{diagnosticRoleLabel(ctx.role)}</span> : null}
93
111
  </div>
94
112
  <ul className="space-y-1.5">
95
113
  {links.map((fact, idx) => (
@@ -16,10 +16,8 @@ export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagno
16
16
  export {
17
17
  ISSUE_SEVERITY_LABEL,
18
18
  ISSUE_SEVERITY_BADGE_CLASS,
19
- ISSUE_SEVERITY_FILL_CLASS,
20
19
  ISSUE_SEVERITY_TEXT_CLASS,
21
20
  ISSUE_SEVERITY_RAIL_CLASS,
22
- groupBadgeClass,
23
21
  categoryLabel,
24
22
  groupLabel,
25
23
  } from './severity';