@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.
- package/package.json +5 -5
- package/src/components/audit/AuditCard.tsx +18 -7
- package/src/components/audit/AuditCoveragePresentation.test.tsx +45 -0
- package/src/components/charts/AreaChart.test.tsx +13 -0
- package/src/components/charts/AreaChart.tsx +28 -25
- package/src/components/charts/PrometheusChartsView.tsx +12 -0
- package/src/components/charts/axis.test.ts +16 -0
- package/src/components/charts/axis.ts +3 -1
- package/src/components/charts/format.ts +4 -0
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/checks/ChecksView.tsx +20 -2
- package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
- package/src/components/gitops/health-provenance.test.ts +54 -0
- package/src/components/gitops/health-provenance.ts +24 -0
- package/src/components/gitops/index.ts +2 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
- package/src/components/gitops/tree/merge.test.ts +34 -0
- package/src/components/gitops/tree/merge.ts +7 -0
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
- package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
- package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
- package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
- package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
- package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
- package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
- package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
- package/src/components/resources/renderers/PodRenderer.tsx +11 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
- package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
- package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
- package/src/components/resources/renderers/WorkloadRenderer.tsx +11 -1
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
- package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
- package/src/components/resources/resource-utils.ts +18 -9
- package/src/components/shared/CreateResourceDialog.tsx +231 -11
- package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
- package/src/components/shared/DrainPlanDialog.tsx +297 -0
- package/src/components/shared/ResourceActionsBar.tsx +53 -26
- package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
- package/src/components/shared/index.ts +4 -0
- package/src/components/ui/FetchResult.test.tsx +20 -2
- package/src/components/ui/FetchResult.tsx +33 -3
- package/src/components/ui/FreshnessControl.tsx +8 -2
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +15 -1
- package/src/types/gitops-insights.ts +23 -0
- package/src/types/gitops-tree.ts +29 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +20 -1
- package/src/utils/navigation.ts +25 -0
- package/src/utils/network-policy.test.ts +42 -0
- package/src/utils/network-policy.ts +59 -0
- package/src/utils/reflector.ts +139 -0
- package/src/utils/yaml-file-import.test.ts +189 -0
- package/src/utils/yaml-file-import.ts +95 -0
- 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
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
}
|