@skyhook-io/k8s-ui 1.14.5 → 1.14.6

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 (67) hide show
  1. package/package.json +5 -5
  2. package/src/components/audit/AuditCard.tsx +18 -7
  3. package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
  4. package/src/components/charts/AreaChart.test.tsx +13 -0
  5. package/src/components/charts/AreaChart.tsx +28 -25
  6. package/src/components/charts/PrometheusChartsView.tsx +12 -0
  7. package/src/components/charts/axis.test.ts +16 -0
  8. package/src/components/charts/axis.ts +3 -1
  9. package/src/components/charts/format.ts +4 -0
  10. package/src/components/charts/nearestSample.test.ts +31 -0
  11. package/src/components/charts/nearestSample.ts +15 -0
  12. package/src/components/checks/ChecksView.tsx +20 -2
  13. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
  14. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  15. package/src/components/gitops/health-provenance.test.ts +54 -0
  16. package/src/components/gitops/health-provenance.ts +24 -0
  17. package/src/components/gitops/index.ts +2 -0
  18. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  19. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  20. package/src/components/gitops/tree/merge.test.ts +34 -0
  21. package/src/components/gitops/tree/merge.ts +7 -0
  22. package/src/components/resources/ResourcesView.tsx +67 -2
  23. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  24. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  25. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  26. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  27. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  28. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  29. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  30. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  31. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  32. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  33. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  34. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  35. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  36. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  37. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  38. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  39. package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
  40. package/src/components/resources/renderers/index.ts +3 -0
  41. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  42. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
  43. package/src/components/resources/resource-utils.ts +18 -9
  44. package/src/components/shared/CreateResourceDialog.tsx +231 -11
  45. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  46. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  47. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  48. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  49. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  50. package/src/components/shared/index.ts +4 -0
  51. package/src/components/ui/FetchResult.test.tsx +20 -2
  52. package/src/components/ui/FetchResult.tsx +33 -3
  53. package/src/components/ui/FreshnessControl.tsx +8 -2
  54. package/src/components/workload/WorkloadView.tsx +2 -2
  55. package/src/theme/components.css +16 -0
  56. package/src/types/core.ts +15 -1
  57. package/src/types/gitops-insights.ts +23 -0
  58. package/src/types/gitops-tree.ts +29 -0
  59. package/src/utils/index.ts +1 -0
  60. package/src/utils/navigation.test.ts +20 -1
  61. package/src/utils/navigation.ts +25 -0
  62. package/src/utils/network-policy.test.ts +42 -0
  63. package/src/utils/network-policy.ts +59 -0
  64. package/src/utils/reflector.ts +139 -0
  65. package/src/utils/yaml-file-import.test.ts +189 -0
  66. package/src/utils/yaml-file-import.ts +95 -0
  67. package/src/components/logs/JsonLogLine.tsx +0 -93
