@skyhook-io/k8s-ui 1.10.3 → 1.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/topology/K8sResourceNode.tsx +5 -0
  5. package/src/components/topology/TopologyGraph.tsx +85 -5
  6. package/src/components/topology/layout.worker.ts +2 -1
  7. package/src/components/trace/ReachabilityGraph.tsx +503 -0
  8. package/src/components/trace/ReachabilityView.tsx +972 -0
  9. package/src/components/trace/TracePanel.test.ts +172 -0
  10. package/src/components/trace/TracePanel.tsx +558 -0
  11. package/src/components/trace/TraceSummary.test.ts +213 -0
  12. package/src/components/trace/TraceSummary.tsx +168 -0
  13. package/src/components/trace/inClusterSummary.test.ts +98 -0
  14. package/src/components/trace/inClusterSummary.ts +81 -0
  15. package/src/components/trace/index.ts +19 -0
  16. package/src/components/trace/podReach.test.ts +71 -0
  17. package/src/components/trace/podReach.ts +37 -0
  18. package/src/components/trace/probe-display.test.ts +107 -0
  19. package/src/components/trace/probe-display.ts +37 -0
  20. package/src/components/trace/reachFixtures.ts +261 -0
  21. package/src/components/trace/reachGraphModel.test.ts +1407 -0
  22. package/src/components/trace/reachGraphModel.ts +1521 -0
  23. package/src/components/trace/reachInspector.test.ts +669 -0
  24. package/src/components/trace/reachInspector.ts +667 -0
  25. package/src/components/trace/reachMarks.test.ts +667 -0
  26. package/src/components/trace/reachMarks.ts +579 -0
  27. package/src/components/trace/reachOrigins.test.ts +392 -0
  28. package/src/components/trace/reachOrigins.ts +437 -0
  29. package/src/components/trace/reachVerdict.test.ts +537 -0
  30. package/src/components/trace/reachVerdict.ts +601 -0
  31. package/src/components/trace/traceFingerprint.test.ts +214 -0
  32. package/src/components/trace/traceFingerprint.ts +122 -0
  33. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  34. package/src/components/trace/traceToSubgraph.ts +463 -0
  35. package/src/components/trace/types.ts +389 -0
  36. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  37. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  38. package/src/components/ui/index.ts +1 -0
  39. package/src/components/workload/WorkloadView.tsx +58 -5
  40. package/src/components/workload/index.ts +1 -1
  41. package/src/components/workload/reachabilityTab.test.ts +47 -0
  42. package/src/index.ts +5 -0
  43. package/src/theme/components.css +38 -0
  44. package/src/theme/variables.css +5 -0
  45. package/src/types/core.ts +23 -0
  46. package/src/utils/application-history.test.ts +1 -1
  47. package/src/utils/applications.test.ts +63 -1
  48. package/src/utils/applications.ts +27 -0
  49. package/src/utils/asset-url.test.ts +113 -0
  50. package/src/utils/asset-url.ts +33 -1
  51. package/src/utils/inClusterConsent.test.ts +109 -0
  52. package/src/utils/inClusterConsent.ts +72 -0
  53. package/src/utils/index.ts +2 -0
  54. package/src/utils/navigation.test.ts +61 -0
  55. package/src/utils/navigation.ts +11 -9
  56. package/src/utils/pluralize.test.ts +80 -1
  57. package/src/utils/pluralize.ts +34 -0
