@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
@@ -20,10 +20,11 @@ import { PaneLoader } from '../../ui/PaneLoader'
20
20
  import { Input } from '../../ui/Input'
21
21
  import { clsx } from 'clsx'
22
22
 
23
- import type { GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
23
+ import type { GitOpsIssue, GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
24
24
  import { displayKind } from '../../../types'
25
25
  import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
26
26
  import { formatCompactAge } from '../../../utils/format'
27
+ import { radarHealthNote } from '../health-provenance'
27
28
  import { getTopologyIcon } from '../../../utils/resource-icons'
28
29
  import { Tooltip } from '../../ui/Tooltip'
29
30
  import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
@@ -64,6 +65,22 @@ interface GitOpsTreeGraphProps {
64
65
  onQueryChange?: (query: string) => void
65
66
  filters?: GitOpsTreeFilters
66
67
  showToolbar?: boolean
68
+ // Root-cause issues from the same detail page's insights fetch (already
69
+ // loaded alongside the tree — no extra request). Matched onto tree nodes
70
+ // by ref so an unhealthy node's hover reveals WHY, not just THAT.
71
+ issues?: GitOpsIssue[]
72
+ }
73
+
74
+ // refKey builds a stable lookup key from either a tree ref or an insight
75
+ // ref. Namespace is omitted for cluster-scoped resources on both sides, so
76
+ // normalize the missing case the same way instead of using `undefined` vs
77
+ // `''` as accidentally distinct keys. Group is included for the same
78
+ // reason CLAUDE.md calls out elsewhere (Knative Service vs core Service,
79
+ // CNPG Cluster vs CAPI Cluster) — kind+namespace+name alone collides across
80
+ // a real, documented class of same-plural CRDs, which would show one
81
+ // resource's cause/tooltip on a completely unrelated node.
82
+ function refKey(ref: { group?: string; kind: string; namespace?: string; name: string }): string {
83
+ return `${ref.group ?? ''}/${ref.kind}/${ref.namespace ?? ''}/${ref.name}`
67
84
  }
68
85
 
69
86
  export function GitOpsTreeGraph(props: GitOpsTreeGraphProps) {
@@ -85,6 +102,7 @@ function GitOpsTreeGraphInner({
85
102
  onQueryChange,
86
103
  filters,
87
104
  showToolbar = true,
105
+ issues,
88
106
  }: GitOpsTreeGraphProps) {
89
107
  const [internalPreset, setInternalPreset] = useState<GitOpsTreePreset>('compact')
90
108
  const [internalQuery, setInternalQuery] = useState('')
@@ -104,7 +122,20 @@ function GitOpsTreeGraphInner({
104
122
  useEffect(() => {
105
123
  if (preset !== 'compact') setExpandedGroups(new Set())
106
124
  }, [preset])
107
- const { nodes, edges } = useMemo(() => buildFlowGraph(tree, preset, query, filters, expandedGroups), [tree, preset, query, filters, expandedGroups])
125
+ const causeByRef = useMemo(() => {
126
+ const map = new Map<string, string>()
127
+ for (const issue of issues ?? []) {
128
+ const cause = issue.cause
129
+ const ref = issue.refs?.[0]
130
+ if (!cause || !ref) continue
131
+ const key = refKey(ref)
132
+ // First match wins — issues arrive ranked by severity, so a node
133
+ // that's the subject of multiple issues keeps its most severe cause.
134
+ if (!map.has(key)) map.set(key, cause)
135
+ }
136
+ return map
137
+ }, [issues])
138
+ const { nodes, edges } = useMemo(() => buildFlowGraph(tree, preset, query, filters, expandedGroups, causeByRef), [tree, preset, query, filters, expandedGroups, causeByRef])
108
139
 
109
140
  useEffect(() => {
110
141
  if (nodes.length === 0) return
@@ -297,7 +328,7 @@ function getEdgeColor(type: string): string {
297
328
  }
298
329
  }
299
330
 
300
- function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>): { nodes: Node[]; edges: Edge[] } {
331
+ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>, causeByRef?: Map<string, string>): { nodes: Node[]; edges: Edge[] } {
301
332
  if (!tree) return { nodes: [], edges: [] }
302
333
  const visibleTree = applyGraphFilters(applyPreset(tree, preset, expandedGroups), filters)
303
334
  const byID = new Map(visibleTree.nodes.map(node => [node.id, node]))
@@ -321,6 +352,7 @@ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePrese
321
352
  data: {
322
353
  node,
323
354
  highlighted: normalizedQuery !== '' && matchesQuery(node, normalizedQuery),
355
+ cause: causeByRef?.get(refKey(node.ref)),
324
356
  },
325
357
  }
326
358
  })
@@ -543,7 +575,7 @@ function positionRanks(ranks: Map<number, string[]>, nodes: Map<string, GitOpsTr
543
575
  return positioned
544
576
  }
545
577
 
546
- const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean }>>) {
578
+ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean; cause?: string }>>) {
547
579
  const node = data.node
548
580
  const kind = normalizeDisplayKind(node)
549
581
  const status = normalizeHealth(node.topologyStatus)
@@ -552,10 +584,16 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
552
584
  const chips = buildChips(node)
553
585
  const dim = getNodeDimensions(node)
554
586
  const KindIcon = getTopologyIcon(kind)
555
-
556
- return (
557
- <>
558
- <Handle type="target" position={Position.Left} className="!h-0 !w-0 !border-0 !bg-transparent" />
587
+ // Only unhealthy nodes carry a cause worth surfacing — a healthy node
588
+ // matched by a stale/unrelated issue ref shouldn't show a tooltip.
589
+ const cause = status === 'unhealthy' || status === 'degraded' ? data.cause : undefined
590
+ // Only declared resources can carry the controller's verdict, so only
591
+ // there does "this one is Radar's" mean anything. Generated children
592
+ // (ReplicaSets, Pods) are always Radar's read; marking each would be
593
+ // noise.
594
+ const radarNote = node.role === 'declared' ? radarHealthNote(node) : ''
595
+
596
+ const card = (
559
597
  <div
560
598
  className={clsx(
561
599
  'relative overflow-hidden rounded-lg border bg-theme-surface shadow-md transition-colors',
@@ -636,10 +674,23 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
636
674
  {chip.label ? `${chip.label}: ` : ''}{chip.value}
637
675
  </span>
638
676
  ))}
677
+ {radarNote && (
678
+ <Tooltip content={radarNote} delay={200}>
679
+ <span className="rounded border border-theme-border bg-theme-elevated/70 px-1.5 py-0.5 text-[10px] leading-3 text-theme-text-tertiary">
680
+ Found by Radar
681
+ </span>
682
+ </Tooltip>
683
+ )}
639
684
  </div>
640
685
  )}
641
686
  </div>
642
687
  </div>
688
+ )
689
+
690
+ return (
691
+ <>
692
+ <Handle type="target" position={Position.Left} className="!h-0 !w-0 !border-0 !bg-transparent" />
693
+ {cause ? <Tooltip content={cause} delay={200}>{card}</Tooltip> : card}
643
694
  <Handle type="source" position={Position.Right} className="!h-0 !w-0 !border-0 !bg-transparent" />
644
695
  </>
645
696
  )
