@skyhook-io/k8s-ui 1.10.5 → 1.12.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 +1 -1
- package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
- package/src/components/gitops/GitOpsTableView.tsx +22 -2
- package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
- package/src/components/gitops/detail-helpers.ts +4 -1
- 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 +261 -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/AlertRenderer.tsx +4 -1
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +133 -9
- package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +19 -4
- package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
- package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/EventRenderer.tsx +10 -18
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
- package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
- 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/OCIRepositoryRenderer.tsx +4 -1
- package/src/components/resources/renderers/PodRenderer.tsx +13 -0
- package/src/components/resources/renderers/PolicySection.test.tsx +102 -0
- package/src/components/resources/renderers/PolicySection.tsx +170 -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/WorkloadRenderer.tsx +15 -1
- 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 +5 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +9 -0
- package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
- package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -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 +590 -1
- package/src/components/resources/resource-utils-cnpg.ts +433 -56
- package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
- 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 +6 -0
- package/src/components/trace/ReachabilityGraph.tsx +20 -6
- package/src/components/trace/ReachabilityView.tsx +253 -52
- package/src/components/trace/problemRows.test.ts +107 -0
- package/src/components/trace/reachGraphModel.test.ts +16 -0
- package/src/components/trace/reachInspector.test.ts +233 -10
- package/src/components/trace/reachInspector.ts +166 -36
- package/src/components/trace/reachMarks.test.ts +32 -1
- package/src/components/trace/reachMarks.ts +44 -0
- package/src/components/trace/reachOrigins.ts +12 -0
- package/src/components/trace/types.ts +25 -0
- package/src/components/ui/Badge.tsx +36 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
- package/src/theme/components.css +26 -0
- package/src/types/gitops-flux-status.test.ts +163 -0
- package/src/types/gitops.ts +80 -24
- package/src/types/index.ts +1 -0
- package/src/types/policy.ts +42 -0
- package/src/utils/api-resources.ts +2 -0
- package/src/utils/resource-icons.test.ts +33 -0
- package/src/utils/resource-icons.ts +65 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Trace, RouteResult, ResourceRef } from './types'
|
|
2
2
|
import type { Mark, SevTone } from './reachMarks'
|
|
3
|
-
import { routeMark, routeChip, routeTone, routeAsSeenFrom, originRouteEvidence, routeForOrigin, traceInClusterRunnable } from './reachMarks'
|
|
3
|
+
import { routeMark, routeChip, routeTone, routeAsSeenFrom, originRouteEvidence, routeForOrigin, traceInClusterRunnable, markHelp } from './reachMarks'
|
|
4
4
|
import type { Origin, OriginId } from './reachOrigins'
|
|
5
|
-
import { strongestGap, actionableGap, originSkipReason } from './reachOrigins'
|
|
5
|
+
import { strongestGap, actionableGap, originSkipReason, originInformationalReason } from './reachOrigins'
|
|
6
6
|
import { hopEvidenceFor, originProducedEvidence, type GraphNode } from './reachGraphModel'
|
|
7
7
|
|
|
8
8
|
// 'run-probes' re-runs the reachability probes. It is deliberately NOT
|
|
@@ -45,7 +45,7 @@ export interface Sidebar {
|
|
|
45
45
|
scope: { k: string; v: string }[]
|
|
46
46
|
evidence: { mark: Mark; text: string }[]
|
|
47
47
|
notProve: string[]
|
|
48
|
-
next: { header: string; body: string; blocked?: string; ctas: InspectorCTA[]
|
|
48
|
+
next: { header: string; body: string; blocked?: string; ctas: InspectorCTA[] }
|
|
49
49
|
}
|
|
50
50
|
/** Every hop on the selected path, in order - the whole story for this path
|
|
51
51
|
* seen from this vantage, rather than a summary plus whichever node was last
|
|
@@ -74,7 +74,7 @@ export interface HopDetail {
|
|
|
74
74
|
/** Configured-but-bypassed resources, grouped under one label naming WHY they
|
|
75
75
|
* are outside this vantage's journey. Their config sections stay readable;
|
|
76
76
|
* their journey-state words do not apply. */
|
|
77
|
-
function contextGroup(ctx: Ctx, byId: Map<string, GraphNode
|
|
77
|
+
function contextGroup(ctx: Ctx, byId: Map<string, GraphNode>, sel: Selection): Sidebar['context'] {
|
|
78
78
|
const nodes = (ctx.contextNodeIds ?? []).map((id) => byId.get(id)).filter((n): n is GraphNode => !!n)
|
|
79
79
|
if (nodes.length === 0) return undefined
|
|
80
80
|
const label =
|
|
@@ -85,12 +85,16 @@ function contextGroup(ctx: Ctx, byId: Map<string, GraphNode>): Sidebar['context'
|
|
|
85
85
|
: 'PARALLEL ENTRY — NOT ON THIS ROUTE\u2019S PATH'
|
|
86
86
|
return {
|
|
87
87
|
label,
|
|
88
|
+
// Context hops are collapsed by default - they are not the journey. But the
|
|
89
|
+
// SELECTED one opens: the entry-problem row's whole purpose is "show me the
|
|
90
|
+
// cause", and landing on a still-collapsed section makes the reader spend a
|
|
91
|
+
// second click on the thing they just asked for.
|
|
88
92
|
hops: nodes.map((n) => ({
|
|
89
93
|
...resourceSection(n),
|
|
90
94
|
id: n.id,
|
|
91
95
|
state: 'plain' as const,
|
|
92
96
|
chipText: '',
|
|
93
|
-
expanded:
|
|
97
|
+
expanded: sel === n.id,
|
|
94
98
|
})),
|
|
95
99
|
}
|
|
96
100
|
}
|
|
@@ -120,7 +124,7 @@ const NOT_DATAPLANE = 'Nothing about the normal network path. Kubernetes relayed
|
|
|
120
124
|
const SYNTHETIC_IDENTITY =
|
|
121
125
|
'That your app can reach it. This test ran from a throwaway Pod under the namespace’s default account with no token mounted — not as your application — so anything that checks who is calling may answer differently.'
|
|
122
126
|
|
|
123
|
-
function originScope(o: Origin, trace: Trace): { k: string; v: string }[] {
|
|
127
|
+
function originScope(o: Origin, trace: Trace, request?: string): { k: string; v: string }[] {
|
|
124
128
|
const runsIn: Record<OriginId, string> = {
|
|
125
129
|
incluster: `a throwaway Pod in ${trace.subject.namespace || 'the cluster'}`,
|
|
126
130
|
'radar-incluster': 'Radar\u2019s own Pod',
|
|
@@ -137,6 +141,9 @@ function originScope(o: Origin, trace: Trace): { k: string; v: string }[] {
|
|
|
137
141
|
// different facts and must not share a label.
|
|
138
142
|
{ k: o.id === 'local' ? 'NETWORK POSITION' : 'IDENTITY', v: o.identity },
|
|
139
143
|
{ k: 'MECHANISM', v: o.mech },
|
|
144
|
+
// A status code cannot be read without knowing what was asked for - "404"
|
|
145
|
+
// means nothing until you know the request was GET /.
|
|
146
|
+
...(request ? [{ k: 'REQUEST', v: request }] : []),
|
|
140
147
|
]
|
|
141
148
|
}
|
|
142
149
|
|
|
@@ -151,6 +158,7 @@ function gapNext(
|
|
|
151
158
|
namespace?: string,
|
|
152
159
|
multiPath?: boolean,
|
|
153
160
|
inClusterRunnable = true,
|
|
161
|
+
canRun = true,
|
|
154
162
|
): Sidebar['path']['next'] {
|
|
155
163
|
// The server can only test a route that carries a concrete in-cluster request.
|
|
156
164
|
// When none does, the run fails with "not supported for this subject" - so the
|
|
@@ -161,7 +169,14 @@ function gapNext(
|
|
|
161
169
|
// graph, which is the thing that would produce the missing evidence - and a
|
|
162
170
|
// third copy of the same control (header, panel, capsule) was one too many.
|
|
163
171
|
// The panel keeps the REASONING, which is what it is good at.
|
|
164
|
-
const inClusterCTA = (): InspectorCTA[] =>
|
|
172
|
+
const inClusterCTA = (): InspectorCTA[] => {
|
|
173
|
+
// No handler wired (a library consumer that omits it) means the click would
|
|
174
|
+
// do nothing at all - offer nothing rather than a button that lies.
|
|
175
|
+
if (!canRun) return []
|
|
176
|
+
return inClusterRunnable
|
|
177
|
+
? [{ text: '⚗ Run the in-cluster test', action: 'run-in-cluster', primary: true }]
|
|
178
|
+
: [{ text: '⚗ Run the in-cluster test', action: 'run-in-cluster', disabledReason: notRunnable }]
|
|
179
|
+
}
|
|
165
180
|
const actionable = actionableGap(origins)
|
|
166
181
|
const ceiling = strongestGap(origins)
|
|
167
182
|
const ceilingNote = ceiling?.unsupported ? `Even then, ${ceiling.name.toLowerCase()} stays untested — ${ceiling.unavailable}` : undefined
|
|
@@ -194,28 +209,23 @@ function gapNext(
|
|
|
194
209
|
}
|
|
195
210
|
}
|
|
196
211
|
if (actionable && actionable.id === current.id) {
|
|
197
|
-
// You are looking at the vantage that is itself the gap.
|
|
212
|
+
// You are looking at the vantage that is itself the gap. The section body
|
|
213
|
+
// already said nothing ran from here, and the caveats section already
|
|
214
|
+
// carries the ceiling - so this is the ACTION and nothing else.
|
|
198
215
|
return {
|
|
199
|
-
header: '
|
|
200
|
-
body: inClusterRunnable
|
|
201
|
-
? `Nothing has been tested from ${actionable.name} yet, and it is the strongest evidence Radar can still collect.${allPaths}`
|
|
202
|
-
: `${notRunnable} Every declared path here was skipped before a request could be formed.`,
|
|
203
|
-
blocked: ceilingNote,
|
|
216
|
+
header: '',
|
|
217
|
+
body: inClusterRunnable ? '' : `${notRunnable} Every declared path here was skipped before a request could be formed.`,
|
|
204
218
|
ctas: actionable.id === 'incluster' ? inClusterCTA() : [{ text: '⟳ Re-run', action: 'run-probes' }],
|
|
205
219
|
}
|
|
206
220
|
}
|
|
207
221
|
if (!actionable) {
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
body: `This is the strongest evidence Radar can collect for this path.${allPaths}`,
|
|
216
|
-
ctas: [],
|
|
217
|
-
quiet: true,
|
|
218
|
-
}
|
|
222
|
+
// Nothing to offer: say nothing. This was a resource-level statement living
|
|
223
|
+
// in the selection-scoped panel, identical on every vantage - the scope
|
|
224
|
+
// mixing this redesign exists to remove - and the ceiling it gestured at is
|
|
225
|
+
// already stated, with specifics, in WHAT THIS DOESN'T PROVE and in the
|
|
226
|
+
// footer's coverage ledger. An empty section is the honest render of
|
|
227
|
+
// "there is no next step".
|
|
228
|
+
return { header: '', body: '', ctas: [] }
|
|
219
229
|
}
|
|
220
230
|
return {
|
|
221
231
|
header: 'RUN THIS NEXT',
|
|
@@ -259,6 +269,9 @@ interface Ctx {
|
|
|
259
269
|
multiPath?: boolean
|
|
260
270
|
/** The HTTP path the run requests, as chosen in "what to test". */
|
|
261
271
|
httpPath?: string
|
|
272
|
+
/** False when the host wired no in-cluster handler, or permission is denied:
|
|
273
|
+
* the run must not be offered at all then. */
|
|
274
|
+
canRunInCluster?: boolean
|
|
262
275
|
}
|
|
263
276
|
|
|
264
277
|
/**
|
|
@@ -280,7 +293,73 @@ function restatesTitle(summary: string | undefined, title: string): boolean {
|
|
|
280
293
|
return !!summary && !!title && norm(summary) === norm(title)
|
|
281
294
|
}
|
|
282
295
|
|
|
296
|
+
/** The request this route would send, in one line - "GET /healthz", or "TCP
|
|
297
|
+
* connect" where there is no application request to make. */
|
|
298
|
+
function requestLabel(route: RouteResult | undefined, httpPath?: string): string | undefined {
|
|
299
|
+
const r = route?.inClusterRequest
|
|
300
|
+
if (!r) return undefined
|
|
301
|
+
if (r.protocol === 'tcp') return 'TCP connect'
|
|
302
|
+
const path = httpPath || r.path || '/'
|
|
303
|
+
return `GET ${path}`
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* What an ANSWER means for this page's question.
|
|
308
|
+
*
|
|
309
|
+
* "reached" plus an HTTP status is the single most-misread result on the tab:
|
|
310
|
+
* a reader sees amber and 404 and cannot tell whether that is a problem. It is
|
|
311
|
+
* not - for the question this page asks. The network path is proven the moment
|
|
312
|
+
* the app answers at all; the status code is a statement about the app's own
|
|
313
|
+
* routing, auth or health, which reachability does not judge. Say that, and say
|
|
314
|
+
* what would turn it into a verified pass.
|
|
315
|
+
*/
|
|
316
|
+
/**
|
|
317
|
+
* What an answer MEANS, and whether that answer is evidence the path works.
|
|
318
|
+
*
|
|
319
|
+
* `pathWorks` is the load-bearing half: an app answering 404 proves the journey
|
|
320
|
+
* reached it, while a gateway answering 502 proves the opposite. Both are
|
|
321
|
+
* "answers", so a caller that prefixes every meaning with "the path works"
|
|
322
|
+
* states the reverse of the truth on exactly the cases that matter most.
|
|
323
|
+
*/
|
|
324
|
+
function statusMeaning(evidence?: string, httpPath?: string, failedLayer?: string): { text: string; pathWorks: boolean } | undefined {
|
|
325
|
+
// A cert failure never gets an HTTP status - the handshake stops before a
|
|
326
|
+
// request is sent - so this must be decided before looking for a code.
|
|
327
|
+
if (failedLayer === 'tls') {
|
|
328
|
+
return { text: 'the TLS handshake did not verify - a certificate problem, not an application one.', pathWorks: false }
|
|
329
|
+
}
|
|
330
|
+
const m = /HTTP\s+(\d{3})/i.exec(evidence ?? '')
|
|
331
|
+
if (!m) return undefined
|
|
332
|
+
const code = Number(m[1])
|
|
333
|
+
const asked = httpPath && httpPath !== '/' ? httpPath : '/'
|
|
334
|
+
// 502/504 are NOT the app: a gateway answering that it could not reach its
|
|
335
|
+
// upstream. The producer says so with failedLayer 'upstream'; blaming app
|
|
336
|
+
// health here contradicted the chip beside it.
|
|
337
|
+
if (failedLayer === 'upstream' || code === 502 || code === 504) {
|
|
338
|
+
return {
|
|
339
|
+
text: `the front door answered, but only to say it could not reach the backend (HTTP ${code}) - the break is between the entry and the app, not inside the app.`,
|
|
340
|
+
pathWorks: false,
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (code >= 500) {
|
|
344
|
+
return { text: `the request reached the app and the app itself returned an error (HTTP ${code}) - application health, which this page does not judge.`, pathWorks: true }
|
|
345
|
+
}
|
|
346
|
+
if (code === 401 || code === 403 || code === 407) {
|
|
347
|
+
return { text: `the app answered by demanding credentials (HTTP ${code}) - it is enforcing auth, which is a different thing from reachability.`, pathWorks: true }
|
|
348
|
+
}
|
|
349
|
+
if (code >= 300 && code < 400) {
|
|
350
|
+
return { text: `the app answered with a redirect (HTTP ${code}), which Radar does not follow - so whatever it points at is untested from here.`, pathWorks: true }
|
|
351
|
+
}
|
|
352
|
+
if (code >= 400) {
|
|
353
|
+
return { text: `the app answered (HTTP ${code}) - that is it saying it serves no route for ${asked}, not a reachability problem. To verify a real route, re-run with a path your app serves.`, pathWorks: true }
|
|
354
|
+
}
|
|
355
|
+
return undefined
|
|
356
|
+
}
|
|
357
|
+
|
|
283
358
|
/** The persistent diagnosis: did traffic get through, from where, and what next. */
|
|
359
|
+
/** The suffix the localization lines carry; stripped when deduping so one
|
|
360
|
+
* observation cannot appear twice at two lengths. */
|
|
361
|
+
const LOCALIZED_SUFFIX_RE = / [-\u2014] checked directly, past the entry point$/
|
|
362
|
+
|
|
284
363
|
function pathSection(ctx: Ctx): Sidebar['path'] {
|
|
285
364
|
const { trace, route, origin, origins } = ctx
|
|
286
365
|
// The route outcome is merged across origins. Without this gate the panel
|
|
@@ -330,14 +409,29 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
|
|
|
330
409
|
}
|
|
331
410
|
|
|
332
411
|
const evidence: { mark: Mark; text: string }[] = []
|
|
333
|
-
const seen = new
|
|
412
|
+
const seen = new Map<string, number>()
|
|
413
|
+
// Two lines can be the SAME observation worded at different lengths: a route's
|
|
414
|
+
// rollup evidence and the localization fact from that same dial differ only by
|
|
415
|
+
// the " - checked directly..." suffix, so exact-string dedupe let both through
|
|
416
|
+
// and the reader saw "HTTP 404 - reached" twice. Key on the observation and
|
|
417
|
+
// keep the more specific wording.
|
|
334
418
|
const add = (m: Mark, text: string) => {
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
419
|
+
const t = text.trim()
|
|
420
|
+
const key = t.toLowerCase().replace(LOCALIZED_SUFFIX_RE, '').trim()
|
|
421
|
+
if (!key) return
|
|
422
|
+
const at = seen.get(key)
|
|
423
|
+
if (at !== undefined) {
|
|
424
|
+
if (t.length > evidence[at].text.length) evidence[at] = { mark: evidence[at].mark, text: t }
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
seen.set(key, evidence.length)
|
|
428
|
+
evidence.push({ mark: m, text: t })
|
|
339
429
|
}
|
|
340
430
|
if ((hasEvidence || fromConfig) && asSeen?.evidence) add(mark, asSeen.evidence)
|
|
431
|
+
// A result with no evidence STRING is still a result: show what the mark
|
|
432
|
+
// means rather than leaving the section empty, which would read as "nothing
|
|
433
|
+
// ran" for a vantage that did.
|
|
434
|
+
else if (hasEvidence && asSeen) add(mark, markHelp(mark))
|
|
341
435
|
// What this vantage saw at each HOP. The route is built from the backend's
|
|
342
436
|
// probes, so a laptop that dialled the front door and got an answer had all
|
|
343
437
|
// of it discarded and read as "no test has been run from here" - beside a
|
|
@@ -374,12 +468,16 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
|
|
|
374
468
|
// has been run" - the capsule preserves the attempt and this row must not
|
|
375
469
|
// erase it. The error itself is the observation.
|
|
376
470
|
const attemptError = origin.id === 'incluster' && origin.mark === 'blocked' ? origin.unavailable : undefined
|
|
471
|
+
// A demoted run produced a real observation; "no test has been run from
|
|
472
|
+
// here" erased it.
|
|
473
|
+
const informational = origin.mark === 'inconclusive' ? originInformationalReason(trace, origin.id) : undefined
|
|
377
474
|
add(
|
|
378
475
|
mark,
|
|
379
476
|
// The attempt error outranks skip rows: a prior Job's leftover skips
|
|
380
477
|
// would otherwise paint a fresh failed run (image pull, quota) with last
|
|
381
478
|
// run's reason, one pane from a capsule saying "test couldn't run".
|
|
382
|
-
|
|
479
|
+
informational ||
|
|
480
|
+
attemptError ||
|
|
383
481
|
skipReason ||
|
|
384
482
|
(origin.unsupported
|
|
385
483
|
? 'Radar cannot test from here, so nothing has been learned this way'
|
|
@@ -388,7 +486,7 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
|
|
|
388
486
|
: // A vantage that CANNOT be used states why (nothing dialable from
|
|
389
487
|
// the laptop, a skipped mechanism) - "no test has been run" reads
|
|
390
488
|
// as a test someone forgot, which is a different claim.
|
|
391
|
-
origin.unavailable || '
|
|
489
|
+
origin.unavailable || ''),
|
|
392
490
|
)
|
|
393
491
|
}
|
|
394
492
|
|
|
@@ -399,12 +497,22 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
|
|
|
399
497
|
const rawDiagnosis = trace.diagnosis
|
|
400
498
|
const diagnosis = rawDiagnosis && (!rawDiagnosis.route || rawDiagnosis.route === route?.route) ? rawDiagnosis : undefined
|
|
401
499
|
const reachedSomething = !hasEvidence && !fromConfig && hopSeen.some((x) => x.ev.mark !== 'failed')
|
|
500
|
+
const answer = statusMeaning(asSeen?.evidence, ctx.httpPath, asSeen?.failedLayer)
|
|
402
501
|
const body = basis === 'cluster-state'
|
|
403
502
|
? 'Nothing is ready to serve this path right now, so it cannot work from any vantage. No request was sent to establish that — it is read off the current state of the cluster, and it changes when the workload does.'
|
|
404
503
|
: fromConfig
|
|
405
504
|
? 'The configuration itself is broken, so this path cannot work from any vantage. No request was sent to establish that — it is read off what is declared.'
|
|
406
505
|
: reachedSomething
|
|
407
506
|
? 'This vantage did reach part of the path — see what it saw below. It has no result for this route as a whole, so the end-to-end journey from here is still unproven.'
|
|
507
|
+
: origin.mark === 'inconclusive' || mark === 'inconclusive'
|
|
508
|
+
// A demoted run RAN and answered - it is only kept out of the verdict. The
|
|
509
|
+
// "nothing has been tested" branch below fired first and denied it happened.
|
|
510
|
+
// Both the origin's mark and the route's own can carry the demotion, and the
|
|
511
|
+
// sentence is the same either way; the reason comes from the informational
|
|
512
|
+
// skip that recorded it, never from the route-scoped skip lookup.
|
|
513
|
+
? `The probe ran and got an answer, but it is kept as evidence rather than a verdict${
|
|
514
|
+
originInformationalReason(trace, origin.id) ? `: ${originInformationalReason(trace, origin.id)}` : ''
|
|
515
|
+
}. A throwaway identity cannot stand in for your application, so what it saw informs but never decides.`
|
|
408
516
|
: !hasEvidence
|
|
409
517
|
? (origin.unavailable || 'Nothing has been tested from here, so this says nothing about whether traffic gets through.') +
|
|
410
518
|
// When NOTHING was tested anywhere, the health dots are the only colour
|
|
@@ -416,11 +524,23 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
|
|
|
416
524
|
: mark === 'proved'
|
|
417
525
|
? 'A real request went through and the target answered.'
|
|
418
526
|
: mark === 'proxied'
|
|
419
|
-
?
|
|
527
|
+
? [
|
|
528
|
+
'Kubernetes relayed a request and the target answered — which shows something is serving, not that the normal path works.',
|
|
529
|
+
// The relay caveat alone leaves "404" unreadable: the reader still
|
|
530
|
+
// cannot tell whether the answer itself was a problem.
|
|
531
|
+
answer && `As for the answer itself: ${answer.text}`,
|
|
532
|
+
]
|
|
533
|
+
.filter(Boolean)
|
|
534
|
+
.join(' ')
|
|
420
535
|
: mark === 'untested'
|
|
421
536
|
? 'Nothing has been tried from here yet. Configuration may look right, but that is intent, not proof.'
|
|
422
537
|
: mark === 'stale'
|
|
423
538
|
? 'This result predates a change to the cluster, so it is set aside rather than trusted.'
|
|
539
|
+
: mark === 'excluded'
|
|
540
|
+
// Benign by design (deliberately scaled to zero, a not-eligible
|
|
541
|
+
// endpoint). It fell through to the generic "answered" sentence,
|
|
542
|
+
// which described a request that was never sent.
|
|
543
|
+
? `${asSeen?.evidence || 'Nothing is behind this path right now'} — that is deliberate, not a failure: nothing was sent, because there is nothing to reach.`
|
|
424
544
|
: mark === 'running'
|
|
425
545
|
? 'A test is running. Earlier results stay until new ones replace them.'
|
|
426
546
|
: // A proxy-only failure wears the same amber mark as a real answer,
|
|
@@ -428,7 +548,14 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
|
|
|
428
548
|
// the reader to debug an application response that never existed.
|
|
429
549
|
asSeen?.outcome === 'unreachable' && asSeen?.confidence === 'indirect'
|
|
430
550
|
? 'The relayed dial failed. The proxy bypasses the real path, so this does not condemn it — but nothing answered, and the real path is still untested.'
|
|
431
|
-
:
|
|
551
|
+
: // Only an answer that proves the journey completed earns "the
|
|
552
|
+
// path works" - a 502 or a failed handshake is an answer that
|
|
553
|
+
// says the opposite.
|
|
554
|
+
(answer ? (answer.pathWorks ? `The path works: ${answer.text}` : `The path did not work: ${answer.text}`) : undefined) ??
|
|
555
|
+
// A transport-only reach: nothing was asked of the application.
|
|
556
|
+
(asSeen?.outcome === 'reached'
|
|
557
|
+
? 'The port accepted a connection, but nothing was asked of the application - the transport works and the application protocol is unverified.'
|
|
558
|
+
: 'The target answered, but not with what was asked for.')
|
|
432
559
|
|
|
433
560
|
return {
|
|
434
561
|
chipTone: asSeen ? routeTone(asSeen, { stale: ctx.stale, running: ctx.running }) : 'unknown',
|
|
@@ -436,7 +563,7 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
|
|
|
436
563
|
title: `${origin.name} → ${route?.target || trace.subject.name}`,
|
|
437
564
|
request: route ? `${route.route}${ctx.httpPath && ctx.httpPath !== '/' ? ` · HTTP path ${ctx.httpPath}` : ''}` : undefined,
|
|
438
565
|
body,
|
|
439
|
-
scope: [...originScope(origin, trace), ...(route ? [{ k: 'PATH', v: route.route }] : [])],
|
|
566
|
+
scope: [...originScope(origin, trace, requestLabel(route, ctx.httpPath)), ...(route ? [{ k: 'PATH', v: route.route }] : [])],
|
|
440
567
|
evidence,
|
|
441
568
|
notProve,
|
|
442
569
|
next:
|
|
@@ -449,7 +576,7 @@ function pathSection(ctx: Ctx): Sidebar['path'] {
|
|
|
449
576
|
...(diagnosis.command ? [{ text: 'Copy the command', action: 'copy-command' as InspectorAction, command: diagnosis.command }] : []),
|
|
450
577
|
],
|
|
451
578
|
}
|
|
452
|
-
: gapNext(origins, origin, trace.subject.namespace, ctx.multiPath, traceInClusterRunnable(trace)),
|
|
579
|
+
: gapNext(origins, origin, trace.subject.namespace, ctx.multiPath, traceInClusterRunnable(trace), ctx.canRunInCluster !== false),
|
|
453
580
|
}
|
|
454
581
|
}
|
|
455
582
|
|
|
@@ -588,7 +715,7 @@ export function buildSidebar(sel: Selection, ctx: Ctx): Sidebar {
|
|
|
588
715
|
expanded: sel ? n.id === sel : i === defaultOpen,
|
|
589
716
|
}
|
|
590
717
|
}),
|
|
591
|
-
context: contextGroup(ctx, byId),
|
|
718
|
+
context: contextGroup(ctx, byId, sel),
|
|
592
719
|
}
|
|
593
720
|
}
|
|
594
721
|
|
|
@@ -607,6 +734,8 @@ export function buildVerdict(
|
|
|
607
734
|
* route-scoped badge apart from the resource-wide headline beside it. */
|
|
608
735
|
chipScope?: string
|
|
609
736
|
scopeLabel?: string
|
|
737
|
+
/** The selected vantage's name - the viewing strip renders it. */
|
|
738
|
+
originName?: string
|
|
610
739
|
title: string
|
|
611
740
|
problem?: string
|
|
612
741
|
body: string
|
|
@@ -643,6 +772,7 @@ export function buildVerdict(
|
|
|
643
772
|
.filter(Boolean)
|
|
644
773
|
.join(' · ') || undefined,
|
|
645
774
|
scopeLabel: opts.pathLabel || opts.originName ? 'THIS RESOURCE' : undefined,
|
|
775
|
+
originName: opts.originName,
|
|
646
776
|
// A stale screen previously led with the old headline ("Reachable...") and
|
|
647
777
|
// then said underneath that the result was excluded. That is a contradiction,
|
|
648
778
|
// not an exclusion.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { routeMark, routeTone, routeChip, orderRoutes, scenariosFor, groupRoutes, vantageSignature, routeIdentity, isSlow, formatLatency, hostMatches, declaredHosts, routeHostOf, routeForOrigin, routeAsSeenFrom, originRouteEvidence, MARKS } from './reachMarks'
|
|
2
|
+
import { routeMark, routeTone, routeChip, orderRoutes, scenariosFor, groupRoutes, vantageSignature, routeIdentity, isSlow, formatLatency, hostMatches, declaredHosts, routeHostOf, routeForOrigin, routeAsSeenFrom, originRouteEvidence, MARKS, edgeHelp, markHelp, edgeHelpIsRedundant } from './reachMarks'
|
|
3
3
|
import type { RouteResult, Hop } from './types'
|
|
4
4
|
|
|
5
5
|
const r = (o: Partial<RouteResult>): RouteResult => ({ route: 'GET /', outcome: 'verified', ...o })
|
|
@@ -665,3 +665,34 @@ describe('a preserved candidate is one gap, not two scenarios', () => {
|
|
|
665
665
|
expect(s).toHaveLength(2)
|
|
666
666
|
})
|
|
667
667
|
})
|
|
668
|
+
|
|
669
|
+
describe('a structural line explains itself, not a test that never existed', () => {
|
|
670
|
+
it('ownership and selection never claim they were "not tested"', () => {
|
|
671
|
+
expect(edgeHelp('runs', 'config')).toContain('ownership')
|
|
672
|
+
expect(edgeHelp('runs', 'config')).not.toContain('not tested')
|
|
673
|
+
expect(edgeHelp('selects', 'config')).toContain('read from the cluster')
|
|
674
|
+
expect(edgeHelp('selects', 'config')).not.toContain('not tested')
|
|
675
|
+
})
|
|
676
|
+
it('declared routing says what WOULD exercise it', () => {
|
|
677
|
+
for (const l of ['routes to', 'sends to']) {
|
|
678
|
+
expect(edgeHelp(l, 'config')).toContain('request through this entry')
|
|
679
|
+
}
|
|
680
|
+
})
|
|
681
|
+
it('anything actually observed keeps the mark vocabulary', () => {
|
|
682
|
+
expect(edgeHelp('selects', 'proved')).toBe(markHelp('proved'))
|
|
683
|
+
expect(edgeHelp('routes to', 'failed')).toBe(markHelp('failed'))
|
|
684
|
+
})
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
describe('the rest of the edge vocabulary', () => {
|
|
688
|
+
it('a collapsed sibling and an off-path backend say why, not "not tested"', () => {
|
|
689
|
+
expect(edgeHelp('also serves', 'config')).toContain('collapsed')
|
|
690
|
+
expect(edgeHelp('also serves', 'config')).not.toContain('not tested')
|
|
691
|
+
expect(edgeHelp('other host', 'excluded')).toContain('different host')
|
|
692
|
+
})
|
|
693
|
+
it("a line whose own label already says it does not say it twice", () => {
|
|
694
|
+
// origin edges are labelled with their evidence
|
|
695
|
+
expect(edgeHelpIsRedundant('not tested', '', 'untested')).toBe(true)
|
|
696
|
+
expect(edgeHelpIsRedundant('HTTP 404 · reached', '', 'proxied')).toBe(false)
|
|
697
|
+
})
|
|
698
|
+
})
|
|
@@ -107,6 +107,50 @@ export function markHelp(m: Mark): string {
|
|
|
107
107
|
return MARK_LEGEND.find((l) => l.mark === m)?.text ?? m
|
|
108
108
|
}
|
|
109
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
|
+
|
|
110
154
|
/** Inline style for a mark glyph. */
|
|
111
155
|
export function glyphStyle(m: Mark): React.CSSProperties {
|
|
112
156
|
return { color: markStyle(m).color, fontWeight: 700, fontSize: '12px', lineHeight: 1.1, flex: 'none' }
|
|
@@ -193,6 +193,18 @@ export function originSkipReason(trace: Trace | undefined, originId: OriginId, r
|
|
|
193
193
|
return pick(allProbes(trace))
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Why this origin's run was kept informational. A demotion applies to the RUN,
|
|
198
|
+
* not to one backend, so this is origin-scoped rather than route-scoped: the
|
|
199
|
+
* route-scoped lookup asks which declared path a skip speaks for, which is the
|
|
200
|
+
* wrong question when the whole run was demoted.
|
|
201
|
+
*/
|
|
202
|
+
export function originInformationalReason(trace: Trace | undefined, originId: OriginId): string | undefined {
|
|
203
|
+
return allProbes(trace).find(
|
|
204
|
+
(p) => p.skipped && p.skipClass === 'informational' && !!p.reason && originOf(p, trace?.runVantage) === originId,
|
|
205
|
+
)?.reason
|
|
206
|
+
}
|
|
207
|
+
|
|
196
208
|
/**
|
|
197
209
|
* Builds the origin rail for a trace.
|
|
198
210
|
*
|
|
@@ -254,6 +254,25 @@ export interface Trace {
|
|
|
254
254
|
* walking the hop chain. Absent when there is nothing to diagnose (every
|
|
255
255
|
* route verified over real traffic). */
|
|
256
256
|
diagnosis?: Diagnosis
|
|
257
|
+
/** Faults on the DECLARED ENTRY POINTS (an Ingress or route that will never
|
|
258
|
+
* receive traffic). Deliberately separate from `diagnosis`: entries are
|
|
259
|
+
* parallel, so a dead front door must not condemn a Service that another
|
|
260
|
+
* entry still serves - but a verdict scoped to the tested path cannot see it
|
|
261
|
+
* either. Vantage-invariant; deduped against `diagnosis` by the producer. */
|
|
262
|
+
entryProblems?: EntryProblem[]
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** One declared entry point that cannot carry traffic, mirrors
|
|
266
|
+
* internal/trace/trace.go EntryProblem. */
|
|
267
|
+
export interface EntryProblem {
|
|
268
|
+
resource: ResourceRef
|
|
269
|
+
/** The human line; `detail` carries the raw controller cause for the hover. */
|
|
270
|
+
summary: string
|
|
271
|
+
detail?: string
|
|
272
|
+
severity: string
|
|
273
|
+
code?: string
|
|
274
|
+
action?: string
|
|
275
|
+
command?: string
|
|
257
276
|
}
|
|
258
277
|
|
|
259
278
|
/** Hoisted lead diagnosis, mirrors internal/trace/coverage.go Diagnosis. Every
|
|
@@ -262,6 +281,12 @@ export interface Trace {
|
|
|
262
281
|
* fingerprints (missing-ref / svc:*); a pod-state code is omitted and summary
|
|
263
282
|
* carries the honest prose instead. */
|
|
264
283
|
export interface Diagnosis {
|
|
284
|
+
/** "fault" (something wrong, promoted from a finding) or "coverage" (a
|
|
285
|
+
* statement about what could be tested). The problem list renders faults
|
|
286
|
+
* only - a coverage sentence there restates the headline. */
|
|
287
|
+
class?: string
|
|
288
|
+
/** Severity of the finding this was promoted from. */
|
|
289
|
+
severity?: string
|
|
265
290
|
causeCode?: string
|
|
266
291
|
/** Which route this diagnosis explains, when attributable to exactly one.
|
|
267
292
|
* Absent means it describes the resource as a whole. The selected-path panel
|
|
@@ -106,6 +106,25 @@ const KIND: Record<string, string> = {
|
|
|
106
106
|
CiliumClusterwideNetworkPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
|
|
107
107
|
ClusterNetworkPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
|
|
108
108
|
|
|
109
|
+
// Kyverno. Policy kinds share the indigo family used by the other
|
|
110
|
+
// policy-shaped kinds above; the destructive ones (Deleting/Cleanup) and
|
|
111
|
+
// PolicyException take amber so they read as "changes or bypasses things".
|
|
112
|
+
Policy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
|
|
113
|
+
ClusterPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
|
|
114
|
+
ValidatingPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
|
|
115
|
+
NamespacedValidatingPolicy: 'bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-300 dark:border-indigo-700/40',
|
|
116
|
+
ImageValidatingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
|
|
117
|
+
NamespacedImageValidatingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
|
|
118
|
+
MutatingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
|
|
119
|
+
NamespacedMutatingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
|
|
120
|
+
GeneratingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
|
|
121
|
+
NamespacedGeneratingPolicy: 'bg-violet-100 text-violet-800 border-violet-300 dark:bg-violet-950/50 dark:text-violet-300 dark:border-violet-700/40',
|
|
122
|
+
DeletingPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
|
|
123
|
+
NamespacedDeletingPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
|
|
124
|
+
PolicyException: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
|
|
125
|
+
CleanupPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
|
|
126
|
+
ClusterCleanupPolicy: 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950/50 dark:text-amber-300 dark:border-amber-700/40',
|
|
127
|
+
|
|
109
128
|
// Cluster-scoped
|
|
110
129
|
Node: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
|
|
111
130
|
Namespace: 'bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-950/50 dark:text-gray-400 dark:border-gray-700/40',
|
|
@@ -144,6 +163,14 @@ const KIND: Record<string, string> = {
|
|
|
144
163
|
// Contour
|
|
145
164
|
HTTPProxy: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
|
|
146
165
|
|
|
166
|
+
// CloudNativePG. Only Pooler is keyed here. getKindColorClass takes the kind
|
|
167
|
+
// string alone, and CNPG's other kinds arrive as bare `Cluster` / `Backup` /
|
|
168
|
+
// `ScheduledBackup`, which collide with CAPI and Velero — keying those would
|
|
169
|
+
// paint another operator's resource in Postgres colors, the exact collision
|
|
170
|
+
// class this integration fixes elsewhere. Coloring them needs the API group
|
|
171
|
+
// threaded through Badge; until then they take the default.
|
|
172
|
+
Pooler: 'bg-sky-100 text-sky-700 border-sky-300 dark:bg-sky-950/50 dark:text-sky-400 dark:border-sky-700/40',
|
|
173
|
+
|
|
147
174
|
// Cluster API
|
|
148
175
|
CAPICluster: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
|
|
149
176
|
MachineDeployment: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
|
|
@@ -154,6 +181,15 @@ const KIND: Record<string, string> = {
|
|
|
154
181
|
ClusterClass: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
|
|
155
182
|
MachineHealthCheck: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
|
|
156
183
|
|
|
184
|
+
// Velero. Only kinds whose names no other operator claims are mapped: this
|
|
185
|
+
// table is keyed on kind alone with no group awareness, so a shared name would
|
|
186
|
+
// paint a foreign resource in Velero's colour. That rules out `Backup` (CNPG),
|
|
187
|
+
// `Restore` (rancher/backup-restore-operator) and `Schedule` (several
|
|
188
|
+
// operators) — the same collisions the renderers guard on velero.io.
|
|
189
|
+
BackupStorageLocation: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
|
|
190
|
+
VolumeSnapshotLocation: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
|
|
191
|
+
BackupRepository: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
|
|
192
|
+
|
|
157
193
|
// Events
|
|
158
194
|
Event: 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-950/50 dark:text-slate-400 dark:border-slate-700/40',
|
|
159
195
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react'
|
|
2
|
-
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
|
|
2
|
+
import { AlertTriangle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
|
|
3
3
|
import { ConfirmDialog } from './ConfirmDialog'
|
|
4
4
|
import { pluralize } from '../../utils/pluralize'
|
|
5
5
|
|
|
@@ -20,6 +20,7 @@ interface ForceDeleteConfirmDialogProps {
|
|
|
20
20
|
isLoading: boolean
|
|
21
21
|
cascadeDependents?: CascadeDependent[]
|
|
22
22
|
cascadeLoading?: boolean
|
|
23
|
+
cascadeRootResolved?: boolean
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
export function ForceDeleteConfirmDialog({
|
|
@@ -32,6 +33,7 @@ export function ForceDeleteConfirmDialog({
|
|
|
32
33
|
isLoading,
|
|
33
34
|
cascadeDependents,
|
|
34
35
|
cascadeLoading,
|
|
36
|
+
cascadeRootResolved,
|
|
35
37
|
}: ForceDeleteConfirmDialogProps) {
|
|
36
38
|
const [forceDelete, setForceDelete] = useState(false)
|
|
37
39
|
|
|
@@ -68,6 +70,15 @@ export function ForceDeleteConfirmDialog({
|
|
|
68
70
|
<CascadeDependentsList dependents={cascadeDependents} />
|
|
69
71
|
)}
|
|
70
72
|
|
|
73
|
+
{!cascadeLoading && cascadeRootResolved === false && (
|
|
74
|
+
<div className="flex items-start gap-2 rounded border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-warning-text">
|
|
75
|
+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
76
|
+
<span>
|
|
77
|
+
Radar couldn't verify which dependent resources Kubernetes will also delete. Additional resources may be deleted but aren't shown here.
|
|
78
|
+
</span>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
|
|
71
82
|
<label className="flex items-center gap-2 text-sm text-theme-text-secondary">
|
|
72
83
|
<input
|
|
73
84
|
type="checkbox"
|