@@ -0,0 +1,389 @@
1
+ // Mirrors internal/trace types.go on the Go side. Keep field names in sync
2
+ // when extending - JSON tags on Go must match these property names exactly.
3
+
4
+ export type Verdict = 'healthy' | 'degraded' | 'broken' | 'unknown'
5
+ export type FindingSeverity = 'critical' | 'warning' | 'info'
6
+
7
+ export interface ResourceRef {
8
+ group?: string
9
+ kind: string
10
+ namespace?: string
11
+ name: string
12
+ }
13
+
14
+ export interface Finding {
15
+ code: string
16
+ severity: FindingSeverity
17
+ message: string
18
+ /** Parsed root cause (plain English) when the detector classified the
19
+ * failure - e.g. "Image not found in registry", "Exit code 137 (OOMKilled)".
20
+ * Empty for generic detections; UI renders message in that case. */
21
+ cause?: string
22
+ /** Next-step guidance paired with cause - e.g. "Push the image or fix the
23
+ * reference", "Increase the container memory limit". Empty when not parsed. */
24
+ action?: string
25
+ remediation?: string
26
+ command?: string
27
+ /** Renderable chip strip carrying structured detail a sentence can't hold -
28
+ * today the egress-note's allowed destinations. On-chip text is jargon-free;
29
+ * the jargon lives in each chip's title. */
30
+ chips?: FindingChip[]
31
+ /** Tiny field label printed before the chip strip ("Allowed out"). Empty when
32
+ * the chips read on their own (deny-all, anywhere). */
33
+ chipsLabel?: string
34
+ /** Quiet tertiary caveats printed under the chips (declared-vs-enforced, the
35
+ * gated DNS observation). Kept subordinate to the inbound verdict. */
36
+ chipNotes?: string[]
37
+ /** The specific object this finding is about when it differs from the hop it
38
+ * hangs on - a pod-fanout hop carries one finding per code but the culprit is
39
+ * a named Pod. Empty = the hop's own resource is the subject. */
40
+ resource?: ResourceRef
41
+ }
42
+
43
+ /** One tag in a Finding's chip strip. Tone 'accent' renders the info-blue
44
+ * variant (anywhere / deny-all); omitted renders the muted variant. */
45
+ export interface FindingChip {
46
+ text: string
47
+ title?: string
48
+ tone?: 'accent' | ''
49
+ }
50
+
51
+ /** Known hop.meta keys stamped by the Go tracer (buildPodsHop et al. in
52
+ * internal/trace). The wire is an open map - property names must match the Go
53
+ * meta keys exactly; unknown keys pass through the index signature. */
54
+ export interface HopMeta {
55
+ /** Count of endpoints the dataplane actually routes to. For a
56
+ * publishNotReadyAddresses Service this is the PUBLISHED endpoint count
57
+ * (every selected pod, readiness not required) - NOT the kubelet-ready
58
+ * count, so it must never render as "N/M ready" there. */
59
+ ready?: number
60
+ /** Pods matched by the Service selector. */
61
+ selected?: number
62
+ /** Set when the Service publishes endpoints regardless of pod readiness
63
+ * (spec.publishNotReadyAddresses) - the signal that meta.ready is a
64
+ * published count, not a readiness count. */
65
+ publishNotReadyAddresses?: boolean
66
+ /** 'pod-readiness' when endpoints were judged from pod state; 'unknown' when
67
+ * the pod lister failed and the count can't be trusted. */
68
+ endpointSource?: string
69
+ selectorless?: boolean
70
+ [key: string]: unknown
71
+ }
72
+
73
+ export interface Hop {
74
+ resource: ResourceRef
75
+ edge: string
76
+ findings: Finding[]
77
+ meta?: HopMeta
78
+ config?: HopConfig
79
+ probes?: ProbeResult[]
80
+ }
81
+
82
+ export type ProbeLayer = 'dns' | 'tcp' | 'tls' | 'http'
83
+ export type ProbeVantage = 'in-cluster' | 'local'
84
+ /**
85
+ * Which route a Service/Pod probe took. "data" = straight to the resource
86
+ * over the network (kube-proxy for ClusterIP, direct dial for PodIP).
87
+ * "apiserver" = through the K8s API server's proxy subresource. Empty when
88
+ * the question doesn't apply (DNS, HTTP to an Ingress hostname, etc.).
89
+ */
90
+ export type ProbePath = 'data' | 'apiserver'
91
+
92
+ /**
93
+ * Tone classifies a Result for the UI when ok alone is too coarse. It encodes
94
+ * the honest distinction between reaching a target, verifying what we asked
95
+ * for, and the server erroring:
96
+ * healthy - reached AND verified (HTTP 2xx; clean DNS/TCP/TLS)
97
+ * reached - reached an HTTP server, but the thing we asked for is unproven
98
+ * (3xx redirect not followed, or 4xx route/auth not what we hit).
99
+ * NOT a reachability failure; never renders as "verified" or
100
+ * "degraded".
101
+ * degraded - reached, but the server answered 5xx. Traffic passed.
102
+ * unhealthy - could not reach (transport failure).
103
+ * Empty falls back to (skipped → unknown, !ok → unhealthy, ok → healthy).
104
+ */
105
+ export type ProbeTone = 'healthy' | 'reached' | 'degraded' | 'unhealthy'
106
+
107
+ export interface ProbeResult {
108
+ layer: ProbeLayer
109
+ target: string
110
+ vantage: ProbeVantage
111
+ path?: ProbePath
112
+ ok: boolean
113
+ tone?: ProbeTone
114
+ skipped?: boolean
115
+ /** Structured skip class from the producer. 'informational' means the probe
116
+ * RAN and was deliberately kept out of the verdict - a disposition, never a
117
+ * coverage gap. */
118
+ skipClass?: string
119
+ reason?: string
120
+ latencyNs?: number
121
+ /** The Service/target port this result is for, when port-scoped (lets the UI group
122
+ * per-port results into one row per path across directions). */
123
+ port?: number
124
+ /** Declared L4 protocol when not TCP - two ServicePorts may share a number. */
125
+ protocol?: string
126
+ detail?: string
127
+ error?: string
128
+ /** What a DNS lookup resolved to. */
129
+ addresses?: string[]
130
+ /** Scope of those addresses. Describes the ADDRESS, not the journey: a public
131
+ * address does not prove a packet crossed the public internet. */
132
+ addressScope?: 'public' | 'private' | 'mixed'
133
+ /** WHO issued this probe. Radar's own process and a throwaway probe Job are
134
+ * both in-cluster over the data path but are different observers with
135
+ * different identities. Absent means 'radar'. */
136
+ source?: 'radar' | 'probe-job'
137
+ /** Copyable command to verify what this probe couldn't (non-HTTP port, HTTPS
138
+ * backend, an address only reachable in-cluster). Set at the skip site. */
139
+ command?: string
140
+ }
141
+
142
+ export interface PortMap {
143
+ name?: string
144
+ port: number
145
+ targetPort?: string
146
+ protocol?: string
147
+ appProtocol?: string
148
+ }
149
+
150
+ export interface ContainerPortRef {
151
+ container: string
152
+ name?: string
153
+ port: number
154
+ protocol?: string
155
+ }
156
+
157
+ /** One row of the Pods hop's per-pod reachability grid. Mirrors Go trace.PodStatus.
158
+ * A ready pod joins its probe result by name (apiserver path) or ip (data path);
159
+ * a not-ready pod has no probe and reads its cause from `reason`. */
160
+ export interface PodStatus {
161
+ name: string
162
+ ip?: string
163
+ ready: boolean
164
+ reason?: string
165
+ }
166
+
167
+ export interface ProbeRef {
168
+ container: string
169
+ type: string
170
+ port?: string
171
+ path?: string
172
+ scheme?: string
173
+ }
174
+
175
+ export interface BackendRef {
176
+ kind: string
177
+ name: string
178
+ namespace?: string
179
+ port?: string
180
+ }
181
+
182
+ export interface RouteRule {
183
+ hosts?: string[]
184
+ paths?: string[]
185
+ backends?: BackendRef[]
186
+ }
187
+
188
+ export interface GatewayListener {
189
+ name?: string
190
+ port: number
191
+ protocol?: string
192
+ hostname?: string
193
+ }
194
+
195
+ export interface HopConfig {
196
+ ports?: PortMap[]
197
+ serviceType?: string
198
+ clusterIP?: string
199
+ selector?: Record<string, string>
200
+ containerPorts?: ContainerPortRef[]
201
+ probes?: ProbeRef[]
202
+ hostnames?: string[]
203
+ rules?: RouteRule[]
204
+ tlsHosts?: string[]
205
+ listeners?: GatewayListener[]
206
+ addresses?: string[]
207
+ servedBy?: string
208
+ servedByTitle?: string
209
+ podIPs?: string[]
210
+ podNames?: string[]
211
+ pods?: PodStatus[]
212
+ podTotal?: number
213
+ /** What RUNS these Pods, resolved through their owner chain by the producer.
214
+ * Absent when the selected Pods have no single owner - a bare Pod, or two
215
+ * workloads behind one Service, where naming one would be a guess. */
216
+ workload?: ResourceRef
217
+ }
218
+
219
+ export type UnknownClass = 'by-design' | 'investigate'
220
+
221
+ export interface Trace {
222
+ subject: ResourceRef
223
+ upstreams: Hop[]
224
+ downstream: Hop[]
225
+ verdict: Verdict
226
+ brokenAt: number
227
+ reason?: string
228
+ /** Where RADAR ITSELF ran the inline probes from: 'in-cluster' when Radar is
229
+ * a Pod (its direct dials are real pod-to-pod traffic, issued with Radar's
230
+ * own identity) or 'local' when it runs on a workstation. Without this an
231
+ * in-cluster probe with no `source` is ambiguous between Radar's own dial and
232
+ * the throwaway Job's. */
233
+ runVantage?: 'in-cluster' | 'local'
234
+ /** Distinguishes the two flavors of the unknown verdict so the UI can
235
+ * pick the right visual register. 'by-design' covers configurations
236
+ * where auto-verification doesn't apply (e.g. selectorless Service);
237
+ * the banner renders as informational. 'investigate' covers cases
238
+ * where the trace tried and couldn't read state (RBAC, cache cold,
239
+ * lookup failure); the banner renders as warning. Absent on
240
+ * non-unknown verdicts. */
241
+ unknownClass?: UnknownClass
242
+ truncated?: boolean
243
+ // Coverage-honest projection (server-authoritative, mirrors internal/trace/coverage.go).
244
+ // These describe ONLY what was actually tested. Localization on a RouteResult is the
245
+ // behind-the-gate evidence, kept separate so it can never feed the headline/outcome.
246
+ coverage?: Coverage
247
+ routes?: RouteResult[]
248
+ notTested?: RouteSkip[]
249
+ brokenRoute?: ResourceRef
250
+ /** The single coverage-honest banner sentence - same string the MCP renders. */
251
+ headline?: string
252
+ /** The one finding that matters - cause, the named culprit, the next action -
253
+ * promoted from the path so a consumer reads the "why + what next" without
254
+ * walking the hop chain. Absent when there is nothing to diagnose (every
255
+ * route verified over real traffic). */
256
+ diagnosis?: Diagnosis
257
+ }
258
+
259
+ /** Hoisted lead diagnosis, mirrors internal/trace/coverage.go Diagnosis. Every
260
+ * field is PROMOTED from a real finding (or coverage state), never synthesized.
261
+ * causeCode is the structured code, present ONLY for trustworthy structural
262
+ * fingerprints (missing-ref / svc:*); a pod-state code is omitted and summary
263
+ * carries the honest prose instead. */
264
+ export interface Diagnosis {
265
+ causeCode?: string
266
+ /** Which route this diagnosis explains, when attributable to exactly one.
267
+ * Absent means it describes the resource as a whole. The selected-path panel
268
+ * must not present another path's cause as this path's. */
269
+ route?: string
270
+ summary: string
271
+ culpritResource?: ResourceRef
272
+ nextAction?: string
273
+ command?: string
274
+ }
275
+
276
+ /** Per-route outcome (pairs with a confidence). Mirrors the Go Outcome* constants. */
277
+ export type RouteOutcome = 'verified' | 'reached' | 'server-error' | 'unreachable' | 'not-tested'
278
+ /** How an outcome was learned: 'real' = real-traffic path; 'indirect' = apiserver proxy
279
+ * (annotates, never sets the headline). '' for a static-known break (missing backend). */
280
+ export type RouteConfidence = 'real' | 'indirect' | ''
281
+ /** Skip impact: 'coverage' + 'vantage' are real gaps (cap green); 'benign' loses no coverage. */
282
+ export type SkipReasonClass = 'coverage' | 'benign' | 'vantage'
283
+
284
+ export interface Coverage {
285
+ tested: number
286
+ passed: number
287
+ failed: number
288
+ skipped: number
289
+ /** Routes known broken WITHOUT being dialled - from what is declared, or from
290
+ * current cluster state. Real breaks, but not test results, so they are
291
+ * reported apart from tested/passed/failed rather than as requests that
292
+ * failed. */
293
+ derived?: number
294
+ }
295
+
296
+ export interface ProbeFact {
297
+ layer: string
298
+ path?: string
299
+ target?: string
300
+ ok: boolean
301
+ tone?: string
302
+ detail?: string
303
+ }
304
+
305
+ export interface RouteResult {
306
+ route: string
307
+ target?: string
308
+ /** Backend Service namespace - set when it differs from the subject namespace
309
+ * (cross-namespace Gateway API backendRef) so the in-cluster probe dials an FQDN. */
310
+ targetNamespace?: string
311
+ outcome: RouteOutcome
312
+ /** Which layer failed on a non-reachable route, so the UI can say exactly what
313
+ * broke: 'tcp' | 'tls' | 'http' (unreachable), or 'upstream' for a 502/504 where
314
+ * HTTP was reached but the gateway couldn't reach its backend. */
315
+ failedLayer?: 'tcp' | 'tls' | 'http' | 'upstream'
316
+ confidence?: RouteConfidence
317
+ evidence?: string
318
+ command?: string
319
+ /** Unreachable by DESIGN (backing workload intentionally scaled to 0) - read
320
+ * amber-benign, not red. */
321
+ benign?: boolean
322
+ /** Behind-the-gate facts (apiserver/direct-pod). Render as plain "checked behind it"
323
+ * sub-lines - never label them "localization" in the UI. */
324
+ localization?: ProbeFact[]
325
+ /** Best-guess concrete request to test this route from inside the cluster.
326
+ * A STARTING POINT the user edits before running; pathGuessed marks a path
327
+ * derived from a pattern (no single faithful request exists). */
328
+ inClusterRequest?: ProbeRequest
329
+ /** Where this route's `outcome` came from. Absent (the normal case) means it
330
+ * was OBSERVED and `byVantage` says by whom. Otherwise it was DERIVED and no
331
+ * vantage dialled it, so it must never be rendered as the selected origin's
332
+ * observation nor in the language of a request:
333
+ *
334
+ * - 'declared-config' read off what is declared (a backendRef naming a
335
+ * Service that does not exist) - broken regardless of
336
+ * what the cluster is currently doing.
337
+ * - 'cluster-state' read off current state (no ready endpoints) - true
338
+ * now, and changes when the workload does. */
339
+ basis?: 'declared-config' | 'cluster-state'
340
+ /** Each vantage's OWN view of this route.
341
+ *
342
+ * `outcome` / `confidence` / `evidence` above are a documented lossy rollup:
343
+ * the producer buckets by mechanism and takes worst-wins, so a route that
344
+ * works in-cluster and fails from a laptop collapses to "unreachable" with no
345
+ * field left to say where it DID work. Anything answering "did this path work
346
+ * from THIS vantage" must read this instead of the rollup. */
347
+ byVantage?: VantageResult[]
348
+ }
349
+
350
+ /** One vantage's unmerged view of a route. Keyed by (vantage, path): the same
351
+ * vantage relayed through the API server is a different claim from one that
352
+ * used the real network path. */
353
+ export interface VantageResult {
354
+ vantage: ProbeVantage
355
+ path: ProbePath
356
+ /** Who dialled: 'radar' (Radar's own process) or 'probe-job' (a throwaway Pod
357
+ * Radar created). Absent means 'radar'. Kept apart from `vantage` because the
358
+ * two share (in-cluster, data) but are different observers. */
359
+ source?: 'radar' | 'probe-job'
360
+ outcome: RouteOutcome
361
+ confidence?: RouteConfidence
362
+ evidence?: string
363
+ failedLayer?: 'tcp' | 'tls' | 'http' | 'upstream'
364
+ /** Which hop-to-hop boundary broke, when two observations either side of it
365
+ * establish that. Empty means we could not tell - the common case, and never
366
+ * to be guessed at in the UI. */
367
+ failedBoundary?: 'service-routing'
368
+ /** Which part of the DECLARED path this row's dial exercised. 'backend' means
369
+ * the dial bypassed the route's front door, so this row can never vouch for
370
+ * the entry path. Empty when there is no separate entry segment. */
371
+ segment?: 'backend'
372
+ }
373
+
374
+ /** A concrete request the in-cluster runner can send to the Service. HTTP
375
+ * fields are absent for transport-only TCP probes. */
376
+ export interface ProbeRequest {
377
+ protocol: 'http' | 'https' | 'tcp'
378
+ scheme?: 'http' | 'https'
379
+ host?: string
380
+ path?: string
381
+ pathGuessed?: boolean
382
+ }
383
+
384
+ export interface RouteSkip {
385
+ route?: string
386
+ reason: string
387
+ reasonClass?: SkipReasonClass
388
+ command?: string
389
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { inClusterConsentDetails } from './InClusterConsentDialog'
3
+
4
+ const req = (route: string, request: string) => ({ route, request })
5
+
6
+ describe('inClusterConsentDetails', () => {
7
+ it('names the requests it is about to send, not just where they land', () => {
8
+ // This is the last screen before real pod-to-pod traffic. It used to show
9
+ // only the cluster and namespace, so the operator confirmed requests they
10
+ // were never shown.
11
+ const out = inClusterConsentDetails({
12
+ cluster: 'prod-us-east1',
13
+ namespace: 'payments',
14
+ requests: [req('checkout.example.com/', 'GET https://checkout.example.com/healthz')],
15
+ })
16
+ expect(out).toContain('prod-us-east1')
17
+ expect(out).toContain('payments')
18
+ expect(out).toContain('GET https://checkout.example.com/healthz')
19
+ })
20
+
21
+ it('shows each route its OWN path — a single "GET /" was wrong in the default case', () => {
22
+ // With no path override the server keeps every route's declared path, so one
23
+ // shared "/" is least accurate exactly when it used to be displayed.
24
+ const out = inClusterConsentDetails({
25
+ namespace: 'payments',
26
+ requests: [
27
+ req('a.example.com/api', 'GET https://a.example.com/api'),
28
+ req('b.example.com/health', 'GET https://b.example.com/health'),
29
+ ],
30
+ })
31
+ expect(out).toContain('/api')
32
+ expect(out).toContain('/health')
33
+ })
34
+
35
+ it('counts declared paths that have no derivable request', () => {
36
+ // They are part of the scope being agreed to; omitting them undercounted
37
+ // what the run covers.
38
+ const out = inClusterConsentDetails({
39
+ namespace: 'payments',
40
+ requests: [req('a/', 'GET http://svc:80/')],
41
+ untestedCount: 2,
42
+ })
43
+ expect(out).toMatch(/Declared:\s+3 paths/)
44
+ expect(out).toMatch(/2 paths with no derivable request/)
45
+ })
46
+
47
+ it('says "path" singular for one', () => {
48
+ expect(inClusterConsentDetails({ namespace: 'p', requests: [req('a', 'GET /')] })).toMatch(/Declared:\s+1 path\b(?!s)/)
49
+ })
50
+
51
+ it('caps the listed requests but says how many are hidden', () => {
52
+ const many = Array.from({ length: 9 }, (_, i) => req(`r${i}`, `GET http://svc:80/${i}`))
53
+ const out = inClusterConsentDetails({ namespace: 'p', requests: many })
54
+ expect(out).toMatch(/Declared:\s+9 paths/)
55
+ expect(out).toMatch(/and 3 more/)
56
+ })
57
+
58
+ it('omits the coverage block entirely when nothing is known, rather than guessing', () => {
59
+ const out = inClusterConsentDetails({ namespace: 'payments' })
60
+ expect(out).not.toMatch(/Declared:/)
61
+ expect(out).toMatch(/Namespace: payments/)
62
+ })
63
+
64
+ it('omits the cluster line when identity is unavailable', () => {
65
+ expect(inClusterConsentDetails({ namespace: 'payments' })).not.toMatch(/Cluster:/)
66
+ })
67
+ it('discloses the probe-Pod cap instead of implying full coverage', () => {
68
+ // Route order is deterministic server-side, so routes past the cap are not
69
+ // deferred - re-running starts from the same first five and never reaches
70
+ // them. A screen that counts them as covered is the misleading part.
71
+ const many = Array.from({ length: 8 }, (_, i) => req(`r${i}`, `GET http://svc:80/${i}`))
72
+ const out = inClusterConsentDetails({ namespace: 'p', requests: many, maxProbes: 5 })
73
+ expect(out).toMatch(/Will run:\s+5 requests/)
74
+ expect(out).toMatch(/3 beyond the 5-Pod limit/)
75
+ expect(out).toMatch(/same first 5/)
76
+ })
77
+
78
+ it('says nothing about a cap when everything fits under it', () => {
79
+ const out = inClusterConsentDetails({ namespace: 'p', requests: [req('a', 'GET http://svc:80/')], maxProbes: 5 })
80
+ expect(out).toMatch(/Will run:\s+1 request\b(?!s)/)
81
+ expect(out).not.toMatch(/beyond the/)
82
+ })
83
+ })
@@ -0,0 +1,141 @@
1
+ import { useState } from 'react'
2
+ import { ConfirmDialog } from './ConfirmDialog'
3
+ import { rememberInClusterConsent } from '../../utils/inClusterConsent'
4
+
5
+ /** One request the in-cluster job will actually send. */
6
+ export interface ConsentRequest {
7
+ /** Route label, for the operator to recognise which path this is. */
8
+ route: string
9
+ /** Concrete request, e.g. "GET https://checkout.example.com/healthz". */
10
+ request: string
11
+ }
12
+
13
+ interface InClusterConsentDialogProps {
14
+ open: boolean
15
+ cluster?: string
16
+ namespace: string
17
+ /** The requests this run will send, one per declared route. */
18
+ requests?: ConsentRequest[]
19
+ /** Declared paths the run covers but for which no concrete request could be
20
+ * derived. They still count toward the scope the operator is agreeing to. */
21
+ untestedCount?: number
22
+ /** Server-side ceiling on probe Pods per call. */
23
+ maxProbes?: number
24
+ onClose: () => void
25
+ onConfirm: () => void
26
+ }
27
+
28
+ /** Rows are capped so the dialog stays a confirmation, not a report. */
29
+ const MAX_LISTED_REQUESTS = 6
30
+
31
+ /**
32
+ * The fact block the operator confirms against. It is the last screen before
33
+ * real pod-to-pod traffic, so it must name the requests being sent - it used to
34
+ * show only where the Job would land, and then only a single "GET /" that was
35
+ * wrong in the DEFAULT case: with no path override the server keeps each
36
+ * route's OWN declared path, so "/" is least accurate exactly when it is shown.
37
+ */
38
+ export function inClusterConsentDetails(o: {
39
+ cluster?: string
40
+ namespace: string
41
+ requests?: ConsentRequest[]
42
+ untestedCount?: number
43
+ /** Server-side ceiling on probe Pods per call. Routes past it do not run, and
44
+ * since ordering is deterministic they are not reached by re-running either -
45
+ * so the screen must say so rather than imply full coverage. */
46
+ maxProbes?: number
47
+ }): string {
48
+ const reqs = o.requests ?? []
49
+ const untested = o.untestedCount ?? 0
50
+ const total = reqs.length + untested
51
+ const cap = o.maxProbes ?? 0
52
+ const willRun = cap > 0 ? Math.min(reqs.length, cap) : reqs.length
53
+ const lines = [cluster(o.cluster), `Namespace: ${o.namespace}`]
54
+ if (total > 0) {
55
+ lines.push(`Declared: ${total} path${total === 1 ? '' : 's'} on this resource`)
56
+ // "0 requests, one Pod each" is nonsense, and it describes a run that would
57
+ // create nothing at all - so say that instead of counting to zero.
58
+ lines.push(
59
+ willRun === 0
60
+ ? 'Will run: nothing — no request could be derived from these paths'
61
+ : `Will run: ${willRun} request${willRun === 1 ? '' : 's'}, one Pod each`,
62
+ )
63
+ }
64
+ for (const r of reqs.slice(0, MAX_LISTED_REQUESTS)) {
65
+ lines.push(` → ${r.request}`)
66
+ }
67
+ const hidden = reqs.length - Math.min(reqs.length, MAX_LISTED_REQUESTS)
68
+ if (hidden > 0) lines.push(` → …and ${hidden} more`)
69
+ // Named, never silently dropped: they are part of the scope being agreed to
70
+ // even though no concrete request could be derived for them.
71
+ if (untested > 0) {
72
+ lines.push(` → ${untested} path${untested === 1 ? '' : 's'} with no derivable request`)
73
+ }
74
+ const beyond = reqs.length - willRun
75
+ if (beyond > 0) {
76
+ lines.push(` → ${beyond} beyond the ${cap}-Pod limit — not tested, and re-running starts from the same first ${cap}`)
77
+ }
78
+ return lines.filter(Boolean).join('\n')
79
+ }
80
+
81
+ function cluster(name?: string): string {
82
+ return name ? `Cluster: ${name}` : ''
83
+ }
84
+
85
+ // Confirms the mutating in-cluster reachability test before it spawns a Job/pod,
86
+ // naming the cluster it lands in. Permission is enforced upstream (the button only
87
+ // renders when the capability SSAR allows), so this is a safety confirm, not authz.
88
+ export function InClusterConsentDialog({ open, cluster, namespace, requests, untestedCount, maxProbes, onClose, onConfirm }: InClusterConsentDialogProps) {
89
+ const [dontAskAgain, setDontAskAgain] = useState(false)
90
+
91
+ function handleClose() {
92
+ onClose()
93
+ setDontAskAgain(false)
94
+ }
95
+
96
+ function handleConfirm() {
97
+ if (dontAskAgain && cluster) rememberInClusterConsent(cluster)
98
+ setDontAskAgain(false)
99
+ onConfirm()
100
+ }
101
+
102
+ return (
103
+ <ConfirmDialog
104
+ open={open}
105
+ onClose={handleClose}
106
+ onConfirm={handleConfirm}
107
+ // Wide enough for a real cluster identity: a GKE context name runs past
108
+ // 50 characters and wrapped mid-name at the default width.
109
+ className="w-full max-w-xl"
110
+ title="Run in-cluster reachability test?"
111
+ message="This creates one short-lived, self-deleting Job per request, sending real pod-to-pod traffic from inside the cluster. Each Pod runs under the target namespace’s default ServiceAccount with no token mounted — not as you — and your cluster may inject sidecars into it."
112
+ details={inClusterConsentDetails({ cluster, namespace, requests, untestedCount, maxProbes })}
113
+ // Nothing to send means nothing to confirm. The button was live for a run
114
+ // that would create no Pods and change no evidence.
115
+ confirmDisabled={(requests?.length ?? 0) === 0}
116
+ confirmLabel="Run test"
117
+ cancelLabel="Cancel"
118
+ variant="warning"
119
+ >
120
+ {/* "Don't ask again" is stored per cluster. Without a cluster identity
121
+ there is nothing safe to key it on (one origin can front many
122
+ clusters), so consent is never remembered - hide the checkbox rather
123
+ than promise a persistence that doesn't stick. */}
124
+ {cluster ? (
125
+ <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
126
+ <input
127
+ type="checkbox"
128
+ checked={dontAskAgain}
129
+ onChange={(e) => setDontAskAgain(e.target.checked)}
130
+ className="w-4 h-4 rounded border-theme-border bg-theme-base text-amber-600 focus:ring-amber-500 focus:ring-offset-0"
131
+ />
132
+ <span>Don&apos;t ask again for this cluster</span>
133
+ </label>
134
+ ) : (
135
+ <p className="text-xs text-theme-text-tertiary">
136
+ Cluster identity unavailable - this confirmation can&apos;t be saved, so it will be asked on every run.
137
+ </p>
138
+ )}
139
+ </ConfirmDialog>
140
+ )
141
+ }
@@ -27,6 +27,7 @@ export * from './drawer-components'
27
27
  export { Collapse, CollapseChevron } from './Collapse'
28
28
  export { ResourceBar } from './ResourceBar'
29
29
  export { ForceDeleteConfirmDialog } from './ForceDeleteConfirmDialog'
30
+ export { InClusterConsentDialog } from './InClusterConsentDialog'
30
31
  export { ToastProvider, useToast, showApiError, showApiSuccess } from './Toast'
31
32
  export { CodeViewer } from './CodeViewer'
32
33
  export { YamlEditor, YamlDiffEditor } from './YamlEditor'