@@ -0,0 +1,141 @@
1
+ import { Info } from 'lucide-react'
2
+ import type { ReactNode } from 'react'
3
+ import type { GitOpsChange, GitOpsInsightSummary } from '../../types'
4
+
5
+ // One quiet line above the Issues band for the two cases where per-resource
6
+ // health on this page is not the controller's own verdict:
7
+ //
8
+ // - Argo CD keeps per-resource health outside the Application object (its
9
+ // 3.0+ default). Radar then reads the live resources itself, and every
10
+ // problem below is Radar's finding — the "Radar" markers say so per row;
11
+ // this line says so once.
12
+ // - The Application deploys to another cluster. Radar can't read its
13
+ // resources from here at all, so nothing is derived. A standalone Radar
14
+ // may add a pointer to Radar Cloud (the host passes it; embedded hosts
15
+ // pass nothing).
16
+ //
17
+ // Copy stays in plain words; the field names live behind the docs link.
18
+
19
+ export const APP_TREE_HEALTH_NOTICE =
20
+ 'Argo CD 3 no longer records health for each resource on the application. Rows marked "Found by Radar" are problems Radar detected by looking at the resources itself.'
21
+ // Same situation, but Radar's own look found nothing to mark: the app is
22
+ // Degraded by Argo's reckoning and nothing here says which resource. What
23
+ // follows the sentence is the way out — the host's connect hint when it
24
+ // has one, and the Argo knob either way.
25
+ export const APP_TREE_NO_FINDINGS_NOTICE =
26
+ "Argo CD 3 no longer records health for each resource on the application, and Radar didn't find a problem on its own."
27
+ export const APP_TREE_PERSIST_REMEDY =
28
+ 'set controller.resource.health.persist to "true" in argocd-cmd-params-cm.'
29
+ // The integration is set up and still didn't deliver: lead with that and
30
+ // why — the user who configured it doesn't need the Argo CD 3 history
31
+ // first — then say what the rows below are meanwhile.
32
+ export const APP_TREE_API_ERROR_NOTICE = "Radar couldn't read Argo's per-resource health from your Argo CD server"
33
+ export const APP_TREE_API_ERROR_FINDINGS = 'Meanwhile, rows marked "Found by Radar" are problems Radar detected by looking at the resources itself.'
34
+ export const APP_TREE_API_ERROR_NO_FINDINGS = "Radar didn't find a problem on its own."
35
+
36
+ export const REMOTE_DESTINATION_NOTICE =
37
+ "This application deploys to a different cluster, so its resources aren't visible from here."
38
+
39
+ export type HealthSourceNoticeSummary = Pick<GitOpsInsightSummary, 'tool' | 'health' | 'resourceHealthMode' | 'remoteDestination' | 'resourceHealthFromApi' | 'resourceHealthApiError'>
40
+
41
+ // The appTree notice explains problems Radar found on its own; on an app
42
+ // Argo calls Healthy nothing is derived (the host doesn't run the overlay
43
+ // there), and when the host read Argo's verdicts from its API server there
44
+ // is nothing of Radar's to explain either.
45
+ export function healthSourceNoticeKind(summary: HealthSourceNoticeSummary | undefined): 'remote' | 'appTree' | null {
46
+ if (!summary || summary.tool !== 'argocd') return null
47
+ if (summary.remoteDestination) return 'remote'
48
+ if (summary.resourceHealthMode === 'appTree' && !summary.resourceHealthFromApi && summary.health !== 'Healthy') return 'appTree'
49
+ return null
50
+ }
51
+
52
+ // Reads the same rows the "Found by Radar" markers read, so the sentence
53
+ // and the markers can't disagree — an Issue can be suppressed (an operation
54
+ // failure already names the resource) while the row still carries Radar's
55
+ // health.
56
+ export function hasRadarFinding(changes: GitOpsChange[] | undefined): boolean {
57
+ return (changes ?? []).some((c) => c.healthSource === 'radar' && (c.health === 'Degraded' || c.health === 'Missing'))
58
+ }
59
+
60
+ export function GitOpsHealthSourceNotice({
61
+ summary,
62
+ changes,
63
+ docsUrl,
64
+ remoteDestinationHint,
65
+ onOpenSettings,
66
+ }: {
67
+ summary: HealthSourceNoticeSummary | undefined
68
+ // Decides between the two appTree sentences: with a Radar-sourced
69
+ // problem row the notice explains the markers; without one it says so
70
+ // and names the way out.
71
+ changes?: GitOpsChange[]
72
+ // Where "Why?" goes for the appTree case. Omitted → no link.
73
+ docsUrl?: string
74
+ // Rendered after the remote-destination sentence. The host decides
75
+ // whether there is anything to say (standalone Radar points at Radar
76
+ // Cloud; an embedded host passes nothing).
77
+ remoteDestinationHint?: ReactNode
78
+ // Opens the host's Argo CD settings. Only a host that has such a place
79
+ // passes it; the notice then offers connecting Radar to argocd-server as
80
+ // the first way out, and "check Settings" when a configured connection
81
+ // didn't deliver. Without it, only the Argo-side knob is named.
82
+ onOpenSettings?: () => void
83
+ }) {
84
+ const kind = healthSourceNoticeKind(summary)
85
+ if (!kind) return null
86
+ // With nothing marked, the sentence only earns its place on an app Argo
87
+ // calls Degraded: that is the badge it explains. A Progressing or Missing
88
+ // app with no Radar finding has nothing here to attribute.
89
+ const radarFinding = hasRadarFinding(changes)
90
+ const apiError = summary?.resourceHealthApiError
91
+ if (kind === 'appTree' && !radarFinding && !apiError && summary?.health !== 'Degraded') return null
92
+ const settingsLink = (label: string) =>
93
+ onOpenSettings ? (
94
+ <button type="button" onClick={onOpenSettings} className="underline decoration-theme-border underline-offset-2 hover:text-theme-text-primary">
95
+ {label}
96
+ </button>
97
+ ) : null
98
+ return (
99
+ <div className="flex shrink-0 items-start gap-2 border-b border-theme-border bg-theme-base px-4 py-2 text-xs text-theme-text-secondary" role="note">
100
+ <Info className="mt-px h-3.5 w-3.5 shrink-0 text-theme-text-tertiary" />
101
+ <span className="min-w-0">
102
+ {kind === 'remote' ? (
103
+ <>
104
+ {REMOTE_DESTINATION_NOTICE}
105
+ {remoteDestinationHint ? <> {remoteDestinationHint}</> : null}
106
+ </>
107
+ ) : (
108
+ <>
109
+ {apiError ? (
110
+ <>
111
+ {APP_TREE_API_ERROR_NOTICE}: {apiError}.
112
+ {onOpenSettings && <> {settingsLink('Check Argo CD in Settings')}.</>}
113
+ {' '}
114
+ {radarFinding ? APP_TREE_API_ERROR_FINDINGS : APP_TREE_API_ERROR_NO_FINDINGS}
115
+ </>
116
+ ) : (
117
+ <>
118
+ {radarFinding ? APP_TREE_HEALTH_NOTICE : APP_TREE_NO_FINDINGS_NOTICE}
119
+ {!radarFinding && (
120
+ <>
121
+ {' '}
122
+ To see Argo's per-resource health here, {onOpenSettings && <>{settingsLink('connect Radar to your Argo CD server')}, or </>}
123
+ {APP_TREE_PERSIST_REMEDY}
124
+ </>
125
+ )}
126
+ </>
127
+ )}
128
+ {docsUrl && (
129
+ <>
130
+ {' '}
131
+ <a href={docsUrl} target="_blank" rel="noreferrer" className="underline decoration-theme-border underline-offset-2 hover:text-theme-text-primary">
132
+ Why?
133
+ </a>
134
+ </>
135
+ )}
136
+ </>
137
+ )}
138
+ </span>
139
+ </div>
140
+ )
141
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { RADAR_HEALTH_NOTE, radarHealthNote } from './health-provenance'
4
+ import { APP_TREE_API_ERROR_NOTICE, APP_TREE_HEALTH_NOTICE, APP_TREE_NO_FINDINGS_NOTICE, APP_TREE_PERSIST_REMEDY, REMOTE_DESTINATION_NOTICE, hasRadarFinding, healthSourceNoticeKind } from './GitOpsHealthSourceNotice'
5
+
6
+ describe('radarHealthNote', () => {
7
+ test('marks only Radar-sourced problems', () => {
8
+ expect(radarHealthNote({ health: 'Degraded', healthSource: 'radar' })).toBe(RADAR_HEALTH_NOTE)
9
+ expect(radarHealthNote({ health: 'Missing', healthSource: 'radar', healthMessage: 'not in cluster' })).toBe(`${RADAR_HEALTH_NOTE} not in cluster`)
10
+ })
11
+ test('is silent for the controller, for healthy values, and for unknown sources', () => {
12
+ expect(radarHealthNote({ health: 'Degraded', healthSource: 'controller' })).toBe('')
13
+ expect(radarHealthNote({ health: 'Degraded', healthSource: 'controllerApi' })).toBe('')
14
+ expect(radarHealthNote({ health: 'Healthy', healthSource: 'radar' })).toBe('')
15
+ expect(radarHealthNote({ health: 'Degraded' })).toBe('')
16
+ expect(radarHealthNote({ health: 'Degraded', healthSource: 'something-newer' })).toBe('')
17
+ })
18
+ test('copy stays in plain words', () => {
19
+ for (const copy of [RADAR_HEALTH_NOTE, APP_TREE_HEALTH_NOTICE, REMOTE_DESTINATION_NOTICE]) {
20
+ expect(copy).not.toMatch(/appTree|resourceHealthSource|persist|Tier|overlay/i)
21
+ }
22
+ // The remedy deliberately names the one Argo knob the user can turn;
23
+ // it must still avoid Radar's internal vocabulary. Radar's own Settings
24
+ // are the host's to name — an embedded host may have no such place.
25
+ for (const copy of [APP_TREE_NO_FINDINGS_NOTICE, APP_TREE_PERSIST_REMEDY, APP_TREE_API_ERROR_NOTICE]) {
26
+ expect(copy).not.toMatch(/appTree|Tier|overlay|Settings/i)
27
+ }
28
+ expect(APP_TREE_PERSIST_REMEDY).toContain('controller.resource.health.persist')
29
+ })
30
+
31
+ test('hasRadarFinding reads the rows the markers read', () => {
32
+ const row = (health: string, healthSource?: string) =>
33
+ ({ ref: { kind: 'Deployment', namespace: 'p', name: 'w' }, category: 'Unknown', health, healthSource, hasDesired: false, hasLive: true }) as const
34
+ expect(hasRadarFinding(undefined)).toBe(false)
35
+ expect(hasRadarFinding([row('Degraded', 'controller')])).toBe(false)
36
+ expect(hasRadarFinding([row('Healthy', 'radar')])).toBe(false)
37
+ expect(hasRadarFinding([row('Degraded', 'radar')])).toBe(true)
38
+ expect(hasRadarFinding([row('Missing', 'radar')])).toBe(true)
39
+ })
40
+ })
41
+
42
+ describe('healthSourceNoticeKind', () => {
43
+ test('remote destination wins over appTree, Flux never notices', () => {
44
+ expect(healthSourceNoticeKind({ tool: 'argocd', resourceHealthMode: 'appTree', remoteDestination: true })).toBe('remote')
45
+ expect(healthSourceNoticeKind({ tool: 'argocd', resourceHealthMode: 'appTree', health: 'Degraded' })).toBe('appTree')
46
+ expect(healthSourceNoticeKind({ tool: 'argocd', resourceHealthMode: 'appTree' })).toBe('appTree')
47
+ expect(healthSourceNoticeKind({ tool: 'argocd', resourceHealthMode: 'appTree', health: 'Healthy' })).toBeNull()
48
+ expect(healthSourceNoticeKind({ tool: 'argocd', resourceHealthMode: 'appTree', health: 'Degraded', resourceHealthFromApi: true })).toBeNull()
49
+ expect(healthSourceNoticeKind({ tool: 'argocd', resourceHealthMode: 'inline' })).toBeNull()
50
+ expect(healthSourceNoticeKind({ tool: 'argocd' })).toBeNull()
51
+ expect(healthSourceNoticeKind({ tool: 'fluxcd', resourceHealthMode: 'appTree' })).toBeNull()
52
+ expect(healthSourceNoticeKind(undefined)).toBeNull()
53
+ })
54
+ })
@@ -0,0 +1,24 @@
1
+ // Copy and predicate for the "Radar" marker shown beside a problem the
2
+ // GitOps controller did not assess itself — Argo CD 3 keeps per-resource
3
+ // health out of the Application object, so Radar reads the live resource
4
+ // instead and must say so. Shared by the tree node card and the Changes
5
+ // table so the two never drift.
6
+
7
+ // One sentence in plain words; the engine's own message follows it when
8
+ // there is one.
9
+ export const RADAR_HEALTH_NOTE = "Found by Radar. Argo CD's own health for this resource isn't available."
10
+
11
+ export interface RadarHealthSubject {
12
+ health?: string
13
+ healthSource?: string
14
+ healthMessage?: string
15
+ }
16
+
17
+ // radarHealthNote returns the marker tooltip for a row/node whose problem
18
+ // came from Radar rather than the controller, or '' when there is no
19
+ // problem to mark or the controller assessed it.
20
+ export function radarHealthNote(subject: RadarHealthSubject): string {
21
+ if (subject.healthSource !== 'radar') return ''
22
+ if (subject.health !== 'Degraded' && subject.health !== 'Missing') return ''
23
+ return subject.healthMessage ? `${RADAR_HEALTH_NOTE} ${subject.healthMessage}` : RADAR_HEALTH_NOTE
24
+ }
@@ -59,3 +59,5 @@ export type {
59
59
  FluxActionHandlers,
60
60
  GitOpsHelmValuesData,
61
61
  } from './GitOpsDetailLayout'
62
+ export { GitOpsHealthSourceNotice, healthSourceNoticeKind, hasRadarFinding, APP_TREE_HEALTH_NOTICE, APP_TREE_NO_FINDINGS_NOTICE, REMOTE_DESTINATION_NOTICE } from './GitOpsHealthSourceNotice'
63
+ export { radarHealthNote, RADAR_HEALTH_NOTE } from './health-provenance'
@@ -4,6 +4,7 @@ import { clsx } from 'clsx'
4
4
  import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
5
5
  import type { GitOpsChange, GitOpsHistoryItem, GitOpsInsight, GitOpsInsightRef, GitOpsIssue, GitOpsPlanItem, GitOpsRemediation, GitOpsResourceTree, GitOpsTreeNode } from '../../../types'
6
6
  import { HealthStatusBadge, SyncStatusBadge } from '../GitOpsStatusBadge'
7
+ import { radarHealthNote } from '../health-provenance'
7
8
  import { SEVERITY_BADGE, SEVERITY_TEXT } from '../../../utils/badge-colors'
8
9
  import { formatRelativeAgeTime } from '../../../utils/format'
9
10
  import { Tooltip } from '../../ui/Tooltip'
@@ -604,31 +605,36 @@ function GitOpsCompactIssueStack({ issues, onSelectIssue }: { issues: GitOpsIssu
604
605
  }}
605
606
  disabled={headlineAction === 'none'}
606
607
  className={clsx(
607
- 'group flex w-full items-center gap-2 px-4 py-2 text-left text-xs transition-colors',
608
+ 'group flex w-full flex-col gap-0.5 px-4 py-2 text-left text-xs transition-colors',
608
609
  headlineAction !== 'none' ? 'hover:bg-theme-hover/50' : 'cursor-default',
609
610
  )}
610
611
  aria-expanded={canExpand ? expanded : undefined}
611
612
  >
612
- {tone.icon}
613
- <span className={clsx('shrink-0 font-semibold', tone.text)}>{headline.reason}</span>
614
- <span className="min-w-0 flex-1 truncate text-theme-text-secondary">{headline.message}</span>
615
- {/* Inline count when there are more issues behind the headline.
616
- Lightweight text — pairs with the chevron as a single disclosure
617
- unit instead of a separator-bordered count button. */}
618
- {remaining > 0 && (
619
- <span className="shrink-0 text-[11px] text-theme-text-tertiary">
620
- +{remaining} more
621
- </span>
622
- )}
623
- {/* Open-resource pill: only shown when the row's action IS to open
624
- (single-issue case). When the row expands, the per-row Open
625
- pills live inside the expanded section, scoped to each item. */}
626
- {headlineAction === 'open' && headlineRef && (
627
- <span className="shrink-0 text-[11px] font-medium text-theme-text-secondary opacity-70 transition-opacity group-hover:opacity-100">
628
- Open {refText(headlineRef)} →
629
- </span>
613
+ <div className="flex w-full items-center gap-2">
614
+ {tone.icon}
615
+ <span className={clsx('shrink-0 font-semibold', tone.text)}>{headline.reason}</span>
616
+ <span className="min-w-0 flex-1 truncate text-theme-text-secondary">{headline.message}</span>
617
+ {/* Inline count when there are more issues behind the headline.
618
+ Lightweight text — pairs with the chevron as a single disclosure
619
+ unit instead of a separator-bordered count button. */}
620
+ {remaining > 0 && (
621
+ <span className="shrink-0 text-[11px] text-theme-text-tertiary">
622
+ +{remaining} more
623
+ </span>
624
+ )}
625
+ {/* Open-resource pill: only shown when the row's action IS to open
626
+ (single-issue case). When the row expands, the per-row Open
627
+ pills live inside the expanded section, scoped to each item. */}
628
+ {headlineAction === 'open' && headlineRef && (
629
+ <span className="shrink-0 text-[11px] font-medium text-theme-text-secondary opacity-70 transition-opacity group-hover:opacity-100">
630
+ Open {refText(headlineRef)} →
631
+ </span>
632
+ )}
633
+ {canExpand && <CollapseChevron open={expanded} className="h-3.5 w-3.5" />}
634
+ </div>
635
+ {headline.cause && (
636
+ <p className="truncate pl-[22px] text-[11px] text-theme-text-tertiary">{headline.cause}</p>
630
637
  )}
631
- {canExpand && <CollapseChevron open={expanded} className="h-3.5 w-3.5" />}
632
638
  </button>
633
639
  {canExpand && (
634
640
  <Collapse open={expanded}>
@@ -1225,6 +1231,9 @@ function buildTreeExtras(nodes: GitOpsTreeNode[], declared: GitOpsChange[]): Git
1225
1231
  category: 'Unknown',
1226
1232
  sync: n.sync,
1227
1233
  health: n.health,
1234
+ // No healthSource: generated rows are always Radar's read and never
1235
+ // had a controller verdict to be distinguished from.
1236
+ message: n.healthMessage,
1228
1237
  hasDesired: false,
1229
1238
  hasLive: true,
1230
1239
  partial: true,
@@ -1347,6 +1356,7 @@ function ChangeRow({
1347
1356
  // and offer "View in Git →" instead so the operator can read the
1348
1357
  // declared source instead of a non-existent live object.
1349
1358
  const isAbsent = change.health === 'Missing' && !change.hasLive
1359
+ const radarNote = radarHealthNote({ health: change.health, healthSource: change.healthSource, healthMessage: change.message })
1350
1360
  const handleRowClick = () => {
1351
1361
  if (expandable) {
1352
1362
  setExpanded((v) => !v)
@@ -1453,7 +1463,16 @@ function ChangeRow({
1453
1463
  <SyncStatusBadge sync={normalizeSyncStatus(change.sync ?? change.category)} />
1454
1464
  )}
1455
1465
  </div>
1456
- <div className="self-start"><HealthStatusBadge health={normalizeHealthStatus(change.health)} /></div>
1466
+ <div className="flex flex-col items-start gap-1 self-start">
1467
+ <HealthStatusBadge health={normalizeHealthStatus(change.health)} />
1468
+ {radarNote && (
1469
+ <Tooltip content={radarNote} delay={200} wrapperClassName="inline-flex">
1470
+ <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">
1471
+ Found by Radar
1472
+ </span>
1473
+ </Tooltip>
1474
+ )}
1475
+ </div>
1457
1476
  <div className="self-start space-y-1.5">
1458
1477
  {syncResourceButton && syncResourceDisabledReason ? (
1459
1478
  <Tooltip content={syncResourceDisabledReason} delay={200} wrapperClassName="block">
@@ -20,10 +20,11 @@ import { PaneLoader } from '../../ui/PaneLoader'
20
20
  import { Input } from '../../ui/Input'
21
21
  import { clsx } from 'clsx'
22
22
 
23
- import type { GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
23
+ import type { GitOpsIssue, GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
24
24
  import { displayKind } from '../../../types'
25
25
  import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
26
26
  import { formatCompactAge } from '../../../utils/format'
27
+ import { radarHealthNote } from '../health-provenance'
27
28
  import { getTopologyIcon } from '../../../utils/resource-icons'
28
29
  import { Tooltip } from '../../ui/Tooltip'
29
30
  import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
@@ -64,6 +65,22 @@ interface GitOpsTreeGraphProps {
64
65
  onQueryChange?: (query: string) => void
65
66
  filters?: GitOpsTreeFilters
66
67
  showToolbar?: boolean
68
+ // Root-cause issues from the same detail page's insights fetch (already
69
+ // loaded alongside the tree — no extra request). Matched onto tree nodes
70
+ // by ref so an unhealthy node's hover reveals WHY, not just THAT.
71
+ issues?: GitOpsIssue[]
72
+ }
73
+
74
+ // refKey builds a stable lookup key from either a tree ref or an insight
75
+ // ref. Namespace is omitted for cluster-scoped resources on both sides, so
76
+ // normalize the missing case the same way instead of using `undefined` vs
77
+ // `''` as accidentally distinct keys. Group is included for the same
78
+ // reason CLAUDE.md calls out elsewhere (Knative Service vs core Service,
79
+ // CNPG Cluster vs CAPI Cluster) — kind+namespace+name alone collides across
80
+ // a real, documented class of same-plural CRDs, which would show one
81
+ // resource's cause/tooltip on a completely unrelated node.
82
+ function refKey(ref: { group?: string; kind: string; namespace?: string; name: string }): string {
83
+ return `${ref.group ?? ''}/${ref.kind}/${ref.namespace ?? ''}/${ref.name}`
67
84
  }
68
85
 
69
86
  export function GitOpsTreeGraph(props: GitOpsTreeGraphProps) {
@@ -85,6 +102,7 @@ function GitOpsTreeGraphInner({
85
102
  onQueryChange,
86
103
  filters,
87
104
  showToolbar = true,
105
+ issues,
88
106
  }: GitOpsTreeGraphProps) {
89
107
  const [internalPreset, setInternalPreset] = useState<GitOpsTreePreset>('compact')
90
108
  const [internalQuery, setInternalQuery] = useState('')
@@ -104,7 +122,20 @@ function GitOpsTreeGraphInner({
104
122
  useEffect(() => {
105
123
  if (preset !== 'compact') setExpandedGroups(new Set())
106
124
  }, [preset])
107
- const { nodes, edges } = useMemo(() => buildFlowGraph(tree, preset, query, filters, expandedGroups), [tree, preset, query, filters, expandedGroups])
125
+ const causeByRef = useMemo(() => {
126
+ const map = new Map<string, string>()
127
+ for (const issue of issues ?? []) {
128
+ const cause = issue.cause
129
+ const ref = issue.refs?.[0]
130
+ if (!cause || !ref) continue
131
+ const key = refKey(ref)
132
+ // First match wins — issues arrive ranked by severity, so a node
133
+ // that's the subject of multiple issues keeps its most severe cause.
134
+ if (!map.has(key)) map.set(key, cause)
135
+ }
136
+ return map
137
+ }, [issues])
138
+ const { nodes, edges } = useMemo(() => buildFlowGraph(tree, preset, query, filters, expandedGroups, causeByRef), [tree, preset, query, filters, expandedGroups, causeByRef])
108
139
 
109
140
  useEffect(() => {
110
141
  if (nodes.length === 0) return
@@ -297,7 +328,7 @@ function getEdgeColor(type: string): string {
297
328
  }
298
329
  }
299
330
 
300
- function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>): { nodes: Node[]; edges: Edge[] } {
331
+ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>, causeByRef?: Map<string, string>): { nodes: Node[]; edges: Edge[] } {
301
332
  if (!tree) return { nodes: [], edges: [] }
302
333
  const visibleTree = applyGraphFilters(applyPreset(tree, preset, expandedGroups), filters)
303
334
  const byID = new Map(visibleTree.nodes.map(node => [node.id, node]))
@@ -321,6 +352,7 @@ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePrese
321
352
  data: {
322
353
  node,
323
354
  highlighted: normalizedQuery !== '' && matchesQuery(node, normalizedQuery),
355
+ cause: causeByRef?.get(refKey(node.ref)),
324
356
  },
325
357
  }
326
358
  })
@@ -543,7 +575,7 @@ function positionRanks(ranks: Map<number, string[]>, nodes: Map<string, GitOpsTr
543
575
  return positioned
544
576
  }
545
577
 
546
- const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean }>>) {
578
+ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean; cause?: string }>>) {
547
579
  const node = data.node
548
580
  const kind = normalizeDisplayKind(node)
549
581
  const status = normalizeHealth(node.topologyStatus)
@@ -552,10 +584,16 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
552
584
  const chips = buildChips(node)
553
585
  const dim = getNodeDimensions(node)
554
586
  const KindIcon = getTopologyIcon(kind)
555
-
556
- return (
557
- <>
558
- <Handle type="target" position={Position.Left} className="!h-0 !w-0 !border-0 !bg-transparent" />
587
+ // Only unhealthy nodes carry a cause worth surfacing — a healthy node
588
+ // matched by a stale/unrelated issue ref shouldn't show a tooltip.
589
+ const cause = status === 'unhealthy' || status === 'degraded' ? data.cause : undefined
590
+ // Only declared resources can carry the controller's verdict, so only
591
+ // there does "this one is Radar's" mean anything. Generated children
592
+ // (ReplicaSets, Pods) are always Radar's read; marking each would be
593
+ // noise.
594
+ const radarNote = node.role === 'declared' ? radarHealthNote(node) : ''
595
+
596
+ const card = (
559
597
  <div
560
598
  className={clsx(
561
599
  'relative overflow-hidden rounded-lg border bg-theme-surface shadow-md transition-colors',
@@ -636,10 +674,23 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
636
674
  {chip.label ? `${chip.label}: ` : ''}{chip.value}
637
675
  </span>
638
676
  ))}
677
+ {radarNote && (
678
+ <Tooltip content={radarNote} delay={200}>
679
+ <span className="rounded border border-theme-border bg-theme-elevated/70 px-1.5 py-0.5 text-[10px] leading-3 text-theme-text-tertiary">
680
+ Found by Radar
681
+ </span>
682
+ </Tooltip>
683
+ )}
639
684
  </div>
640
685
  )}
641
686
  </div>
642
687
  </div>
688
+ )
689
+
690
+ return (
691
+ <>
692
+ <Handle type="target" position={Position.Left} className="!h-0 !w-0 !border-0 !bg-transparent" />
693
+ {cause ? <Tooltip content={cause} delay={200}>{card}</Tooltip> : card}
643
694
  <Handle type="source" position={Position.Right} className="!h-0 !w-0 !border-0 !bg-transparent" />
644
695
  </>
645
696
  )
@@ -41,6 +41,40 @@ function destNode(
41
41
  }
42
42
 
43
43
  describe('mergeGitOpsTrees', () => {
44
+ // Health provenance follows the health value: when the destination's live
45
+ // health replaces the controller's, its source/reason/message come along;
46
+ // a destination node with no health leaves the controller's provenance.
47
+ test('health provenance travels with whichever health wins', () => {
48
+ const controller: GitOpsResourceTree = {
49
+ root: ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
50
+ nodes: [
51
+ ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
52
+ ctrlNode('dep1', 'Deployment', 'web', 'prod', { health: 'Healthy', healthSource: 'controller' }),
53
+ ctrlNode('css1', 'ClusterSecretStore', 'platform', '', { health: 'Degraded', healthSource: 'radar', healthReason: 'Ready: Bad', healthMessage: 'no route' }),
54
+ ],
55
+ edges: [],
56
+ }
57
+ const destination: GitOpsResourceTree = {
58
+ root: destNode('dest-root', 'Application', 'app1', 'argocd', { role: 'root' }),
59
+ nodes: [
60
+ destNode('dest-root', 'Application', 'app1', 'argocd', { role: 'root' }),
61
+ destNode('dest-dep1', 'Deployment', 'web', 'prod', { health: 'Degraded', healthSource: 'radar', healthReason: 'CrashLoopBackOff', healthMessage: 'back-off' }),
62
+ destNode('dest-css1', 'ClusterSecretStore', 'platform', ''),
63
+ ],
64
+ edges: [],
65
+ }
66
+ const merged = mergeGitOpsTrees(controller, destination)
67
+ const dep = merged.nodes.find((n) => n.ref.kind === 'Deployment')!
68
+ expect(dep.health).toBe('Degraded')
69
+ expect(dep.healthSource).toBe('radar')
70
+ expect(dep.healthReason).toBe('CrashLoopBackOff')
71
+ expect(dep.healthMessage).toBe('back-off')
72
+ const css = merged.nodes.find((n) => n.ref.kind === 'ClusterSecretStore')!
73
+ expect(css.health).toBe('Degraded')
74
+ expect(css.healthSource).toBe('radar')
75
+ expect(css.healthReason).toBe('Ready: Bad')
76
+ })
77
+
44
78
  // The classic in-cluster case — caller didn't fetch a destination tree
45
79
  // (single-cluster app) so destination is null. Returning the controller
46
80
  // tree unchanged is what preserves Argo's status + summary tile counts
@@ -85,10 +85,17 @@ export function mergeGitOpsTrees(
85
85
  // resource-viewer routing.
86
86
  const nodes: GitOpsTreeNode[] = controller.nodes.map((n) => {
87
87
  const dest = destByKey.get(refKey(n.ref))
88
+ // Health provenance travels with the health it describes: when the
89
+ // destination's live health wins, so do its source/reason/message.
90
+ const healthFrom = dest?.health ? dest : n
88
91
  const merged = dest
89
92
  ? {
90
93
  ...n,
91
94
  health: dest.health ?? n.health,
95
+ healthSource: healthFrom.healthSource,
96
+ healthReason: healthFrom.healthReason,
97
+ healthMessage: healthFrom.healthMessage,
98
+ healthSeverity: healthFrom.healthSeverity,
92
99
  info: dest.info ?? n.info,
93
100
  topologyStatus: dest.topologyStatus ?? n.topologyStatus,
94
101
  }