@skyhook-io/k8s-ui 1.17.1 → 1.18.1

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 (107) hide show
  1. package/README.md +13 -0
  2. package/package.json +4 -4
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  7. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  8. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  9. package/src/components/gitops/detail-helpers.test.ts +58 -13
  10. package/src/components/gitops/detail-helpers.ts +37 -6
  11. package/src/components/gitops/index.ts +1 -0
  12. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  14. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  15. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  16. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  17. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  18. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  19. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  20. package/src/components/issues/IssuesView.tsx +18 -10
  21. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  22. package/src/components/issues/diagnostic.ts +2 -0
  23. package/src/components/issues/index.ts +1 -1
  24. package/src/components/issues/issues.test.ts +26 -0
  25. package/src/components/issues/severity.ts +9 -2
  26. package/src/components/issues/types.ts +32 -4
  27. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  28. package/src/components/logs/LogCore.tsx +159 -40
  29. package/src/components/logs/LogsViewer.test.tsx +183 -60
  30. package/src/components/logs/LogsViewer.tsx +49 -12
  31. package/src/components/logs/StructuredLogLine.tsx +1 -1
  32. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  33. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  34. package/src/components/logs/useLogBuffer.ts +48 -18
  35. package/src/components/logs/useLogSearch.ts +3 -1
  36. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  37. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  38. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  39. package/src/components/resources/ColumnPickerList.tsx +174 -0
  40. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  41. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  42. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  43. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  44. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  45. package/src/components/resources/ResourcesView.tsx +198 -93
  46. package/src/components/resources/column-order.test.ts +104 -0
  47. package/src/components/resources/column-order.ts +64 -0
  48. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  49. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  50. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  51. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  52. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  53. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  54. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  55. package/src/components/resources/renderers/PodRenderer.tsx +72 -20
  56. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  57. package/src/components/resources/resource-utils-trivy.ts +122 -0
  58. package/src/components/resources/resource-utils.ts +15 -2
  59. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  60. package/src/components/shared/DetailShell.tsx +9 -7
  61. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  62. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  63. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  64. package/src/components/shared/ManagedByChip.tsx +24 -4
  65. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  66. package/src/components/shared/ResourceActionsBar.tsx +12 -6
  67. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  68. package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
  69. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  70. package/src/components/timeline/DiffViewer.tsx +113 -4
  71. package/src/components/timeline/TimelineList.tsx +5 -1
  72. package/src/components/timeline/shared.test.tsx +48 -0
  73. package/src/components/timeline/shared.tsx +14 -19
  74. package/src/components/ui/FetchResult.test.tsx +19 -2
  75. package/src/components/ui/FetchResult.tsx +13 -1
  76. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  77. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  78. package/src/components/ui/PageHeader.tsx +6 -4
  79. package/src/components/ui/Toast.tsx +48 -30
  80. package/src/components/ui/drawer-components.test.tsx +253 -0
  81. package/src/components/ui/drawer-components.tsx +315 -11
  82. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  83. package/src/components/ui/index.ts +1 -1
  84. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  85. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  86. package/src/components/workload/WorkloadView.tsx +68 -14
  87. package/src/hooks/index.ts +2 -0
  88. package/src/hooks/useMediaQuery.ts +21 -0
  89. package/src/hooks/usePhoneLayout.ts +12 -0
  90. package/src/theme/tailwind-theme.css +5 -0
  91. package/src/types/core.ts +91 -3
  92. package/src/types/gitops-flux-inventory.test.ts +24 -0
  93. package/src/types/gitops-insights.ts +1 -0
  94. package/src/types/gitops-tree.ts +2 -0
  95. package/src/types/gitops.ts +5 -5
  96. package/src/types/index.ts +3 -0
  97. package/src/types/read-coverage.ts +65 -0
  98. package/src/types/resource-coverage.ts +40 -0
  99. package/src/utils/format.test.ts +7 -0
  100. package/src/utils/format.ts +6 -1
  101. package/src/utils/gitops-owner.test.ts +42 -1
  102. package/src/utils/gitops-owner.ts +22 -0
  103. package/src/utils/index.ts +2 -0
  104. package/src/utils/read-actions.test.ts +22 -0
  105. package/src/utils/read-actions.ts +56 -0
  106. package/src/utils/resource-coverage.test.ts +121 -0
  107. package/src/utils/resource-coverage.ts +117 -0
