@skyhook-io/k8s-ui 1.10.4 → 1.11.0
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/applications/ApplicationDetail.test.tsx +46 -0
- package/src/components/applications/ApplicationDetail.tsx +14 -15
- package/src/components/issues/IssuesView.tsx +9 -0
- package/src/components/issues/diagnostic.ts +3 -0
- package/src/components/issues/issues.test.ts +11 -0
- package/src/components/issues/severity.ts +3 -0
- package/src/components/issues/types.ts +3 -0
- package/src/components/resources/ResourcesSidebar.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +259 -30
- package/src/components/resources/index.ts +2 -0
- package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
- package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
- package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/EventRenderer.tsx +10 -18
- package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
- package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
- package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
- package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
- package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
- package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
- package/src/components/resources/renderers/index.ts +4 -0
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
- package/src/components/resources/renderers/velero-cells.tsx +128 -9
- package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
- package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
- package/src/components/resources/resource-utils-cnpg.ts +373 -56
- package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
- package/src/components/resources/resource-utils-velero.test.ts +237 -0
- package/src/components/resources/resource-utils-velero.ts +214 -22
- package/src/components/resources/resource-utils.ts +70 -3
- package/src/components/shared/ResourceActionsBar.tsx +3 -1
- package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
- package/src/components/topology/K8sResourceNode.tsx +11 -0
- package/src/components/topology/TopologyGraph.tsx +85 -5
- package/src/components/trace/ReachabilityGraph.tsx +517 -0
- package/src/components/trace/ReachabilityView.tsx +1173 -0
- package/src/components/trace/TracePanel.test.ts +172 -0
- package/src/components/trace/TracePanel.tsx +558 -0
- package/src/components/trace/TraceSummary.test.ts +213 -0
- package/src/components/trace/TraceSummary.tsx +168 -0
- package/src/components/trace/inClusterSummary.test.ts +98 -0
- package/src/components/trace/inClusterSummary.ts +81 -0
- package/src/components/trace/index.ts +19 -0
- package/src/components/trace/podReach.test.ts +71 -0
- package/src/components/trace/podReach.ts +37 -0
- package/src/components/trace/probe-display.test.ts +107 -0
- package/src/components/trace/probe-display.ts +37 -0
- package/src/components/trace/problemRows.test.ts +107 -0
- package/src/components/trace/reachFixtures.ts +261 -0
- package/src/components/trace/reachGraphModel.test.ts +1423 -0
- package/src/components/trace/reachGraphModel.ts +1521 -0
- package/src/components/trace/reachInspector.test.ts +892 -0
- package/src/components/trace/reachInspector.ts +797 -0
- package/src/components/trace/reachMarks.test.ts +698 -0
- package/src/components/trace/reachMarks.ts +623 -0
- package/src/components/trace/reachOrigins.test.ts +392 -0
- package/src/components/trace/reachOrigins.ts +449 -0
- package/src/components/trace/reachVerdict.test.ts +537 -0
- package/src/components/trace/reachVerdict.ts +601 -0
- package/src/components/trace/traceFingerprint.test.ts +214 -0
- package/src/components/trace/traceFingerprint.ts +122 -0
- package/src/components/trace/traceToSubgraph.test.ts +623 -0
- package/src/components/trace/traceToSubgraph.ts +463 -0
- package/src/components/trace/types.ts +414 -0
- package/src/components/ui/Badge.tsx +36 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
- package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
- package/src/components/ui/InClusterConsentDialog.tsx +141 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +58 -5
- package/src/components/workload/index.ts +1 -1
- package/src/components/workload/reachabilityTab.test.ts +47 -0
- package/src/index.ts +5 -0
- package/src/theme/components.css +64 -0
- package/src/theme/variables.css +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/api-resources.ts +2 -0
- package/src/utils/application-history.test.ts +1 -1
- package/src/utils/applications.test.ts +63 -1
- package/src/utils/applications.ts +27 -0
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +61 -0
- package/src/utils/navigation.ts +11 -9
- package/src/utils/pluralize.test.ts +80 -1
- package/src/utils/pluralize.ts +34 -0
- package/src/utils/resource-icons.test.ts +33 -0
- package/src/utils/resource-icons.ts +65 -2
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import type { Trace, RouteResult, RouteOutcome, ProbeResult, Hop, VantageResult } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Mark is the evidence class of ONE path segment for one scenario and one
|
|
5
|
+
* origin. It is deliberately distinct from health: a node's dot says whether a
|
|
6
|
+
* resource is well, a mark says what we actually learned about traffic reaching
|
|
7
|
+
* it. The two must never be conflated - a healthy node behind a failed edge
|
|
8
|
+
* stays healthy and the edge stays red.
|
|
9
|
+
*
|
|
10
|
+
* The vocabulary is closed. Adding a member means deciding its glyph, its tone,
|
|
11
|
+
* and its line style, because every mark renders in all three.
|
|
12
|
+
*/
|
|
13
|
+
export type Mark =
|
|
14
|
+
| 'proved' // observed through the dataplane - real packets, real path
|
|
15
|
+
| 'answered' // something replied, but not the thing we asked for
|
|
16
|
+
| 'proxied' // reached via the apiserver proxy - bypasses the dataplane
|
|
17
|
+
| 'config' // declared in configuration; predicted, never observed
|
|
18
|
+
| 'failed' // confirmed failure
|
|
19
|
+
| 'blocked' // never ran because an earlier segment failed
|
|
20
|
+
| 'excluded' // not in the path by design (NotReady, not an eligible endpoint)
|
|
21
|
+
| 'untested' // no origin has exercised this
|
|
22
|
+
| 'inconclusive' // ran, and deliberately kept informational - never a verdict
|
|
23
|
+
| 'stale' // observed, but before a change that invalidates it
|
|
24
|
+
| 'running' // in flight right now
|
|
25
|
+
| 'denied' // we are not permitted to run this test
|
|
26
|
+
| 'slow' // answered, far outside the normal latency band
|
|
27
|
+
|
|
28
|
+
export interface MarkStyle {
|
|
29
|
+
glyph: string
|
|
30
|
+
/** CSS custom property reference - never a literal color. */
|
|
31
|
+
color: string
|
|
32
|
+
/** SVG stroke-dasharray; 'none' for the solid lines that carry proof. */
|
|
33
|
+
dash: string
|
|
34
|
+
strokeWidth: number
|
|
35
|
+
strokeOpacity: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Only `proved` and `failed` draw solid. Solid means "a packet did this" -
|
|
40
|
+
* everything softer is dashed so a predicted or relayed segment can never be
|
|
41
|
+
* misread as observed truth at a glance.
|
|
42
|
+
*/
|
|
43
|
+
export const MARKS: Record<Mark, MarkStyle> = {
|
|
44
|
+
proved: { glyph: '●', color: 'var(--color-success-dark)', dash: 'none', strokeWidth: 2.6, strokeOpacity: 1 },
|
|
45
|
+
answered: { glyph: '◑', color: 'var(--color-warning-dark)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 1 },
|
|
46
|
+
proxied: { glyph: '◐', color: 'var(--color-info)', dash: '8 5', strokeWidth: 1.8, strokeOpacity: 1 },
|
|
47
|
+
config: { glyph: '◇', color: 'var(--text-tertiary)', dash: '2 5', strokeWidth: 1.8, strokeOpacity: 1 },
|
|
48
|
+
failed: { glyph: '✕', color: 'var(--color-error-dark)', dash: 'none', strokeWidth: 2.6, strokeOpacity: 1 },
|
|
49
|
+
blocked: { glyph: '⊘', color: 'var(--text-disabled)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 0.75 },
|
|
50
|
+
excluded: { glyph: '⊗', color: 'var(--text-disabled)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 0.75 },
|
|
51
|
+
// Info-blue, not amber: amber is "answered with a caveat / needs attention"
|
|
52
|
+
// (answered, slow, stale, denied); blue is "not the real path / disposition"
|
|
53
|
+
// (proxied, running, inconclusive); grey is "nothing ran". Three families
|
|
54
|
+
// instead of one overloaded amber.
|
|
55
|
+
inconclusive: { glyph: '◍', color: 'var(--color-info)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 0.9 },
|
|
56
|
+
untested: { glyph: '○', color: 'var(--text-disabled)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 0.75 },
|
|
57
|
+
stale: { glyph: '◷', color: 'var(--color-warning-dark)', dash: '6 4', strokeWidth: 1.8, strokeOpacity: 1 },
|
|
58
|
+
running: { glyph: '◌', color: 'var(--color-info)', dash: '4 4', strokeWidth: 1.8, strokeOpacity: 1 },
|
|
59
|
+
denied: { glyph: '⊘', color: 'var(--color-warning-dark)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 1 },
|
|
60
|
+
slow: { glyph: '◔', color: 'var(--color-warning-dark)', dash: '3 6', strokeWidth: 1.8, strokeOpacity: 1 },
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** One vocabulary, shared with the lane labels and the sidebar. The legend used
|
|
64
|
+
* to say "observed through the dataplane" while the lane said "REAL TRAFFIC"
|
|
65
|
+
* for the same thing.
|
|
66
|
+
*
|
|
67
|
+
* ONE flat registry; `category` is metadata for grouping at render time. The
|
|
68
|
+
* flat list teaches the enum as one axis, which it is not - what happened,
|
|
69
|
+
* how it was tested, and why nothing ran are different questions. */
|
|
70
|
+
export type MarkCategory = 'happened' | 'tested' | 'why-not' | 'state'
|
|
71
|
+
export const MARK_LEGEND: { mark: Mark; text: string; category: MarkCategory }[] = [
|
|
72
|
+
{ mark: 'proved', text: 'a request got through', category: 'happened' },
|
|
73
|
+
// 'answered' also wears a proxy-only failure (nothing answered there), so the
|
|
74
|
+
// legend describes the CLASS - an attempt that fell short of verification -
|
|
75
|
+
// and leaves "answered" vs "couldn't get through" to the chip.
|
|
76
|
+
{ mark: 'answered', text: 'the attempt didn’t verify the asked-for path', category: 'happened' },
|
|
77
|
+
{ mark: 'failed', text: 'a request was refused', category: 'happened' },
|
|
78
|
+
{ mark: 'proxied', text: 'answered via the API server — not live traffic', category: 'tested' },
|
|
79
|
+
{ mark: 'inconclusive', text: 'tested, but kept informational — a throwaway identity can\u2019t condemn the path', category: 'tested' },
|
|
80
|
+
{ mark: 'config', text: 'configured this way — not tested', category: 'tested' },
|
|
81
|
+
{ mark: 'untested', text: 'not tested from here', category: 'why-not' },
|
|
82
|
+
// 'blocked' covers two honest cases - a segment downstream of a failure, and
|
|
83
|
+
// a vantage whose every dial was skipped or abandoned. The legend must not
|
|
84
|
+
// pick one: "never tried" was false for a proxy dial that ran and timed out.
|
|
85
|
+
{ mark: 'blocked', text: 'never completed — an earlier failure or a skip stopped it', category: 'why-not' },
|
|
86
|
+
{ mark: 'denied', text: 'not allowed to test this', category: 'why-not' },
|
|
87
|
+
{ mark: 'excluded', text: 'not sent any traffic', category: 'why-not' },
|
|
88
|
+
{ mark: 'running', text: 'testing now', category: 'state' },
|
|
89
|
+
{ mark: 'stale', text: 'out of date', category: 'state' },
|
|
90
|
+
{ mark: 'slow', text: 'answered, but very slowly', category: 'state' },
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
export const MARK_CATEGORY_LABEL: Record<MarkCategory, string> = {
|
|
94
|
+
happened: 'what happened',
|
|
95
|
+
tested: 'how it was tested',
|
|
96
|
+
'why-not': 'why nothing ran',
|
|
97
|
+
state: 'state',
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function markStyle(m: Mark): MarkStyle {
|
|
101
|
+
return MARKS[m] ?? MARKS.untested
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Hover text for a mark. The legend sits below the graph and was the ONLY
|
|
105
|
+
* decoder, so reading a symbol meant travelling to it and back every time. */
|
|
106
|
+
export function markHelp(m: Mark): string {
|
|
107
|
+
return MARK_LEGEND.find((l) => l.mark === m)?.text ?? m
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What a LINE means, when the generic mark help would mislead.
|
|
112
|
+
*
|
|
113
|
+
* Structural edges are drawn `config` because nothing was dialled along them -
|
|
114
|
+
* but "not tested" then invites the reader to ask why we didn't, and the honest
|
|
115
|
+
* answer differs per edge: ownership is not a traffic hop at all, a selector is
|
|
116
|
+
* read from the cluster rather than proven by a request, and declared routing
|
|
117
|
+
* can only be exercised by a request that actually goes through that entry.
|
|
118
|
+
* Saying "not tested" for all three trains the reader to expect a test that in
|
|
119
|
+
* two of the cases does not exist.
|
|
120
|
+
*/
|
|
121
|
+
export function edgeHelp(label: string, mark: Mark): string {
|
|
122
|
+
if (mark === 'config') {
|
|
123
|
+
switch (label.trim().toLowerCase()) {
|
|
124
|
+
case 'runs':
|
|
125
|
+
return 'ownership, not a traffic hop - the workload owns these Pods'
|
|
126
|
+
case 'selects':
|
|
127
|
+
return 'read from the cluster - which Pods this Service selects'
|
|
128
|
+
case 'routes to':
|
|
129
|
+
case 'sends to':
|
|
130
|
+
return 'declared routing - only a request through this entry exercises it'
|
|
131
|
+
case 'also serves':
|
|
132
|
+
return 'other backends this entry declares, collapsed here'
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Excluded-by-design, not a gap: this backend serves a different host, so no
|
|
136
|
+
// request for THIS path would ever go down it. "not sent any traffic" read as
|
|
137
|
+
// something we skipped.
|
|
138
|
+
if (mark === 'excluded' && label.trim().toLowerCase() === 'other host') {
|
|
139
|
+
return 'not part of this path - this backend serves a different host'
|
|
140
|
+
}
|
|
141
|
+
return markHelp(mark)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Whether a line's own words already say what its mark would say. An origin
|
|
145
|
+
* edge is labelled with its evidence ("not tested"), so appending the mark help
|
|
146
|
+
* ("not tested from here") repeated it back at the reader. */
|
|
147
|
+
export function edgeHelpIsRedundant(title: string, label: string, mark: Mark): boolean {
|
|
148
|
+
const t = title.trim().toLowerCase()
|
|
149
|
+
if (!t) return false
|
|
150
|
+
const help = edgeHelp(label, mark).toLowerCase()
|
|
151
|
+
return help.startsWith(t) || t.startsWith(help)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Inline style for a mark glyph. */
|
|
155
|
+
export function glyphStyle(m: Mark): React.CSSProperties {
|
|
156
|
+
return { color: markStyle(m).color, fontWeight: 700, fontSize: '12px', lineHeight: 1.1, flex: 'none' }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The selected origin's OWN result for a route, or undefined when that origin
|
|
161
|
+
* produced nothing for it.
|
|
162
|
+
*
|
|
163
|
+
* Origin ids map onto (vantage, path) exactly as originOf() derives them in the
|
|
164
|
+
* other direction: anything relayed by the API server is the apiserver origin
|
|
165
|
+
* whatever machine issued it, and everything else is named by its vantage.
|
|
166
|
+
*/
|
|
167
|
+
export function routeForOrigin(route: RouteResult | undefined, originId: string, runVantage?: string): VantageResult | undefined {
|
|
168
|
+
const rows = route?.byVantage
|
|
169
|
+
if (!rows || rows.length === 0) return undefined
|
|
170
|
+
if (originId === 'apiserver') return rows.find((v) => v.path === 'apiserver')
|
|
171
|
+
if (originId === 'local') return rows.find((v) => v.path !== 'apiserver' && v.vantage === 'local')
|
|
172
|
+
// Both in-cluster origins share (in-cluster, data); `source` is what tells
|
|
173
|
+
// Radar's own dial apart from the throwaway Job's. An absent source resolves
|
|
174
|
+
// the same way originOf does, so the rail and the rows can't disagree.
|
|
175
|
+
const wantJob = originId === 'incluster'
|
|
176
|
+
return rows.find((v) => {
|
|
177
|
+
if (v.path === 'apiserver' || v.vantage !== 'in-cluster') return false
|
|
178
|
+
const src = v.source || (runVantage === 'in-cluster' ? 'radar' : 'probe-job')
|
|
179
|
+
return wantJob ? src === 'probe-job' : src === 'radar'
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* What we can honestly say about a route FROM one origin. Four cases that must
|
|
185
|
+
* never be collapsed:
|
|
186
|
+
*
|
|
187
|
+
* - `own` this origin's own result for this route. Use it.
|
|
188
|
+
* - `config` the producer says the outcome was DERIVED, not dialled (`basis`):
|
|
189
|
+
* read off what is declared, or off current cluster state. It is
|
|
190
|
+
* true of every origin and observed by none, so it is rendered as
|
|
191
|
+
* the fact it is and never as the selected origin's dial.
|
|
192
|
+
* - `none` the producer DID send per-vantage results and this origin has no
|
|
193
|
+
* row, so it did not test this route. Not "unknown, guess from the
|
|
194
|
+
* rollup" - the absence is itself the evidence, and inheriting the
|
|
195
|
+
* merged verdict here is precisely the misattribution byVantage
|
|
196
|
+
* exists to remove. What the origin did on OTHER routes is
|
|
197
|
+
* irrelevant to this one.
|
|
198
|
+
* - `rollup` no per-vantage results at all (a trace from a producer that
|
|
199
|
+
* predates the field). The merged verdict is all there is; it is
|
|
200
|
+
* NOT a claim about the selected origin, and callers must keep
|
|
201
|
+
* treating it with the coarse pre-existing gate.
|
|
202
|
+
*/
|
|
203
|
+
export type OriginEvidence =
|
|
204
|
+
| { kind: 'own'; result: RouteResult }
|
|
205
|
+
| { kind: 'config'; result: RouteResult }
|
|
206
|
+
| { kind: 'rollup'; result: RouteResult }
|
|
207
|
+
| { kind: 'none' }
|
|
208
|
+
|
|
209
|
+
export function originRouteEvidence(route: RouteResult | undefined, originId: string, runVantage?: string): OriginEvidence {
|
|
210
|
+
if (!route) return { kind: 'none' }
|
|
211
|
+
// Checked before the per-vantage rows: a config-derived break has none by
|
|
212
|
+
// construction, and without this it would fall through to the rollup branch
|
|
213
|
+
// and be attributed to whichever origin happened to be selected.
|
|
214
|
+
if (route.basis) return { kind: 'config', result: route }
|
|
215
|
+
const own = routeForOrigin(route, originId, runVantage)
|
|
216
|
+
if (own) {
|
|
217
|
+
return {
|
|
218
|
+
kind: 'own',
|
|
219
|
+
result: { ...route, outcome: own.outcome, confidence: own.confidence, evidence: own.evidence, failedLayer: own.failedLayer },
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const hasBreakdown = !!route.byVantage && route.byVantage.length > 0
|
|
223
|
+
return hasBreakdown ? { kind: 'none' } : { kind: 'rollup', result: route }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The route as ONE origin saw it, or undefined when that origin has nothing to
|
|
227
|
+
* say about it. Prefer originRouteEvidence when the caller needs to tell a
|
|
228
|
+
* legacy rollup apart from a genuine "not tested from here". */
|
|
229
|
+
export function routeAsSeenFrom(route: RouteResult | undefined, originId: string): RouteResult | undefined {
|
|
230
|
+
const ev = originRouteEvidence(route, originId)
|
|
231
|
+
return ev.kind === 'none' ? undefined : ev.result
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The evidence class of a route's headline result.
|
|
236
|
+
*
|
|
237
|
+
* `confidence` is what separates proof from relay: an 'indirect' outcome came
|
|
238
|
+
* through the apiserver proxy, which bypasses kube-proxy, NetworkPolicy and the
|
|
239
|
+
* mesh - so it can never be `proved` no matter how clean the response was.
|
|
240
|
+
* A benign unreachable (deliberately scaled to zero) is not a failure.
|
|
241
|
+
*/
|
|
242
|
+
export function routeMark(r: RouteResult, opts: { stale?: boolean; running?: boolean } = {}): Mark {
|
|
243
|
+
if (opts.running) return 'running'
|
|
244
|
+
if (opts.stale) return 'stale'
|
|
245
|
+
// A derived break was never dialled. 'failed' is the mark for a request that
|
|
246
|
+
// was sent and did not arrive, so using it here claims an observation that
|
|
247
|
+
// never happened - the contradiction the basis field exists to end.
|
|
248
|
+
if (r.basis) return 'config'
|
|
249
|
+
const indirect = r.confidence === 'indirect'
|
|
250
|
+
switch (r.outcome) {
|
|
251
|
+
case 'verified':
|
|
252
|
+
return indirect ? 'proxied' : 'proved'
|
|
253
|
+
case 'reached':
|
|
254
|
+
return indirect ? 'proxied' : 'answered'
|
|
255
|
+
case 'server-error':
|
|
256
|
+
return 'answered'
|
|
257
|
+
case 'unreachable':
|
|
258
|
+
// A proxy-only failure never condemns the real path - it was never tested.
|
|
259
|
+
if (indirect) return 'answered'
|
|
260
|
+
return r.benign ? 'excluded' : 'failed'
|
|
261
|
+
case 'not-tested':
|
|
262
|
+
default:
|
|
263
|
+
return 'untested'
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** A route outcome's severity tone, for the scenario tab dot and verdict dot. */
|
|
268
|
+
export type SevTone = 'healthy' | 'degraded' | 'alert' | 'unhealthy' | 'unknown' | 'info'
|
|
269
|
+
|
|
270
|
+
export const SEV_COLOR: Record<SevTone, string> = {
|
|
271
|
+
healthy: 'var(--color-success)',
|
|
272
|
+
degraded: 'var(--color-warning)',
|
|
273
|
+
alert: 'var(--color-alert)',
|
|
274
|
+
unhealthy: 'var(--color-error)',
|
|
275
|
+
unknown: 'var(--text-disabled)',
|
|
276
|
+
info: 'var(--color-info)',
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Badge class for a tone - the repo's canonical `.status-*` vocabulary. */
|
|
280
|
+
export const SEV_BADGE: Record<SevTone, string> = {
|
|
281
|
+
healthy: 'status-healthy',
|
|
282
|
+
degraded: 'status-degraded',
|
|
283
|
+
alert: 'status-alert',
|
|
284
|
+
unhealthy: 'status-unhealthy',
|
|
285
|
+
unknown: 'status-unknown',
|
|
286
|
+
info: 'status-neutral',
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function routeTone(r: RouteResult, opts: { stale?: boolean; running?: boolean } = {}): SevTone {
|
|
290
|
+
if (opts.running) return 'info'
|
|
291
|
+
if (opts.stale) return 'unknown'
|
|
292
|
+
const indirect = r.confidence === 'indirect'
|
|
293
|
+
switch (r.outcome) {
|
|
294
|
+
case 'verified':
|
|
295
|
+
return indirect ? 'degraded' : 'healthy'
|
|
296
|
+
case 'reached':
|
|
297
|
+
case 'server-error':
|
|
298
|
+
return 'degraded'
|
|
299
|
+
case 'unreachable':
|
|
300
|
+
// Indirect-only and benign unreachables are never red - see routeMark.
|
|
301
|
+
return indirect || r.benign ? 'degraded' : 'unhealthy'
|
|
302
|
+
case 'not-tested':
|
|
303
|
+
default:
|
|
304
|
+
return 'unknown'
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Short qualifier chip for a scenario tab - what kind of evidence backs it. */
|
|
309
|
+
/** Every row's dial bypassed the front door - rows exist and none exercised
|
|
310
|
+
* the entry path. The chip and headline must then never read as a bare pass. */
|
|
311
|
+
export function routeBackendScoped(r: RouteResult): boolean {
|
|
312
|
+
const rows = r.byVantage ?? []
|
|
313
|
+
return rows.length > 0 && rows.every((v) => v.segment === 'backend')
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function routeChip(r: RouteResult, opts: { stale?: boolean; running?: boolean } = {}): string {
|
|
317
|
+
if (opts.running) return 'probing…'
|
|
318
|
+
if (opts.stale) return 'stale'
|
|
319
|
+
// Named for what was read, not for a request: "could not get through" would
|
|
320
|
+
// describe traffic that was never sent.
|
|
321
|
+
if (r.basis === 'declared-config') return 'broken as declared'
|
|
322
|
+
if (r.basis === 'cluster-state') return 'nothing ready to serve'
|
|
323
|
+
const indirect = r.confidence === 'indirect'
|
|
324
|
+
switch (r.outcome) {
|
|
325
|
+
case 'verified':
|
|
326
|
+
if (indirect) return 'got through via the API server'
|
|
327
|
+
// The segment qualifier APPENDS to the outcome word, never replaces it:
|
|
328
|
+
// proof strength stays outcome-derived, so a future transport-only reach
|
|
329
|
+
// reads "answered · backend", not "verified".
|
|
330
|
+
return routeBackendScoped(r) ? 'got through · backend' : 'got through'
|
|
331
|
+
case 'reached':
|
|
332
|
+
if (indirect) return 'answered via the API server'
|
|
333
|
+
return routeBackendScoped(r) ? 'answered · backend' : 'answered, not confirmed'
|
|
334
|
+
case 'server-error':
|
|
335
|
+
// server-error is NEVER an ordinary app 5xx (those classify as reached):
|
|
336
|
+
// it is a TLS verification failure or a gateway's 502/504 - blaming "the
|
|
337
|
+
// app" here named the one party that is not at fault.
|
|
338
|
+
if (r.failedLayer === 'tls') return 'TLS certificate failed'
|
|
339
|
+
if (r.failedLayer === 'upstream') return 'the front door couldn’t reach the backend'
|
|
340
|
+
return 'answered with a server-side fault'
|
|
341
|
+
case 'unreachable':
|
|
342
|
+
// "only the shortcut failed" left "shortcut" undefined on the page.
|
|
343
|
+
if (indirect) return 'couldn’t get through via the API server'
|
|
344
|
+
return r.benign ? 'nothing running (on purpose)' : 'could not get through'
|
|
345
|
+
case 'not-tested':
|
|
346
|
+
default:
|
|
347
|
+
return 'not tested'
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const OUTCOME_RANK: Record<RouteOutcome, number> = {
|
|
352
|
+
unreachable: 0,
|
|
353
|
+
'server-error': 1,
|
|
354
|
+
'not-tested': 2,
|
|
355
|
+
reached: 3,
|
|
356
|
+
verified: 4,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Worst-first ordering, so the scenario that needs attention leads the strip. */
|
|
360
|
+
export function orderRoutes(routes: RouteResult[]): RouteResult[] {
|
|
361
|
+
return [...routes].sort((a, b) => OUTCOME_RANK[a.outcome] - OUTCOME_RANK[b.outcome])
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A scenario is one testable path as an operator thinks about it. It is NOT
|
|
366
|
+
* always one RouteResult: a Gateway route declaring several hostnames produces
|
|
367
|
+
* one result per host, and when those results agree in every respect they are
|
|
368
|
+
* one situation with several front doors, not several situations.
|
|
369
|
+
*/
|
|
370
|
+
export interface Scenario {
|
|
371
|
+
key: string
|
|
372
|
+
/** Tab title. */
|
|
373
|
+
label: string
|
|
374
|
+
/** Secondary line - the backend, plus the host count when grouped. */
|
|
375
|
+
sub: string
|
|
376
|
+
/** Every route folded into this scenario. */
|
|
377
|
+
routes: RouteResult[]
|
|
378
|
+
/** Representative used for tone, chip and the graph. */
|
|
379
|
+
primary: RouteResult
|
|
380
|
+
/** The distinct entry hostnames, when this scenario groups several. */
|
|
381
|
+
hosts: string[]
|
|
382
|
+
/** Every route label folded into this tab, for the hover. Not all of them are
|
|
383
|
+
* hostnames - a Service port and a Pod both appear here as themselves. */
|
|
384
|
+
members: string[]
|
|
385
|
+
/** Name of the front door serving this scenario, when one is declared. */
|
|
386
|
+
entry?: string
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** The host part of a route label like "example.com/path". */
|
|
390
|
+
function hostOf(route: string): string {
|
|
391
|
+
const slash = route.indexOf('/')
|
|
392
|
+
return slash === -1 ? route : route.slice(0, slash)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The request host a route label names, or "" when it names none.
|
|
397
|
+
*
|
|
398
|
+
* Route labels are NOT all hostnames: the producer emits "host+path" for
|
|
399
|
+
* host-based rules but ":80 → 8080" for port-based ones and "default backend"
|
|
400
|
+
* where no host is declared (internal/trace routeLabel). Feeding those to a
|
|
401
|
+
* host matcher is how a Gateway listener with no hostname - which legitimately
|
|
402
|
+
* serves ANY host - ends up claiming a port-based route that never went near it.
|
|
403
|
+
*/
|
|
404
|
+
export function routeHostOf(route: string): string {
|
|
405
|
+
const candidate = hostOf(route).trim().toLowerCase()
|
|
406
|
+
// A dot is required. Without it "GET / · :80 → 8080" yields the bare word
|
|
407
|
+
// "get", which reads as a valid single-label host and gets attributed to a
|
|
408
|
+
// catch-all listener. A genuinely single-label host loses its front-door tag,
|
|
409
|
+
// which is the safe direction to fail: no attribution beats a wrong one.
|
|
410
|
+
return /^[a-z0-9*][a-z0-9*-]*(\.[a-z0-9*-]+)+$/.test(candidate) ? candidate : ''
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Whether a declared hostname serves a request host. Gateway API and Ingress
|
|
415
|
+
* both allow a leading `*.` wildcard, which matches exactly one extra label -
|
|
416
|
+
* `*.example.com` covers `api.example.com` but NOT `a.b.example.com` or the
|
|
417
|
+
* apex. An EMPTY declared hostname is the Gateway-API "any host" listener.
|
|
418
|
+
*/
|
|
419
|
+
export function hostMatches(declared: string, host: string): boolean {
|
|
420
|
+
const d = declared.trim().toLowerCase()
|
|
421
|
+
const h = host.trim().toLowerCase()
|
|
422
|
+
if (!h) return false
|
|
423
|
+
if (d === '') return true
|
|
424
|
+
if (!d.startsWith('*.')) return d === h
|
|
425
|
+
const suffix = d.slice(1) // ".example.com"
|
|
426
|
+
if (!h.endsWith(suffix)) return false
|
|
427
|
+
return !h.slice(0, h.length - suffix.length).includes('.')
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Every hostname a hop declares. Ingress/HTTPRoute/GRPCRoute publish them on
|
|
432
|
+
* `hostnames` (+ `tlsHosts`); a GATEWAY publishes them per listener and has no
|
|
433
|
+
* top-level `hostnames` at all - reading only the former made every real Gateway
|
|
434
|
+
* look like it served nothing.
|
|
435
|
+
*/
|
|
436
|
+
export function declaredHosts(h: Hop): string[] {
|
|
437
|
+
const listeners = (h.config?.listeners ?? []).map((l) => l.hostname ?? '')
|
|
438
|
+
return [...(h.config?.hostnames ?? []), ...(h.config?.tlsHosts ?? []), ...listeners]
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Which declared entry point serves a hostname. Two hosts that land on the same
|
|
443
|
+
* backend with the same outcome are still different situations when they come in
|
|
444
|
+
* through DIFFERENT front doors: one Gateway can be misconfigured while its
|
|
445
|
+
* sibling is fine, and merging them would hide that behind a shared verdict.
|
|
446
|
+
*
|
|
447
|
+
* When several entries could serve the host, the most SPECIFIC declaration wins
|
|
448
|
+
* (exact over wildcard over catch-all), mirroring how the proxies themselves
|
|
449
|
+
* resolve it - otherwise a catch-all listener would swallow every host and
|
|
450
|
+
* collapse the scenarios it exists to separate.
|
|
451
|
+
*/
|
|
452
|
+
export function entryForHost(host: string, upstreams: Hop[] = []): string {
|
|
453
|
+
const h = host.trim().toLowerCase()
|
|
454
|
+
if (!h) return ''
|
|
455
|
+
const specificity = (d: string): number => (d === '' ? 0 : d.startsWith('*.') ? 1 : 2)
|
|
456
|
+
let best: { hop: Hop; rank: number } | undefined
|
|
457
|
+
for (const u of upstreams) {
|
|
458
|
+
for (const d of declaredHosts(u)) {
|
|
459
|
+
if (!hostMatches(d, h)) continue
|
|
460
|
+
const rank = specificity(d.trim().toLowerCase())
|
|
461
|
+
if (!best || rank > best.rank) best = { hop: u, rank }
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const r = best?.hop.resource
|
|
465
|
+
return r ? `${r.kind}/${r.namespace ?? ''}/${r.name}` : ''
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Groups routes that are indistinguishable in outcome. Splitting them out again
|
|
470
|
+
* happens exactly when it carries information - a differing outcome, layer,
|
|
471
|
+
* confidence or serving entry point - so identical hosts collapse but a host
|
|
472
|
+
* that behaves differently, or arrives through a different front door, always
|
|
473
|
+
* keeps its own tab.
|
|
474
|
+
*/
|
|
475
|
+
/**
|
|
476
|
+
* A route's stable identity: what it IS, never what happened to it.
|
|
477
|
+
*
|
|
478
|
+
* Composed exactly like the producer's InClusterResultKey (coverage.go), so the
|
|
479
|
+
* two cannot drift. Selection anchors on this rather than on a scenario's group
|
|
480
|
+
* key, which mixes in outcome, confidence and evidence - so a re-run that
|
|
481
|
+
* changed a result changed the key and silently moved the user to a different
|
|
482
|
+
* path.
|
|
483
|
+
*/
|
|
484
|
+
/** Whether the in-cluster test has ANYTHING to run: the server only tests a
|
|
485
|
+
* route carrying a concrete InClusterRequest. Offering the control without
|
|
486
|
+
* one spends a click (and a consent dialog) on a guaranteed no-op. */
|
|
487
|
+
export function traceInClusterRunnable(trace: Trace): boolean {
|
|
488
|
+
return (trace.routes ?? []).some((r) => !!r.inClusterRequest && !r.benign)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function routeIdentity(r: RouteResult): string {
|
|
492
|
+
return `${r.route}\u0000${r.target ?? ''}\u0000${r.targetNamespace ?? ''}`
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** A route's per-vantage outcomes, order-independent so two routes observed in a
|
|
496
|
+
* different sequence still compare equal. */
|
|
497
|
+
export function vantageSignature(r: RouteResult): string {
|
|
498
|
+
return (r.byVantage ?? [])
|
|
499
|
+
.map(
|
|
500
|
+
(v) =>
|
|
501
|
+
`${v.vantage}/${v.path}/${v.source || 'radar'}=${v.outcome}${v.failedBoundary ? `@${v.failedBoundary}` : ''}${v.segment ? `#${v.segment}` : ''}`,
|
|
502
|
+
)
|
|
503
|
+
.sort()
|
|
504
|
+
.join(',')
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function groupRoutes(routes: RouteResult[], upstreams: Hop[] = []): Scenario[] {
|
|
508
|
+
const groups = new Map<string, RouteResult[]>()
|
|
509
|
+
for (const r of orderRoutes(routes)) {
|
|
510
|
+
// For an untested route the REASON is the whole content - "port 443 can't
|
|
511
|
+
// be verified through the proxy" and "port 80 timed out" are different
|
|
512
|
+
// situations even though both are merely not-tested. Folding them together
|
|
513
|
+
// would hide the distinction the operator needs.
|
|
514
|
+
// The evidence distinguishes routes at EVERY outcome, not only not-tested:
|
|
515
|
+
// "connection refused" and "timed out" are the same outcome and layer but
|
|
516
|
+
// different situations, and folding them shows the operator one and hides
|
|
517
|
+
// the other behind a tab that claims to speak for both.
|
|
518
|
+
const distinguishing = r.evidence ?? ''
|
|
519
|
+
const entry = entryForHost(routeHostOf(r.route), upstreams)
|
|
520
|
+
const key = [
|
|
521
|
+
r.target ?? '',
|
|
522
|
+
// Same-named Services in different namespaces are different backends.
|
|
523
|
+
r.targetNamespace ?? '',
|
|
524
|
+
r.outcome,
|
|
525
|
+
r.confidence ?? '',
|
|
526
|
+
r.failedLayer ?? '',
|
|
527
|
+
r.benign ? 'benign' : '',
|
|
528
|
+
r.basis ?? '',
|
|
529
|
+
// Two routes whose rollups agree can still disagree per vantage - one
|
|
530
|
+
// working from a laptop and one not. Collapsing them puts rs[0] in charge
|
|
531
|
+
// of the whole board and destroys exactly the distinction byVantage was
|
|
532
|
+
// added to preserve.
|
|
533
|
+
vantageSignature(r),
|
|
534
|
+
distinguishing,
|
|
535
|
+
entry,
|
|
536
|
+
].join('|')
|
|
537
|
+
const arr = groups.get(key) ?? []
|
|
538
|
+
arr.push(r)
|
|
539
|
+
groups.set(key, arr)
|
|
540
|
+
}
|
|
541
|
+
return [...groups.entries()].map(([key, rs]) => {
|
|
542
|
+
const primary = rs[0]
|
|
543
|
+
// Only genuine hostnames. hostOf returns the whole label when there is no
|
|
544
|
+
// path separator, so port- and pod-shaped route labels ("port 80",
|
|
545
|
+
// "web-abc123 port 8080") were being counted and announced as "hostnames".
|
|
546
|
+
const hosts = [...new Set(rs.map((r) => routeHostOf(r.route)).filter(Boolean))]
|
|
547
|
+
const members = [...new Set(rs.map((r) => r.route).filter(Boolean))]
|
|
548
|
+
const grouped = rs.length > 1
|
|
549
|
+
const entryId = entryForHost(routeHostOf(rs[0].route), upstreams)
|
|
550
|
+
const entry = entryId ? entryId.split('/').pop() : undefined
|
|
551
|
+
const via = entry ? ` · via ${entry}` : ''
|
|
552
|
+
return {
|
|
553
|
+
key,
|
|
554
|
+
label: grouped ? primary.target || `${rs.length} routes` : primary.route,
|
|
555
|
+
sub: grouped
|
|
556
|
+
? // Named for what the members ACTUALLY are. Routes folded together can
|
|
557
|
+
// share a hostname and differ only by path, and many carry no hostname
|
|
558
|
+
// at all - so "N hostnames" both undercounted and mis-described them.
|
|
559
|
+
`${hosts.length === rs.length ? `${hosts.length} hostname${hosts.length === 1 ? '' : 's'}` : `${rs.length} paths`}${primary.target ? ` · ${primary.target}` : ''}${via}`
|
|
560
|
+
: `${primary.target || ''}${via}`,
|
|
561
|
+
routes: rs,
|
|
562
|
+
primary,
|
|
563
|
+
hosts,
|
|
564
|
+
members,
|
|
565
|
+
entry,
|
|
566
|
+
}
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Routes the tracer declined to test still describe real paths, so they become
|
|
572
|
+
* scenarios too. Without this an untested resource renders no strip at all and
|
|
573
|
+
* the paths it has are invisible.
|
|
574
|
+
*/
|
|
575
|
+
export function scenariosFor(
|
|
576
|
+
routes: RouteResult[],
|
|
577
|
+
notTested: { route?: string; reason: string }[],
|
|
578
|
+
upstreams: Hop[] = [],
|
|
579
|
+
): Scenario[] {
|
|
580
|
+
// A not-tested ROUTE and the raw skip rows for its host describe the SAME
|
|
581
|
+
// gap (the producer preserves the declared candidate as a route so the
|
|
582
|
+
// in-cluster recovery has a target). Mirroring recountCoverage's host-level
|
|
583
|
+
// absorption: rows whose host a route already covers must not become a
|
|
584
|
+
// second scenario for the same gap.
|
|
585
|
+
const covered = new Set(
|
|
586
|
+
routes.flatMap((r) => [routeHostOf(r.route), hostOfTarget(r.target)]).filter(Boolean),
|
|
587
|
+
)
|
|
588
|
+
const synthesized: RouteResult[] = notTested
|
|
589
|
+
.filter((s) => !!s.route)
|
|
590
|
+
.filter((s) => {
|
|
591
|
+
const h = routeHostOf(s.route as string) || hostOfTarget(s.route)
|
|
592
|
+
return !h || !covered.has(h)
|
|
593
|
+
})
|
|
594
|
+
.map((s) => ({ route: s.route as string, outcome: 'not-tested' as RouteOutcome, evidence: s.reason }))
|
|
595
|
+
return groupRoutes([...routes, ...synthesized], upstreams)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The bare host of a probe-target-shaped string ("api:80" → "api"). */
|
|
599
|
+
function hostOfTarget(t?: string): string {
|
|
600
|
+
const s = (t ?? '').trim()
|
|
601
|
+
if (!s) return ''
|
|
602
|
+
const i = s.lastIndexOf(':')
|
|
603
|
+
return (i > 0 ? s.slice(0, i) : s).toLowerCase()
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Latency far outside the band is its own signal - a route that answers in
|
|
608
|
+
* seconds is not simply "healthy". Threshold is deliberately generous: this
|
|
609
|
+
* flags pathology, not ordinary variance.
|
|
610
|
+
*/
|
|
611
|
+
export const SLOW_THRESHOLD_NS = 1_000_000_000
|
|
612
|
+
|
|
613
|
+
export function isSlow(p: ProbeResult): boolean {
|
|
614
|
+
return typeof p.latencyNs === 'number' && p.latencyNs >= SLOW_THRESHOLD_NS
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export function formatLatency(ns?: number): string {
|
|
618
|
+
if (typeof ns !== 'number' || ns <= 0) return ''
|
|
619
|
+
const ms = ns / 1_000_000
|
|
620
|
+
if (ms < 1) return '<1 ms'
|
|
621
|
+
if (ms < 1000) return `${Math.round(ms)} ms`
|
|
622
|
+
return `${(ms / 1000).toFixed(1)} s`
|
|
623
|
+
}
|