@@ -41,6 +41,40 @@ function destNode(
41
41
  }
42
42
 
43
43
  describe('mergeGitOpsTrees', () => {
44
+ // Health provenance follows the health value: when the destination's live
45
+ // health replaces the controller's, its source/reason/message come along;
46
+ // a destination node with no health leaves the controller's provenance.
47
+ test('health provenance travels with whichever health wins', () => {
48
+ const controller: GitOpsResourceTree = {
49
+ root: ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
50
+ nodes: [
51
+ ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
52
+ ctrlNode('dep1', 'Deployment', 'web', 'prod', { health: 'Healthy', healthSource: 'controller' }),
53
+ ctrlNode('css1', 'ClusterSecretStore', 'platform', '', { health: 'Degraded', healthSource: 'radar', healthReason: 'Ready: Bad', healthMessage: 'no route' }),
54
+ ],
55
+ edges: [],
56
+ }
57
+ const destination: GitOpsResourceTree = {
58
+ root: destNode('dest-root', 'Application', 'app1', 'argocd', { role: 'root' }),
59
+ nodes: [
60
+ destNode('dest-root', 'Application', 'app1', 'argocd', { role: 'root' }),
61
+ destNode('dest-dep1', 'Deployment', 'web', 'prod', { health: 'Degraded', healthSource: 'radar', healthReason: 'CrashLoopBackOff', healthMessage: 'back-off' }),
62
+ destNode('dest-css1', 'ClusterSecretStore', 'platform', ''),
63
+ ],
64
+ edges: [],
65
+ }
66
+ const merged = mergeGitOpsTrees(controller, destination)
67
+ const dep = merged.nodes.find((n) => n.ref.kind === 'Deployment')!
68
+ expect(dep.health).toBe('Degraded')
69
+ expect(dep.healthSource).toBe('radar')
70
+ expect(dep.healthReason).toBe('CrashLoopBackOff')
71
+ expect(dep.healthMessage).toBe('back-off')
72
+ const css = merged.nodes.find((n) => n.ref.kind === 'ClusterSecretStore')!
73
+ expect(css.health).toBe('Degraded')
74
+ expect(css.healthSource).toBe('radar')
75
+ expect(css.healthReason).toBe('Ready: Bad')
76
+ })
77
+
44
78
  // The classic in-cluster case — caller didn't fetch a destination tree
45
79
  // (single-cluster app) so destination is null. Returning the controller
46
80
  // tree unchanged is what preserves Argo's status + summary tile counts
@@ -85,10 +85,17 @@ export function mergeGitOpsTrees(
85
85
  // resource-viewer routing.
86
86
  const nodes: GitOpsTreeNode[] = controller.nodes.map((n) => {
87
87
  const dest = destByKey.get(refKey(n.ref))
88
+ // Health provenance travels with the health it describes: when the
89
+ // destination's live health wins, so do its source/reason/message.
90
+ const healthFrom = dest?.health ? dest : n
88
91
  const merged = dest
89
92
  ? {
90
93
  ...n,
91
94
  health: dest.health ?? n.health,
95
+ healthSource: healthFrom.healthSource,
96
+ healthReason: healthFrom.healthReason,
97
+ healthMessage: healthFrom.healthMessage,
98
+ healthSeverity: healthFrom.healthSeverity,
92
99
  info: dest.info ?? n.info,
93
100
  topologyStatus: dest.topologyStatus ?? n.topologyStatus,
94
101
  }
@@ -0,0 +1,178 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { renderToString } from "react-dom/server";
3
+ import {
4
+ HPADiagnosisSummary,
5
+ hpaBadgeSeverity,
6
+ hpaReasonGroups,
7
+ isHPAReasonRedundant,
8
+ } from "./HPADiagnosisSummary";
9
+ import type { HPADiagnosisState, HPADiagnosisView } from "../../types";
10
+
11
+ // React marks interpolation boundaries with empty comments; strip them so
12
+ // assertions can quote the sentence a reader actually sees.
13
+ const render = (node: Parameters<typeof renderToString>[0]) =>
14
+ renderToString(node).replace(/<!-- -->/g, "");
15
+
16
+ const maxed: HPADiagnosisView = {
17
+ state: "limited_max",
18
+ summary: "HPA wants more replicas but is capped at maxReplicas=10",
19
+ bounds: { min: 1, max: 10, current: 10, desired: 10 },
20
+ reasons: [
21
+ {
22
+ id: "limited_max",
23
+ message: "HPA is capped at maxReplicas=10",
24
+ detail:
25
+ "the desired replica count is more than the maximum replica count",
26
+ conditionType: "ScalingLimited",
27
+ conditionReason: "TooManyReplicas",
28
+ },
29
+ {
30
+ id: "missing_current_metric",
31
+ message: "HPA is missing current metric values",
32
+ detail: "cpu",
33
+ },
34
+ ],
35
+ };
36
+
37
+ describe("hpaBadgeSeverity", () => {
38
+ const expected: Record<HPADiagnosisState, string> = {
39
+ ok: "success",
40
+ scaling_up: "warning",
41
+ scaling_down: "warning",
42
+ limited_max: "warning",
43
+ metrics_incomplete: "warning",
44
+ limited_min: "info",
45
+ scaled_to_zero: "info",
46
+ disabled: "info",
47
+ pinned: "info",
48
+ stabilized: "info",
49
+ metrics_unavailable: "error",
50
+ unable_to_scale: "error",
51
+ stale: "neutral",
52
+ unknown: "neutral",
53
+ };
54
+
55
+ it.each(Object.entries(expected))(
56
+ "maps %s to the %s badge",
57
+ (state, severity) => {
58
+ expect(hpaBadgeSeverity(state as HPADiagnosisState)).toBe(severity);
59
+ },
60
+ );
61
+ });
62
+
63
+ describe("isHPAReasonRedundant", () => {
64
+ it("treats the reason that named the state as a restatement", () => {
65
+ expect(isHPAReasonRedundant(maxed, maxed.reasons![0])).toBe(true);
66
+ });
67
+
68
+ it("treats a reason whose message repeats the summary as a restatement", () => {
69
+ const echo = { id: "other", message: maxed.summary };
70
+ expect(isHPAReasonRedundant(maxed, echo)).toBe(true);
71
+ });
72
+
73
+ it("keeps a reason that adds something the headline does not", () => {
74
+ expect(isHPAReasonRedundant(maxed, maxed.reasons![1])).toBe(false);
75
+ });
76
+
77
+ it("splits reasons into restatements and additions", () => {
78
+ const groups = hpaReasonGroups(maxed);
79
+ expect(groups.redundant.map((reason) => reason.id)).toEqual([
80
+ "limited_max",
81
+ ]);
82
+ expect(groups.additional.map((reason) => reason.id)).toEqual([
83
+ "missing_current_metric",
84
+ ]);
85
+ });
86
+ });
87
+
88
+ describe("HPADiagnosisSummary", () => {
89
+ it("shows the detail variant evidence without repeating the headline", () => {
90
+ const html = render(
91
+ <HPADiagnosisSummary diagnosis={maxed} variant="detail" />,
92
+ );
93
+
94
+ expect(html).toContain(
95
+ "HPA wants more replicas but is capped at maxReplicas=10",
96
+ );
97
+ expect(html).toContain("10/10 replicas, bounds 1-10");
98
+ expect(html).toContain("Maxed");
99
+ expect(html).toContain("Evidence");
100
+ expect(html).toContain("limited max");
101
+ expect(html).toContain("ScalingLimited");
102
+ expect(html).toContain("TooManyReplicas");
103
+ expect(html).not.toContain("HPA is capped at maxReplicas=10");
104
+ expect(html).toContain("HPA is missing current metric values");
105
+ });
106
+
107
+ // `detail` is the controller's sentence only on a condition-built reason.
108
+ // On the others it is Radar's own — a metric-name list, a note about an
109
+ // unobserved generation — and quoting it as Kubernetes invents a quote.
110
+ it("never attributes Radar's own detail to Kubernetes", () => {
111
+ const radarDetail: HPADiagnosisView = {
112
+ state: "stale_status",
113
+ summary: "HPA status has not observed the latest spec generation yet",
114
+ bounds: { min: 1, max: 10, current: 3, desired: 3 },
115
+ reasons: [
116
+ {
117
+ id: "stale_status",
118
+ message: "HPA status has not observed the latest spec generation yet",
119
+ detail: "the controller has recorded no status for generation 7",
120
+ },
121
+ {
122
+ id: "missing_current_metric",
123
+ message:
124
+ "HPA status is missing current values for one or more configured metrics",
125
+ detail: "cpu, memory",
126
+ },
127
+ ],
128
+ } as unknown as HPADiagnosisView;
129
+
130
+ for (const variant of ["detail", "inline"] as const) {
131
+ const html = render(
132
+ <HPADiagnosisSummary diagnosis={radarDetail} variant={variant} />,
133
+ );
134
+ expect(html, variant).toContain(
135
+ "the controller has recorded no status for generation 7",
136
+ );
137
+ expect(html, variant).toContain("cpu, memory");
138
+ expect(html, variant).not.toContain("Kubernetes");
139
+ }
140
+ });
141
+
142
+ it("attributes the controller condition in the inline variant and lists the rest", () => {
143
+ const html = render(
144
+ <HPADiagnosisSummary
145
+ diagnosis={maxed}
146
+ variant="inline"
147
+ header={
148
+ <span data-testid="host-header">HorizontalPodAutoscaler api-hpa</span>
149
+ }
150
+ />,
151
+ );
152
+
153
+ expect(html).toContain("HorizontalPodAutoscaler api-hpa");
154
+ expect(html).toContain("Maxed");
155
+ expect(html).toContain("10/10 replicas · bounds 1-10");
156
+ expect(html).toContain(
157
+ "HPA wants more replicas but is capped at maxReplicas=10",
158
+ );
159
+ expect(html).not.toContain("HPA is capped at maxReplicas=10");
160
+ expect(html).toContain(
161
+ "Kubernetes ScalingLimited · TooManyReplicas: “the desired replica count is more than the maximum replica count”",
162
+ );
163
+ expect(html).toContain("HPA is missing current metric values");
164
+ expect(html).toContain("· cpu");
165
+ });
166
+
167
+ it("omits the bounds line when the producer withheld bounds", () => {
168
+ const html = render(
169
+ <HPADiagnosisSummary
170
+ diagnosis={{ state: "ok", summary: "HPA is tracking its target" }}
171
+ variant="inline"
172
+ />,
173
+ );
174
+
175
+ expect(html).toContain("HPA is tracking its target");
176
+ expect(html).not.toContain("replicas");
177
+ });
178
+ });
@@ -0,0 +1,218 @@
1
+ import type { ReactNode } from "react";
2
+ import { Badge, type BadgeSeverity } from "../ui/Badge";
3
+ import { StatusDot } from "../ui/status-tone";
4
+ import { hpaStateLabel, hpaStateLevel } from "./resource-utils-hpa";
5
+ import type {
6
+ HPADiagnosisState,
7
+ HPADiagnosisView,
8
+ HPAReasonSummary,
9
+ } from "../../types";
10
+
11
+ export function hpaBadgeSeverity(state: HPADiagnosisState): BadgeSeverity {
12
+ switch (hpaStateLevel(state)) {
13
+ case "healthy":
14
+ return "success";
15
+ case "unhealthy":
16
+ return "error";
17
+ case "degraded":
18
+ return "warning";
19
+ case "alert":
20
+ return "alert";
21
+ case "neutral":
22
+ return "info";
23
+ default:
24
+ return "neutral";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * A reason that named the state, or repeats the summary verbatim, says the
30
+ * headline over again — only the controller's own sentence behind it (the
31
+ * reason's detail) adds anything a reader does not already have.
32
+ */
33
+ export function isHPAReasonRedundant(
34
+ diagnosis: HPADiagnosisView,
35
+ reason: HPAReasonSummary,
36
+ ): boolean {
37
+ return reason.id === diagnosis.state || reason.message === diagnosis.summary;
38
+ }
39
+
40
+ export interface HPAReasonGroups {
41
+ /** Reasons whose message restates the headline; render their detail, not their message. */
42
+ redundant: HPAReasonSummary[];
43
+ /** Reasons that carry something the headline does not. */
44
+ additional: HPAReasonSummary[];
45
+ }
46
+
47
+ export function hpaReasonGroups(diagnosis: HPADiagnosisView): HPAReasonGroups {
48
+ const reasons = diagnosis.reasons ?? [];
49
+ const redundant = reasons.filter((reason) =>
50
+ isHPAReasonRedundant(diagnosis, reason),
51
+ );
52
+ return {
53
+ redundant,
54
+ additional: reasons.filter((reason) => !redundant.includes(reason)),
55
+ };
56
+ }
57
+
58
+ function formatReasonID(id: string): string {
59
+ return id.replace(/_/g, " ");
60
+ }
61
+
62
+ export interface HPADiagnosisSummaryProps {
63
+ diagnosis: HPADiagnosisView;
64
+ /**
65
+ * `detail` — the HPA detail page: a boxed Evidence block per reason.
66
+ * `inline` — an evidence card on the workload the HPA scales: the headline
67
+ * once, then the controller's condition quoted and attributed.
68
+ */
69
+ variant: "detail" | "inline";
70
+ /**
71
+ * Host-rendered identity for the `inline` variant (the scaler's name, owner
72
+ * links) placed before the state indicator. Navigation stays with the host.
73
+ */
74
+ header?: ReactNode;
75
+ }
76
+
77
+ export function HPADiagnosisSummary({
78
+ diagnosis,
79
+ variant,
80
+ header,
81
+ }: HPADiagnosisSummaryProps) {
82
+ const { redundant, additional } = hpaReasonGroups(diagnosis);
83
+ const bounds = diagnosis.bounds;
84
+
85
+ if (variant === "inline") {
86
+ return (
87
+ <div className="space-y-1 text-xs">
88
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
89
+ {header}
90
+ <span className="inline-flex items-center gap-1.5 text-theme-text-secondary">
91
+ <StatusDot
92
+ tone={hpaStateLevel(diagnosis.state)}
93
+ className="shrink-0"
94
+ />
95
+ {hpaStateLabel(diagnosis.state)}
96
+ </span>
97
+ {bounds ? (
98
+ <span className="font-mono tabular-nums text-theme-text-tertiary">
99
+ {bounds.current}/{bounds.desired} replicas · bounds {bounds.min}-
100
+ {bounds.max}
101
+ </span>
102
+ ) : null}
103
+ </div>
104
+ <p className="text-theme-text-secondary">{diagnosis.summary}</p>
105
+ {redundant.map((reason) =>
106
+ reason.detail ? (
107
+ <p
108
+ key={`${reason.id}-${reason.detail}`}
109
+ className="text-theme-text-tertiary"
110
+ >
111
+ {/* Same rule as the detail variant: only a condition-built
112
+ reason carries the controller's own sentence. */}
113
+ {reason.conditionType ? (
114
+ <>
115
+ Kubernetes {reason.conditionType}
116
+ {reason.conditionReason ? ` · ${reason.conditionReason}` : ""}
117
+ : &ldquo;{reason.detail}&rdquo;
118
+ </>
119
+ ) : (
120
+ reason.detail
121
+ )}
122
+ </p>
123
+ ) : null,
124
+ )}
125
+ {additional.length > 0 ? (
126
+ <ul className="list-disc space-y-0.5 pl-4 text-theme-text-secondary marker:text-theme-text-tertiary">
127
+ {additional.map((reason) => (
128
+ <li key={`${reason.id}-${reason.message}`}>
129
+ {reason.message}
130
+ {reason.detail ? (
131
+ <span className="text-theme-text-tertiary">
132
+ {" "}
133
+ · {reason.detail}
134
+ </span>
135
+ ) : null}
136
+ </li>
137
+ ))}
138
+ </ul>
139
+ ) : null}
140
+ </div>
141
+ );
142
+ }
143
+
144
+ const reasons = diagnosis.reasons ?? [];
145
+ return (
146
+ <div className="card-inner space-y-3">
147
+ <div className="flex flex-wrap items-start justify-between gap-2">
148
+ <div className="min-w-0">
149
+ <div className="text-sm font-medium text-theme-text-primary">
150
+ {diagnosis.summary}
151
+ </div>
152
+ {bounds ? (
153
+ <div className="mt-1 text-xs text-theme-text-secondary">
154
+ {bounds.current}/{bounds.desired} replicas, bounds {bounds.min}-
155
+ {bounds.max}
156
+ </div>
157
+ ) : null}
158
+ </div>
159
+ <Badge severity={hpaBadgeSeverity(diagnosis.state)}>
160
+ {hpaStateLabel(diagnosis.state)}
161
+ </Badge>
162
+ </div>
163
+ {reasons.length > 0 && (
164
+ <div className="space-y-2">
165
+ {reasons.map((reason) => (
166
+ <div
167
+ key={`${reason.id}-${reason.message}`}
168
+ className="rounded border border-theme-border bg-theme-surface p-2"
169
+ >
170
+ <div className="flex flex-wrap items-center gap-2 text-xs">
171
+ <span className="font-medium text-theme-text-primary">
172
+ Evidence
173
+ </span>
174
+ <span className="text-theme-text-tertiary">
175
+ {formatReasonID(reason.id)}
176
+ </span>
177
+ {reason.conditionType && (
178
+ <span className="text-theme-text-tertiary">
179
+ {reason.conditionType}
180
+ </span>
181
+ )}
182
+ {reason.conditionReason && (
183
+ <span className="text-theme-text-tertiary">
184
+ {reason.conditionReason}
185
+ </span>
186
+ )}
187
+ </div>
188
+ {/* Radar's reading leads, as the reason is built to; the
189
+ controller's own sentence follows it, quoted and attributed
190
+ the same way the inline variant attributes it. Unlabelled
191
+ beside the condition chips, it read as Radar's words. */}
192
+ {!isHPAReasonRedundant(diagnosis, reason) && (
193
+ <div className="mt-1 text-xs text-theme-text-secondary">
194
+ {reason.message}
195
+ </div>
196
+ )}
197
+ {reason.detail &&
198
+ // `detail` is the controller's own sentence only on a reason
199
+ // built from a condition, which is the only kind that carries
200
+ // a condition type. Elsewhere it is Radar's — a list of metric
201
+ // names, a note about an unobserved generation — and quoting
202
+ // that as Kubernetes would put words in its mouth.
203
+ (reason.conditionType ? (
204
+ <p className="mt-1 text-xs text-theme-text-tertiary">
205
+ Kubernetes: &ldquo;{reason.detail}&rdquo;
206
+ </p>
207
+ ) : (
208
+ <p className="mt-1 text-xs text-theme-text-tertiary">
209
+ {reason.detail}
210
+ </p>
211
+ ))}
212
+ </div>
213
+ ))}
214
+ </div>
215
+ )}
216
+ </div>
217
+ );
218
+ }