@skyhook-io/k8s-ui 1.8.9 → 1.8.11

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 (130) hide show
  1. package/package.json +5 -4
  2. package/src/assets/gitops/argocd.png +0 -0
  3. package/src/assets/gitops/flux.svg +7 -0
  4. package/src/components/applications/AppChips.tsx +10 -0
  5. package/src/components/applications/ApplicationDetail.test.tsx +569 -0
  6. package/src/components/applications/ApplicationDetail.tsx +2517 -450
  7. package/src/components/applications/ApplicationsList.tsx +4 -1
  8. package/src/components/applications/ApplicationsView.tsx +101 -18
  9. package/src/components/applications/applications-view.test.tsx +47 -0
  10. package/src/components/applications/index.ts +1 -1
  11. package/src/components/audit/AuditAlerts.tsx +14 -10
  12. package/src/components/checks/ChecksView.tsx +81 -51
  13. package/src/components/checks/severity.ts +39 -45
  14. package/src/components/cluster-switcher/ClusterSwitcher.tsx +2 -2
  15. package/src/components/compare/CompareResourcePicker.tsx +2 -2
  16. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -1
  17. package/src/components/gitops/GitOpsTableView.tsx +14 -59
  18. package/src/components/gitops/ManagedResourcesList.tsx +8 -11
  19. package/src/components/gitops/SyncCountdown.tsx +26 -17
  20. package/src/components/gitops/SyncOptionsDialog.tsx +2 -2
  21. package/src/components/gitops/insights/GitOpsInsightViews.tsx +277 -104
  22. package/src/components/gitops/insights/insights-helpers.test.ts +86 -1
  23. package/src/components/gitops/insights/insights-helpers.ts +69 -1
  24. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +5 -2
  25. package/src/components/issues/IssuesView.tsx +219 -143
  26. package/src/components/issues/ResourceIssuesSection.test.tsx +40 -0
  27. package/src/components/issues/ResourceIssuesSection.tsx +28 -128
  28. package/src/components/issues/index.ts +3 -2
  29. package/src/components/issues/issue-timing.ts +53 -0
  30. package/src/components/issues/issues.test.ts +146 -4
  31. package/src/components/issues/severity.ts +29 -32
  32. package/src/components/logs/LogCore.tsx +14 -4
  33. package/src/components/logs/WorkloadLogsViewer.tsx +31 -6
  34. package/src/components/logs/useLogStream.ts +8 -1
  35. package/src/components/namespace-switcher/NamespacePicker.tsx +86 -107
  36. package/src/components/resources/ResourcesSidebar.test.tsx +107 -0
  37. package/src/components/resources/ResourcesSidebar.tsx +70 -52
  38. package/src/components/resources/ResourcesView.selected-kind.test.ts +143 -0
  39. package/src/components/resources/ResourcesView.tsx +243 -53
  40. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +2 -2
  41. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +99 -0
  42. package/src/components/resources/renderers/JobRenderer.tsx +3 -1
  43. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +47 -0
  44. package/src/components/resources/renderers/NodeRenderer.test.tsx +103 -0
  45. package/src/components/resources/renderers/NodeRenderer.tsx +20 -12
  46. package/src/components/resources/renderers/PVCRenderer.test.tsx +23 -0
  47. package/src/components/resources/renderers/PVCRenderer.tsx +2 -2
  48. package/src/components/resources/renderers/PodRenderer.test.tsx +101 -0
  49. package/src/components/resources/renderers/PodRenderer.tsx +81 -70
  50. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +2 -2
  51. package/src/components/resources/renderers/SbomReportRenderer.tsx +2 -2
  52. package/src/components/resources/renderers/SecretRenderer.tsx +2 -0
  53. package/src/components/resources/renderers/ServiceRenderer.tsx +58 -40
  54. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +2 -2
  55. package/src/components/resources/renderers/WorkflowRenderer.test.tsx +43 -0
  56. package/src/components/resources/renderers/WorkflowRenderer.tsx +60 -78
  57. package/src/components/resources/renderers/index.ts +1 -0
  58. package/src/components/resources/resource-utils-capi.ts +1 -1
  59. package/src/components/resources/resource-utils.ts +31 -0
  60. package/src/components/shared/DetailShell.tsx +2 -2
  61. package/src/components/shared/ResourceActionsBar.tsx +140 -108
  62. package/src/components/shared/ResourceRendererDispatch.tsx +33 -4
  63. package/src/components/timeline/TimelineList.tsx +371 -354
  64. package/src/components/timeline/TimelineStrip.test.tsx +194 -0
  65. package/src/components/timeline/TimelineStrip.tsx +836 -0
  66. package/src/components/timeline/TimelineSwimlanes.test.tsx +1028 -0
  67. package/src/components/timeline/TimelineSwimlanes.tsx +2498 -593
  68. package/src/components/timeline/TimelineToolbar.test.tsx +320 -0
  69. package/src/components/timeline/TimelineToolbar.tsx +759 -0
  70. package/src/components/timeline/TimelineToolbarState.test.tsx +64 -0
  71. package/src/components/timeline/index.ts +23 -0
  72. package/src/components/timeline/scrubber-math.test.ts +235 -0
  73. package/src/components/timeline/scrubber-math.ts +249 -0
  74. package/src/components/timeline/shared.test.tsx +219 -0
  75. package/src/components/timeline/shared.tsx +232 -447
  76. package/src/components/timeline/timeline-filters.test.ts +196 -0
  77. package/src/components/timeline/timeline-filters.ts +180 -0
  78. package/src/components/timeline/timeline-lane-sort.test.ts +97 -0
  79. package/src/components/timeline/timeline-lane-sort.ts +77 -0
  80. package/src/components/timeline/timeline-live.test.ts +131 -0
  81. package/src/components/timeline/timeline-live.ts +77 -0
  82. package/src/components/topology/GroupNode.tsx +9 -2
  83. package/src/components/topology/K8sResourceNode.test.ts +11 -0
  84. package/src/components/topology/K8sResourceNode.tsx +387 -243
  85. package/src/components/topology/TopologyFilterSidebar.tsx +9 -0
  86. package/src/components/topology/TopologyGraph.tsx +89 -16
  87. package/src/components/topology/TopologySearch.tsx +2 -2
  88. package/src/components/topology/layout.ts +10 -2
  89. package/src/components/topology/topology.css +0 -92
  90. package/src/components/ui/Badge.tsx +5 -0
  91. package/src/components/ui/BoardSkeleton.tsx +47 -0
  92. package/src/components/ui/CardSection.tsx +117 -0
  93. package/src/components/ui/CodeViewer.tsx +2 -2
  94. package/src/components/ui/Collapse.tsx +59 -0
  95. package/src/components/ui/ConfirmDialog.tsx +7 -3
  96. package/src/components/ui/DialogPortal.tsx +31 -9
  97. package/src/components/ui/Input.tsx +24 -0
  98. package/src/components/ui/MultiSelectPicker.test.tsx +101 -0
  99. package/src/components/ui/MultiSelectPicker.tsx +167 -0
  100. package/src/components/ui/PaneLoader.tsx +10 -2
  101. package/src/components/ui/ResourceBar.tsx +68 -25
  102. package/src/components/ui/SearchBox.tsx +18 -4
  103. package/src/components/ui/SearchPillInput.tsx +2 -2
  104. package/src/components/ui/Toast.tsx +1 -1
  105. package/src/components/ui/drawer-components.tsx +39 -26
  106. package/src/components/ui/index.ts +6 -0
  107. package/src/components/ui/severity-tone.ts +63 -0
  108. package/src/components/workload/ResourceDetailDrawer.tsx +6 -2
  109. package/src/components/workload/WorkloadView.tsx +2682 -472
  110. package/src/components/workload/index.ts +1 -1
  111. package/src/theme/variables.css +15 -1
  112. package/src/types/core.ts +57 -4
  113. package/src/utils/api-resources.test.ts +54 -0
  114. package/src/utils/api-resources.ts +68 -1
  115. package/src/utils/application-topology.test.ts +198 -0
  116. package/src/utils/application-topology.ts +261 -0
  117. package/src/utils/applications.test.ts +167 -1
  118. package/src/utils/applications.ts +1157 -393
  119. package/src/utils/index.ts +2 -0
  120. package/src/utils/navigation.test.ts +32 -1
  121. package/src/utils/navigation.ts +70 -0
  122. package/src/utils/resource-hierarchy.test.ts +990 -1
  123. package/src/utils/resource-hierarchy.ts +905 -267
  124. package/src/utils/resource-icons.ts +2 -0
  125. package/src/utils/topology-neighborhood.test.ts +202 -1
  126. package/src/utils/topology-neighborhood.ts +194 -13
  127. package/src/utils/workflow-execution.test.ts +556 -0
  128. package/src/utils/workflow-execution.ts +814 -0
  129. package/src/utils/workload-colors.ts +2 -3
  130. package/tsconfig.json +2 -2
