@skyhook-io/k8s-ui 1.8.7 → 1.8.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 (36) hide show
  1. package/package.json +3 -3
  2. package/src/components/applications/ApplicationsList.tsx +5 -2
  3. package/src/components/applications/ApplicationsView.tsx +4 -1
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +27 -9
  5. package/src/components/gitops/GitOpsTableView.tsx +46 -45
  6. package/src/components/gitops/insights/GitOpsInsightViews.tsx +12 -5
  7. package/src/components/issues/IssuesView.tsx +41 -5
  8. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  9. package/src/components/issues/diagnostic.ts +22 -0
  10. package/src/components/issues/index.ts +1 -1
  11. package/src/components/issues/issues.test.ts +21 -0
  12. package/src/components/issues/types.ts +18 -0
  13. package/src/components/namespace-switcher/NamespacePicker.tsx +381 -0
  14. package/src/components/namespace-switcher/index.ts +6 -0
  15. package/src/components/resources/ResourcesView.tsx +20 -81
  16. package/src/components/scope-pill/ScopePill.tsx +35 -0
  17. package/src/components/scope-pill/index.ts +2 -0
  18. package/src/components/timeline/TimelineList.tsx +27 -1
  19. package/src/components/topology/TopologyControls.tsx +90 -14
  20. package/src/components/ui/FreshnessControl.tsx +153 -0
  21. package/src/components/ui/SortableTh.tsx +16 -10
  22. package/src/components/ui/Toast.tsx +1 -1
  23. package/src/components/ui/index.ts +2 -0
  24. package/src/components/workload/ResourceDetailDrawer.tsx +215 -32
  25. package/src/components/workload/WorkloadView.tsx +26 -8
  26. package/src/hooks/index.ts +1 -0
  27. package/src/hooks/useKeyboardShortcuts.tsx +23 -2
  28. package/src/hooks/useRefreshAnimation.ts +15 -2
  29. package/src/index.ts +8 -0
  30. package/src/types/core.ts +42 -0
  31. package/src/types/gitops-insights.ts +4 -0
  32. package/src/utils/animation.ts +10 -0
  33. package/src/utils/format-freshness.test.ts +34 -0
  34. package/src/utils/format.ts +32 -0
  35. package/src/utils/resource-hierarchy.test.ts +51 -0
  36. package/src/utils/resource-hierarchy.ts +7 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.8.7",
3
+ "version": "1.8.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -85,7 +85,7 @@
85
85
  "yaml": ">=2.0.0"
86
86
  },
