@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.
- 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/topology/layout.worker.ts +2 -1
- 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 +23 -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/asset-url.test.ts +113 -0
- package/src/utils/asset-url.ts +33 -1
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +2 -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,213 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { summarizeTrace } from './TraceSummary'
|
|
3
|
+
import type { Trace, Coverage, RouteResult } from './types'
|
|
4
|
+
|
|
5
|
+
const trace = (o: Partial<Trace>): Trace => ({
|
|
6
|
+
subject: { kind: 'Service', namespace: 'prod', name: 'api' },
|
|
7
|
+
upstreams: [], downstream: [], verdict: 'healthy', brokenAt: -1, ...o,
|
|
8
|
+
})
|
|
9
|
+
const cov = (o: Partial<Coverage>): Coverage => ({ tested: 0, passed: 0, failed: 0, skipped: 0, ...o })
|
|
10
|
+
const route = (o: Partial<RouteResult>): RouteResult => ({ route: '/', outcome: 'verified', ...o })
|
|
11
|
+
|
|
12
|
+
describe('summarizeTrace - the passive drawer glance', () => {
|
|
13
|
+
it('minimal when not probed (static / config-only): invites the run positively, no tone, no worst line', () => {
|
|
14
|
+
const v = summarizeTrace(trace({}))
|
|
15
|
+
expect(v.kind).toBe('minimal')
|
|
16
|
+
expect(v.ctaLabel).toBe('Run test →')
|
|
17
|
+
// Positive, action-first framing - never the negative "not tested".
|
|
18
|
+
expect(v.headline.toLowerCase()).not.toContain('not tested')
|
|
19
|
+
expect(v.headline.toLowerCase()).toContain('path')
|
|
20
|
+
expect(v.subtitle).toBeTruthy()
|
|
21
|
+
expect(v.tone).toBeUndefined()
|
|
22
|
+
expect(v.worst).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('a coverage projection with tested===0 is NOT a probed glance - falls through to config-only (never "not yet tested" headline + "See test detail")', () => {
|
|
26
|
+
const v = summarizeTrace(trace({
|
|
27
|
+
verdict: 'healthy',
|
|
28
|
+
headline: 'Configuration only - not yet tested',
|
|
29
|
+
coverage: cov({ tested: 0, skipped: 1 }),
|
|
30
|
+
notTested: [{}] as Trace['notTested'],
|
|
31
|
+
}))
|
|
32
|
+
expect(v.headline).not.toContain('Configuration only')
|
|
33
|
+
expect(v.ctaLabel).toBe('Run test →')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// The drawer performs NO detection of its own - every config-only (unprobed) trace
|
|
37
|
+
// yields the minimal invite button. Config findings, verdicts, and the diagnosis are
|
|
38
|
+
// owned by the resource's Operational Issues section; the drawer never echoes them.
|
|
39
|
+
it('a config red flag (e.g. a NetworkPolicy would-deny) is NOT surfaced in the drawer - it stays the invite button', () => {
|
|
40
|
+
const v = summarizeTrace(trace({
|
|
41
|
+
verdict: 'degraded',
|
|
42
|
+
downstream: [
|
|
43
|
+
{ resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [
|
|
44
|
+
{ code: 'netpol:would-deny', severity: 'warning', message: 'A cluster network rule would block traffic to these pods on :80' },
|
|
45
|
+
] },
|
|
46
|
+
],
|
|
47
|
+
}))
|
|
48
|
+
expect(v.kind).toBe('minimal')
|
|
49
|
+
expect(v.ctaLabel).toBe('Run test →')
|
|
50
|
+
expect(v.headline).not.toContain('network rule')
|
|
51
|
+
expect(v.tone).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('partial failure: warning tone + the worst-offender line + Open Reachability CTA', () => {
|
|
55
|
+
const v = summarizeTrace(trace({
|
|
56
|
+
headline: '1 of 2 routes reachable · 1 unreachable',
|
|
57
|
+
coverage: cov({ tested: 2, passed: 1, failed: 1 }),
|
|
58
|
+
routes: [
|
|
59
|
+
route({ route: '/web', outcome: 'verified', confidence: 'real' }),
|
|
60
|
+
route({ route: '/admin', target: 'admin:9090', outcome: 'unreachable', confidence: 'real', evidence: 'connection refused' }),
|
|
61
|
+
],
|
|
62
|
+
}))
|
|
63
|
+
expect(v.kind).toBe('glance')
|
|
64
|
+
expect(v.tone).toBe('warning')
|
|
65
|
+
expect(v.worst).toEqual({ route: '/admin', target: 'admin:9090', evidence: 'connection refused', failingCount: 1 })
|
|
66
|
+
expect(v.ctaLabel).toBe('Open Reachability →')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('all unreachable (none passed): error tone + worst line', () => {
|
|
70
|
+
const v = summarizeTrace(trace({
|
|
71
|
+
headline: 'None of 1 routes reachable',
|
|
72
|
+
coverage: cov({ tested: 1, passed: 0, failed: 1 }),
|
|
73
|
+
routes: [route({ route: '/', target: 'api:80', outcome: 'unreachable', confidence: 'real', evidence: 'connection refused' })],
|
|
74
|
+
}))
|
|
75
|
+
expect(v.tone).toBe('error')
|
|
76
|
+
expect(v.worst?.route).toBe('/')
|
|
77
|
+
// A single failing route is just "the failing route" - not "worst of N".
|
|
78
|
+
expect(v.worst?.failingCount).toBe(1)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('multiple failing routes: failingCount reflects all of them (label becomes "Worst of N")', () => {
|
|
82
|
+
const v = summarizeTrace(trace({
|
|
83
|
+
headline: '1 of 3 routes reachable · 2 unreachable',
|
|
84
|
+
coverage: cov({ tested: 3, passed: 1, failed: 2 }),
|
|
85
|
+
routes: [
|
|
86
|
+
route({ route: '/web', outcome: 'verified', confidence: 'real' }),
|
|
87
|
+
route({ route: '/admin', target: 'admin:9090', outcome: 'unreachable', confidence: 'real', evidence: 'connection refused' }),
|
|
88
|
+
route({ route: '/api', target: 'api:80', outcome: 'server-error', confidence: 'real', evidence: 'HTTP 503' }),
|
|
89
|
+
],
|
|
90
|
+
}))
|
|
91
|
+
expect(v.worst?.failingCount).toBe(2)
|
|
92
|
+
// Worst-first: unreachable outranks server-error.
|
|
93
|
+
expect(v.worst?.route).toBe('/admin')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('healthy verified: calm - success tone, NO worst line, "See test detail" CTA', () => {
|
|
97
|
+
const v = summarizeTrace(trace({
|
|
98
|
+
headline: 'All 1 routes reachable',
|
|
99
|
+
coverage: cov({ tested: 1, passed: 1 }),
|
|
100
|
+
routes: [route({ outcome: 'verified', confidence: 'real' })],
|
|
101
|
+
}))
|
|
102
|
+
expect(v.kind).toBe('glance')
|
|
103
|
+
expect(v.tone).toBe('success')
|
|
104
|
+
expect(v.worst).toBeUndefined()
|
|
105
|
+
expect(v.ctaLabel).toBe('See test detail →')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('benign scale-to-0 reads amber and is NEVER surfaced as the worst offender', () => {
|
|
109
|
+
const v = summarizeTrace(trace({
|
|
110
|
+
headline: 'No running backends (scaled to 0)',
|
|
111
|
+
coverage: cov({ tested: 1, passed: 0, failed: 1 }),
|
|
112
|
+
routes: [route({ route: '/', outcome: 'unreachable', benign: true })],
|
|
113
|
+
}))
|
|
114
|
+
expect(v.tone).toBe('warning') // benign → amber, not red
|
|
115
|
+
expect(v.worst).toBeUndefined() // not called out as a failure
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('surfaces the not-tested count from notTested length', () => {
|
|
119
|
+
const v = summarizeTrace(trace({
|
|
120
|
+
headline: 'All 1 tested routes reachable · 2 not tested',
|
|
121
|
+
coverage: cov({ tested: 1, passed: 1, skipped: 2 }),
|
|
122
|
+
routes: [route({ outcome: 'verified', confidence: 'real' })],
|
|
123
|
+
notTested: [{}, {}] as Trace['notTested'],
|
|
124
|
+
}))
|
|
125
|
+
expect(v.notTested).toBe(2)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('a broken / degraded / unknown verdict is NOT surfaced in the drawer - no config headline, just the invite button', () => {
|
|
129
|
+
for (const verdict of ['broken', 'degraded', 'unknown'] as const) {
|
|
130
|
+
const v = summarizeTrace(trace({
|
|
131
|
+
verdict,
|
|
132
|
+
headline: 'Configuration only - not yet tested',
|
|
133
|
+
reason: 'RBAC denied: cannot list pods in namespace prod',
|
|
134
|
+
diagnosis: { summary: '0/2 selected pods ready' } as Trace['diagnosis'],
|
|
135
|
+
}))
|
|
136
|
+
expect(v.kind).toBe('minimal')
|
|
137
|
+
expect(v.ctaLabel).toBe('Run test →')
|
|
138
|
+
expect(v.headline).not.toContain('0/2 selected pods ready')
|
|
139
|
+
expect(v.headline).not.toContain('RBAC denied')
|
|
140
|
+
expect(v.headline).not.toContain('Configuration only')
|
|
141
|
+
expect(v.tone).toBeUndefined()
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('clean static (healthy, not probed) is a minimal entry point - the Ports section already shows the wiring, so the glance must NOT restate "routes to N pods on :80"', () => {
|
|
146
|
+
const v = summarizeTrace(trace({
|
|
147
|
+
verdict: 'healthy',
|
|
148
|
+
headline: 'Configuration only - not yet tested',
|
|
149
|
+
downstream: [
|
|
150
|
+
{ resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [], config: { ports: [{ port: 80, targetPort: '8080' }], hostnames: ['shop.example.com'] } },
|
|
151
|
+
{ resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], meta: { ready: 1, selected: 1 } },
|
|
152
|
+
],
|
|
153
|
+
}))
|
|
154
|
+
expect(v.kind).toBe('minimal')
|
|
155
|
+
expect(v.headline.toLowerCase()).not.toContain('not tested')
|
|
156
|
+
expect(v.headline).not.toContain('running pod')
|
|
157
|
+
expect(v.headline).not.toContain('shop.example.com')
|
|
158
|
+
expect(v.headline).not.toContain('port 80')
|
|
159
|
+
expect(v.subtitle).toBe('A quick live connection test from where Radar runs.')
|
|
160
|
+
expect(v.ctaLabel).toBe('Run test →')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('path-owning subject (Ingress) clean+unprobed: NO wiring restatement - "routes to N pods" both repeats the Rules section and overclaims the unverified front door', () => {
|
|
164
|
+
const v = summarizeTrace(trace({
|
|
165
|
+
subject: { kind: 'Ingress', namespace: 'prod', name: 'wild' },
|
|
166
|
+
verdict: 'healthy',
|
|
167
|
+
headline: 'Configuration only - not yet tested',
|
|
168
|
+
downstream: [
|
|
169
|
+
{ resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [], config: { ports: [{ port: 80 }], hostnames: ['shop.example.com'] } },
|
|
170
|
+
{ resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], meta: { ready: 1, selected: 1 } },
|
|
171
|
+
],
|
|
172
|
+
}))
|
|
173
|
+
expect(v.kind).toBe('minimal')
|
|
174
|
+
expect(v.headline.toLowerCase()).not.toContain('not tested')
|
|
175
|
+
expect(v.headline.toLowerCase()).toContain('front door')
|
|
176
|
+
// Never asserts the backend wiring as if it were the verified entry.
|
|
177
|
+
expect(v.headline).not.toContain('routes to')
|
|
178
|
+
expect(v.headline).not.toContain('running pod')
|
|
179
|
+
expect(v.headline).not.toContain('shop.example.com')
|
|
180
|
+
expect(v.subtitle).toBe('A quick live probe of the declared host and path.')
|
|
181
|
+
expect(v.tone).toBeUndefined()
|
|
182
|
+
expect(v.ctaLabel).toBe('Run test →')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('Service subject clean+unprobed does NOT restate the wiring - the Ports section already covers port→running-pod, so the glance stays a minimal Run entry point', () => {
|
|
186
|
+
const v = summarizeTrace(trace({
|
|
187
|
+
subject: { kind: 'Service', namespace: 'prod', name: 'echo' },
|
|
188
|
+
verdict: 'healthy',
|
|
189
|
+
downstream: [
|
|
190
|
+
{ resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [], config: { ports: [{ port: 80 }], hostnames: ['shop.example.com'] } },
|
|
191
|
+
{ resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], meta: { ready: 1, selected: 1 } },
|
|
192
|
+
],
|
|
193
|
+
}))
|
|
194
|
+
expect(v.kind).toBe('minimal')
|
|
195
|
+
expect(v.headline.toLowerCase()).not.toContain('not tested')
|
|
196
|
+
expect(v.headline).not.toContain('running pod')
|
|
197
|
+
expect(v.headline).not.toContain('shop.example.com')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('0 ready pods (crashing / scaled-to-zero / no selector match) is NOT surfaced - the invite button, not a "no running pods" line', () => {
|
|
201
|
+
const v = summarizeTrace(trace({
|
|
202
|
+
verdict: 'healthy',
|
|
203
|
+
downstream: [
|
|
204
|
+
{ resource: { kind: 'Service', namespace: 'prod', name: 'echo' }, edge: 'entry:Service', findings: [{ code: 'svc:no-ready-endpoints', severity: 'warning', message: 'no ready endpoints' }], config: { ports: [{ port: 80 }] } },
|
|
205
|
+
{ resource: { kind: 'Pods', namespace: 'prod', name: '' }, edge: 'Service->Pods', findings: [], meta: { ready: 0, selected: 2 } },
|
|
206
|
+
],
|
|
207
|
+
}))
|
|
208
|
+
expect(v.kind).toBe('minimal')
|
|
209
|
+
expect(v.headline).not.toContain('running pod')
|
|
210
|
+
expect(v.headline).not.toContain('endpoints')
|
|
211
|
+
expect(v.ctaLabel).toBe('Run test →')
|
|
212
|
+
})
|
|
213
|
+
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { ComponentType } from 'react'
|
|
2
|
+
import { CheckCircle2, AlertTriangle, ShieldAlert, Info } from 'lucide-react'
|
|
3
|
+
import { AlertBanner } from '../ui/drawer-components'
|
|
4
|
+
import { coverageBannerTone, routeOutcomeRank } from './TracePanel'
|
|
5
|
+
import type { Trace } from './types'
|
|
6
|
+
|
|
7
|
+
type Tone = ReturnType<typeof coverageBannerTone>
|
|
8
|
+
|
|
9
|
+
const ICON: Record<Tone, ComponentType<{ className?: string }>> = {
|
|
10
|
+
success: CheckCircle2,
|
|
11
|
+
warning: AlertTriangle,
|
|
12
|
+
error: ShieldAlert,
|
|
13
|
+
info: Info,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The passive drawer view-model - extracted as a pure function so the decision
|
|
17
|
+
* logic (tone, worst-offender-when-failing, CTA label) is unit-testable without
|
|
18
|
+
* a DOM. `minimal` = no coverage projection yet (static/config-only); `glance` =
|
|
19
|
+
* a coverage headline with its single honest tone. */
|
|
20
|
+
export interface TraceSummaryView {
|
|
21
|
+
kind: 'glance' | 'minimal'
|
|
22
|
+
headline: string
|
|
23
|
+
tone?: Tone
|
|
24
|
+
/** The failing route to spotlight under the headline, with a count of how many
|
|
25
|
+
* routes failed in total - so the label can read honestly ("Failing route"
|
|
26
|
+
* for one, "Worst of N failing routes" when there are more). */
|
|
27
|
+
worst?: { route: string; target?: string; evidence?: string; failingCount: number }
|
|
28
|
+
/** A muted descriptive line under the headline. */
|
|
29
|
+
subtitle?: string
|
|
30
|
+
notTested: number
|
|
31
|
+
ctaLabel: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The drawer never runs active probes (that emits real traffic - deliberate, see
|
|
35
|
+
// client.ts useTrace), so before a run it only INVITES the test, never implies one
|
|
36
|
+
// ran. The "Reachability · Network Path" section header already carries the word
|
|
37
|
+
// "reachability", so the button drops it ("Run test →") to avoid the echo.
|
|
38
|
+
const RUN_CTA_SHORT = 'Run test →'
|
|
39
|
+
|
|
40
|
+
export function summarizeTrace(trace: Trace): TraceSummaryView {
|
|
41
|
+
const coverage = trace.coverage
|
|
42
|
+
const routes = trace.routes ?? []
|
|
43
|
+
|
|
44
|
+
// PROBED - actual probe results exist (tested > 0) → the route-coverage glance.
|
|
45
|
+
// A coverage projection with tested === 0 is NOT a tested result (the drawer never
|
|
46
|
+
// probes), so it falls through to the invite-to-test button below.
|
|
47
|
+
if (coverage && coverage.tested > 0 && trace.headline) {
|
|
48
|
+
const tone = coverageBannerTone(coverage, routes)
|
|
49
|
+
const failing = coverage.failed > 0
|
|
50
|
+
// The worst-offender line shows ONLY when a route genuinely failed. Benign
|
|
51
|
+
// scale-to-0 is intentional dormancy, not a failure to call out.
|
|
52
|
+
const failingRoutes = failing
|
|
53
|
+
? [...routes]
|
|
54
|
+
// An apiserver-proxy-only (indirect) unreachable never confirmed the real
|
|
55
|
+
// path - it's amber in coverageBannerTone, so it must NOT be spotlighted as
|
|
56
|
+
// a definitive "Failing route" here. A genuinely-down backend is upgraded to
|
|
57
|
+
// confidence 'real' upstream (definitive 0-ready endpoints), so it still shows.
|
|
58
|
+
.filter((r) => (r.outcome === 'server-error' || (r.outcome === 'unreachable' && r.confidence !== 'indirect')) && !r.benign)
|
|
59
|
+
.sort((a, b) => routeOutcomeRank(a.outcome) - routeOutcomeRank(b.outcome))
|
|
60
|
+
: []
|
|
61
|
+
const worstRoute = failingRoutes[0]
|
|
62
|
+
return {
|
|
63
|
+
kind: 'glance',
|
|
64
|
+
headline: trace.headline,
|
|
65
|
+
tone,
|
|
66
|
+
worst: worstRoute && {
|
|
67
|
+
route: worstRoute.route,
|
|
68
|
+
target: worstRoute.target,
|
|
69
|
+
evidence: worstRoute.evidence,
|
|
70
|
+
failingCount: failingRoutes.length,
|
|
71
|
+
},
|
|
72
|
+
notTested: trace.notTested?.length ?? coverage.skipped,
|
|
73
|
+
// Calm when healthy ("see detail"), actionable when failing ("open").
|
|
74
|
+
ctaLabel: failing ? 'Open Reachability →' : 'See test detail →',
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Nothing actively tested. The drawer does NOT detect config issues - those are
|
|
79
|
+
// owned by the resource's Operational Issues section (main's central detector). The
|
|
80
|
+
// reachability glance is purely the invitation to run the live path test; it never
|
|
81
|
+
// re-states anything the config already shows above it.
|
|
82
|
+
//
|
|
83
|
+
// For a PATH-OWNING subject (Ingress/Gateway/Route) it frames the front door;
|
|
84
|
+
// the host→backend wiring is already in the resource's own Rules section.
|
|
85
|
+
if (PATH_OWNING_KINDS.has(trace.subject.kind)) {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'minimal',
|
|
88
|
+
headline: 'Verify the path in through the front door',
|
|
89
|
+
subtitle: 'A quick live probe of the declared host and path.',
|
|
90
|
+
notTested: 0,
|
|
91
|
+
ctaLabel: RUN_CTA_SHORT,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// For a Service/Pod subject the Ports section already shows the port →
|
|
95
|
+
// running-pod wiring, so restating it here ("routes to N pods on :80") just
|
|
96
|
+
// duplicates it. Frame the empty state around the VALUE of the live test
|
|
97
|
+
// (positive, action-first) - never a negative "not tested yet".
|
|
98
|
+
return {
|
|
99
|
+
kind: 'minimal',
|
|
100
|
+
headline: 'Verify the live path to these pods',
|
|
101
|
+
subtitle: 'A quick live connection test from where Radar runs.',
|
|
102
|
+
notTested: 0,
|
|
103
|
+
ctaLabel: RUN_CTA_SHORT,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Subjects whose own renderer already prints their routing rules (host→backend),
|
|
108
|
+
// so the reachability glance must NOT restate the wiring - and "routes to N
|
|
109
|
+
// pods" would overclaim a front door it never verified.
|
|
110
|
+
const PATH_OWNING_KINDS = new Set(['Ingress', 'Gateway', 'HTTPRoute', 'GRPCRoute'])
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* TraceSummary is the PASSIVE drawer glance for the network-reachability path:
|
|
114
|
+
* one honest headline (tone from coverageBannerTone - the SAME single tone source
|
|
115
|
+
* the full panel uses), a worst-offender line ONLY when something failed, a muted
|
|
116
|
+
* not-tested count, and ONE CTA into the full Reachability tab. It never nags - a
|
|
117
|
+
* healthy verified path reads as a calm one-liner. The full route matrix, per-route
|
|
118
|
+
* localization, path topology, and the in-cluster test all live on the tab
|
|
119
|
+
* (TracePanel), reachable via the CTA. The drawer stays a glance, not a console.
|
|
120
|
+
*/
|
|
121
|
+
export function TraceSummary({ trace, onOpenReachability }: { trace: Trace; onOpenReachability: () => void }) {
|
|
122
|
+
const v = summarizeTrace(trace)
|
|
123
|
+
|
|
124
|
+
if (v.kind === 'minimal') {
|
|
125
|
+
return (
|
|
126
|
+
<div className="flex flex-col gap-1">
|
|
127
|
+
<div className="flex items-center justify-between gap-2 text-sm text-theme-text-secondary">
|
|
128
|
+
<span className="truncate">{v.headline}</span>
|
|
129
|
+
<OpenButton onClick={onOpenReachability} label={v.ctaLabel} />
|
|
130
|
+
</div>
|
|
131
|
+
{v.subtitle && <span className="text-xs text-theme-text-tertiary">{v.subtitle}</span>}
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<AlertBanner variant={v.tone!} icon={ICON[v.tone!]} title={v.headline}>
|
|
138
|
+
{v.worst && (
|
|
139
|
+
<div className="mt-1 text-xs">
|
|
140
|
+
<span className="text-theme-text-secondary">
|
|
141
|
+
{v.worst.failingCount > 1 ? `Worst of ${v.worst.failingCount} failing routes: ` : 'Failing route: '}
|
|
142
|
+
</span>
|
|
143
|
+
<span className="font-mono text-theme-text-primary break-all">{v.worst.route}</span>
|
|
144
|
+
{v.worst.target && <span className="text-theme-text-tertiary break-all"> → {v.worst.target}</span>}
|
|
145
|
+
{v.worst.evidence && <span className="text-theme-text-tertiary"> · {v.worst.evidence}</span>}
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
{v.subtitle ? (
|
|
149
|
+
<div className="mt-1 text-xs text-theme-text-tertiary">{v.subtitle}</div>
|
|
150
|
+
) : (
|
|
151
|
+
v.notTested > 0 && <div className="mt-1 text-xs text-theme-text-tertiary">{v.notTested} not tested</div>
|
|
152
|
+
)}
|
|
153
|
+
<OpenButton onClick={onOpenReachability} label={v.ctaLabel} />
|
|
154
|
+
</AlertBanner>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function OpenButton({ onClick, label }: { onClick: () => void; label: string }) {
|
|
159
|
+
return (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={onClick}
|
|
163
|
+
className="mt-2 text-xs px-2 py-1 rounded border border-theme-border bg-theme-surface text-theme-text-primary hover:bg-theme-hover transition-colors"
|
|
164
|
+
>
|
|
165
|
+
{label}
|
|
166
|
+
</button>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { summarizeInClusterTests, evidenceBannerTitle, type InClusterTestRow } from './inClusterSummary'
|
|
3
|
+
|
|
4
|
+
// The three server row shapes (internal/reachability/incluster.go):
|
|
5
|
+
// folded - results, NO status (default branch: outcome moved)
|
|
6
|
+
// evidence - results AND status (throwaway-denied / override-mismatch / guessed)
|
|
7
|
+
// couldn't-run - status/fallback, NO results (Job failed / capped / budget)
|
|
8
|
+
const folded = (): InClusterTestRow => ({ results: [{ layer: 'http', ok: true }] })
|
|
9
|
+
const throwawayDenied = (): InClusterTestRow => ({
|
|
10
|
+
results: [{ layer: 'http', ok: false }],
|
|
11
|
+
status: "in-cluster probe from a throwaway pod didn't get through - not a confirmed failure",
|
|
12
|
+
fallbackCommand: 'kubectl run ...',
|
|
13
|
+
})
|
|
14
|
+
const overrideMismatch = (): InClusterTestRow => ({
|
|
15
|
+
results: [{ layer: 'http', ok: true }],
|
|
16
|
+
status: 'tested custom path /x: shown as evidence only; the declared path / was not tested',
|
|
17
|
+
})
|
|
18
|
+
const guessedPath = (): InClusterTestRow => ({
|
|
19
|
+
results: [{ layer: 'http', ok: true }],
|
|
20
|
+
status: 'in-cluster probe reached the backend on a GUESSED path',
|
|
21
|
+
})
|
|
22
|
+
const jobFailed = (): InClusterTestRow => ({ status: 'in-cluster probe timed out: probe container couldn\'t start', fallbackCommand: 'kubectl ...' })
|
|
23
|
+
const capped = (): InClusterTestRow => ({ status: 'not tested in-cluster: capped at 8 probe pods per call' })
|
|
24
|
+
|
|
25
|
+
describe('summarizeInClusterTests', () => {
|
|
26
|
+
it('nothing ran (all status-only, no results) → couldn\'t run, not partial, not evidence-only', () => {
|
|
27
|
+
const s = summarizeInClusterTests([jobFailed()])
|
|
28
|
+
expect(s.error).toContain('timed out')
|
|
29
|
+
expect(s.fallback).toBe('kubectl ...')
|
|
30
|
+
expect(s.partial).toBe(false)
|
|
31
|
+
expect(s.evidenceOnly).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('some folded + a couldn\'t-run row → error set AND partial true (partially completed)', () => {
|
|
35
|
+
const s = summarizeInClusterTests([folded(), capped()])
|
|
36
|
+
expect(s.error).toContain('capped')
|
|
37
|
+
expect(s.partial).toBe(true)
|
|
38
|
+
expect(s.evidenceOnly).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('single throwaway-denied row (results+status+fallback, none folded) → evidence-only, NOT partial, NOT couldn\'t-run', () => {
|
|
42
|
+
const s = summarizeInClusterTests([throwawayDenied()])
|
|
43
|
+
expect(s.error).toBeUndefined()
|
|
44
|
+
expect(s.partial).toBe(false)
|
|
45
|
+
expect(s.evidenceOnly).toBe(true)
|
|
46
|
+
expect(s.evidence).toContain('throwaway pod')
|
|
47
|
+
expect(s.fallback).toBe('kubectl run ...')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('all override-mismatch rows (results+status, none folded) → evidence-only note, not a silent no-op', () => {
|
|
51
|
+
const s = summarizeInClusterTests([overrideMismatch(), overrideMismatch()])
|
|
52
|
+
expect(s.evidenceOnly).toBe(true)
|
|
53
|
+
expect(s.evidence).toContain('evidence only')
|
|
54
|
+
expect(s.error).toBeUndefined()
|
|
55
|
+
expect(s.partial).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('guessed-path-only row → evidence-only', () => {
|
|
59
|
+
const s = summarizeInClusterTests([guessedPath()])
|
|
60
|
+
expect(s.evidenceOnly).toBe(true)
|
|
61
|
+
expect(s.evidence).toContain('GUESSED')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('clean full run (everything folded) → no banner at all', () => {
|
|
65
|
+
const s = summarizeInClusterTests([folded(), folded()])
|
|
66
|
+
expect(s.error).toBeUndefined()
|
|
67
|
+
expect(s.partial).toBe(false)
|
|
68
|
+
expect(s.evidenceOnly).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('a couldn\'t-run row outranks evidence rows (real failure surfaces first)', () => {
|
|
72
|
+
const s = summarizeInClusterTests([overrideMismatch(), jobFailed()])
|
|
73
|
+
expect(s.error).toContain('timed out')
|
|
74
|
+
expect(s.partial).toBe(false) // nothing folded
|
|
75
|
+
expect(s.evidenceOnly).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('empty / undefined input → no banner', () => {
|
|
79
|
+
expect(summarizeInClusterTests(undefined)).toEqual({ partial: false, evidenceOnly: false })
|
|
80
|
+
expect(summarizeInClusterTests([])).toEqual({ partial: false, evidenceOnly: false })
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('a mixed run never says "unchanged"', () => {
|
|
85
|
+
const folded = { route: 'a', results: [{}] }
|
|
86
|
+
const evidence = { route: 'b', results: [{}], status: 'kept informational - throwaway identity' }
|
|
87
|
+
|
|
88
|
+
it('the reducer marks the mixed case partial', () => {
|
|
89
|
+
const s = summarizeInClusterTests([folded, evidence] as any)
|
|
90
|
+
expect(s.evidenceOnly).toBe(true)
|
|
91
|
+
expect(s.partial).toBe(true)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('the banner title follows the state', () => {
|
|
95
|
+
expect(evidenceBannerTitle({ partial: true, evidenceOnly: true })).toMatch(/some routes updated/)
|
|
96
|
+
expect(evidenceBannerTitle({ partial: false, evidenceOnly: true })).toMatch(/unchanged/)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// summarizeInClusterTests reduces the per-route in-cluster outcome rows to what
|
|
2
|
+
// the banner needs. The server (internal/reachability/incluster.go) returns one
|
|
3
|
+
// row per route with three distinct shapes that must NOT be conflated:
|
|
4
|
+
//
|
|
5
|
+
// FOLDED results && !status - the probe ran clean and was folded into the
|
|
6
|
+
// merged trace (the default branch). The route
|
|
7
|
+
// outcome actually moved.
|
|
8
|
+
// EVIDENCE results && status - the probe ran and produced results but was
|
|
9
|
+
// NOT folded: a throwaway-pod NetworkPolicy/mTLS
|
|
10
|
+
// denial, an override-mismatch (custom path), or
|
|
11
|
+
// a guessed wildcard path. Route outcome UNCHANGED.
|
|
12
|
+
// COULDN'T-RUN !results && (status||fallback) - the Job couldn't start / timed out /
|
|
13
|
+
// RBAC / pod-cap / budget. Nothing was tested.
|
|
14
|
+
//
|
|
15
|
+
// From these, the banner has exactly three honest states:
|
|
16
|
+
// couldn't-run - a COULDN'T-RUN row exists and nothing folded
|
|
17
|
+
// partial - a COULDN'T-RUN row exists but other routes DID fold
|
|
18
|
+
// evidence-only - rows carried results but NONE folded (route outcomes unchanged)
|
|
19
|
+
// (clean full run - everything folded - shows no banner)
|
|
20
|
+
|
|
21
|
+
export interface InClusterTestRow {
|
|
22
|
+
status?: string
|
|
23
|
+
fallbackCommand?: string
|
|
24
|
+
results?: unknown[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface InClusterSummary {
|
|
28
|
+
/** A route that genuinely could NOT be tested in-cluster (Job failed / capped /
|
|
29
|
+
* budget). Paired with `fallback`. Absent when every route at least ran. */
|
|
30
|
+
error?: string
|
|
31
|
+
fallback?: string
|
|
32
|
+
/** ≥1 route folded live results into the merged trace AND ≥1 route could not run.
|
|
33
|
+
* The banner reads "partially completed" instead of the false "couldn't run". */
|
|
34
|
+
partial: boolean
|
|
35
|
+
/** Rows carried results but NONE folded (throwaway-denied / override-mismatch /
|
|
36
|
+
* guessed path) - the run changed no route outcome. Surfaced as an informational
|
|
37
|
+
* note so an all-evidence run isn't a silent no-op. */
|
|
38
|
+
evidenceOnly: boolean
|
|
39
|
+
/** The evidence row's status message (the honest per-shape explanation). */
|
|
40
|
+
evidence?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isFolded = (t: InClusterTestRow): boolean => !!t.results?.length && !t.status
|
|
44
|
+
const isEvidence = (t: InClusterTestRow): boolean => !!t.results?.length && !!t.status
|
|
45
|
+
const isCouldntRun = (t: InClusterTestRow): boolean => !t.results?.length && (!!t.status || !!t.fallbackCommand)
|
|
46
|
+
|
|
47
|
+
export function summarizeInClusterTests(tests: InClusterTestRow[] | undefined): InClusterSummary {
|
|
48
|
+
const entries = tests ?? []
|
|
49
|
+
const anyFolded = entries.some(isFolded)
|
|
50
|
+
const failed = entries.find(isCouldntRun)
|
|
51
|
+
if (failed) {
|
|
52
|
+
return {
|
|
53
|
+
error: failed.status || 'the in-cluster test could not run',
|
|
54
|
+
fallback: failed.fallbackCommand,
|
|
55
|
+
partial: anyFolded,
|
|
56
|
+
evidenceOnly: false,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// No hard failure. Any evidence row means at least one route's outcome did
|
|
60
|
+
// NOT move - surface its honest per-shape explanation. This deliberately
|
|
61
|
+
// includes the mixed case (some routes folded, some kept informational):
|
|
62
|
+
// suppressing the note there presented a partially-inconclusive run as a
|
|
63
|
+
// clean one, with the kept-informational route's status silently vanished.
|
|
64
|
+
const evidence = entries.find(isEvidence)
|
|
65
|
+
if (evidence) {
|
|
66
|
+
// partial: some routes DID fold - the banner must not say "unchanged".
|
|
67
|
+
return { partial: anyFolded, evidenceOnly: true, evidence: evidence.status, fallback: evidence.fallbackCommand }
|
|
68
|
+
}
|
|
69
|
+
// Clean full run - everything folded; the merged trace already reflects the
|
|
70
|
+
// outcomes, so there is nothing left to explain.
|
|
71
|
+
return { partial: false, evidenceOnly: false }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The evidence banner's title. Kept beside the reducer so the words cannot
|
|
75
|
+
* drift from the state: "unchanged" was still shown for mixed runs where some
|
|
76
|
+
* routes HAD updated. */
|
|
77
|
+
export function evidenceBannerTitle(s: InClusterSummary): string {
|
|
78
|
+
return s.partial
|
|
79
|
+
? 'In-cluster test: some routes updated — others kept informational'
|
|
80
|
+
: 'In-cluster test ran as evidence only - route outcomes unchanged'
|
|
81
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { inClusterOutcome, inClusterEligible } from './TracePanel'
|
|
2
|
+
export { ReachabilityView } from './ReachabilityView'
|
|
3
|
+
export { podReach, podProbeKey } from './podReach'
|
|
4
|
+
// Reachability v2 internals - exported so hosts can compose the same vocabulary
|
|
5
|
+
// (marks, origins) elsewhere without re-deriving it.
|
|
6
|
+
export { buildOrigins, defaultOrigin, strongestGap } from './reachOrigins'
|
|
7
|
+
export type { Origin, OriginId, Lane, OriginKind } from './reachOrigins'
|
|
8
|
+
export { routeMark, routeTone, routeChip, orderRoutes, MARKS, MARK_LEGEND } from './reachMarks'
|
|
9
|
+
export type { Mark, SevTone } from './reachMarks'
|
|
10
|
+
export { traceToSubgraph } from './traceToSubgraph'
|
|
11
|
+
export { traceFingerprint, staticPollUnreliable } from './traceFingerprint'
|
|
12
|
+
export { summarizeInClusterTests } from './inClusterSummary'
|
|
13
|
+
export type { InClusterTestRow, InClusterSummary } from './inClusterSummary'
|
|
14
|
+
export { TraceSummary } from './TraceSummary'
|
|
15
|
+
export type { InClusterCapability } from './TracePanel'
|
|
16
|
+
// ResourceRef intentionally NOT re-exported from the package root - it would
|
|
17
|
+
// collide with the global ResourceRef in types.ts. Trace consumers import it
|
|
18
|
+
// from the panel module directly when they need the typed shape.
|
|
19
|
+
export type { Trace, Hop, Finding, FindingSeverity, Verdict } from './types'
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { podReach, podProbeKey } from './podReach'
|
|
3
|
+
import type { ProbeResult } from './types'
|
|
4
|
+
|
|
5
|
+
describe('podProbeKey', () => {
|
|
6
|
+
it('extracts the pod identity from a "<name> port N" probe target', () => {
|
|
7
|
+
expect(podProbeKey('fleet-abc-xyz port 80')).toBe('fleet-abc-xyz') // apiserver path
|
|
8
|
+
expect(podProbeKey('10.244.0.33:8080')).toBe('10.244.0.33') // data path (ip:port)
|
|
9
|
+
expect(podProbeKey('10.244.0.33 port 8080')).toBe('10.244.0.33')
|
|
10
|
+
expect(podProbeKey('pod-1')).toBe('pod-1')
|
|
11
|
+
expect(podProbeKey(undefined)).toBe('')
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const p = (o: Partial<ProbeResult>): ProbeResult => ({ layer: 'http', target: 'pod-1', vantage: 'local', ok: true, ...o })
|
|
16
|
+
|
|
17
|
+
describe('podReach', () => {
|
|
18
|
+
it('no live probes → not tested, no vantage', () => {
|
|
19
|
+
expect(podReach([])).toEqual({ tone: 'neutral', text: 'not tested', vantage: '' })
|
|
20
|
+
expect(podReach([p({ skipped: true })])).toEqual({ tone: 'neutral', text: 'not tested', vantage: '' })
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('healthy 2xx via api proxy → healthy + "via API server"', () => {
|
|
24
|
+
const r = podReach([p({ path: 'apiserver', ok: true, tone: 'healthy', detail: 'HTTP 200' })])
|
|
25
|
+
expect(r.tone).toBe('healthy')
|
|
26
|
+
expect(r.text).toBe('HTTP 200')
|
|
27
|
+
expect(r.vantage).toBe('via API server')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('in-cluster direct dial → labels the vantage "real"', () => {
|
|
31
|
+
const r = podReach([p({ vantage: 'in-cluster', path: 'data', ok: true, detail: 'HTTP 200' })])
|
|
32
|
+
expect(r.vantage).toBe('real')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('a failure wins over an ok sibling (never mask a bad pod)', () => {
|
|
36
|
+
const r = podReach([
|
|
37
|
+
p({ layer: 'tcp', ok: true }),
|
|
38
|
+
p({ layer: 'http', ok: false, tone: 'unhealthy', error: 'connection refused' }),
|
|
39
|
+
])
|
|
40
|
+
expect(r.tone).toBe('unhealthy')
|
|
41
|
+
expect(r.text).toBe('connection refused')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('a 5xx reads degraded, not unhealthy', () => {
|
|
45
|
+
const r = podReach([p({ layer: 'http', ok: true, tone: 'degraded', detail: 'HTTP 503' })])
|
|
46
|
+
expect(r.tone).toBe('degraded')
|
|
47
|
+
expect(r.text).toBe('HTTP 503')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('a reached 404 (ok, tone "reached") is NOT green - neutral, matching the honest headline', () => {
|
|
51
|
+
const r = podReach([p({ layer: 'http', ok: true, tone: 'reached', detail: 'HTTP 404' })])
|
|
52
|
+
expect(r.tone).toBe('neutral')
|
|
53
|
+
expect(r.text).toBe('HTTP 404')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('a TCP-only success is "port reachable", not verified - neutral, not green', () => {
|
|
57
|
+
const r = podReach([p({ layer: 'tcp', ok: true, detail: 'connected' })])
|
|
58
|
+
expect(r.tone).toBe('neutral')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('a TLS-only success (no HTTP 2xx) is neutral, not green', () => {
|
|
62
|
+
const r = podReach([p({ layer: 'tcp', ok: true }), p({ layer: 'tls', ok: true, detail: 'valid' })])
|
|
63
|
+
expect(r.tone).toBe('neutral')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('a real HTTP 2xx stays green (verified path unchanged)', () => {
|
|
67
|
+
const r = podReach([p({ layer: 'tcp', ok: true }), p({ layer: 'http', ok: true, tone: 'healthy', detail: 'HTTP 200' })])
|
|
68
|
+
expect(r.tone).toBe('healthy')
|
|
69
|
+
expect(r.text).toBe('HTTP 200')
|
|
70
|
+
})
|
|
71
|
+
})
|