@@ -23,7 +23,7 @@ import {
23
23
  healthStatusRank,
24
24
  isArgoResourceSyncEligible,
25
25
  messageToPhase,
26
- normalizeHealthStatus,
26
+ missingResourceMessage,
27
27
  normalizeSyncStatus,
28
28
  resourceStatusCounts,
29
29
  syncStatusRank,
@@ -237,7 +237,8 @@ function buildHealthSummary(allChanges: GitOpsChange[]): { text: string; tone: s
237
237
  // Count HEALTH, not sync — a Synced resource can still be Degraded, so check
238
238
  // degraded/missing FIRST and never let a Synced sync-state mask it. A Synced
239
239
  // resource with no assessed health (ConfigMaps etc.) still reads as fine.
240
- if (c.health === 'Degraded' || c.category === 'Degraded') degraded++
240
+ if (c.missing) missing++
241
+ else if (c.health === 'Degraded' || c.category === 'Degraded') degraded++
241
242
  else if (c.health === 'Missing' || c.category === 'Missing') missing++
242
243
  else if (c.health === 'Healthy' || c.category === 'Synced') healthy++
243
244
  else if (c.category === 'OutOfSync') outOfSync++
@@ -1096,9 +1097,11 @@ export function GitOpsChangesView({ insight, error, onOpenResource, onSyncResour
1096
1097
  const waveSet = !!planItem?.waveSet
1097
1098
  const rowKey = refKey(change.ref)
1098
1099
  const focused = focusKey === rowKey
1099
- const explanation = !change.syncError && !change.message
1100
- ? explainChangeStatus(change.sync, change.health, insight.summary)
1101
- : ''
1100
+ const explanation = change.missing
1101
+ ? missingResourceMessage(change.sync)
1102
+ : !change.syncError && !change.message
1103
+ ? explainChangeStatus(change.sync, change.health, insight.summary)
1104
+ : ''
1102
1105
  const hasInlineDetail = !!(
1103
1106
  (change.drift && change.drift.entries.length > 0) ||
1104
1107
  (change.recentEvents && change.recentEvents.length > 0)
@@ -1245,7 +1248,8 @@ function buildTreeExtras(nodes: GitOpsTreeNode[], declared: GitOpsChange[]): Git
1245
1248
  // had a controller verdict to be distinguished from.
1246
1249
  message: n.healthMessage,
1247
1250
  hasDesired: false,
1248
- hasLive: true,
1251
+ hasLive: !n.missing,
1252
+ missing: n.missing,
1249
1253
  partial: true,
1250
1254
  partialNote: 'Generated resource — not directly tracked by the GitOps controller (no drift / event diagnostics).',
1251
1255
  })
@@ -1365,8 +1369,10 @@ function ChangeRow({
1365
1369
  // differently: hide the Open pill, suppress the drawer-open click path,
1366
1370
  // and offer "View in Git →" instead so the operator can read the
1367
1371
  // declared source instead of a non-existent live object.
1368
- const isAbsent = change.health === 'Missing' && !change.hasLive
1369
- const radarNote = radarHealthNote({ health: change.health, healthSource: change.healthSource, healthMessage: change.message })
1372
+ const isAbsent = change.missing || (change.health === 'Missing' && !change.hasLive)
1373
+ const radarNote = change.missing
1374
+ ? 'Radar confirmed that this resource is not present in the cluster.'
1375
+ : radarHealthNote({ health: change.health, healthSource: change.healthSource, healthMessage: change.message })
1370
1376
  const handleRowClick = () => {
1371
1377
  if (expandable) {
1372
1378
  setExpanded((v) => !v)
@@ -1463,7 +1469,7 @@ function ChangeRow({
1463
1469
  </Tooltip>
1464
1470
  )}
1465
1471
  {change.message && !change.syncError && <div className="ml-[18px] mt-1 line-clamp-2 text-xs text-theme-text-secondary">{change.message}</div>}
1466
- {!change.syncError && !change.message && explanation && (
1472
+ {!change.syncError && (change.missing || !change.message) && explanation && (
1467
1473
  <div className="ml-[18px] mt-1 text-xs text-theme-text-tertiary">{explanation}</div>
1468
1474
  )}
1469
1475
  </button>
@@ -1477,7 +1483,7 @@ function ChangeRow({
1477
1483
  )}
1478
1484
  </div>
1479
1485
  <div className="flex flex-col items-start gap-1 self-start">
1480
- <HealthStatusBadge health={normalizeHealthStatus(change.health)} />
1486
+ <HealthStatusBadge health={changeHealth(change)} />
1481
1487
  {radarNote && (
1482
1488
  <Tooltip content={radarNote} delay={200} wrapperClassName="inline-flex">
1483
1489
  <span className="cursor-help select-none rounded border border-theme-border bg-theme-elevated/70 px-1.5 py-0.5 text-[10px] leading-3 text-theme-text-tertiary">
@@ -3,6 +3,7 @@ import { SEVERITY_DOT } from '../../../utils/badge-colors'
3
3
  import type { GitOpsChange } from '../../../types'
4
4
  import {
5
5
  changeMatchesFacets,
6
+ changeHealth,
6
7
  changeMatchesSearch,
7
8
  compactSource,
8
9
  entryTone,
@@ -10,6 +11,7 @@ import {
10
11
  healthStatusRank,
11
12
  isArgoResourceSyncEligible,
12
13
  messageToPhase,
14
+ missingResourceMessage,
13
15
  phaseToTone,
14
16
  resourceStatusCounts,
15
17
  syncStatusRank,
@@ -19,6 +21,22 @@ function change(partial: Partial<GitOpsChange> & { ref: GitOpsChange['ref'] }):
19
21
  return { category: 'Unknown', hasDesired: true, hasLive: true, partial: false, ...partial }
20
22
  }
21
23
 
24
+ describe('confirmed missing resources', () => {
25
+ it('counts, filters and enables sync independently of stale health and sync', () => {
26
+ const item = change({ ref: { kind: 'Secret', name: 'values' }, missing: true, hasLive: false, health: 'Healthy', sync: 'Synced' })
27
+ expect(changeHealth(item)).toBe('Missing')
28
+ expect(resourceStatusCounts([item]).missing).toBe(1)
29
+ expect(changeMatchesFacets(item, new Set(['missing']))).toBe(true)
30
+ expect(isArgoResourceSyncEligible(item, true)).toBe(true)
31
+ })
32
+
33
+ it('asks for a refresh when Argo still reports Synced', () => {
34
+ expect(missingResourceMessage('Synced')).toContain('Refresh')
35
+ expect(missingResourceMessage('Synced')).not.toContain('Sync the application')
36
+ expect(missingResourceMessage('OutOfSync')).toContain('Sync the application')
37
+ })
38
+ })
39
+
22
40
  describe('gitopsToSeverity', () => {
23
41
  it.each([
24
42
  ['critical', 'error'],
@@ -43,9 +43,16 @@ export function changeSync(change: GitOpsChange): SyncStatus {
43
43
  }
44
44
 
45
45
  export function changeHealth(change: GitOpsChange): GitOpsHealthStatus {
46
+ if (change.missing) return 'Missing'
46
47
  return normalizeHealthStatus(change.health)
47
48
  }
48
49
 
50
+ export function missingResourceMessage(sync?: string): string {
51
+ return sync === 'Synced'
52
+ ? 'Argo reports this resource as synced, but it is not present in the cluster. Refresh the application to check its status.'
53
+ : 'Not present in the cluster. Sync the application to create it.'
54
+ }
55
+
49
56
  export function isArgoResourceSyncEligible(change: GitOpsChange, declared: boolean, hook?: string): boolean {
50
57
  if (!declared || hook || change.hookPhase) return false
51
58
  return changeSync(change) === 'OutOfSync' || changeHealth(change) === 'Missing' || change.category === 'Missing'
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { getSubtitle, nodeCause } from './GitOpsTreeGraph'
4
- import type { GitOpsTreeNode } from '../../../types'
3
+ import { buildCauseByRef, getSubtitle, nodeCause } from './GitOpsTreeGraph'
4
+ import type { GitOpsIssue, GitOpsTreeNode } from '../../../types'
5
5
 
6
6
  function node(extras: Partial<GitOpsTreeNode> = {}): GitOpsTreeNode {
7
7
  return {
@@ -14,6 +14,9 @@ function node(extras: Partial<GitOpsTreeNode> = {}): GitOpsTreeNode {
14
14
  }
15
15
 
16
16
  describe('getSubtitle', () => {
17
+ it('shows confirmed absence even when controller health is unknown or stale', () => {
18
+ expect(getSubtitle(node({ tool: 'argocd', missing: true, sync: 'OutOfSync', health: 'Healthy' }))).toBe('OutOfSync • Not found in cluster')
19
+ })
17
20
  it('shows what a healthy resource exposes, not that it is healthy', () => {
18
21
  // The status dot and the left border stripe already paint a healthy node
19
22
  // green, so the word adds nothing and the ports are the only thing here the
@@ -95,23 +98,84 @@ describe('nodeCause', () => {
95
98
  // Shapes as the tree API sends them: the Go side maps Argo's Degraded and
96
99
  // Missing to topologyStatus 'unhealthy', Progressing and Suspended to
97
100
  // 'degraded' (pkg/gitops/tree healthToTopology).
98
- const CAUSE = 'Back-off restarting failed container main'
101
+ const FAILURE = { text: 'Back-off restarting failed container main', lead: false }
102
+ const LEAD = { text: 'Elasticsearch cluster health degraded', lead: true }
99
103
 
100
104
  it('shows the cause on a node the controller calls Degraded', () => {
101
- expect(nodeCause(node({ health: 'Degraded', healthSource: 'controller', topologyStatus: 'unhealthy' }), CAUSE)).toBe(CAUSE)
105
+ expect(nodeCause(node({ health: 'Degraded', healthSource: 'controller', topologyStatus: 'unhealthy' }), FAILURE)).toBe(FAILURE.text)
102
106
  })
103
107
 
104
- it('hides it on a Progressing node, even when an issue matched it', () => {
108
+ it('hides a failure verdict on a Progressing node, even when an issue matched it', () => {
105
109
  // A rollout in flight is not broken; a cause tooltip on it says it is.
106
- expect(nodeCause(node({ health: 'Progressing', healthSource: 'controller', topologyStatus: 'degraded' }), CAUSE)).toBeUndefined()
107
- expect(nodeCause(node({ health: 'Progressing', healthSource: 'radar', topologyStatus: 'degraded' }), CAUSE)).toBeUndefined()
110
+ expect(nodeCause(node({ health: 'Progressing', healthSource: 'controller', topologyStatus: 'degraded' }), FAILURE)).toBeUndefined()
111
+ expect(nodeCause(node({ health: 'Progressing', healthSource: 'radar', topologyStatus: 'degraded' }), FAILURE)).toBeUndefined()
108
112
  })
109
113
 
110
114
  it('hides it on a Suspended node, even when an issue matched it', () => {
111
- expect(nodeCause(node({ health: 'Suspended', healthSource: 'controller', topologyStatus: 'degraded' }), CAUSE)).toBeUndefined()
115
+ expect(nodeCause(node({ health: 'Suspended', healthSource: 'controller', topologyStatus: 'degraded' }), FAILURE)).toBeUndefined()
116
+ expect(nodeCause(node({ health: 'Suspended', healthSource: 'controller', topologyStatus: 'degraded' }), LEAD)).toBeUndefined()
112
117
  })
113
118
 
114
119
  it('never shows it on a healthy node', () => {
115
- expect(nodeCause(node({ health: 'Healthy', healthSource: 'controller', topologyStatus: 'healthy' }), CAUSE)).toBeUndefined()
120
+ expect(nodeCause(node({ health: 'Healthy', healthSource: 'controller', topologyStatus: 'healthy' }), FAILURE)).toBeUndefined()
121
+ expect(nodeCause(node({ health: 'Healthy', healthSource: 'radar', topologyStatus: 'healthy' }), LEAD)).toBeUndefined()
122
+ })
123
+
124
+ it('shows a lead on a node that is not failing, framed as possible', () => {
125
+ // Argo CD 3 does not persist per-resource health, so an Argo-Degraded app
126
+ // can have every node read Progressing or unknown by Radar. The events
127
+ // lead is then the only pointer, and it lands on one of those nodes.
128
+ expect(nodeCause(node({ health: 'Progressing', healthSource: 'radar', topologyStatus: 'degraded' }), LEAD))
129
+ .toBe('Possible cause: Elasticsearch cluster health degraded')
130
+ expect(nodeCause(node({ ref: { group: 'elasticsearch.k8s.elastic.co', kind: 'Elasticsearch', name: 'logs', namespace: 'search' }, topologyStatus: 'unknown' }), LEAD))
131
+ .toBe('Possible cause: Elasticsearch cluster health degraded')
132
+ })
133
+
134
+ it('shows a Flux source fetch error on the source while Flux retries it', () => {
135
+ const source = node({
136
+ ref: { group: 'source.toolkit.fluxcd.io', kind: 'GitRepository', name: 'podinfo', namespace: 'flux-system' },
137
+ health: 'Progressing',
138
+ topologyStatus: 'degraded',
139
+ })
140
+ // Keyed like the tree's own nodes, so the issue's ref finds this node.
141
+ const cause = buildCauseByRef([{
142
+ severity: 'warning',
143
+ scope: 'tree',
144
+ reason: 'SourceNotReady',
145
+ message: 'GitRepository flux-system/podinfo can\'t fetch updates.',
146
+ cause: 'authentication required',
147
+ refs: [{ group: 'source.toolkit.fluxcd.io', kind: 'GitRepository', namespace: 'flux-system', name: 'podinfo' }],
148
+ }]).get('source.toolkit.fluxcd.io/GitRepository/flux-system/podinfo')
149
+ expect(nodeCause(source, cause)).toBe('Possible cause: authentication required')
150
+ })
151
+
152
+ it('shows a lead as is on a failing node', () => {
153
+ expect(nodeCause(node({ health: 'Degraded', topologyStatus: 'unhealthy' }), LEAD)).toBe(LEAD.text)
154
+ })
155
+ })
156
+
157
+ describe('buildCauseByRef', () => {
158
+ const ref = { group: 'apps', kind: 'Deployment', namespace: 'demo', name: 'web' }
159
+ function issue(extras: Partial<GitOpsIssue>): GitOpsIssue {
160
+ return { severity: 'critical', scope: 'resource', reason: 'Degraded', message: 'Deployment web is Degraded', cause: 'crashloop', refs: [ref], ...extras }
161
+ }
162
+
163
+ it('marks the Warning-event lead and the Flux blockers as leads, and nothing else', () => {
164
+ const lead = (reason: string) => [...buildCauseByRef([issue({ reason })]).values()][0]?.lead
165
+ expect(lead('PossibleCause')).toBe(true)
166
+ expect(lead('SourceNotReady')).toBe(true)
167
+ expect(lead('ArtifactFailed')).toBe(true)
168
+ expect(lead('DependencyNotReady')).toBe(true)
169
+ expect(lead('Degraded')).toBe(false)
170
+ expect(lead('Failed')).toBe(false)
171
+ })
172
+
173
+ it('keeps the first, most severe cause for a node', () => {
174
+ const map = buildCauseByRef([issue({ cause: 'crashloop' }), issue({ severity: 'warning', reason: 'PossibleCause', cause: 'probe failed' })])
175
+ expect([...map.values()]).toEqual([{ text: 'crashloop', lead: false }])
176
+ })
177
+
178
+ it('skips issues without a cause or a ref', () => {
179
+ expect(buildCauseByRef([issue({ cause: undefined }), issue({ refs: [] })]).size).toBe(0)
116
180
  })
117
181
  })
@@ -28,7 +28,7 @@ import { radarHealthNote } from '../health-provenance'
28
28
  import { servicePortsTooltip, type ServicePortEntry } from '../../topology/K8sResourceNode'
29
29
  import { getTopologyIcon } from '../../../utils/resource-icons'
30
30
  import { Tooltip } from '../../ui/Tooltip'
31
- import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
31
+ import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, treeNodeHealth, type GitOpsTreeFilters } from './tree-helpers'
32
32
  import { rankTree, renderedEdge, shortcutEdges, upstreamClosure, upstreamEdges } from './layout'
33
33
 
34
34
  export type GitOpsTreePreset = 'full' | 'compact' | 'workloads' | 'app'
@@ -124,19 +124,7 @@ function GitOpsTreeGraphInner({
124
124
  useEffect(() => {
125
125
  if (preset !== 'compact') setExpandedGroups(new Set())
126
126
  }, [preset])
127
- const causeByRef = useMemo(() => {
128
- const map = new Map<string, string>()
129
- for (const issue of issues ?? []) {
130
- const cause = issue.cause
131
- const ref = issue.refs?.[0]
132
- if (!cause || !ref) continue
133
- const key = refKey(ref)
134
- // First match wins — issues arrive ranked by severity, so a node
135
- // that's the subject of multiple issues keeps its most severe cause.
136
- if (!map.has(key)) map.set(key, cause)
137
- }
138
- return map
139
- }, [issues])
127
+ const causeByRef = useMemo(() => buildCauseByRef(issues), [issues])
140
128
  const { nodes, edges } = useMemo(() => buildFlowGraph(tree, preset, query, filters, expandedGroups, causeByRef), [tree, preset, query, filters, expandedGroups, causeByRef])
141
129
 
142
130
  useEffect(() => {
@@ -330,7 +318,7 @@ function getEdgeColor(type: string): string {
330
318
  }
331
319
  }
332
320
 
333
- function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>, causeByRef?: Map<string, string>): { nodes: Node[]; edges: Edge[] } {
321
+ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>, causeByRef?: Map<string, NodeCause>): { nodes: Node[]; edges: Edge[] } {
334
322
  if (!tree) return { nodes: [], edges: [] }
335
323
  const visibleTree = applyGraphFilters(applyPreset(tree, preset, expandedGroups), filters)
336
324
  const byID = new Map(visibleTree.nodes.map(node => [node.id, node]))
@@ -550,17 +538,15 @@ function positionRanks(ranks: Map<number, string[]>, nodes: Map<string, GitOpsTr
550
538
  return positioned
551
539
  }
552
540
 
553
- const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean; cause?: string }>>) {
541
+ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean; cause?: NodeCause }>>) {
554
542
  const node = data.node
555
543
  const kind = normalizeDisplayKind(node)
556
- const status = normalizeHealth(node.topologyStatus)
544
+ const status = node.missing ? 'unhealthy' : normalizeHealth(node.topologyStatus)
557
545
  const terminating = isNodeTerminating(node)
558
546
  const gitopsTool = gitopsToolForNode(node)
559
547
  const chips = buildChips(node)
560
548
  const dim = getNodeDimensions(node)
561
549
  const KindIcon = getTopologyIcon(kind)
562
- // Only unhealthy nodes carry a cause worth surfacing — a healthy node
563
- // matched by a stale/unrelated issue ref shouldn't show a tooltip.
564
550
  const cause = nodeCause(node, data.cause)
565
551
  // Only declared resources can carry the controller's verdict, so only
566
552
  // there does "this one is Radar's" mean anything. Generated children
@@ -741,17 +727,47 @@ function buildChips(node: GitOpsTreeNode): Array<{ label?: string; value: string
741
727
  const relationship = stringData(data.relationship)
742
728
  if (relationship) chips.push({ value: relationship })
743
729
  if (node.sync === 'OutOfSync') chips.push({ value: 'OutOfSync', tone: 'warning' })
744
- if (node.health === 'Degraded' || node.health === 'Missing') chips.push({ value: node.health, tone: 'danger' })
730
+ const health = treeNodeHealth(node)
731
+ if (health === 'Degraded' || health === 'Missing') chips.push({ value: health, tone: 'danger' })
745
732
  return chips
746
733
  }
747
734
 
748
- // nodeCause returns the matched issue's cause when the node's hover should
749
- // show it: only on a node that is actually failing (Degraded or Missing,
750
- // topology 'unhealthy'). 'degraded' is also where Progressing and Suspended
751
- // land, and a cause there reads as "this is broken" on a rollout in flight
752
- // or a deliberate pause.
753
- export function nodeCause(node: GitOpsTreeNode, cause?: string): string | undefined {
754
- return normalizeHealth(node.topologyStatus) === 'unhealthy' ? cause : undefined
735
+ export interface NodeCause {
736
+ text: string
737
+ // An issue that names a resource as a lead without saying it failed: Argo's
738
+ // Warning-event lead (Go: degradedResourceFromEvents) and the Flux
739
+ // source/dependency blockers (Go: attachFluxBlocker, fluxSourceWarnings).
740
+ lead: boolean
741
+ }
742
+
743
+ const LEAD_REASONS = new Set(['PossibleCause', 'SourceNotReady', 'ArtifactFailed', 'DependencyNotReady'])
744
+
745
+ export function buildCauseByRef(issues?: GitOpsIssue[]): Map<string, NodeCause> {
746
+ const map = new Map<string, NodeCause>()
747
+ for (const issue of issues ?? []) {
748
+ const cause = issue.cause
749
+ const ref = issue.refs?.[0]
750
+ if (!cause || !ref) continue
751
+ const key = refKey(ref)
752
+ // First match wins — issues arrive ranked by severity, so a node
753
+ // that's the subject of multiple issues keeps its most severe cause.
754
+ if (!map.has(key)) map.set(key, { text: cause, lead: LEAD_REASONS.has(issue.reason) })
755
+ }
756
+ return map
757
+ }
758
+
759
+ // A failing node (topology 'unhealthy') shows any cause as is. A failure
760
+ // verdict on a Progressing node would read as "this is broken" on a rollout
761
+ // in flight, so elsewhere only a lead shows, framed as possible. That is where
762
+ // leads land: the Warning-event lead is only emitted when no resource reads
763
+ // as failing, and a Flux source mid-retry reads Progressing while its fetch
764
+ // error is still the answer. Healthy and Suspended nodes show nothing.
765
+ export function nodeCause(node: GitOpsTreeNode, cause?: NodeCause): string | undefined {
766
+ if (!cause) return undefined
767
+ const status = normalizeHealth(node.topologyStatus)
768
+ if (status === 'unhealthy') return cause.text
769
+ if (!cause.lead || status === 'healthy' || node.health === 'Suspended') return undefined
770
+ return `Possible cause: ${cause.text}`
755
771
  }
756
772
 
757
773
  // The subtitle is the node's one line of prose, and sync, health and the
@@ -774,6 +790,7 @@ export function getSubtitle(node: GitOpsTreeNode): string {
774
790
  // a state description, not an affordance.
775
791
  return 'Click to expand'
776
792
  }
793
+ if (node.missing) return `${node.sync ? `${node.sync} • ` : ''}Not found in cluster`
777
794
  if (isNodeTerminating(node)) {
778
795
  return 'Pending deletion'
779
796
  }
@@ -810,7 +827,7 @@ function matchesQuery(node: GitOpsTreeNode, query: string): boolean {
810
827
  node.ref.namespace,
811
828
  node.ref.group,
812
829
  node.sync,
813
- node.health,
830
+ treeNodeHealth(node),
814
831
  ].some(value => String(value ?? '').toLowerCase().includes(query))
815
832
  }
816
833
 
@@ -6,6 +6,12 @@ import type { GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
6
6
  const node = (group: string): GitOpsTreeNode => ({ id: group, ref: { kind: 'Service', group, name: 'same', namespace: 'team' }, role: 'generated', tool: 'fluxcd' })
7
7
 
8
8
  describe('GitOps kind filter contract', () => {
9
+ it('uses confirmed absence in health facets rather than stale health', () => {
10
+ const missing = { ...node(''), missing: true, health: 'Healthy' as const }
11
+ expect(matchesGitOpsTreeFilters(missing, { health: ['Missing'] })).toBe(true)
12
+ expect(matchesGitOpsTreeFilters(missing, { health: ['Healthy'] })).toBe(false)
13
+ expect(buildTreeFacets({ nodes: [missing] } as GitOpsResourceTree).health).toEqual([{ name: 'Missing', count: 1 }])
14
+ })
9
15
  it('uses bare Kubernetes kinds across API groups', () => {
10
16
  const nodes = [node(''), node('serving.knative.dev')]
11
17
  expect(buildTreeFacets({ nodes } as GitOpsResourceTree).kinds).toEqual([{ name: 'Service', count: 2 }])
@@ -20,6 +20,10 @@ export function treeNodeFilterRole(node: GitOpsTreeNode): string {
20
20
  return node.dependency ? 'dependency' : node.role
21
21
  }
22
22
 
23
+ export function treeNodeHealth(node: GitOpsTreeNode): string {
24
+ return node.missing ? 'Missing' : node.health || 'Unknown'
25
+ }
26
+
23
27
  export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsTreeFilters): boolean {
24
28
  if (!filters) return true
25
29
  const kinds = gitOpsFilterSet(filters.kinds)
@@ -31,7 +35,7 @@ export function matchesGitOpsTreeFilters(node: GitOpsTreeNode, filters?: GitOpsT
31
35
  if (kinds && !kinds.has(node.ref.kind)) return false
32
36
  if (namespaces && !namespaces.has(node.ref.namespace || '(cluster)')) return false
33
37
  if (sync && !sync.has(node.sync || 'Unknown')) return false
34
- if (health && !health.has(node.health || 'Unknown')) return false
38
+ if (health && !health.has(treeNodeHealth(node))) return false
35
39
  if (roles && !roles.has(treeNodeFilterRole(node))) return false
36
40
  return true
37
41
  }
@@ -288,13 +288,16 @@ export function IssueRow({
288
288
  onToggle();
289
289
  }
290
290
  }}
291
- className={`group @container/issue 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,var(--accent))]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
291
+ className={`group @container/issue flex cursor-pointer items-center gap-3 phone:flex-wrap phone:gap-y-2 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,var(--accent))]/40 ${open ? ISSUE_SEVERITY_HEADER_BAND_CLASS[severity] : ISSUE_SEVERITY_RAIL_CLASS[severity]}`}
292
292
  >
293
293
  <SeverityIcon className={`h-[18px] w-[18px] shrink-0 ${ISSUE_SEVERITY_TEXT_CLASS[severity]}`} aria-hidden />
294
294
 
295
- <div className="flex min-w-0 flex-1 flex-col gap-1">
296
- <div className="flex min-w-0 items-baseline gap-2">
297
- <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary">{issueTitle(issue)}</span>
295
+ {/* Phones: the title takes its own line with the badge and reason
296
+ under it, and the severity, actions and chevron get a last line
297
+ (the right cluster below wraps under the text). */}
298
+ <div className="flex min-w-0 flex-1 flex-col gap-1 phone:basis-[calc(100%-30px)]">
299
+ <div className="flex min-w-0 items-baseline gap-2 phone:flex-wrap phone:gap-y-1">
300
+ <span className="min-w-0 truncate text-sm font-medium text-theme-text-primary phone:basis-full">{issueTitle(issue)}</span>
298
301
  <span className={`shrink-0 self-center ${groupBadgeClass(issue.category_group)}`}>{groupLabel(issue.category_group)}</span>
299
302
  {renderBadges?.(slotCtx)}
300
303
  {/* The detector reason rides the title row while COLLAPSED so the
@@ -351,15 +354,17 @@ export function IssueRow({
351
354
  {/* Narrow container: the meta chips drop to their own line here rather
352
355
  than stealing width from the title on the row above. Hidden once the
353
356
  row is wide enough to hold them inline in the right cluster. */}
354
- {metaChips('flex @2xl/issue:hidden')}
357
+ {metaChips('flex @2xl/issue:hidden phone:hidden')}
355
358
  </div>
356
359
 
357
360
  {/* Right cluster — the always-visible affordances (per-row action +
358
361
  chevron) plus, on a wide container, the inline meta chips. */}
359
- <div className="flex shrink-0 items-center gap-3">
360
- {metaChips('hidden @2xl/issue:flex')}
361
- {renderActions?.(slotCtx)}
362
- <CollapseChevron open={open} className="h-4 w-4" />
362
+ <div className="flex shrink-0 items-center gap-3 phone:basis-full phone:pl-[30px]">
363
+ {metaChips('hidden @2xl/issue:flex phone:flex')}
364
+ <div className="contents phone:ml-auto phone:flex phone:items-center phone:gap-3">
365
+ {renderActions?.(slotCtx)}
366
+ <CollapseChevron open={open} className="h-4 w-4" />
367
+ </div>
363
368
  </div>
364
369
  </div>
365
370
 
@@ -435,9 +440,12 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
435
440
  const partialUnknown = issue.onset_coverage?.unknown ?? 0;
436
441
  const partialOnset = Boolean(issue.first_seen && partialUnknown > 0);
437
442
  const timing = issueTimingForDisplay(issue);
443
+ // A message Radar wrote itself is not raw output, so it never lands in the
444
+ // terminal block; its cause carries the same facts.
445
+ const messageIsRaw = issue.message_source !== 'radar';
438
446
  const rawError = [
439
447
  ...new Set(
440
- [issue.raw_message ?? (issue.cause ? issue.message : undefined), detail].filter(
448
+ [issue.raw_message ?? (issue.cause && messageIsRaw ? issue.message : undefined), detail].filter(
441
449
  (s): s is string => !!s && s !== shownText,
442
450
  ),
443
451
  ),
@@ -1,7 +1,7 @@
1
- import { useMemo, useState } from 'react'
1
+ import { useMemo, useState, type ReactNode } from 'react'
2
2
  import { AlertTriangle } from 'lucide-react'
3
3
  import { kindToPluralWithGroup } from '../../utils/navigation'
4
- import { IssueRow } from './IssuesView'
4
+ import { IssueRow, type IssueRowSlotContext } from './IssuesView'
5
5
  import { compareIssues, subjectRef, type Issue, type IssueResourceRef } from './types'
6
6
 
7
7
  export function ResourceIssuesSection({
@@ -9,8 +9,10 @@ export function ResourceIssuesSection({
9
9
  onResourceClick,
10
10
  subjectResource,
11
11
  compact,
12
+ renderDetailSection,
12
13
  }: {
13
14
  compact?: boolean
15
+ renderDetailSection?: (ctx: IssueRowSlotContext) => ReactNode
14
16
  issues: Issue[] | undefined
15
17
  /** When provided, related resources in a causal link become clickable. */
16
18
  onResourceClick?: (ref: IssueResourceRef) => void
@@ -39,6 +41,7 @@ export function ResourceIssuesSection({
39
41
  key={key}
40
42
  issue={issue}
41
43
  compact={compact}
44
+ renderDetailSection={renderDetailSection}
42
45
  open={openId === key}
43
46
  onToggle={() => setOpenId((cur) => (cur === key ? null : key))}
44
47
  onResourceClick={onResourceClick}
@@ -52,6 +52,8 @@ export function diagnosticFactLabel(type: string): string {
52
52
  return 'Dependent pods';
53
53
  case 'admission_webhook_backend':
54
54
  return 'Admission dependency';
55
+ case 'network_policy_isolates':
56
+ return 'Affected resources';
55
57
  default:
56
58
  return type.replace(/_/g, ' ');
57
59
  }
@@ -16,7 +16,7 @@ export {
16
16
  issueSortAnchor,
17
17
  compareIssueSortAnchors,
18
18
  } from './types';
19
- export type { Issue, IssueConditionalCleanup, IssueFinalizerRemoval, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField } from './types';
19
+ export type { Issue, IssueConditionalCleanup, IssueFinalizerRemoval, IssueSeverity, IssueAffected, IssueResourceRef, IssueDiagnosticContext, IssueDiagnosticFact, IssueDiagnosticConfidence, IssueDiagnosticIssueRef, IssueDiagnosticRole, IssueIncidentParent, IssueChangeContext, IssueRecentChange, IssueRecentChangeField, IssueRunningPreviousValue, IssueWithheld } from './types';
20
20
  export {
21
21
  ISSUE_SEVERITY_LABEL,
22
22
  ISSUE_SEVERITY_BADGE_CLASS,
@@ -537,4 +537,30 @@ describe('IssueRow diagnosis raw messages', () => {
537
537
  expect(html).toContain('app path does not exist')
538
538
  expect(html).toContain('rpc error: code = Unknown desc = app path does not exist')
539
539
  })
540
+
541
+ it("doesn't present Radar's own summary as a raw error", () => {
542
+ const base = {
543
+ category: 'network_policy_denied',
544
+ category_group: 'networking',
545
+ severity: 'warning' as const,
546
+ reason: 'NetworkPolicy denies all ingress',
547
+ message: 'Would block all inbound traffic to the 2 pods behind Service recommendation',
548
+ cause: 'NetworkPolicy deny-all leaves the 2 pods behind Service recommendation with no allowed inbound traffic.',
549
+ }
550
+ const radar = renderToString(createElement(IssueRow, {
551
+ issue: mk({
552
+ ...base,
553
+ message_source: 'radar',
554
+ diagnostic_context: { role: 'candidate', facts: [{ type: 'network_policy_isolates', confidence: 'high', refs: [{ kind: 'Service', namespace: 'shop', name: 'recommendation' }] }] },
555
+ }),
556
+ open: true, onToggle: () => undefined, as: 'div',
557
+ }))
558
+ expect(radar).not.toContain('Raw error')
559
+ expect(radar).toContain('leaves the 2 pods behind Service recommendation')
560
+ expect(radar).toContain('Affected resources')
561
+
562
+ // Without the marker the message is still treated as detector output.
563
+ const unmarked = renderToString(createElement(IssueRow, { issue: mk(base), open: true, onToggle: () => undefined, as: 'div' }))
564
+ expect(unmarked).toContain('Raw error')
565
+ })
540
566
  })
@@ -70,13 +70,14 @@ const CATEGORY_LABEL: Record<string, string> = {
70
70
  pdb_blocks_evictions: 'Evictions blocked',
71
71
  secret_sync_failed: 'Secret sync failed',
72
72
  service_no_endpoints: 'No endpoints',
73
+ service_mixed_backends: 'Mixed Service backends',
73
74
  ingress_backend_missing: 'Ingress backend missing',
74
75
  ingress_class_missing: 'Ingress class missing',
75
76
  load_balancer_pending: 'Load balancer pending',
76
77
  gateway_not_ready: 'Gateway not ready',
77
78
  gateway_route_invalid: 'Gateway route invalid',
78
79
  dns_failure: 'DNS failure',
79
- network_policy_block: 'Network policy block',
80
+ network_policy_denied: 'Traffic denied by NetworkPolicy',
80
81
  pvc_pending: 'PVC pending',
81
82
  pvc_lost: 'PVC lost',
82
83
  pv_failed: 'PV failed',
@@ -87,7 +88,7 @@ const CATEGORY_LABEL: Record<string, string> = {
87
88
  backup_target_unavailable: 'Backup target unavailable',
88
89
  backup_stalled: 'Backup not progressing',
89
90
  job_failed: 'Job failed',
90
- cronjob_failed: 'CronJob failed',
91
+ cronjob_failed: 'CronJob needs attention',
91
92
  rollout_stalled: 'Rollout stalled',
92
93
  hpa_limited_or_failed: 'Autoscaling limited',
93
94
  rbac_forbidden: 'Permission denied',
@@ -139,6 +140,12 @@ export function categoryLabel(category: string): string {
139
140
  // Reasons filed under a category whose label names a different operation:
140
141
  // CNPG archiving and an unanswered scheduled run sit under backup_failed.
141
142
  const REASON_TITLE: Record<string, string> = {
143
+ InvalidSchedule: 'Scheduling error reported',
144
+ UnparseableSchedule: 'Scheduling error reported',
145
+ UnParseableCronJobSchedule: 'Scheduling error reported',
146
+ UnknownTimeZone: 'Time zone error reported',
147
+ MissSchedule: 'Missed schedule reported',
148
+ TooManyMissedTimes: 'Missed schedules reported',
142
149
  CNPGWALArchivingFailing: "WAL archiving failing",
143
150
  CNPGLastBackupFailed: "Latest backup failed",
144
151
  CNPGScheduleDestinationMissing: "Backup schedule has no destination",