@@ -1,12 +1,17 @@
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
+ import { Tooltip } from '../ui/Tooltip';
4
5
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
5
6
  import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
7
+ import { issueTiming } from './issue-timing';
6
8
  import {
7
9
  ISSUE_SEVERITY_BADGE_CLASS,
10
+ ISSUE_SEVERITY_HEADER_BAND_CLASS,
8
11
  ISSUE_SEVERITY_LABEL,
9
12
  ISSUE_SEVERITY_RAIL_CLASS,
13
+ ISSUE_SEVERITY_SOLID_CLASS,
14
+ ISSUE_SEVERITY_TEXT_CLASS,
10
15
  categoryLabel,
11
16
  groupBadgeClass,
12
17
  groupLabel,
@@ -19,8 +24,25 @@ import {
19
24
  type Issue,
20
25
  type IssueAffected,
21
26
  type IssueResourceRef,
27
+ type IssueSeverity,
22
28
  } from './types';
23
29
 
30
+ // Leading severity glyph — the at-a-glance severity cue on every row: critical =
31
+ // octagon (stop), warning = triangle.
32
+ const ISSUE_SEVERITY_ICON: Record<IssueSeverity, ComponentType<{ className?: string }>> = {
33
+ critical: AlertOctagon,
34
+ warning: AlertTriangle,
35
+ };
36
+
37
+ // An out-of-contract severity the backend might emit is coerced to this tier
38
+ // once, up front (normalizeIssueSeverity), so the icon AND every color map
39
+ // (text/rail/band/pill) resolve together — a raw miss would crash the icon and
40
+ // silently drop the tint everywhere else.
41
+ const ISSUE_SEVERITY_FALLBACK: IssueSeverity = 'warning';
42
+ // Object.hasOwn (not `in`) so inherited keys like "toString" don't slip past.
43
+ const normalizeIssueSeverity = (s: IssueSeverity): IssueSeverity =>
44
+ Object.hasOwn(ISSUE_SEVERITY_ICON, s) ? s : ISSUE_SEVERITY_FALLBACK;
45
+
24
46
  export interface IssuesViewProps {
25
47
  /** Grouped live issues — one row per subject+category. Typically flattened
26
48
  * across the fleet by the host (the hub) or a single cluster (OSS). */
@@ -40,12 +62,15 @@ export interface IssuesViewProps {
40
62
  clusterLabel?: (issue: Issue) => string | undefined;
41
63
  /** Empty-state CTA shown when there's no data. */
42
64
  emptyAction?: ReactNode;
65
+ /** Per-row trailing action, rendered after the severity badge — e.g. the
66
+ * "Diagnose with AI" button in OSS. Omit to render no per-row action. */
67
+ renderActions?: (ctx: IssueRowSlotContext) => ReactNode;
43
68
  }
44
69
 
45
70
  // The queue list. Filtering/faceting is the host page's job (FleetPageShell on
46
71
  // the hub, a thin wrapper in OSS) — this renders the rows + the healthy /
47
72
  // no-data terminal states only.
48
- export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction }: IssuesViewProps) {
73
+ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clusterLabel, emptyAction, renderActions }: IssuesViewProps) {
49
74
  // Single-open accordion: opening a row collapses the previous one, so the
50
75
  // queue stays scannable and you never lose your place to a wall of expansions.
51
76
  const [openId, setOpenId] = useState<string | null>(null);
@@ -84,6 +109,7 @@ export function IssuesView({ issues, anyData, resourceHref, onResourceClick, clu
84
109
  onToggle={() => setOpenId((cur) => (cur === rowKey ? null : rowKey))}
85
110
  resourceHref={resourceHref}
86
111
  onResourceClick={onResourceClick}
112
+ renderActions={renderActions}
87
113
  />
88
114
  );
89
115
  })}