87
87
  "devDependencies": {
88
- "@types/node": "^26.0.0",
88
+ "@types/node": "^26.0.1",
89
89
  "@types/react": "^19.2.17",
90
90
  "@types/react-dom": "^19.2.3",
91
91
  "@xterm/addon-fit": "^0.11.0",
@@ -95,7 +95,7 @@
95
95
  "clsx": "^2.1.1",
96
96
  "diff": "^9.0.0",
97
97
  "elkjs": "^0.11.1",
98
- "lucide-react": "^1.16.0",
98
+ "lucide-react": "^1.22.0",
99
99
  "react": "^19.2.7",
100
100
  "react-dom": "^19.2.7",
101
101
  "typescript": "^6.0.2",
@@ -1,4 +1,4 @@
1
- import { useMemo } from 'react'
1
+ import { useMemo, type ReactNode } from 'react'
2
2
  import { type AppRow, buildSingleAppEntry } from '../../utils/applications'
3
3
  import { ApplicationsView } from './ApplicationsView'
4
4
 
@@ -11,9 +11,11 @@ import { ApplicationsView } from './ApplicationsView'
11
11
  export interface ApplicationsListProps {
12
12
  apps: AppRow[]
13
13
  onSelect: (key: string) => void
14
+ /** Leading element in the header actions (e.g. a freshness control). */
15
+ headerActions?: ReactNode
14
16
  }
15
17
 
16
- export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
18
+ export function ApplicationsList({ apps, onSelect, headerActions }: ApplicationsListProps) {
17
19
  // Env tokens this CLUSTER proved (identity classifications on the wire) feed
18
20
  // the namespace heuristic, so sibling-less rows in discovered env namespaces
19
21
  // still label without any hardcoded vocabulary.
@@ -25,6 +27,7 @@ export function ApplicationsList({ apps, onSelect }: ApplicationsListProps) {
25
27
  variant="single"
26
28
  entries={entries}
27
29
  onSelect={onSelect}
30
+ headerActions={headerActions}
28
31
  title="Applications"
29
32
  description="Deployable software in this cluster — your services, workers, and jobs, grouped by app/release evidence."
30
33
  />
@@ -97,9 +97,11 @@ export interface ApplicationsViewProps {
97
97
  /** Rendered instead of the built-in EmptyState when there are zero entries
98
98
  * pre-filter (the fleet host injects a coverage/offline-aware empty). */
99
99
  emptySlot?: ReactNode
100
+ /** Leading element in the header actions cluster (e.g. a freshness control). */
101
+ headerActions?: ReactNode
100
102
  }
101
103
 
102
- export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot }: ApplicationsViewProps) {
104
+ export function ApplicationsView({ entries: allEntries, variant, onSelect, title = 'Applications', description, emptySlot, headerActions }: ApplicationsViewProps) {
103
105
  const [textFilter, setTextFilter] = useState('')
104
106
  const [fHealth, setFHealth] = useState<Set<AppHealth>>(new Set())
105
107
  const [fEnv, setFEnv] = useState<Set<string>>(new Set())
@@ -294,6 +296,7 @@ export function ApplicationsView({ entries: allEntries, variant, onSelect, title
294
296
  description={description}
295
297
  actions={
296
298
  <>
299
+ {headerActions}
297
300
  <SummaryTile label={total === 1 ? 'application' : 'applications'} value={total} />
298
301
  {healthTile('unhealthy', 'error')}
299
302
  {healthTile('degraded', 'warning')}
@@ -58,6 +58,15 @@ export interface ClusterSwitcherProps {
58
58
  errorSlot?: ReactNode
59
59
  className?: string
60
60
  align?: 'left' | 'right'
61
+ /**
62
+ * 'chip' (default) renders a self-contained bordered pill. 'segment' renders
63
+ * a borderless label+value cell for embedding in a shared bordered container
64
+ * (the unified cluster+namespace scope control) — no border/background/min-w
65
+ * of its own, with an optional muted {@link label} before the value.
66
+ */
67
+ variant?: 'chip' | 'segment'
68
+ /** Muted label shown before the value in the 'segment' variant (e.g. "Cluster"). */
69
+ label?: string
61
70
  }
62
71
 
63
72
  // Trigger width cap. With middle-truncation kicking in, this is a
@@ -83,6 +92,8 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
83
92
  errorSlot,
84
93
  className = '',
85
94
  align = 'left',
95
+ variant = 'chip',
96
+ label,
86
97
  }, ref) => {
87
98
  const [isOpen, setIsOpen] = useState(false)
88
99
  const [search, setSearch] = useState('')
@@ -195,14 +206,21 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
195
206
  type="button"
196
207
  onClick={() => setIsOpen(v => !v)}
197
208
  disabled={disabled || loading}
198
- className={`
199
- flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
200
- bg-theme-elevated border border-theme-border rounded text-sm font-medium
201
- text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
202
- transition-colors cursor-pointer
203
- disabled:opacity-50 disabled:cursor-not-allowed
204
- `}
209
+ className={
210
+ variant === 'segment'
211
+ ? `flex items-center gap-1.5 px-3 py-1.5 h-full min-w-[150px] max-w-[264px] text-[13px] font-medium
212
+ text-theme-text-primary hover:bg-theme-hover transition-colors cursor-pointer
213
+ disabled:opacity-50 disabled:cursor-not-allowed`
214
+ : `flex items-center gap-1.5 px-2.5 py-1.5 min-w-[140px]
215
+ bg-theme-elevated border border-theme-border rounded text-sm font-medium
216
+ text-theme-text-primary hover:bg-theme-hover hover:border-theme-border-light
217
+ transition-colors cursor-pointer
218
+ disabled:opacity-50 disabled:cursor-not-allowed`
219
+ }
205
220
  >
221
+ {label && (
222
+ <span className="shrink-0 font-normal text-theme-text-tertiary">{label}</span>
223
+ )}
206
224
  {loading ? (
207
225
  <>
208
226
  <Loader2 className="w-3.5 h-3.5 animate-spin" />
@@ -220,7 +238,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
220
238
  <ClusterName
221
239
  name={currentName}
222
240
  fallbackBadge={<Server className="w-3.5 h-3.5 text-theme-text-secondary" />}
223
- className={TRIGGER_NAME_MAX_WIDTH}
241
+ className={variant === 'segment' ? 'min-w-0 max-w-[214px]' : TRIGGER_NAME_MAX_WIDTH}
224
242
  noTooltip={isOpen}
225
243
  />
226
244
  {currentSourceLabel && (
@@ -240,7 +258,7 @@ export const ClusterSwitcher = forwardRef<ClusterSwitcherHandle, ClusterSwitcher
240
258
  )}
241
259
  </>
242
260
  )}
243
- <ChevronDown className={`w-3 h-3 ml-auto transition-transform ${isOpen ? 'rotate-180' : ''}`} />
261
+ <ChevronDown className={`w-3 h-3 shrink-0 transition-transform ${variant === 'segment' ? '' : 'ml-auto'} ${isOpen ? 'rotate-180' : ''}`} />
244
262
  </button>
245
263
 
246
264
  {isOpen && (
@@ -33,7 +33,6 @@ import { SortableTh, TH_CLASS, type SortDir } from '../ui/SortableTh'
33
33
  import { DistributionBar } from '../ui/DistributionBar'
34
34
  import { RowActionMenu, type RowActionItem } from '../ui/RowActionMenu'
35
35
  import { PaneLoader } from '../ui/PaneLoader'
36
- import { useRefreshAnimation } from '../../hooks/useRefreshAnimation'
37
36
  import { getGitOpsResourceStatus } from './detail-helpers'
38
37
  import { isArgoSuspendedByRadar } from '../resources/resource-utils-argo'
39
38
  import { toggleSet } from './GitOpsGraphFilterRail'
@@ -192,8 +191,13 @@ export interface GitOpsTableViewProps {
192
191
  // the Scope-section mode tabs and the empty-state check.
193
192
  counts: Record<string, number>
194
193
  countsUnavailable?: string[]
195
- // Caller refresh typically invalidates its useQuery + refetches.
194
+ // @deprecated Superseded by `freshnessSlot` kept for source compatibility
195
+ // with existing consumers; no longer drives any affordance here.
196
196
  onRefresh?: () => void
197
+ // Host-injected freshness/liveness control (e.g. a <FreshnessControl>),
198
+ // rendered leading the header actions. The host owns the mode + data, so this
199
+ // shared table makes no assumption about whether the view auto-updates.
200
+ freshnessSlot?: ReactNode
197
201
  // Row click — caller routes to its own detail page. When the host also
198
202
  // passes `rowHrefFor`, the callback receives the MouseEvent so it can
199
203
  // `preventDefault()` for same-tree nav (e.g. react-router) or skip the
@@ -275,7 +279,7 @@ export function GitOpsTableView({
275
279
  error,
276
280
  counts,
277
281
  countsUnavailable,
278
- onRefresh,
282
+ freshnessSlot,
279
283
  onRowClick,
280
284
  rowHrefFor,
281
285
  onDestinationClick,
@@ -317,15 +321,19 @@ export function GitOpsTableView({
317
321
  })
318
322
  }, [])
319
323
  const [lifecycleFilter, setLifecycleFilter] = useState<'all' | 'terminating' | 'active'>('all')
320
- const [sort, setSort] = useState<{ key: SortKey; dir: SortDir }>({ key: 'urgency', dir: 'asc' })
321
- // Shared refresh feedback (spin ≥400mscheckmark) so clicking Refresh gives
322
- // the same visual confirmation as every other view, even when the refetch is
323
- // instant (the cache is already warm).
324
- const [triggerRefresh, , refreshPhase] = useRefreshAnimation(onRefresh ?? (() => {}))
325
- // Clicking a column sorts by it (starting at the column's natural direction —
326
- // e.g. last-sync newest-first); clicking the active column reverses.
324
+ const [sort, setSort] = useState<{ key: SortKey; dir: SortDir } | null>({ key: 'urgency', dir: 'asc' })
325
+ // 3-state cycle: natural direction reversed off. The first click uses each
326
+ // column's natural direction (SORT_DEFAULT_DIR e.g. Last Sync is newest-first)
327
+ // so the header cycle agrees with the tile-mode sort menu, which seeds the same
328
+ // default. "Off" (null) falls back to the urgency/health-worst-first ordering.
327
329
  const onSort = useCallback(
328
- (key: SortKey) => setSort((prev) => (prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: SORT_DEFAULT_DIR[key] })),
330
+ (key: SortKey) =>
331
+ setSort((prev) => {
332
+ const natural = SORT_DEFAULT_DIR[key]
333
+ if (!prev || prev.key !== key) return { key, dir: natural }
334
+ if (prev.dir === natural) return { key, dir: natural === 'asc' ? 'desc' : 'asc' }
335
+ return null
336
+ }),
329
337
  [],
330
338
  )
331
339
 
@@ -453,7 +461,8 @@ export function GitOpsTableView({
453
461
  }
454
462
  return true
455
463
  })
456
- return [...rows].sort((a, b) => compareRows(a, b, sort.key) * (sort.dir === 'asc' ? 1 : -1))
464
+ const eff = sort ?? { key: 'urgency' as SortKey, dir: 'asc' as SortDir }
465
+ return [...rows].sort((a, b) => compareRows(a, b, eff.key) * (eff.dir === 'asc' ? 1 : -1))
457
466
  }, [allRows, automationFilters, healthFilters, labelFilters, lifecycleFilter, mode, namespaceFilters, projectFilters, search, sort, syncFilters, destinationFilter])
458
467
 
459
468
  const terminatingCount = useMemo(() => allRows.filter((row) => row.terminating).length, [allRows])
@@ -596,19 +605,24 @@ export function GitOpsTableView({
596
605
  icon={GitBranch}
597
606
  title="GitOps"
598
607
  description="Applications and reconciliations with source, destination, sync, and health state."
599
- actions={summaryTiles.map((tile) => (
600
- <SummaryTile
601
- key={tile.key}
602
- label={tile.label}
603
- value={tile.value}
604
- tone={tile.tone}
605
- active={tile.active}
606
- onClick={() => {
607
- if (tile.active) tile.clear?.()
608
- else tile.apply?.()
609
- }}
610
- />
611
- ))}
608
+ actions={
609
+ <>
610
+ {freshnessSlot}
611
+ {summaryTiles.map((tile) => (
612
+ <SummaryTile
613
+ key={tile.key}
614
+ label={tile.label}
615
+ value={tile.value}
616
+ tone={tile.tone}
617
+ active={tile.active}
618
+ onClick={() => {
619
+ if (tile.active) tile.clear?.()
620
+ else tile.apply?.()
621
+ }}
622
+ />
623
+ ))}
624
+ </>
625
+ }
612
626
  />
613
627
  </div>
614
628
  <div
@@ -678,7 +692,7 @@ export function GitOpsTableView({
678
692
  pattern); tile mode has no headers, so it keeps a compact sort
679
693
  control wired to the same sort state. */}
680
694
  {viewMode === 'tiles' && (
681
- <GitOpsSortMenu sortKey={sort.key} onChange={(k) => setSort({ key: k, dir: SORT_DEFAULT_DIR[k] })} />
695
+ <GitOpsSortMenu sortKey={sort?.key ?? 'urgency'} onChange={(k) => setSort({ key: k, dir: SORT_DEFAULT_DIR[k] })} />
682
696
  )}
683
697
  {labels.length > 0 && (
684
698
  <LabelsDropdown
@@ -727,19 +741,6 @@ export function GitOpsTableView({
727
741
  <GitOpsIconToggle active={viewMode === 'table'} label="Table view" icon={List} onClick={() => setViewMode('table')} />
728
742
  <GitOpsIconToggle active={viewMode === 'tiles'} label="Tiles view" icon={LayoutGrid} onClick={() => setViewMode('tiles')} />
729
743
  </div>
730
- {onRefresh && (
731
- <Tooltip content="Refresh GitOps resources">
732
- <button
733
- type="button"
734
- onClick={triggerRefresh}
735
- className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-theme-border bg-theme-base text-theme-text-secondary hover:bg-theme-hover hover:text-theme-text-primary"
736
- >
737
- {refreshPhase === 'success'
738
- ? <Check className="h-3.5 w-3.5 text-emerald-500" />
739
- : <RefreshCw className={clsx('h-3.5 w-3.5', (refreshPhase === 'spinning' || loading) && 'animate-spin')} />}
740
- </button>
741
- </Tooltip>
742
- )}
743
744
  </div>
744
745
  </div>
745
746
 
@@ -1187,7 +1188,7 @@ function GitOpsTable({
1187
1188
  pendingRowActions,
1188
1189
  }: {
1189
1190
  rows: GitOpsRow[]
1190
- sort: { key: SortKey; dir: SortDir }
1191
+ sort: { key: SortKey; dir: SortDir } | null
1191
1192
  onSort: (key: SortKey) => void
1192
1193
  onOpen: (row: GitOpsRow, event?: ReactMouseEvent) => void
1193
1194
  hrefFor?: (row: GitOpsRow) => string
@@ -1202,13 +1203,13 @@ function GitOpsTable({
1202
1203
  <table className="w-full min-w-[1040px] table-fixed border-separate border-spacing-0 text-sm">
1203
1204
  <thead className="sticky top-0 z-10 bg-theme-base">
1204
1205
  <tr>
1205
- <SortableTh label="Application" sortKey="name" activeKey={sort.key} direction={sort.dir} onSort={onSort} className={showActions ? 'w-[16%]' : 'w-[22%]'} />
1206
- <SortableTh label="Project" sortKey="project" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1207
- <SortableTh label="Sync" sortKey="sync" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1208
- <SortableTh label="Health" sortKey="health" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[9%]" />
1206
+ <SortableTh label="Application" sortKey="name" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className={showActions ? 'w-[16%]' : 'w-[22%]'} />
1207
+ <SortableTh label="Project" sortKey="project" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
1208
+ <SortableTh label="Sync" sortKey="sync" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
1209
+ <SortableTh label="Health" sortKey="health" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[9%]" />
1209
1210
  <th className={clsx(TH_CLASS, showDestination ? 'w-[20%]' : 'w-[28%]')}>Source</th>
1210
1211
  {showDestination && <th className={clsx(TH_CLASS, 'w-[14%]')}>Destination</th>}
1211
- <SortableTh label="Last Sync" sortKey="lastSync" activeKey={sort.key} direction={sort.dir} onSort={onSort} className="w-[10%]" />
1212
+ <SortableTh label="Last Sync" sortKey="lastSync" activeKey={sort?.key ?? null} direction={sort?.dir ?? 'asc'} onSort={onSort} className="w-[10%]" />
1212
1213
  {showActions && (
1213
1214
  <th className={clsx(TH_CLASS, 'w-[6%] text-right')}>
1214
1215
  <span className="sr-only">Actions</span>
@@ -56,6 +56,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
56
56
  const operationFailure = (insight.issues ?? []).find(
57
57
  (i) => i.severity === 'critical' && i.scope === 'operation' && i.stuck,
58
58
  )
59
+ const operationTooltipMessage = summary.rawOperationMessage || summary.operationMessage
59
60
  return (
60
61
  <div className="border-b border-theme-border bg-theme-base px-4 py-2">
61
62
  <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
@@ -87,7 +88,7 @@ export function GitOpsStatusStrip({ insight, loading }: GitOpsStatusStripProps)
87
88
  (parsed cause, retry count, raw message) so the strip stays a
88
89
  calm orientation row instead of duplicating the error three times. */}
89
90
  {operation && summary.operationMessage && isInFlightPhase(operation) && (
90
- <Tooltip content={summary.operationMessage} delay={400} wrapperClassName="min-w-0 max-w-[60ch]">
91
+ <Tooltip content={operationTooltipMessage} delay={400} wrapperClassName="min-w-0 max-w-[60ch]">
91
92
  <span className="block truncate text-[11px] text-theme-text-secondary">
92
93
  {summary.operationMessage}
93
94
  </span>
@@ -424,6 +425,8 @@ function GitOpsFailureCard({
424
425
  const [showRaw, setShowRaw] = useState(false)
425
426
  const stuck = !!issue.stuck
426
427
  const ref = issue.refs?.[0]
428
+ const rawControllerMessage = issue.rawMessage || issue.message
429
+ const rawControllerLabel = issue.rawMessage ? 'raw controller error' : 'controller message'
427
430
  // Title prioritizes the parsed cause's first sentence. Without parsing we
428
431
  // get the bare phase ("Failed") which alone tells the user nothing — fall
429
432
  // back to the first sentence of the raw message in that case so something
@@ -486,12 +489,12 @@ function GitOpsFailureCard({
486
489
  className="inline-flex items-center gap-1 text-[11px] text-theme-text-tertiary transition-colors hover:text-theme-text-secondary"
487
490
  >
488
491
  {showRaw ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
489
- {showRaw ? 'Hide raw controller error' : 'Show raw controller error'}
492
+ {showRaw ? `Hide ${rawControllerLabel}` : `Show ${rawControllerLabel}`}
490
493
  </button>
491
494
  </div>
492
495
  {showRaw && (
493
496
  <pre className="mt-2 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded border border-theme-border bg-theme-base px-3 py-2 font-mono text-[11px] text-theme-text-secondary">
494
- {issue.message}
497
+ {rawControllerMessage}
495
498
  </pre>
496
499
  )}
497
500
  </div>
@@ -609,6 +612,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
609
612
  const t = severityTone(issue.severity)
610
613
  const ref = issue.refs?.[0]
611
614
  const actionable = !!(onSelectIssue && ref)
615
+ const rawMessage = issue.rawMessage && issue.rawMessage !== issue.message ? issue.rawMessage : ''
612
616
  return (
613
617
  <button
614
618
  key={`${issue.reason}-${index}`}
@@ -628,6 +632,7 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
628
632
  </div>
629
633
  <p className="mt-0.5 text-theme-text-secondary">{issue.message}</p>
630
634
  {issue.cause && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.cause}</p>}
635
+ {rawMessage && <p className="mt-0.5 break-words font-mono text-[11px] text-theme-text-tertiary">{rawMessage}</p>}
631
636
  {issue.action && <p className="mt-0.5 text-[11px] text-theme-text-tertiary">{issue.action}</p>}
632
637
  </div>
633
638
  {actionable && ref && (
@@ -1112,7 +1117,7 @@ function ChangeRow({
1112
1117
  live health message — operators chasing a broken sync want
1113
1118
  the failure reason on the same row, not in a drawer. */}
1114
1119
  {change.syncError && (
1115
- <Tooltip content={change.syncError} delay={400} wrapperClassName="ml-[18px] mt-1 block max-w-full">
1120
+ <Tooltip content={change.rawSyncError || change.syncError} delay={400} wrapperClassName="ml-[18px] mt-1 block max-w-full">
1116
1121
  <span className="line-clamp-3 text-xs text-red-600 dark:text-red-400">{change.syncError}</span>
1117
1122
  </Tooltip>
1118
1123
  )}
@@ -1386,7 +1391,9 @@ function HistoryRows({
1386
1391
  </Tooltip>
1387
1392
  )}
1388
1393
  {item.message && (
1389
- <div className={clsx('mt-0.5 line-clamp-2 text-[11px]', sourceDisplay ? 'text-theme-text-tertiary' : 'text-theme-text-secondary')}>{item.message}</div>
1394
+ <Tooltip content={item.rawMessage || item.message} delay={400} wrapperClassName="mt-0.5 block max-w-full">
1395
+ <div className={clsx('line-clamp-2 text-[11px]', sourceDisplay ? 'text-theme-text-tertiary' : 'text-theme-text-secondary')}>{item.message}</div>
1396
+ </Tooltip>
1390
1397
  )}
1391
1398
  </div>
1392
1399
  </li>
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from
2
2
  import { ChevronRight, CircleCheck, Clock, ExternalLink } from 'lucide-react';
3
3
  import { ClusterName, EmptyState } from '../ui';
4
4
  import { formatCompactAge, formatRelativeAgeTime } from '../../utils/format';
5
- import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle } from './diagnostic';
5
+ import { diagnosticRoleLabel, diagnosticFactLabel, confidenceTitle, incidentParentLabel } from './diagnostic';
6
6
  import {
7
7
  ISSUE_SEVERITY_BADGE_CLASS,
8
8
  ISSUE_SEVERITY_LABEL,
@@ -209,6 +209,17 @@ export function IssueRow({
209
209
  <span className="shrink-0 tabular-nums">{affected}</span>
210
210
  </>
211
211
  ) : null}
212
+ {issue.incident_parent ? (
213
+ <>
214
+ <span aria-hidden>·</span>
215
+ {/* Non-interactive signal (the header is the toggle — a nested
216
+ 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>
221
+ </>
222
+ ) : null}
212
223
  {renderMeta?.(slotCtx)}
213
224
  </div>
214
225
  </div>
@@ -249,6 +260,21 @@ export function IssueRow({
249
260
  <div className="border-t border-theme-border bg-theme-base/40 px-4 py-4 pl-11">
250
261
  <div className="flex flex-col gap-4">
251
262
  <Diagnosis issue={issue} />
263
+ {issue.incident_parent ? (
264
+ <section className="flex flex-col gap-1">
265
+ <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
266
+ {incidentParentLabel(issue.incident_parent.fact_type, issue.incident_parent.confidence)}
267
+ {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>
271
+ ) : null}
272
+ </h4>
273
+ <ul className="flex flex-col gap-px">
274
+ <ResourceLine refForLink={memberRef(issue, issue.incident_parent.ref)} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
275
+ </ul>
276
+ </section>
277
+ ) : null}
252
278
  <DiagnosticContext issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
253
279
  <div className="border-t border-theme-border/70 pt-3">
254
280
  <AffectedResources issue={issue} resourceHref={resourceHref} onResourceClick={onResourceClick} ResourceLinkIcon={ResourceLinkIcon} />
@@ -274,7 +300,11 @@ function Diagnosis({ issue }: { issue: Issue }) {
274
300
  const { headline, detail } = issueMessageParts(issue);
275
301
  // When the issue carries a parsed plain-English cause, lead with it. The raw
276
302
  // detector message is kept below as de-emphasized detail.
277
- const rawMessage = issue.cause ? issue.message ?? '' : [headline, detail].filter(Boolean).join(' ');
303
+ 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);
278
308
  return (
279
309
  <section className="flex flex-col gap-1">
280
310
  <h4 className="text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What's wrong</h4>
@@ -309,9 +339,9 @@ function Diagnosis({ issue }: { issue: Issue }) {
309
339
  {issue.operation_retry_count ? ` · retried ${issue.operation_retry_count}×` : ''}
310
340
  </p>
311
341
  ) : null}
312
- {/* Raw detector message, de-emphasized shown below the parsed cause so
313
- the precise error (URLs, resource names) is available without leading. */}
314
- {issue.cause && rawMessage ? (
342
+ {/* Raw detector message, de-emphasized so precise controller/kubelet text
343
+ remains available without leading the diagnosis. */}
344
+ {shouldShowRawMessage ? (
315
345
  <p className="break-words font-mono text-[11px] leading-relaxed text-theme-text-tertiary">{rawMessage}</p>
316
346
  ) : null}
317
347
  {crash ? <p className="text-xs text-theme-text-tertiary tabular-nums">{crash}</p> : null}
@@ -384,6 +414,7 @@ function DiagnosticContext({
384
414
  key={`${related.ref.group ?? ''}/${related.ref.kind}/${related.ref.namespace ?? ''}/${related.ref.name}#${relIdx}`}
385
415
  label="Related"
386
416
  refForLink={memberRef(issue, related.ref)}
417
+ count={related.count}
387
418
  resourceHref={resourceHref}
388
419
  onResourceClick={onResourceClick}
389
420
  ResourceLinkIcon={ResourceLinkIcon}
@@ -478,12 +509,14 @@ function AffectedResources({
478
509
  function ResourceLine({
479
510
  label,
480
511
  refForLink,
512
+ count,
481
513
  resourceHref,
482
514
  onResourceClick,
483
515
  ResourceLinkIcon,
484
516
  }: {
485
517
  label?: string;
486
518
  refForLink: IssueResourceRef;
519
+ count?: number;
487
520
  resourceHref?: (ref: IssueResourceRef) => string;
488
521
  onResourceClick?: (ref: IssueResourceRef) => void;
489
522
  ResourceLinkIcon: ComponentType<{ className?: string }>;
@@ -498,6 +531,9 @@ function ResourceLine({
498
531
  {r.namespace ? `${r.namespace} / ` : ''}
499
532
  {r.name}
500
533
  </span>
534
+ {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>
536
+ ) : null}
501
537
  {linkable && <ResourceLinkIcon className="h-3 w-3 shrink-0 text-theme-text-tertiary opacity-0 transition-opacity group-hover/r:opacity-100" />}
502
538
  </>
503
539
  );
@@ -113,6 +113,9 @@ function CausalContext({ issue, onResourceClick }: { issue: Issue; onResourceCli
113
113
  {rel.ref.namespace ? `${rel.ref.namespace} / ` : ''}
114
114
  {rel.ref.name}
115
115
  </span>
116
+ {rel.count && rel.count > 1 ? (
117
+ <span className="ml-1 tabular-nums" title={`${rel.count} affected resources grouped under this issue`}>· {rel.count} affected</span>
118
+ ) : null}
116
119
  </>
117
120
  )
118
121
  return (
@@ -42,11 +42,33 @@ export function diagnosticFactLabel(type: string): string {
42
42
  return 'Affected workloads';
43
43
  case 'pvc_blast_radius':
44
44
  return 'Blocked pods';
45
+ case 'apiservice_hpa':
46
+ return 'Stalled autoscalers';
47
+ case 'secret_not_ready':
48
+ return 'Dependent pods';
45
49
  default:
46
50
  return type.replace(/_/g, ' ');
47
51
  }
48
52
  }
49
53
 
54
+ // Operator-facing lead-in for the symptom→root pointer chip. Honest per fact
55
+ // type: a declared PVC/Secret edge is a cause; a co-located node is only
56
+ // "related" (node pressure can be a shared victim, not the root), so it must not
57
+ // claim "caused by".
58
+ export function incidentParentLabel(factType?: string, confidence?: string): string {
59
+ switch (factType) {
60
+ case 'pvc_blast_radius':
61
+ case 'secret_not_ready':
62
+ return 'Caused by';
63
+ case 'apiservice_hpa':
64
+ return 'Likely cause';
65
+ case 'node_blast_radius':
66
+ return 'Related';
67
+ default:
68
+ return confidence === 'high' ? 'Caused by' : 'Possible cause';
69
+ }
70
+ }
71
+
50
72
  // Plain-language gloss for the confidence chip's tooltip — the operator should
51
73
  // know a medium link is "these are co-located, the node may be the cause", not a
52
74
  // proven fact.
@@ -12,7 +12,7 @@ export {
12
12
  subjectRef,
13
13
  memberRef,
14
14
  } from './types';
15
- export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
15
+ export type { Issue, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
16
16
  export {
17
17
  ISSUE_SEVERITY_LABEL,
18
18
  ISSUE_SEVERITY_BADGE_CLASS,
@@ -1,6 +1,9 @@
1
1
  import { describe, it, expect } from 'vitest'
2
+ import { createElement } from 'react'
3
+ import { renderToString } from 'react-dom/server'
2
4
  import { compareIssues, subjectRef, memberRef, normalizeImagePullMessage, issueMessageParts, type Issue } from './types'
3
5
  import { categoryLabel, groupLabel, groupBadgeClass } from './severity'
6
+ import { IssueRow } from './IssuesView'
4
7
 
5
8
  const base: Issue = {
6
9
  id: 'id-0',
@@ -105,3 +108,21 @@ describe('image-pull message normalization', () => {
105
108
  expect(parts.detail).toBe('')
106
109
  })
107
110
  })
111
+
112
+ describe('IssueRow diagnosis raw messages', () => {
113
+ it('shows raw_message when cleaned issue copy has no parsed cause', () => {
114
+ const issue = mk({
115
+ category: 'gitops_operation_failed',
116
+ category_group: 'configuration',
117
+ severity: 'critical',
118
+ reason: 'OperationFailed',
119
+ message: 'app path does not exist',
120
+ raw_message: 'rpc error: code = Unknown desc = app path does not exist',
121
+ })
122
+
123
+ const html = renderToString(createElement(IssueRow, { issue, open: true, onToggle: () => undefined, as: 'div' }))
124
+
125
+ expect(html).toContain('app path does not exist')
126
+ expect(html).toContain('rpc error: code = Unknown desc = app path does not exist')
127
+ })
128
+ })
@@ -71,10 +71,26 @@ export interface IssueDiagnosticIssueRef {
71
71
  reason?: string;
72
72
  category?: string;
73
73
  severity?: IssueSeverity;
74
+ /** How many affected resources fold into this linked issue from the root's
75
+ * perspective (e.g. 5 of a PVC's mounting pods under one Deployment issue).
76
+ * Absent when the link covers a single resource. */
77
+ count?: number;
74
78
  }
75
79
 
76
80
  export type IssueDiagnosticConfidence = 'high' | 'medium' | 'low';
77
81
 
82
+ /** Reverse pointer from a symptom issue to the root issue that explains it
83
+ * (the inverse of diagnostic_context's root→symptom facts). Set only when a
84
+ * single root is unambiguous. `ref` is the parent subject for display + deep
85
+ * navigation (thread the issue's cluster_id onto it via memberRef). */
86
+ export interface IssueIncidentParent {
87
+ id: string;
88
+ ref: IssueResourceRef;
89
+ category?: string;
90
+ confidence?: IssueDiagnosticConfidence;
91
+ fact_type?: string;
92
+ }
93
+
78
94
  export interface IssueDiagnosticFact {
79
95
  type: string;
80
96
  message?: string;
@@ -158,6 +174,7 @@ export interface Issue {
158
174
 
159
175
  reason: string;
160
176
  message?: string;
177
+ raw_message?: string;
161
178
  /** Parsed domain diagnosis: plain-English cause, suggested next step, and
162
179
  * an optional structured one-click fix.
163
180
  * Server-emitted (omitempty); empty for issues without a parser. */
@@ -182,6 +199,7 @@ export interface Issue {
182
199
  members?: IssueResourceRef[];
183
200
  members_truncated?: boolean;
184
201
  diagnostic_context?: IssueDiagnosticContext;
202
+ incident_parent?: IssueIncidentParent;
185
203
  change_context?: IssueChangeContext;
186
204
 
187
205
  // Pod crash context carried from the representative member.