@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.
Files changed (85) 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 +173 -0
  5. package/src/components/charts/AreaChart.tsx +208 -53
  6. package/src/components/charts/PrometheusChartsView.test.tsx +36 -0
  7. package/src/components/charts/PrometheusChartsView.tsx +205 -76
  8. package/src/components/charts/SeriesLegend.tsx +8 -3
  9. package/src/components/charts/annotations.ts +59 -0
  10. package/src/components/charts/axis.test.ts +48 -0
  11. package/src/components/charts/axis.ts +62 -0
  12. package/src/components/charts/colors.test.ts +27 -0
  13. package/src/components/charts/colors.ts +23 -0
  14. package/src/components/charts/format.test.ts +19 -0
  15. package/src/components/charts/format.ts +21 -1
  16. package/src/components/charts/index.ts +3 -1
  17. package/src/components/charts/nearestSample.test.ts +31 -0
  18. package/src/components/charts/nearestSample.ts +15 -0
  19. package/src/components/charts/types.ts +12 -0
  20. package/src/components/checks/ChecksView.tsx +20 -2
  21. package/src/components/gitops/GitOpsDetailLayout.tsx +13 -0
  22. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +141 -0
  23. package/src/components/gitops/health-provenance.test.ts +54 -0
  24. package/src/components/gitops/health-provenance.ts +24 -0
  25. package/src/components/gitops/index.ts +2 -0
  26. package/src/components/gitops/insights/GitOpsInsightViews.tsx +40 -21
  27. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +59 -8
  28. package/src/components/gitops/tree/merge.test.ts +34 -0
  29. package/src/components/gitops/tree/merge.ts +7 -0
  30. package/src/components/resources/HPADiagnosisSummary.test.tsx +178 -0
  31. package/src/components/resources/HPADiagnosisSummary.tsx +218 -0
  32. package/src/components/resources/ResourcesView.tsx +67 -2
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/index.ts +7 -0
  35. package/src/components/resources/renderers/AnalysisTemplateRenderer.tsx +150 -0
  36. package/src/components/resources/renderers/ConfigMapRenderer.tsx +8 -1
  37. package/src/components/resources/renderers/ExperimentRenderer.tsx +179 -0
  38. package/src/components/resources/renderers/HPARenderer.tsx +4 -57
  39. package/src/components/resources/renderers/LimitRangeRenderer.test.tsx +156 -0
  40. package/src/components/resources/renderers/LimitRangeRenderer.tsx +277 -0
  41. package/src/components/resources/renderers/NamespaceRenderer.tsx +24 -1
  42. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +9 -16
  43. package/src/components/resources/renderers/NetworkPolicyRenderer.test.tsx +75 -0
  44. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +50 -36
  45. package/src/components/resources/renderers/PodRenderer.tsx +11 -0
  46. package/src/components/resources/renderers/ReflectorSection.test.tsx +281 -0
  47. package/src/components/resources/renderers/ReflectorSection.tsx +296 -0
  48. package/src/components/resources/renderers/RolloutRenderer.test.tsx +117 -0
  49. package/src/components/resources/renderers/RolloutRenderer.tsx +219 -56
  50. package/src/components/resources/renderers/SecretRenderer.tsx +10 -3
  51. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -21
  52. package/src/components/resources/renderers/index.ts +3 -0
  53. package/src/components/resources/renderers/rollout/CanaryStepTimeline.tsx +199 -0
  54. package/src/components/resources/renderers/rollout/ReplicaSetProgression.tsx +116 -0
  55. package/src/components/resources/resource-utils.ts +18 -9
  56. package/src/components/shared/CreateResourceDialog.tsx +231 -11
  57. package/src/components/shared/DrainPlanDialog.test.tsx +154 -0
  58. package/src/components/shared/DrainPlanDialog.tsx +297 -0
  59. package/src/components/shared/ResourceActionsBar.tsx +53 -26
  60. package/src/components/shared/ResourceRendererDispatch.test.tsx +45 -0
  61. package/src/components/shared/ResourceRendererDispatch.tsx +27 -6
  62. package/src/components/shared/index.ts +4 -0
  63. package/src/components/topology/K8sResourceNode.test.ts +9 -0
  64. package/src/components/topology/K8sResourceNode.tsx +56 -5
  65. package/src/components/ui/Badge.tsx +5 -1
  66. package/src/components/ui/FetchResult.test.tsx +20 -2
  67. package/src/components/ui/FetchResult.tsx +33 -3
  68. package/src/components/ui/FreshnessControl.tsx +8 -2
  69. package/src/components/ui/index.ts +5 -0
  70. package/src/components/ui/monacoRuntime.ts +14 -0
  71. package/src/components/workload/WorkloadView.tsx +2 -2
  72. package/src/monaco-deep.d.ts +10 -0
  73. package/src/theme/components.css +16 -0
  74. package/src/types/core.ts +38 -11
  75. package/src/types/gitops-insights.ts +23 -0
  76. package/src/types/gitops-tree.ts +29 -0
  77. package/src/utils/index.ts +1 -0
  78. package/src/utils/navigation.test.ts +20 -1
  79. package/src/utils/navigation.ts +25 -0
  80. package/src/utils/network-policy.test.ts +42 -0
  81. package/src/utils/network-policy.ts +59 -0
  82. package/src/utils/reflector.ts +139 -0
  83. package/src/utils/yaml-file-import.test.ts +189 -0
  84. package/src/utils/yaml-file-import.ts +95 -0
  85. 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 / 1000).toFixed(1)}k`
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 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">