@@ -132,7 +158,10 @@ export function IssueRow({
132
158
  const { headline } = issueMessageParts(issue);
133
159
  const [renderDetails, setRenderDetails] = useState(open);
134
160
  const Container = as;
161
+ const severity = normalizeIssueSeverity(issue.severity);
162
+ const SeverityIcon = ISSUE_SEVERITY_ICON[severity];
135
163
  const slotCtx = { issue, open };
164
+ const timing = issueTiming(issue);
136
165
 
137
166
  useEffect(() => {
138
167
  if (open) {
@@ -148,15 +177,23 @@ export function IssueRow({
148
177
  return (
149
178
  <Container
150
179
  className={[
151
- 'overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm',
180
+ 'overflow-hidden rounded-xl border bg-theme-surface transition-[border-color,box-shadow] duration-200',
181
+ // The open card lifts via elevation — heavier shadow + a bright
182
+ // emphasis edge that clearly separates it from sibling cards. Severity
183
+ // stays rationed to the band + pill; separation is depth, not more color.
184
+ // ring-1 widens the edge to 2px without the layout shift a border-2
185
+ // swap would cause on expand.
186
+ open ? 'border-[var(--border-emphasis)] ring-1 ring-[var(--border-emphasis)] shadow-theme-md' : 'border-theme-border shadow-theme-sm',
152
187
  dimmed ? 'opacity-60' : '',
153
188
  className ?? '',
154
189
  ].filter(Boolean).join(' ')}
155
190
  style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 72px' }}
156
191
  >
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). */}
192
+ {/* The whole header is the single toggle target. The leading severity
193
+ icon is the at-a-glance cue; a trailing chevron shows open/closed.
194
+ Deep-links live in the expanded body (a link nested in a button would
195
+ be invalid). Collapsed: neutral row + rail. Expanded: severity-tinted
196
+ band + solid pill — the tint is a focus signal, not per-row alarm. */}
160
197
  <div
161
198
  role="button"
162
199
  tabIndex={0}
@@ -169,28 +206,32 @@ export function IssueRow({
169
206
  onToggle();
170
207
  }
171
208
  }}
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]}`}
209
+ 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
210
  >
174
- <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
211
+ <SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
175
212
 
176
213
  <div className="flex min-w-0 flex-1 flex-col gap-1">
177
214
  <div className="flex min-w-0 items-baseline gap-2">
178
215
  <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>
216
+ <span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
180
217
  {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. */}
218
+ {/* The detector reason rides the title row while COLLAPSED so the
219
+ key triage signal shows without expanding. When open, the full
220
+ cause lives in the WHAT'S WRONG section below, so it fades out
221
+ here (stays mounted — it's the flex-1 filler, so unmounting
222
+ wouldn't reflow anything, but fading avoids the pop). */}
185
223
  {issue.reason ? (
186
- <span className="min-w-0 flex-1 truncate text-xs text-theme-text-tertiary">
224
+ <span
225
+ aria-hidden={open || undefined}
226
+ className={`min-w-0 flex-1 truncate text-xs text-theme-text-tertiary transition-opacity duration-200 ${open ? 'opacity-0' : 'opacity-100'}`}
227
+ >
187
228
  <span className="font-medium text-theme-text-secondary">{issue.reason}</span>
188
229
  {headline ? <span> — {headline}</span> : null}
189
230
  </span>
190
231
  ) : null}
191
232
  </div>
192
233
  <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>
234
+ <span className={KIND_CHIP_CLASS}>{issue.kind}</span>
194
235
  <span className="min-w-0 truncate font-medium text-theme-text-secondary">
195
236
  {issue.namespace ? `${issue.namespace} / ` : ''}
196
237
  {issue.name}
@@ -214,37 +255,45 @@ export function IssueRow({
214
255
  <span aria-hidden>·</span>
215
256
  {/* Non-interactive signal (the header is the toggle — a nested
216
257
  button would be invalid); the clickable link lives in the body. */}
217
- <span className="min-w-0 truncate text-theme-text-tertiary" title={confidenceTitle(issue.incident_parent.confidence ?? '')}>
218
- ↳ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}{' '}
219
- <span className="font-medium text-theme-text-secondary">{issue.incident_parent.ref.kind} / {issue.incident_parent.ref.name}</span>
220
- </span>
258
+ <Tooltip content={confidenceTitle(issue.incident_parent.confidence ?? '')} delay={200} wrapperClassName="min-w-0">
259
+ <span className="truncate text-theme-text-tertiary">
260
+ ↳ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}{' '}
261
+ <span className="font-medium text-theme-text-secondary">{issue.incident_parent.ref.kind} / {issue.incident_parent.ref.name}</span>
262
+ </span>
263
+ </Tooltip>
221
264
  </>
222
265
  ) : null}
223
266
  {renderMeta?.(slotCtx)}
224
267
  </div>
225
268
  </div>
226
269
 
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)}
270
+ {/* Right cluster — severity pill THEN age, vertically centered as a group. */}
271
+ <div className="flex shrink-0 items-center gap-3">
272
+ <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]}`}>
273
+ {ISSUE_SEVERITY_LABEL[severity]}
274
+ </span>
275
+ {/* Age chip: keep recency visible; the deployment-start signal rides as a secondary tag. */}
276
+ {issue.first_seen ? (
277
+ <>
278
+ <Tooltip content={ageTitle(issue)} delay={200} wrapperClassName="shrink-0">
279
+ <time
280
+ dateTime={issue.first_seen}
281
+ className="flex items-center gap-1 text-xs tabular-nums text-theme-text-tertiary"
282
+ >
283
+ <Clock className="h-3 w-3" aria-hidden />
284
+ {formatCompactAge(issue.first_seen)}
285
+ </time>
286
+ </Tooltip>
287
+ {timing ? (
288
+ <Tooltip content={timing.tooltip} delay={200}>
289
+ <span className="badge-sm text-[10px] text-theme-text-secondary">{timing.chip}</span>
290
+ </Tooltip>
291
+ ) : null}
292
+ </>
293
+ ) : null}
294
+ {renderActions?.(slotCtx)}
295
+ <ChevronRight className={`h-4 w-4 shrink-0 text-theme-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`} />
296
+ </div>
248
297
  </div>
249
298
 
250
299
  {renderDetails ? (
@@ -257,17 +306,21 @@ export function IssueRow({
257
306
  }}
258
307
  >
259
308
  <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">
309
+ {/* Body sits on the card surface (not a recessed grey panel) so its
310
+ text keeps enough contrast. */}
311
+ <div className="border-t border-theme-border bg-theme-surface py-4 pl-6 pr-4">
312
+ <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
262
313
  <Diagnosis issue={issue} />
263
314
  {issue.incident_parent ? (
264
315
  <section className="flex flex-col gap-1">
265
316
  <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
266
317
  {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
267
318
  {issue.incident_parent.confidence ? (
268
- <span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary" title={confidenceTitle(issue.incident_parent.confidence)}>
269
- {issue.incident_parent.confidence} confidence
270
- </span>
319
+ <Tooltip content={confidenceTitle(issue.incident_parent.confidence)} delay={200}>
320
+ <span className="ml-2 badge-sm text-[10px] font-normal text-theme-text-tertiary">
321
+ {issue.incident_parent.confidence} confidence
322
+ </span>
323
+ </Tooltip>
271
324
  ) : null}
272
325
  </h4>
273
326
  <ul className="flex flex-col gap-px">
@@ -276,9 +329,7 @@ export function IssueRow({
276
329
  </section>
277
330
  ) : null}
278
331
  <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>
332
+ <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
282
333
  </div>
283
334
  </div>
284
335
  </div>
@@ -288,8 +339,9 @@ export function IssueRow({
288
339
  );
289
340
  }
290
341
 
291
- // What's-wrong block: the specific detector reason + message, plus pod crash
292
- // context when present (the "chronic vs acute" signal).
342
+ // Diagnosis: WHAT'S WRONG (amber) → NEXT STEP (emerald) → RAW ERROR (monochrome
343
+ // terminal block). Status/timing facts share one muted meta line under the
344
+ // diagnosis so the body reads as three beats rather than a stack of one-liners.
293
345
  function Diagnosis({ issue }: { issue: Issue }) {
294
346
  const crash =
295
347
  issue.restart_count || issue.last_terminated_reason
@@ -298,71 +350,84 @@ function Diagnosis({ issue }: { issue: Issue }) {
298
350
  .join(' · ')
299
351
  : null;
300
352
  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.
353
+ // Raw detector text → terminal block. Two independent sources: the explicit
354
+ // raw_message (or, for a derived cause, the original message it came from) and
355
+ // the precise kubelet/containerd `detail` a short image-pull headline replaced
356
+ // — both surface when distinct, so setting raw_message never hides the CRI
357
+ // detail. Each is kept only when it adds something the prose above doesn't
358
+ // already show, compared against what's actually rendered (the cause when
359
+ // present, else headline+detail) rather than a string that may never appear.
303
360
  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);
361
+ const shownText = issue.cause ?? visibleMessage;
362
+ const timing = issueTiming(issue);
363
+ const rawError = [
364
+ ...new Set(
365
+ [issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
366
+ (s): s is string => !!s && s !== shownText,
367
+ ),
368
+ ),
369
+ ].join('\n');
370
+
371
+ // One muted line — stuck/retry · crash · timing · change. A retrying-but-not-
372
+ // yet-stuck operation leads with "Retrying"; once it's declared stuck, "Stuck"
373
+ // takes over.
374
+ const meta: string[] = [];
375
+ if (issue.stuck) meta.push('Stuck');
376
+ else if (issue.operation_retry_count) meta.push('Retrying');
377
+ if (issue.operation_retry_count) meta.push(`retried ${issue.operation_retry_count}×`);
378
+ if (crash) meta.push(crash);
379
+ if (timing) {
380
+ meta.push(timing.meta);
381
+ } else if (issue.first_seen) {
382
+ meta.push(`started ${formatRelativeAgeTime(issue.first_seen)}`);
383
+ }
384
+ if (issue.first_seen) {
385
+ if (issue.last_seen && timing?.kind !== 'creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
386
+ }
387
+ if (issue.change_context) meta.push(changeContextText(issue.change_context));
388
+
389
+ const hasNextStep = !!issue.action || (issue.remediation_kind === 'create-namespace' && !!issue.remediation_target);
390
+
308
391
  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>
392
+ <div className="flex flex-col divide-y divide-theme-border/70 [&>*]:py-4 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0">
393
+ {/* When a parsed cause replaces the detector text, the terse reason code
394
+ (CrashLoopBackOff, FailedMount…) rides the eyebrow — it's the greppable
395
+ identifier operators anchor on, and the collapsed header drops it while
396
+ open. The non-cause branch already leads with it in the prose. */}
397
+ <CardSection
398
+ icon={AlertTriangle}
399
+ label="What's wrong"
400
+ tone="warn"
401
+ labelExtra={issue.cause && issue.reason ? `· ${issue.reason}` : undefined}
402
+ >
403
+ {issue.cause ? (
404
+ <p className="text-sm leading-relaxed text-theme-text-primary">{issue.cause}</p>
405
+ ) : (
406
+ <p className="text-sm leading-relaxed text-theme-text-primary">
407
+ <span className="font-medium">{issue.reason}</span>
408
+ {headline ? <span className="text-theme-text-secondary"> — {headline}</span> : null}
409
+ </p>
410
+ )}
411
+ {meta.length > 0 ? <p className="text-xs leading-relaxed text-theme-text-tertiary tabular-nums">{meta.join(' · ')}</p> : null}
412
+ </CardSection>
413
+
414
+ {hasNextStep ? (
415
+ <CardSection icon={ArrowRight} label="Next step" tone="fix">
416
+ {issue.action ? <CardBody>{issue.action}</CardBody> : null}
417
+ {issue.remediation_kind === 'create-namespace' && issue.remediation_target ? (
418
+ <p className="text-xs text-theme-text-tertiary">
419
+ Suggested fix: create namespace <code className="inline-code">{issue.remediation_target}</code> — apply it from the GitOps detail page.
420
+ </p>
421
+ ) : null}
422
+ </CardSection>
352
423
  ) : 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>
424
+
425
+ {rawError ? (
426
+ <CardSection icon={Terminal} label="Raw error">
427
+ <TerminalBlock>{rawError}</TerminalBlock>
428
+ </CardSection>
364
429
  ) : null}
365
- </section>
430
+ </div>
366
431
  );
367
432
  }
368
433
 
@@ -387,23 +452,22 @@ function DiagnosticContext({
387
452
  if (!ctx || facts.length === 0) return null;
388
453
 
389
454
  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>
455
+ <CardSection
456
+ icon={Workflow}
457
+ label="Context"
458
+ labelExtra={ctx.role ? diagnosticRoleLabel(ctx.role) : undefined}
459
+ >
395
460
  <ul className="flex flex-col gap-2">
396
461
  {facts.map((fact, idx) => (
397
462
  <li key={`${fact.type}-${idx}`} className="flex flex-col gap-1.5 rounded-md border border-theme-border/70 px-2.5 py-2">
398
463
  <div className="flex min-w-0 items-baseline gap-2">
399
464
  <span className="shrink-0 text-xs font-medium text-theme-text-secondary">{diagnosticFactLabel(fact.type)}</span>
400
465
  {fact.confidence ? (
401
- <span
402
- className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary"
403
- title={confidenceTitle(fact.confidence)}
404
- >
405
- {fact.confidence} confidence
406
- </span>
466
+ <Tooltip content={confidenceTitle(fact.confidence)} delay={200}>
467
+ <span className="shrink-0 badge-sm text-[10px] text-theme-text-tertiary">
468
+ {fact.confidence} confidence
469
+ </span>
470
+ </Tooltip>
407
471
  ) : null}
408
472
  {fact.message ? <span className="min-w-0 break-words text-xs leading-relaxed text-theme-text-tertiary">{fact.message}</span> : null}
409
473
  </div>
@@ -438,7 +502,7 @@ function DiagnosticContext({
438
502
  </li>
439
503
  ))}
440
504
  </ul>
441
- </section>
505
+ </CardSection>
442
506
  );
443
507
  }
444
508
 
@@ -446,11 +510,8 @@ function DiagnosticContext({
446
510
  // freshness, the two facts the compact "2h" hides.
447
511
  function ageTitle(issue: Issue): string {
448
512
  const parts: string[] = [];
449
- if (issue.issue_timing === 'started_at_resource_creation') {
450
- parts.push('Present since deployment — treat as baseline');
451
- } else if (issue.issue_timing === 'started_after_resource_was_healthy') {
452
- parts.push('Resource was previously healthy');
453
- }
513
+ const timing = issueTiming(issue);
514
+ if (timing) parts.push(timing.tooltip);
454
515
  if (issue.first_seen) parts.push(`First seen ${new Date(issue.first_seen).toLocaleString()}`);
455
516
  if (issue.last_seen) parts.push(`Last seen ${formatRelativeAgeTime(issue.last_seen)}`);
456
517
  return parts.join('\n');
@@ -472,18 +533,15 @@ function AffectedResources({
472
533
  // grouping.go); fall back to the inline member count, not +1.
473
534
  const total = issue.count ?? members.length;
474
535
  return (
475
- <section className="flex flex-col gap-1.5">
536
+ <section className="flex flex-col gap-3">
476
537
  {/* The subject (the grouped thing — e.g. the Deployment) is always the
477
538
  first deep-link; members (the folded pods) follow. ResourceLine emits
478
539
  an <li>, so it needs a list parent of its own. */}
479
540
  <ul className="flex flex-col gap-px">
480
- <ResourceLine label="Subject" refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
541
+ <ResourceLine label="Subject" compact refForLink={subjectRef(issue)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
481
542
  </ul>
482
543
  {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>
544
+ <CardSection icon={Layers} label="Affected resources" labelExtra={`· ${total}`}>
487
545
  <ul className="flex flex-col gap-px">
488
546
  {members.map((m, i) => (
489
547
  <ResourceLine
@@ -496,11 +554,11 @@ function AffectedResources({
496
554
  ))}
497
555
  </ul>
498
556
  {issue.members_truncated && (
499
- <p className="mt-0.5 text-xs text-theme-text-tertiary">
557
+ <p className="text-xs text-theme-text-tertiary">
500
558
  Showing {members.length} of {total} — open the subject to see the rest.
501
559
  </p>
502
560
  )}
503
- </>
561
+ </CardSection>
504
562
  )}
505
563
  </section>
506
564
  );
@@ -513,6 +571,7 @@ function ResourceLine({
513
571
  resourceHref,
514
572
  onResourceClick,
515
573
  ResourceLinkIcon,
574
+ compact,
516
575
  }: {
517
576
  label?: string;
518
577
  refForLink: IssueResourceRef;
@@ -520,24 +579,41 @@ function ResourceLine({
520
579
  resourceHref?: (ref: IssueResourceRef) => string;
521
580
  onResourceClick?: (ref: IssueResourceRef) => void;
522
581
  ResourceLinkIcon: ComponentType<{ className?: string }>;
582
+ // Footer variant (the Subject row): renders the kind as a grey mono chip and
583
+ // a slightly bolder name, distinguishing the subject from the affected-members
584
+ // list below it.
585
+ compact?: boolean;
523
586
  }) {
524
587
  const r = refForLink;
525
588
  const linkable = !!(onResourceClick || resourceHref);
526
589
  const body = (
527
590
  <>
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'}`}>
591
+ {label ? <span className="shrink-0 text-[11px] font-semibold uppercase tracking-[0.06em] text-theme-text-tertiary">{label}</span> : null}
592
+ {/* Footer (compact): the kind is a grey mono chip; member rows keep plain mono. */}
593
+ {compact ? (
594
+ <span className={KIND_CHIP_CLASS}>{r.kind}</span>
595
+ ) : (
596
+ <span className="shrink-0 font-mono text-[11px] uppercase tracking-wide text-theme-text-tertiary">{r.kind}</span>
597
+ )}
598
+ <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
599
  {r.namespace ? `${r.namespace} / ` : ''}
532
600
  {r.name}
533
601
  </span>
534
602
  {count && count > 1 ? (
535
- <span className="shrink-0 text-[10px] text-theme-text-tertiary tabular-nums" title={`${count} affected resources grouped under this issue`}>{count} affected</span>
603
+ <Tooltip content={`${count} affected resources grouped under this issue`} delay={200}>
604
+ <span className="shrink-0 text-[10px] text-theme-text-tertiary tabular-nums">{count} affected</span>
605
+ </Tooltip>
536
606
  ) : null}
537
607
  {linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
538
608
  </>
539
609
  );
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';
610
+ // compact = Subject row; default = the affected-members list. Both get the
611
+ // padded, rounded hover hit-box so the Subject reads as clickable like the
612
+ // members below it. items-baseline so the label + kind chip sit on the same
613
+ // baseline as the larger resource name.
614
+ const cls = compact
615
+ ? 'group/r flex w-full items-baseline gap-2 rounded-md px-2 py-1 text-left transition-colors hover:bg-theme-hover/60'
616
+ : '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
617
  return (
542
618
  <li>
543
619
  {onResourceClick ? (
@@ -549,7 +625,7 @@ function ResourceLine({
549
625
  {body}
550
626
  </a>
551
627
  ) : (
552
- <span className="flex items-center gap-2 rounded-md px-2 py-1 text-sm">{body}</span>
628
+ <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
629
  )}
554
630
  </li>
555
631
  );
@@ -0,0 +1,40 @@
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('uses the Issues row treatment and defaults collapsed', () => {
32
+ const html = renderToString(<ResourceIssuesSection issues={[issue]} />)
33
+
34
+ expect(html).toContain('Operational issues')
35
+ expect(html).toContain('StorageClassMissing')
36
+ expect(html).not.toContain('What&#x27;s wrong')
37
+ expect(html).not.toContain('Context')
38
+ expect(html).not.toContain('Blocked pods')
39
+ })
40
+ })