@skyhook-io/k8s-ui 1.14.4 → 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 +173 -0
- package/src/components/charts/AreaChart.tsx +208 -53
- package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
- package/src/components/charts/PrometheusChartsView.tsx +205 -76
- package/src/components/charts/SeriesLegend.tsx +8 -3
- package/src/components/charts/annotations.ts +59 -0
- package/src/components/charts/axis.test.ts +48 -0
- package/src/components/charts/axis.ts +62 -0
- package/src/components/charts/colors.test.ts +27 -0
- package/src/components/charts/colors.ts +23 -0
- package/src/components/charts/format.test.ts +19 -0
- package/src/components/charts/format.ts +21 -1
- package/src/components/charts/index.ts +3 -1
- package/src/components/charts/nearestSample.test.ts +31 -0
- package/src/components/charts/nearestSample.ts +15 -0
- package/src/components/charts/types.ts +12 -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/HPADiagnosisSummary.test.tsx +178 -0
- package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
- package/src/components/resources/ResourcesView.tsx +67 -2
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/index.ts +7 -0
- 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/HPARenderer.tsx +4 -57
- 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 +15 -21
- 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/topology/K8sResourceNode.test.ts +9 -0
- package/src/components/topology/K8sResourceNode.tsx +56 -5
- package/src/components/ui/Badge.tsx +5 -1
- 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/ui/index.ts +5 -0
- package/src/components/ui/monacoRuntime.ts +14 -0
- package/src/components/workload/WorkloadView.tsx +2 -2
- package/src/monaco-deep.d.ts +10 -0
- package/src/theme/components.css +16 -0
- package/src/types/core.ts +38 -11
- 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
|
@@ -4,8 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
export function formatMetricValue(value: number, unit: string): string {
|
|
6
6
|
if (value === 0) return '0'
|
|
7
|
+
if (value < 0) return `-${formatMetricValue(-value, unit)}`
|
|
7
8
|
|
|
8
9
|
switch (unit) {
|
|
10
|
+
case 'percent':
|
|
11
|
+
return `${value.toFixed(value < 1 ? 2 : 1)}%`
|
|
12
|
+
case 'requests/s':
|
|
13
|
+
return `${value.toFixed(value < 1 ? 3 : 1)} req/s`
|
|
9
14
|
case 'cores': {
|
|
10
15
|
if (value < 0.0001) return '< 0.1m'
|
|
11
16
|
if (value < 0.001) return `${(value * 1000).toFixed(1)}m`
|
|
@@ -26,12 +31,27 @@ export function formatMetricValue(value: number, unit: string): string {
|
|
|
26
31
|
if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(1)} MiB/s`
|
|
27
32
|
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GiB/s`
|
|
28
33
|
}
|
|
34
|
+
case 'count':
|
|
35
|
+
case 'restarts': {
|
|
36
|
+
if (value < 10000) return value.toFixed(0)
|
|
37
|
+
if (value < 1e6) return `${(value / 1e3).toFixed(1)}k`
|
|
38
|
+
return `${(value / 1e6).toFixed(1)}M`
|
|
39
|
+
}
|
|
40
|
+
case 'seconds': {
|
|
41
|
+
if (value < 0.001) return `${(value * 1_000_000).toFixed(0)}µs`
|
|
42
|
+
if (value < 1) return `${(value * 1000).toFixed(value < 0.01 ? 2 : 0)}ms`
|
|
43
|
+
if (value < 60) return `${value.toFixed(value < 10 ? 2 : 1)}s`
|
|
44
|
+
if (value < 3600) return `${(value / 60).toFixed(1)}m`
|
|
45
|
+
return `${(value / 3600).toFixed(1)}h`
|
|
46
|
+
}
|
|
29
47
|
default:
|
|
30
48
|
if (value < 0.01) return value.toExponential(1)
|
|
31
49
|
if (value < 1) return value.toFixed(3)
|
|
32
50
|
if (value < 100) return value.toFixed(2)
|
|
33
51
|
if (value < 10000) return value.toFixed(0)
|
|
34
|
-
return `${(value /
|
|
52
|
+
if (value < 1e6) return `${(value / 1e3).toFixed(1)}k`
|
|
53
|
+
if (value < 1e9) return `${(value / 1e6).toFixed(1)}M`
|
|
54
|
+
return `${(value / 1e9).toFixed(2)}G`
|
|
35
55
|
}
|
|
36
56
|
}
|
|
37
57
|
|
|
@@ -6,6 +6,7 @@ export {
|
|
|
6
6
|
WORKLOAD_METRIC_CATEGORIES,
|
|
7
7
|
NODE_METRIC_CATEGORIES,
|
|
8
8
|
METRIC_TIME_RANGES,
|
|
9
|
+
describePodCoverage,
|
|
9
10
|
} from './PrometheusChartsView'
|
|
10
11
|
export type {
|
|
11
12
|
PrometheusChartsViewProps,
|
|
@@ -14,13 +15,14 @@ export type {
|
|
|
14
15
|
MetricCategoryDef,
|
|
15
16
|
PrometheusResourceMetricsResult,
|
|
16
17
|
} from './PrometheusChartsView'
|
|
17
|
-
export { SERIES_COLORS, seriesColor, seriesFill, computeShortLabels } from './colors'
|
|
18
|
+
export { SERIES_COLORS, seriesColor, seriesFill, computeShortLabels, seriesDisplayLabels } from './colors'
|
|
18
19
|
export { formatMetricValue, formatTimestamp } from './format'
|
|
19
20
|
export { computeSaturation } from './saturation'
|
|
20
21
|
export type {
|
|
21
22
|
TimeSeriesPoint,
|
|
22
23
|
TimeSeries,
|
|
23
24
|
ReferenceLine,
|
|
25
|
+
ChartAnnotation,
|
|
24
26
|
// Deprecated Prom-prefixed aliases — see types.ts.
|
|
25
27
|
PrometheusDataPoint,
|
|
26
28
|
PrometheusSeries,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { nearestSample } from './nearestSample'
|
|
3
|
+
|
|
4
|
+
const points = [
|
|
5
|
+
{ timestamp: 0, value: 0 },
|
|
6
|
+
{ timestamp: 15, value: 2 },
|
|
7
|
+
{ timestamp: 105, value: 3 },
|
|
8
|
+
{ timestamp: 120, value: 4 },
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
describe('nearestSample', () => {
|
|
12
|
+
it('withholds hover values inside missing evaluations, including a single missing slot', () => {
|
|
13
|
+
expect(nearestSample(points, 60, 15)).toBeNull()
|
|
14
|
+
expect(nearestSample([{ timestamp: 0, value: 1 }, { timestamp: 30, value: 2 }], 15, 15)).toBeNull()
|
|
15
|
+
expect(nearestSample([...points, { timestamp: 135, value: null }], 135, 15)).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps measured zero and only snaps within half an evaluation interval', () => {
|
|
19
|
+
expect(nearestSample(points, 0, 15)).toEqual(points[0])
|
|
20
|
+
expect(nearestSample(points, 110, 15)).toEqual(points[2])
|
|
21
|
+
expect(nearestSample(points, -8, 15)).toBeNull()
|
|
22
|
+
expect(nearestSample(points, 128, 15)).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('preserves unrestricted nearest-finite behavior for generic charts', () => {
|
|
26
|
+
expect(nearestSample(points, 60)).toEqual(points[1])
|
|
27
|
+
expect(nearestSample([...points, { timestamp: 135, value: null }], 135)).toEqual(points[3])
|
|
28
|
+
expect(nearestSample([], 0)).toBeNull()
|
|
29
|
+
expect(nearestSample([{ timestamp: 0, value: null }], 0)).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TimeSeriesPoint } from './types'
|
|
2
|
+
|
|
3
|
+
export function nearestSample(points: TimeSeriesPoint[], timestamp: number, stepSeconds?: number): { timestamp: number; value: number } | null {
|
|
4
|
+
let closest: { timestamp: number; value: number } | null = null
|
|
5
|
+
let distance = Infinity
|
|
6
|
+
for (const point of points) {
|
|
7
|
+
if (point.value == null) continue
|
|
8
|
+
const candidateDistance = Math.abs(point.timestamp - timestamp)
|
|
9
|
+
if (candidateDistance < distance) {
|
|
10
|
+
distance = candidateDistance
|
|
11
|
+
closest = { timestamp: point.timestamp, value: point.value }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return stepSeconds !== undefined && distance > stepSeconds / 2 ? null : closest
|
|
15
|
+
}
|
|
@@ -35,3 +35,15 @@ export interface ReferenceLine {
|
|
|
35
35
|
label: string
|
|
36
36
|
kind: 'request' | 'limit'
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Vertical marker on a chart at one instant. `kind` names what Radar recorded
|
|
41
|
+
* there; the chart draws the marker and its label and never implies that the
|
|
42
|
+
* marked instant explains the series around it.
|
|
43
|
+
*/
|
|
44
|
+
export interface ChartAnnotation {
|
|
45
|
+
/** Unix seconds, the same clock as {@link TimeSeriesPoint.timestamp}. */
|
|
46
|
+
timestamp: number
|
|
47
|
+
label: string
|
|
48
|
+
kind: 'change'
|
|
49
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { AlertCircle, AlertOctagon, AlertTriangle, ChevronDown, ChevronRight, ExternalLink, EyeOff, Info, Layers, MoreHorizontal, Search, ShieldCheck, Wrench, X } from 'lucide-react'
|
|
4
|
-
import { CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, Input, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
|
|
4
|
+
import { AlertBanner, CardBody, CardSection, ClusterName, EmptyState, FilterPill, DistributionBar, DistributionLegendChip, Input, NEUTRAL_CHIP_CLASS, renderProse } from '../ui'
|
|
5
5
|
import { useFilterState, defineFilterSchema } from '../../filter-state'
|
|
6
6
|
import type { CheckMeta, CheckReference } from '../audit'
|
|
7
7
|
import { CHECK_SEVERITIES, CHECK_SEVERITY_RANK, type Check, type CheckSeverity, type EffectiveCheckFinding, type CheckResourceRef } from './types'
|
|
@@ -56,6 +56,9 @@ export interface ChecksViewProps {
|
|
|
56
56
|
catalog: Record<string, CheckMeta>
|
|
57
57
|
/** True when at least one source returned audit data. */
|
|
58
58
|
anyData: boolean
|
|
59
|
+
/** Evaluations and unavailable inputs for the scope supplied by the host. */
|
|
60
|
+
evaluated?: number
|
|
61
|
+
missingInputs?: string[]
|
|
59
62
|
/** Deep-link href for a resource (host routing). Omit for non-link text. */
|
|
60
63
|
resourceHref?: (ref: CheckResourceRef) => string
|
|
61
64
|
/** In-app resource navigation (client-side). Takes precedence over href. */
|
|
@@ -106,7 +109,7 @@ const CHECKS_FILTER_SCHEMA = defineFilterSchema({
|
|
|
106
109
|
q: { param: 'q', type: 'text' },
|
|
107
110
|
})
|
|
108
111
|
|
|
109
|
-
export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
|
|
112
|
+
export function ChecksView({ checks, catalog, anyData, evaluated, missingInputs = [], resourceHref, onResourceClick, clusterLabel, clusterLabelById, clusterFilter: clusterFilterProp, onClusterFilterChange, emptyAction, onHideCheck, onHideCategory }: ChecksViewProps) {
|
|
110
113
|
// Severity / category / framework / search live in the URL (shareable,
|
|
111
114
|
// bookmarkable audit links) via the shared filter-state contract. The cluster
|
|
112
115
|
// facet is deliberately NOT here — it's a host-controlled seam (see
|
|
@@ -345,6 +348,14 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
|
|
|
345
348
|
</div>
|
|
346
349
|
</div>
|
|
347
350
|
|
|
351
|
+
{missingInputs.length > 0 && (
|
|
352
|
+
<AlertBanner
|
|
353
|
+
variant="warning"
|
|
354
|
+
title="Some checks could not run"
|
|
355
|
+
message={<>Findings cover only available inputs. Unavailable inputs: {missingInputs.join(', ')}.</>}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
358
|
+
|
|
348
359
|
{fleetChecks.length === 0 ? (
|
|
349
360
|
hasFilters ? (
|
|
350
361
|
<EmptyState
|
|
@@ -362,6 +373,13 @@ export function ChecksView({ checks, catalog, anyData, resourceHref, onResourceC
|
|
|
362
373
|
</button>
|
|
363
374
|
}
|
|
364
375
|
/>
|
|
376
|
+
) : missingInputs.length > 0 ? (
|
|
377
|
+
<EmptyState
|
|
378
|
+
headline="No findings in the available data"
|
|
379
|
+
body="The audit is incomplete. This does not mean all checks passed."
|
|
380
|
+
/>
|
|
381
|
+
) : evaluated === 0 ? (
|
|
382
|
+
<EmptyState headline="No resources evaluated" body="No resources were evaluated with the current scope and check settings." />
|
|
365
383
|
) : anyData ? (
|
|
366
384
|
<EmptyState
|
|
367
385
|
tone="healthy"
|
|
@@ -4,6 +4,7 @@ import { PaneLoader } from '../ui/PaneLoader'
|
|
|
4
4
|
|
|
5
5
|
import { HealthStatusBadge, SyncStatusBadge } from './GitOpsStatusBadge'
|
|
6
6
|
import { GitOpsIssuesBand, GitOpsStatusStrip } from './insights'
|
|
7
|
+
import { GitOpsHealthSourceNotice } from './GitOpsHealthSourceNotice'
|
|
7
8
|
import { Tooltip } from '../ui/Tooltip'
|
|
8
9
|
import type { GitOpsHealthStatus, GitOpsInsight, GitOpsIssue, GitOpsRemediation, SyncStatus } from '../../types'
|
|
9
10
|
|
|
@@ -138,6 +139,14 @@ export interface GitOpsDetailLayoutProps {
|
|
|
138
139
|
// on the OSS-specific CodeViewer component.
|
|
139
140
|
helmValuesContent?: ReactNode
|
|
140
141
|
|
|
142
|
+
// GitOpsHealthSourceNotice inputs: where its "Why?" link goes, and what
|
|
143
|
+
// (if anything) to say after the remote-destination sentence. Both are
|
|
144
|
+
// host decisions; the notice itself keys off `insight.summary`.
|
|
145
|
+
healthDocsUrl?: string
|
|
146
|
+
remoteDestinationHint?: ReactNode
|
|
147
|
+
// Opens the host's Argo CD settings, when it has such a place.
|
|
148
|
+
onOpenSettings?: () => void
|
|
149
|
+
|
|
141
150
|
// Action buttons — Argo + Flux. Exactly one applies for any given CR.
|
|
142
151
|
isArgoApp: boolean
|
|
143
152
|
isFlux: boolean
|
|
@@ -222,6 +231,9 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
|
|
|
222
231
|
helmValuesOpen,
|
|
223
232
|
onToggleHelmValues,
|
|
224
233
|
helmValuesContent,
|
|
234
|
+
healthDocsUrl,
|
|
235
|
+
remoteDestinationHint,
|
|
236
|
+
onOpenSettings,
|
|
225
237
|
isArgoApp,
|
|
226
238
|
isFlux,
|
|
227
239
|
isFluxWorkload,
|
|
@@ -458,6 +470,7 @@ export function GitOpsDetailLayout(props: GitOpsDetailLayoutProps) {
|
|
|
458
470
|
{!fullscreen && (
|
|
459
471
|
<>
|
|
460
472
|
<GitOpsStatusStrip insight={insight ?? undefined} loading={insightLoading} renderRevisionMeta={renderRevisionMeta} />
|
|
473
|
+
<GitOpsHealthSourceNotice summary={insight?.summary} changes={insight?.changes} docsUrl={healthDocsUrl} remoteDestinationHint={remoteDestinationHint} onOpenSettings={onOpenSettings} />
|
|
461
474
|
<GitOpsIssuesBand
|
|
462
475
|
issues={insight?.issues}
|
|
463
476
|
terminating={terminating}
|
|
@@ -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">
|