@skyhook-io/k8s-ui 1.10.4 → 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.
- package/package.json +5 -5
- package/src/components/applications/ApplicationDetail.test.tsx +46 -0
- package/src/components/applications/ApplicationDetail.tsx +14 -15
- package/src/components/topology/K8sResourceNode.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +85 -5
- package/src/components/trace/ReachabilityGraph.tsx +503 -0
- package/src/components/trace/ReachabilityView.tsx +972 -0
- package/src/components/trace/TracePanel.test.ts +172 -0
- package/src/components/trace/TracePanel.tsx +558 -0
- package/src/components/trace/TraceSummary.test.ts +213 -0
- package/src/components/trace/TraceSummary.tsx +168 -0
- package/src/components/trace/inClusterSummary.test.ts +98 -0
- package/src/components/trace/inClusterSummary.ts +81 -0
- package/src/components/trace/index.ts +19 -0
- package/src/components/trace/podReach.test.ts +71 -0
- package/src/components/trace/podReach.ts +37 -0
- package/src/components/trace/probe-display.test.ts +107 -0
- package/src/components/trace/probe-display.ts +37 -0
- package/src/components/trace/reachFixtures.ts +261 -0
- package/src/components/trace/reachGraphModel.test.ts +1407 -0
- package/src/components/trace/reachGraphModel.ts +1521 -0
- package/src/components/trace/reachInspector.test.ts +669 -0
- package/src/components/trace/reachInspector.ts +667 -0
- package/src/components/trace/reachMarks.test.ts +667 -0
- package/src/components/trace/reachMarks.ts +579 -0
- package/src/components/trace/reachOrigins.test.ts +392 -0
- package/src/components/trace/reachOrigins.ts +437 -0
- package/src/components/trace/reachVerdict.test.ts +537 -0
- package/src/components/trace/reachVerdict.ts +601 -0
- package/src/components/trace/traceFingerprint.test.ts +214 -0
- package/src/components/trace/traceFingerprint.ts +122 -0
- package/src/components/trace/traceToSubgraph.test.ts +623 -0
- package/src/components/trace/traceToSubgraph.ts +463 -0
- package/src/components/trace/types.ts +389 -0
- package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
- package/src/components/ui/InClusterConsentDialog.tsx +141 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +58 -5
- package/src/components/workload/index.ts +1 -1
- package/src/components/workload/reachabilityTab.test.ts +47 -0
- package/src/index.ts +5 -0
- package/src/theme/components.css +38 -0
- package/src/theme/variables.css +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/application-history.test.ts +1 -1
- package/src/utils/applications.test.ts +63 -1
- package/src/utils/applications.ts +27 -0
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +61 -0
- package/src/utils/navigation.ts +11 -9
- package/src/utils/pluralize.test.ts +80 -1
- package/src/utils/pluralize.ts +34 -0
|
@@ -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 “/” and the in-cluster test uses each route’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 & 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
|
+
}
|