@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,558 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react'
2
+ import { Copy, Check, Loader2, Info, RefreshCw, MoreHorizontal, Pencil, Network } from 'lucide-react'
3
+ import { Tooltip } from '../ui/Tooltip'
4
+ import type { Trace, Finding, FindingSeverity, ProbeResult, ResourceRef, RouteResult, Coverage } from './types'
5
+ import { Badge } from '../ui/Badge'
6
+ import { StatusDot, type StatusTone } from '../ui/status-tone'
7
+
8
+ export interface TracePanelProps {
9
+ trace: Trace | undefined
10
+ isLoading?: boolean
11
+ error?: Error | null
12
+ /** A reachability-probe run that failed. Rendered inline (NOT as a
13
+ * full-panel error) so the valid static trace stays visible - the user
14
+ * clicked "run test", it failed, and they see why instead of nothing. */
15
+ probeError?: Error | null
16
+ /** Optional: parent-provided refetch action surfaced when the trace verdict
17
+ * is unknown - gives the operator a way to retry after RBAC/cache changes
18
+ * without forcing a drawer close+reopen. */
19
+ onRefresh?: () => void
20
+ /** True when the next refetch should request reachability probes. The
21
+ * parent passes back the resulting Trace via the trace prop. Probes are
22
+ * one-shot - the parent should reset this to false after results arrive. */
23
+ probeRequested?: boolean
24
+ /** Callback when the operator asks for a reachability test. The parent
25
+ * is expected to set probeRequested=true and trigger a refetch; results
26
+ * flow back through `trace.downstream[].probes`. */
27
+ onRunProbes?: () => void
28
+ /** True once a probe run has COMPLETED (the parent holds a probed trace),
29
+ * even if that probe found nothing testable (e.g. an ExternalName, or every
30
+ * route skipped from this vantage). Lets the view distinguish "not yet
31
+ * probed" from "probed, no result" so clicking Run is never a silent no-op. */
32
+ probed?: boolean
33
+ /** When provided, the resource label on each hop becomes clickable and
34
+ * invokes this with the hop's ResourceRef so the host can open the
35
+ * resource's detail view. Hops without a routable name (collections like
36
+ * the Pods fan-out) stay non-clickable. Without this prop, hop rows are
37
+ * inert text and the only interaction is the expand chevron. */
38
+ onNavigateToResource?: (ref: ResourceRef) => void
39
+ /** One-click "test every path from inside the cluster" - runs the in-cluster
40
+ * probe for the whole subject and merges the real-traffic results back into the
41
+ * trace, filling the matrix's "Real in-cluster traffic" column + upgrading the
42
+ * verdict. Shown only when the in-cluster test is actually permitted. */
43
+ onRunInCluster?: () => void
44
+ inClusterRunning?: boolean
45
+ inClusterAllowed?: boolean
46
+ /** WHY the in-cluster test is denied, from the server's capability check -
47
+ * it names the first missing verb ("you don't have permission to create
48
+ * Jobs in …"). Without it every denial rendered the same generic line. */
49
+ inClusterDeniedReason?: string
50
+ /** Set when the in-cluster test couldn't produce a result (e.g. a cold-start
51
+ * timeout) - surfaced so the click is never a silent no-op. */
52
+ inClusterError?: string
53
+ /** True when the run that set inClusterError still produced results for OTHER
54
+ * routes (folded into the merged trace) - e.g. trailing routes hit the
55
+ * per-call pod cap or the request time budget. The banner then reads
56
+ * "partially completed" instead of the false "couldn't run". */
57
+ inClusterPartial?: boolean
58
+ /** The copyable kubectl command to run the probe by hand when the in-cluster
59
+ * Job couldn't run - paired with inClusterError. */
60
+ inClusterFallback?: string
61
+ /** True when the in-cluster run produced results but folded NONE of them into
62
+ * the merged trace (every route was override-mismatch / throwaway-denied /
63
+ * guessed path) - route outcomes are unchanged. Surfaced as an informational
64
+ * note so the run isn't a silent no-op. Mutually exclusive with inClusterError. */
65
+ inClusterEvidenceOnly?: boolean
66
+ /** The evidence row's honest status message, paired with inClusterEvidenceOnly. */
67
+ inClusterEvidenceNote?: string
68
+ /** The HTTP path the probes request (default "/"); shown as the tested-request
69
+ * indicator. onApplyProbePath sets a new path and re-runs. */
70
+ probePath?: string
71
+ onApplyProbePath?: (path: string) => void
72
+ /** Bumped by the host every time a test run COMPLETES - drives the transient
73
+ * "updated just now" confirmation so an identical-looking result still reads as fresh. */
74
+ runNonce?: number
75
+ /** Wall-clock time the displayed probe/in-cluster results were produced.
76
+ * Rendered as "tested HH:MM:SS" so even a kept snapshot is honestly dated. */
77
+ testedAt?: Date
78
+ /** True when the polled cluster state changed under a shown probe snapshot and
79
+ * the host dropped it - the panel shows a notice explaining the fallback to
80
+ * live state and inviting a re-run. */
81
+ clusterChangedSinceTest?: boolean
82
+ }
83
+
84
+ /** Whether an in-cluster reachability test can run for this caller, and the
85
+ * cluster + namespace the probe pod would be created in (the safety rail). */
86
+ export interface InClusterCapability {
87
+ allowed: boolean
88
+ reason?: string
89
+ cluster?: string
90
+ namespace: string
91
+ /** Per-call ceiling on probe Pods. Routes past it never run - ordering is
92
+ * deterministic, so re-running starts from the same first maxProbes. */
93
+ maxProbes?: number
94
+ }
95
+
96
+ type AlertTone = 'success' | 'warning' | 'error' | 'info'
97
+
98
+ // coverageBannerTone derives the SINGLE banner tone from what we actually
99
+ // tested - never the legacy verdict word, which can over-claim a confident
100
+ // "healthy" on apiserver-only evidence. Honesty rules: indirect-only and
101
+ // zero-tested are never green (the real-traffic path was not confirmed);
102
+ // not-tested is never red.
103
+ export function coverageBannerTone(coverage: Coverage, routes: RouteResult[]): AlertTone {
104
+ if (coverage.tested === 0) return 'info' // nothing actively tested
105
+ if (coverage.passed === 0 && coverage.failed > 0) {
106
+ // Red ONLY when something is genuinely unreachable. Failures that still
107
+ // REACHED the server are amber, not red - a 5xx (degraded: traffic passed,
108
+ // the app errored) or an intentional scale-to-0 (benign dormancy) - which
109
+ // keeps the banner consistent with the degraded verdict.
110
+ const hardUnreach = routes.filter((r) => r.outcome === 'unreachable' && !r.benign)
111
+ // An apiserver-proxy-only (indirect) failure must NEVER set a red headline -
112
+ // the real path was never tested (mirrors reachVerdict's onlyIndirectUnreach).
113
+ // When every unreachable route is indirect, warn, don't condemn.
114
+ const anyRealUnreach = hardUnreach.some((r) => r.confidence !== 'indirect')
115
+ if (hardUnreach.length > 0) return anyRealUnreach ? 'error' : 'warning'
116
+ return 'warning'
117
+ }
118
+ if (coverage.failed > 0) return 'warning' // some reachable, some not
119
+ // Every tested route passed - green ONLY if a real-traffic path was VERIFIED.
120
+ // A route that only 'reached' a server (3xx/4xx, route not verified) is not
121
+ // confirmation; including it would overclaim a green headline while the coverage
122
+ // strip below stays neutral.
123
+ const realPass = routes.some((r) => r.confidence === 'real' && r.outcome === 'verified')
124
+ if (!realPass) return 'info'
125
+ // Green ONLY when coverage is complete. A skipped/not-tested route, or a
126
+ // proxy-only (indirect) unreachable route whose real path was never confirmed,
127
+ // is a coverage gap - down-rank to info so the drawer glance never greens a
128
+ // partial result (mirrors the backend CoverageVerdict and the full-tab banner).
129
+ const hasGap =
130
+ (coverage.skipped ?? 0) > 0 || routes.some((r) => r.outcome === 'unreachable' && r.confidence === 'indirect')
131
+ return hasGap ? 'info' : 'success'
132
+ }
133
+
134
+ // routeOutcomeRank sorts the matrix failures-first. Exported for the test.
135
+ export function routeOutcomeRank(o: RouteResult['outcome']): number {
136
+ switch (o) {
137
+ case 'unreachable': return 0
138
+ case 'server-error': return 1
139
+ case 'reached': return 2
140
+ case 'verified': return 3
141
+ default: return 4
142
+ }
143
+ }
144
+
145
+ // inClusterOutcome reduces real-dataplane probe results to a route label/tone.
146
+ // A 2xx HERE is real-traffic verified - the payoff over the indirect API-proxy read.
147
+ export function inClusterOutcome(results: ProbeResult[]): { label: string; tone: StatusTone; severity: 'error' | 'warning' | 'info' | 'success' | 'neutral' } {
148
+ // A SKIPPED probe has ok=false but is NOT a failure - classifying it would
149
+ // false-condemn a port that actually connected. Drop skips first so a skipped
150
+ // HTTP probe falls through to the TCP "port reachable" / "tested" branches.
151
+ const real = results.filter((r) => !r.skipped)
152
+ const http = real.find((r) => r.layer === 'http')
153
+ if (http) {
154
+ if (http.ok && http.tone === 'healthy') return { label: 'verified', tone: 'healthy', severity: 'success' }
155
+ if (http.ok && http.tone === 'reached') return { label: 'reached', tone: 'neutral', severity: 'info' }
156
+ if (http.ok && http.tone === 'degraded') return { label: 'server error', tone: 'degraded', severity: 'warning' }
157
+ if (!http.ok) return { label: 'unreachable', tone: 'unhealthy', severity: 'error' }
158
+ }
159
+ const tcp = real.find((r) => r.layer === 'tcp')
160
+ if (tcp) return tcp.ok ? { label: 'port reachable', tone: 'healthy', severity: 'success' } : { label: 'unreachable', tone: 'unhealthy', severity: 'error' }
161
+ return { label: 'tested', tone: 'neutral', severity: 'neutral' }
162
+ }
163
+
164
+ // inClusterEligible: an indirect (API-proxy-only) or failed route is worth
165
+ // confirming from inside the cluster. A real-traffic verified route already is.
166
+ export function inClusterEligible(r: RouteResult): boolean {
167
+ return !r.benign && (r.confidence === 'indirect' || r.outcome === 'unreachable' || r.outcome === 'server-error')
168
+ }
169
+
170
+ // JustTestedNote shows a brief "✓ updated just now" next to the verdict whenever a
171
+ // test completes, giving closure even when the result is identical to the prior run (a
172
+ // fast probe otherwise looks like nothing happened, since only the button moved). Driven
173
+ // by a run nonce the host bumps on every completed run, so it fires even with no value change.
174
+ function useFreshFlag(nonce?: number): boolean {
175
+ const [fresh, setFresh] = useState(false)
176
+ const prev = useRef<number | undefined>(nonce)
177
+ useEffect(() => {
178
+ if (prev.current !== undefined && nonce !== prev.current) {
179
+ setFresh(true)
180
+ const t = setTimeout(() => setFresh(false), 2600)
181
+ prev.current = nonce
182
+ return () => clearTimeout(t)
183
+ }
184
+ prev.current = nonce
185
+ }, [nonce])
186
+ return fresh
187
+ }
188
+ export function JustTestedNote({ nonce }: { nonce?: number }) {
189
+ const fresh = useFreshFlag(nonce)
190
+ if (!fresh) return null
191
+ return (
192
+ <span className="ml-2 inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[10px] font-medium text-accent-text" style={{ backgroundColor: 'var(--accent-muted)' }}>
193
+ ✓ updated just now
194
+ </span>
195
+ )
196
+ }
197
+
198
+ // VerdictCaveat renders the verdict's muted second line: a short one-liner on
199
+ // screen, with the full explanation tucked into the project Tooltip behind an
200
+ // info icon - so a long "why + how to fix" never bloats the banner into a wall
201
+ // of text.
202
+ export function VerdictCaveat({ caveat, detail }: { caveat?: string; detail?: string }) {
203
+ if (!caveat) return null
204
+ return (
205
+ <div className="text-xs text-theme-text-tertiary mt-0.5 flex items-start gap-1">
206
+ <span className="min-w-0">{caveat}</span>
207
+ {detail ? (
208
+ <Tooltip content={<span className="block max-w-xs leading-snug">{detail}</span>} position="bottom">
209
+ <Info className="w-3.5 h-3.5 mt-px shrink-0 cursor-help text-theme-text-tertiary hover:text-theme-text-secondary" aria-label="More detail" />
210
+ </Tooltip>
211
+ ) : null}
212
+ </div>
213
+ )
214
+ }
215
+
216
+ // RequestIndicator names the exact request the probes made and WHEN - shown
217
+ // in the verdict bar (always visible after a run) so the operator knows what was
218
+ // tested and that the result is a dated snapshot, not a live read.
219
+ export type RequestMode = 'http' | 'tcp' | 'mixed' | 'none'
220
+
221
+ /** What actually RAN, derived from the folded trace: which declared TCP-only
222
+ * targets saw a live TCP dial, and whether any live HTTP request completed.
223
+ * Feeds RequestIndicator so "tested with GET /" is never claimed for a run
224
+ * that only connected a socket - or never connected anything. */
225
+ export function completedRequestMode(trace: Trace): RequestMode {
226
+ const tcpTargets = new Set(
227
+ (trace.routes ?? [])
228
+ .filter((route) => route.inClusterRequest?.protocol === 'tcp' && !!route.target)
229
+ .map((route) => {
230
+ const ns = route.targetNamespace || trace.subject.namespace || ''
231
+ return ns ? `${ns}/${route.target}` : route.target!
232
+ }),
233
+ )
234
+ let testedHTTP = false
235
+ let testedTCP = false
236
+ for (const hop of [...(trace.downstream ?? []), ...(trace.upstreams ?? [])]) {
237
+ const ns = hop.resource?.namespace || trace.subject.namespace || ''
238
+ const name = hop.resource?.name || ''
239
+ for (const result of hop.probes ?? []) {
240
+ if (result.skipped) continue
241
+ if (result.layer === 'http') testedHTTP = true
242
+ if (result.layer === 'tcp' && result.port && name) {
243
+ const target = `${name}:${result.port}`
244
+ if (tcpTargets.has(ns ? `${ns}/${target}` : target)) testedTCP = true
245
+ }
246
+ }
247
+ }
248
+ return testedHTTP && testedTCP ? 'mixed' : testedHTTP ? 'http' : testedTCP ? 'tcp' : 'none'
249
+ }
250
+
251
+ export function RequestIndicator({ path, onApplyProbePath, testedAt, requestMode = 'http' }: {
252
+ path?: string
253
+ onApplyProbePath?: (p: string) => void
254
+ testedAt?: Date
255
+ requestMode?: RequestMode
256
+ }) {
257
+ const [editing, setEditing] = useState(false)
258
+ const [draft, setDraft] = useState(path ?? '/')
259
+ useEffect(() => { setDraft(path ?? '/') }, [path])
260
+ const apply = () => {
261
+ const t = draft.trim()
262
+ onApplyProbePath?.(!t ? '/' : t.startsWith('/') ? t : '/' + t)
263
+ setEditing(false)
264
+ }
265
+ const testedTime = testedAt ? ` ${testedAt.toLocaleTimeString(undefined, { hour12: false })}` : ''
266
+ if (requestMode === 'none') {
267
+ return (
268
+ <div className="mt-1 text-[11px] text-theme-text-tertiary">
269
+ test completed{testedTime}; no application request completed from here
270
+ </div>
271
+ )
272
+ }
273
+ if (requestMode === 'tcp') {
274
+ return (
275
+ <div className="mt-1 text-[11px] text-theme-text-tertiary">
276
+ tested{testedTime} with <span className="font-mono text-theme-text-secondary">TCP connection</span>
277
+ </div>
278
+ )
279
+ }
280
+ if (editing) {
281
+ return (
282
+ <div className="mt-1 flex items-center gap-1.5 text-[11px] text-theme-text-tertiary">
283
+ <span>tested with <span className="font-mono text-theme-text-secondary">GET</span></span>
284
+ <input
285
+ autoFocus
286
+ value={draft}
287
+ onChange={(e) => setDraft(e.target.value)}
288
+ onKeyDown={(e) => { if (e.key === 'Enter') apply(); else if (e.key === 'Escape') { setDraft(path ?? '/'); setEditing(false) } }}
289
+ onBlur={apply}
290
+ placeholder="/"
291
+ className="w-36 rounded border border-theme-border bg-theme-base px-1.5 py-0.5 font-mono text-[11px] text-theme-text-primary"
292
+ />
293
+ <span>↵ applies · esc cancels</span>
294
+ </div>
295
+ )
296
+ }
297
+ return (
298
+ <div className="mt-1 flex items-center gap-1 text-[11px] text-theme-text-tertiary">
299
+ <span>
300
+ tested{testedTime} with <span className="font-mono text-theme-text-secondary">GET {path || '/'}{requestMode === 'mixed' ? ' + TCP' : ''}</span>
301
+ </span>
302
+ {onApplyProbePath && (
303
+ <Tooltip content="Edit the request path">
304
+ <button
305
+ type="button"
306
+ onClick={() => setEditing(true)}
307
+ aria-label="Edit the request path"
308
+ className="rounded p-0.5 text-theme-text-tertiary transition-colors hover:bg-theme-hover hover:text-theme-text-secondary"
309
+ >
310
+ <Pencil className="h-3 w-3" />
311
+ </button>
312
+ </Tooltip>
313
+ )}
314
+ </div>
315
+ )
316
+ }
317
+
318
+ // ProbeOptionsMenu is the "⋯ more options" overflow next to the run buttons -
319
+ // keeps the verdict bar clean and gives a home for power options. Today its one
320
+ // item is "Customize what we test…", which opens a small form to change the HTTP
321
+ // path the probes request. A custom path applies to BOTH tests; the "/" default
322
+ // is a sentinel for the in-cluster test, which then probes each route's own
323
+ // declared path (reachability_run.go strips it), while the laptop/proxy probes
324
+ // request "/" literally - so root on an /admin route can't be forced in-cluster.
325
+ function ProbeOptionsMenu({ probePath, onApplyProbePath }: { probePath?: string; onApplyProbePath?: (p: string) => void }) {
326
+ const [open, setOpen] = useState(false)
327
+ const [editing, setEditing] = useState(false)
328
+ const [draft, setDraft] = useState(probePath ?? '/')
329
+ const ref = useRef<HTMLDivElement>(null)
330
+ useEffect(() => { setDraft(probePath ?? '/') }, [probePath])
331
+ useEffect(() => {
332
+ if (!open) return
333
+ const onDoc = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) { setOpen(false); setEditing(false) } }
334
+ document.addEventListener('mousedown', onDoc)
335
+ return () => document.removeEventListener('mousedown', onDoc)
336
+ }, [open])
337
+ if (!onApplyProbePath) return null
338
+ const apply = () => {
339
+ const t = draft.trim()
340
+ onApplyProbePath(!t ? '/' : t.startsWith('/') ? t : '/' + t)
341
+ setOpen(false); setEditing(false)
342
+ }
343
+ return (
344
+ <div ref={ref} className="relative">
345
+ <Tooltip content="More options">
346
+ <button type="button" onClick={() => setOpen((o) => !o)} aria-label="More options" className="btn-brand-muted px-1.5 py-1 text-xs">
347
+ <MoreHorizontal className="w-3.5 h-3.5" />
348
+ </button>
349
+ </Tooltip>
350
+ {open && (
351
+ <div className="absolute right-0 mt-1 z-20 w-72 rounded-lg border border-theme-border bg-theme-surface shadow-theme-lg p-1 text-xs">
352
+ {!editing ? (
353
+ <button type="button" onClick={() => setEditing(true)} className="w-full text-left px-2 py-1.5 rounded hover:bg-theme-hover flex items-center gap-2 text-theme-text-primary">
354
+ <Pencil className="w-3.5 h-3.5 shrink-0" /> Customize what we test…
355
+ </button>
356
+ ) : (
357
+ <div className="p-1.5 flex flex-col gap-2">
358
+ <div className="text-[10px] uppercase tracking-wide text-theme-text-tertiary">What to test</div>
359
+ <label className="text-theme-text-secondary">HTTP path to request</label>
360
+ <input
361
+ autoFocus
362
+ value={draft}
363
+ onChange={(e) => setDraft(e.target.value)}
364
+ onKeyDown={(e) => { if (e.key === 'Enter') apply(); if (e.key === 'Escape') { setOpen(false); setEditing(false) } }}
365
+ placeholder="/"
366
+ className="px-2 py-1 rounded border border-theme-border bg-theme-base font-mono text-theme-text-primary"
367
+ />
368
+ <div className="text-[11px] text-theme-text-tertiary">
369
+ Method GET. A custom path applies to the reachability + in-cluster test; leave &ldquo;/&rdquo; and the in-cluster test uses each route&rsquo;s own declared path.
370
+ </div>
371
+ <div className="flex items-center justify-between pt-0.5">
372
+ <button type="button" onClick={() => setDraft('/')} className="text-theme-text-tertiary hover:text-theme-text-primary">Reset to /</button>
373
+ <button type="button" onClick={apply} className="btn-brand px-2.5 py-1 inline-flex items-center gap-1.5"><RefreshCw className="w-3 h-3" /> Apply &amp; run</button>
374
+ </div>
375
+ </div>
376
+ )}
377
+ </div>
378
+ )}
379
+ </div>
380
+ )
381
+ }
382
+
383
+ // ReachActions is the verdict card's action area: the primary "Run reachability
384
+ // test" (proxy/laptop vantage) plus, once allowed, the secondary "Test inside the
385
+ // cluster" (real pod-to-pod) - one grouped control instead of two split corners.
386
+ // Uses the design-system button classes (.btn-brand / .btn-brand-muted).
387
+ export function ReachActions({ onRunProbes, probeRequested, probed, onRunInCluster, inClusterRunning, inClusterAllowed, inClusterRunnable, inClusterTested, probePath, onApplyProbePath, supportsHTTPPath = true }: {
388
+ onRunProbes?: () => void
389
+ probeRequested?: boolean
390
+ // probed/inClusterTested: each test has already produced a result, so its
391
+ // button is a RE-RUN - labeled and iconed accordingly. The control set itself
392
+ // never changes (consistency): both tests stay available to re-run whenever
393
+ // they apply. They auto-run on load, so they're almost always re-runs.
394
+ probed?: boolean
395
+ onRunInCluster?: () => void
396
+ inClusterRunning?: boolean
397
+ inClusterAllowed?: boolean
398
+ inClusterTested?: boolean
399
+ /** False when the trace carries no runnable in-cluster request - the button
400
+ * then renders DISABLED with the reason, never as a live control whose
401
+ * click is a guaranteed no-op. */
402
+ inClusterRunnable?: boolean
403
+ probePath?: string
404
+ onApplyProbePath?: (p: string) => void
405
+ supportsHTTPPath?: boolean
406
+ }) {
407
+ // The in-cluster test stays available whenever the cluster allows it - NEVER
408
+ // gated on the verdict. Hiding it after it succeeds (when the verdict turns
409
+ // healthy) is exactly when an operator wants to re-run it after a change.
410
+ const showInCluster = !!(inClusterAllowed && onRunInCluster)
411
+ const notRunnable = inClusterRunnable === false
412
+ if (!onRunProbes && !showInCluster) return null
413
+ return (
414
+ <div className="flex shrink-0 items-center gap-2">
415
+ {/* Secondary: the in-cluster Job (real pod-to-pod traffic) - a real button
416
+ (it mutates: spawns a Job), clearly subordinate to the primary, with an
417
+ in-cluster glyph so it's distinguishable without hover. */}
418
+ {showInCluster && (
419
+ <Tooltip
420
+ content={
421
+ notRunnable
422
+ ? 'No path on this resource has a request Radar can send from inside the cluster, so this test has nothing to run.'
423
+ : 'Runs the probe from short-lived Jobs INSIDE the cluster — real pod-to-pod traffic — to confirm the in-cluster data path. Covers the testable paths on this resource, not only the one selected above; the confirmation names how many will actually run.'
424
+ }
425
+ >
426
+ <button
427
+ type="button"
428
+ onClick={onRunInCluster}
429
+ disabled={inClusterRunning || notRunnable}
430
+ className="btn-brand-muted px-2.5 py-1 text-xs inline-flex items-center gap-1.5 disabled:cursor-not-allowed disabled:opacity-60"
431
+ >
432
+ {inClusterRunning ? <Loader2 className="w-3 h-3 animate-spin" /> : <Network className="w-3 h-3" />}
433
+ {inClusterRunning ? 'Running in-cluster test…' : inClusterTested ? 'Re-run in-cluster' : 'Test in-cluster'}
434
+ </button>
435
+ </Tooltip>
436
+ )}
437
+ {/* Primary: the reachability test. The refresh icon signals it's
438
+ re-runnable (it auto-ran on load), so re-running reads as a refresh. */}
439
+ {onRunProbes && (
440
+ <Tooltip content="Tests reachability from your machine and through the API server. Covers EVERY path on this resource, not only the one selected above.">
441
+ <button
442
+ type="button"
443
+ onClick={onRunProbes}
444
+ disabled={probeRequested}
445
+ className="btn-brand px-2.5 py-1 text-xs inline-flex items-center gap-1.5"
446
+ >
447
+ {probeRequested ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
448
+ {probeRequested ? 'Testing…' : probed ? 'Re-run reachability test' : 'Run reachability test'}
449
+ </button>
450
+ </Tooltip>
451
+ )}
452
+ {/* Path customization applies only to HTTP(S) requests. */}
453
+ {supportsHTTPPath && <ProbeOptionsMenu probePath={probePath} onApplyProbePath={onApplyProbePath} />}
454
+ </div>
455
+ )
456
+ }
457
+
458
+ // CopyableCommand renders a one-line command in a mono box with a copy button.
459
+ // Used for finding reproducers: the worst acceptable case is handing the user
460
+ // exactly what to run.
461
+ export function CopyableCommand({ command }: { command: string }) {
462
+ const [copied, setCopied] = useState(false)
463
+ const onCopy = useCallback(() => {
464
+ void navigator.clipboard.writeText(command)
465
+ setCopied(true)
466
+ setTimeout(() => setCopied(false), 1500)
467
+ }, [command])
468
+ return (
469
+ <div className="mt-1.5 flex items-center gap-2 bg-theme-base rounded px-2 py-1 font-mono text-[11px] text-theme-text-secondary group">
470
+ <Tooltip content={<span className="font-mono">{command}</span>} wrapperClassName="min-w-0 flex-1">
471
+ <code className="w-full truncate">{command}</code>
472
+ </Tooltip>
473
+ <Tooltip content={copied ? 'Copied!' : 'Copy'} wrapperClassName="shrink-0">
474
+ <button
475
+ type="button"
476
+ onClick={onCopy}
477
+ className="text-theme-text-tertiary hover:text-theme-text-primary"
478
+ aria-label="Copy command"
479
+ >
480
+ {copied ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />}
481
+ </button>
482
+ </Tooltip>
483
+ </div>
484
+ )
485
+ }
486
+
487
+ // severityToTone bridges the trace's Finding vocabulary (critical / warning /
488
+ // info, plus empty when a hop is clean) to Radar's shared StatusTone, so the
489
+ // dot rendering uses the same primitive every other surface does.
490
+ function severityToTone(s: FindingSeverity | ''): StatusTone {
491
+ switch (s) {
492
+ case 'critical': return 'unhealthy'
493
+ case 'warning': return 'degraded'
494
+ case 'info': return 'unknown'
495
+ default: return 'healthy'
496
+ }
497
+ }
498
+
499
+ export function FindingRow({ finding, onNavigate }: { finding: Finding; onNavigate?: (ref: ResourceRef) => void }) {
500
+ // When the detector parsed a domain-specific cause, lead with it - that's
501
+ // the "why" the operator needs first. The raw detector message stays
502
+ // visible below as secondary evidence. Without a parsed cause, message
503
+ // is the primary line as before.
504
+ const primary = finding.cause || finding.message
505
+ const secondary = finding.cause && finding.message && finding.message !== finding.cause ? finding.message : ''
506
+ // When the finding names a SPECIFIC culprit resource (e.g. the one crashing
507
+ // Pod behind a Service's "0 ready" symptom), the operator's real next move is
508
+ // to open that resource and read its logs/events - not to paste a describe.
509
+ // Offer the click-through as the primary action; keep the command as the
510
+ // terminal/CI fallback below it.
511
+ const culprit = finding.resource?.name ? finding.resource : undefined
512
+ return (
513
+ <div className="flex gap-2.5 items-start">
514
+ <span className="mt-1 shrink-0" aria-hidden><StatusDot tone={severityToTone(finding.severity)} size="sm" /></span>
515
+ <div className="flex-1 min-w-0">
516
+ <div className="text-xs text-theme-text-primary">{primary}</div>
517
+ {secondary && (
518
+ <div className="text-[11px] text-theme-text-tertiary mt-0.5">{secondary}</div>
519
+ )}
520
+ {finding.chips && finding.chips.length > 0 && (
521
+ <div className="mt-1 flex items-center gap-1 flex-wrap">
522
+ {finding.chipsLabel && (
523
+ <span className="text-[11px] text-theme-text-tertiary mr-0.5">{finding.chipsLabel}:</span>
524
+ )}
525
+ {finding.chips.map((c, i) => (
526
+ <Tooltip key={i} content={c.title ?? ''} wrapperClassName="cursor-help">
527
+ <Badge size="sm" severity={c.tone === 'accent' ? 'info' : 'neutral'}>
528
+ {c.text}
529
+ </Badge>
530
+ </Tooltip>
531
+ ))}
532
+ </div>
533
+ )}
534
+ {finding.chipNotes?.map((n, i) => (
535
+ <div key={i} className="text-[11px] text-theme-text-tertiary mt-0.5">{n}</div>
536
+ ))}
537
+ {finding.action && (
538
+ <div className="text-[11px] text-theme-text-secondary mt-0.5">
539
+ <span className="font-medium">Next step:</span> {finding.action}
540
+ </div>
541
+ )}
542
+ {finding.remediation && !finding.action && (
543
+ <div className="text-[11px] text-theme-text-tertiary mt-0.5">{finding.remediation}</div>
544
+ )}
545
+ {culprit && onNavigate && (
546
+ <button
547
+ type="button"
548
+ onClick={() => onNavigate(culprit)}
549
+ className="mt-1 inline-flex items-center gap-1 text-[11px] text-accent-text hover:underline"
550
+ >
551
+ Open {culprit.kind} {culprit.name} ↗
552
+ </button>
553
+ )}
554
+ {finding.command && <CopyableCommand command={finding.command} />}
555
+ </div>
556
+ </div>
557
+ )
558
+ }