@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
|
@@ -20,10 +20,11 @@ import { PaneLoader } from '../../ui/PaneLoader'
|
|
|
20
20
|
import { Input } from '../../ui/Input'
|
|
21
21
|
import { clsx } from 'clsx'
|
|
22
22
|
|
|
23
|
-
import type { GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
|
|
23
|
+
import type { GitOpsIssue, GitOpsResourceTree, GitOpsTreeNode, GitOpsTreeRef, HealthStatus } from '../../../types'
|
|
24
24
|
import { displayKind } from '../../../types'
|
|
25
25
|
import { healthToSeverity, SEVERITY_DOT } from '../../../utils/badge-colors'
|
|
26
26
|
import { formatCompactAge } from '../../../utils/format'
|
|
27
|
+
import { radarHealthNote } from '../health-provenance'
|
|
27
28
|
import { getTopologyIcon } from '../../../utils/resource-icons'
|
|
28
29
|
import { Tooltip } from '../../ui/Tooltip'
|
|
29
30
|
import { hasGitOpsTreeFilters, matchesGitOpsTreeFilters, type GitOpsTreeFilters } from './tree-helpers'
|
|
@@ -64,6 +65,22 @@ interface GitOpsTreeGraphProps {
|
|
|
64
65
|
onQueryChange?: (query: string) => void
|
|
65
66
|
filters?: GitOpsTreeFilters
|
|
66
67
|
showToolbar?: boolean
|
|
68
|
+
// Root-cause issues from the same detail page's insights fetch (already
|
|
69
|
+
// loaded alongside the tree — no extra request). Matched onto tree nodes
|
|
70
|
+
// by ref so an unhealthy node's hover reveals WHY, not just THAT.
|
|
71
|
+
issues?: GitOpsIssue[]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// refKey builds a stable lookup key from either a tree ref or an insight
|
|
75
|
+
// ref. Namespace is omitted for cluster-scoped resources on both sides, so
|
|
76
|
+
// normalize the missing case the same way instead of using `undefined` vs
|
|
77
|
+
// `''` as accidentally distinct keys. Group is included for the same
|
|
78
|
+
// reason CLAUDE.md calls out elsewhere (Knative Service vs core Service,
|
|
79
|
+
// CNPG Cluster vs CAPI Cluster) — kind+namespace+name alone collides across
|
|
80
|
+
// a real, documented class of same-plural CRDs, which would show one
|
|
81
|
+
// resource's cause/tooltip on a completely unrelated node.
|
|
82
|
+
function refKey(ref: { group?: string; kind: string; namespace?: string; name: string }): string {
|
|
83
|
+
return `${ref.group ?? ''}/${ref.kind}/${ref.namespace ?? ''}/${ref.name}`
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
export function GitOpsTreeGraph(props: GitOpsTreeGraphProps) {
|
|
@@ -85,6 +102,7 @@ function GitOpsTreeGraphInner({
|
|
|
85
102
|
onQueryChange,
|
|
86
103
|
filters,
|
|
87
104
|
showToolbar = true,
|
|
105
|
+
issues,
|
|
88
106
|
}: GitOpsTreeGraphProps) {
|
|
89
107
|
const [internalPreset, setInternalPreset] = useState<GitOpsTreePreset>('compact')
|
|
90
108
|
const [internalQuery, setInternalQuery] = useState('')
|
|
@@ -104,7 +122,20 @@ function GitOpsTreeGraphInner({
|
|
|
104
122
|
useEffect(() => {
|
|
105
123
|
if (preset !== 'compact') setExpandedGroups(new Set())
|
|
106
124
|
}, [preset])
|
|
107
|
-
const
|
|
125
|
+
const causeByRef = useMemo(() => {
|
|
126
|
+
const map = new Map<string, string>()
|
|
127
|
+
for (const issue of issues ?? []) {
|
|
128
|
+
const cause = issue.cause
|
|
129
|
+
const ref = issue.refs?.[0]
|
|
130
|
+
if (!cause || !ref) continue
|
|
131
|
+
const key = refKey(ref)
|
|
132
|
+
// First match wins — issues arrive ranked by severity, so a node
|
|
133
|
+
// that's the subject of multiple issues keeps its most severe cause.
|
|
134
|
+
if (!map.has(key)) map.set(key, cause)
|
|
135
|
+
}
|
|
136
|
+
return map
|
|
137
|
+
}, [issues])
|
|
138
|
+
const { nodes, edges } = useMemo(() => buildFlowGraph(tree, preset, query, filters, expandedGroups, causeByRef), [tree, preset, query, filters, expandedGroups, causeByRef])
|
|
108
139
|
|
|
109
140
|
useEffect(() => {
|
|
110
141
|
if (nodes.length === 0) return
|
|
@@ -297,7 +328,7 @@ function getEdgeColor(type: string): string {
|
|
|
297
328
|
}
|
|
298
329
|
}
|
|
299
330
|
|
|
300
|
-
function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>): { nodes: Node[]; edges: Edge[] } {
|
|
331
|
+
function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePreset, query: string, filters?: GitOpsTreeFilters, expandedGroups?: Set<string>, causeByRef?: Map<string, string>): { nodes: Node[]; edges: Edge[] } {
|
|
301
332
|
if (!tree) return { nodes: [], edges: [] }
|
|
302
333
|
const visibleTree = applyGraphFilters(applyPreset(tree, preset, expandedGroups), filters)
|
|
303
334
|
const byID = new Map(visibleTree.nodes.map(node => [node.id, node]))
|
|
@@ -321,6 +352,7 @@ function buildFlowGraph(tree: GitOpsResourceTree | null, preset: GitOpsTreePrese
|
|
|
321
352
|
data: {
|
|
322
353
|
node,
|
|
323
354
|
highlighted: normalizedQuery !== '' && matchesQuery(node, normalizedQuery),
|
|
355
|
+
cause: causeByRef?.get(refKey(node.ref)),
|
|
324
356
|
},
|
|
325
357
|
}
|
|
326
358
|
})
|
|
@@ -543,7 +575,7 @@ function positionRanks(ranks: Map<number, string[]>, nodes: Map<string, GitOpsTr
|
|
|
543
575
|
return positioned
|
|
544
576
|
}
|
|
545
577
|
|
|
546
|
-
const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean }>>) {
|
|
578
|
+
const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<Node<{ node: GitOpsTreeNode; highlighted?: boolean; cause?: string }>>) {
|
|
547
579
|
const node = data.node
|
|
548
580
|
const kind = normalizeDisplayKind(node)
|
|
549
581
|
const status = normalizeHealth(node.topologyStatus)
|
|
@@ -552,10 +584,16 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
|
|
|
552
584
|
const chips = buildChips(node)
|
|
553
585
|
const dim = getNodeDimensions(node)
|
|
554
586
|
const KindIcon = getTopologyIcon(kind)
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
587
|
+
// Only unhealthy nodes carry a cause worth surfacing — a healthy node
|
|
588
|
+
// matched by a stale/unrelated issue ref shouldn't show a tooltip.
|
|
589
|
+
const cause = status === 'unhealthy' || status === 'degraded' ? data.cause : undefined
|
|
590
|
+
// Only declared resources can carry the controller's verdict, so only
|
|
591
|
+
// there does "this one is Radar's" mean anything. Generated children
|
|
592
|
+
// (ReplicaSets, Pods) are always Radar's read; marking each would be
|
|
593
|
+
// noise.
|
|
594
|
+
const radarNote = node.role === 'declared' ? radarHealthNote(node) : ''
|
|
595
|
+
|
|
596
|
+
const card = (
|
|
559
597
|
<div
|
|
560
598
|
className={clsx(
|
|
561
599
|
'relative overflow-hidden rounded-lg border bg-theme-surface shadow-md transition-colors',
|
|
@@ -636,10 +674,23 @@ const GitOpsResourceNode = memo(function GitOpsResourceNode({ data }: NodeProps<
|
|
|
636
674
|
{chip.label ? `${chip.label}: ` : ''}{chip.value}
|
|
637
675
|
</span>
|
|
638
676
|
))}
|
|
677
|
+
{radarNote && (
|
|
678
|
+
<Tooltip content={radarNote} delay={200}>
|
|
679
|
+
<span className="rounded border border-theme-border bg-theme-elevated/70 px-1.5 py-0.5 text-[10px] leading-3 text-theme-text-tertiary">
|
|
680
|
+
Found by Radar
|
|
681
|
+
</span>
|
|
682
|
+
</Tooltip>
|
|
683
|
+
)}
|
|
639
684
|
</div>
|
|
640
685
|
)}
|
|
641
686
|
</div>
|
|
642
687
|
</div>
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
return (
|
|
691
|
+
<>
|
|
692
|
+
<Handle type="target" position={Position.Left} className="!h-0 !w-0 !border-0 !bg-transparent" />
|
|
693
|
+
{cause ? <Tooltip content={cause} delay={200}>{card}</Tooltip> : card}
|
|
643
694
|
<Handle type="source" position={Position.Right} className="!h-0 !w-0 !border-0 !bg-transparent" />
|
|
644
695
|
</>
|
|
645
696
|
)
|
|
@@ -41,6 +41,40 @@ function destNode(
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
describe('mergeGitOpsTrees', () => {
|
|
44
|
+
// Health provenance follows the health value: when the destination's live
|
|
45
|
+
// health replaces the controller's, its source/reason/message come along;
|
|
46
|
+
// a destination node with no health leaves the controller's provenance.
|
|
47
|
+
test('health provenance travels with whichever health wins', () => {
|
|
48
|
+
const controller: GitOpsResourceTree = {
|
|
49
|
+
root: ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
50
|
+
nodes: [
|
|
51
|
+
ctrlNode('app1', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
52
|
+
ctrlNode('dep1', 'Deployment', 'web', 'prod', { health: 'Healthy', healthSource: 'controller' }),
|
|
53
|
+
ctrlNode('css1', 'ClusterSecretStore', 'platform', '', { health: 'Degraded', healthSource: 'radar', healthReason: 'Ready: Bad', healthMessage: 'no route' }),
|
|
54
|
+
],
|
|
55
|
+
edges: [],
|
|
56
|
+
}
|
|
57
|
+
const destination: GitOpsResourceTree = {
|
|
58
|
+
root: destNode('dest-root', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
59
|
+
nodes: [
|
|
60
|
+
destNode('dest-root', 'Application', 'app1', 'argocd', { role: 'root' }),
|
|
61
|
+
destNode('dest-dep1', 'Deployment', 'web', 'prod', { health: 'Degraded', healthSource: 'radar', healthReason: 'CrashLoopBackOff', healthMessage: 'back-off' }),
|
|
62
|
+
destNode('dest-css1', 'ClusterSecretStore', 'platform', ''),
|
|
63
|
+
],
|
|
64
|
+
edges: [],
|
|
65
|
+
}
|
|
66
|
+
const merged = mergeGitOpsTrees(controller, destination)
|
|
67
|
+
const dep = merged.nodes.find((n) => n.ref.kind === 'Deployment')!
|
|
68
|
+
expect(dep.health).toBe('Degraded')
|
|
69
|
+
expect(dep.healthSource).toBe('radar')
|
|
70
|
+
expect(dep.healthReason).toBe('CrashLoopBackOff')
|
|
71
|
+
expect(dep.healthMessage).toBe('back-off')
|
|
72
|
+
const css = merged.nodes.find((n) => n.ref.kind === 'ClusterSecretStore')!
|
|
73
|
+
expect(css.health).toBe('Degraded')
|
|
74
|
+
expect(css.healthSource).toBe('radar')
|
|
75
|
+
expect(css.healthReason).toBe('Ready: Bad')
|
|
76
|
+
})
|
|
77
|
+
|
|
44
78
|
// The classic in-cluster case — caller didn't fetch a destination tree
|
|
45
79
|
// (single-cluster app) so destination is null. Returning the controller
|
|
46
80
|
// tree unchanged is what preserves Argo's status + summary tile counts
|
|
@@ -85,10 +85,17 @@ export function mergeGitOpsTrees(
|
|
|
85
85
|
// resource-viewer routing.
|
|
86
86
|
const nodes: GitOpsTreeNode[] = controller.nodes.map((n) => {
|
|
87
87
|
const dest = destByKey.get(refKey(n.ref))
|
|
88
|
+
// Health provenance travels with the health it describes: when the
|
|
89
|
+
// destination's live health wins, so do its source/reason/message.
|
|
90
|
+
const healthFrom = dest?.health ? dest : n
|
|
88
91
|
const merged = dest
|
|
89
92
|
? {
|
|
90
93
|
...n,
|
|
91
94
|
health: dest.health ?? n.health,
|
|
95
|
+
healthSource: healthFrom.healthSource,
|
|
96
|
+
healthReason: healthFrom.healthReason,
|
|
97
|
+
healthMessage: healthFrom.healthMessage,
|
|
98
|
+
healthSeverity: healthFrom.healthSeverity,
|
|
92
99
|
info: dest.info ?? n.info,
|
|
93
100
|
topologyStatus: dest.topologyStatus ?? n.topologyStatus,
|
|
94
101
|
}
|
|
@@ -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
|
+
: “{reason.detail}”
|
|
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: “{reason.detail}”
|
|
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
|
+
}
|