@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,1521 @@
|
|
|
1
|
+
import type { Trace, Hop, ResourceRef, RouteResult, PodStatus, ProbeResult } from './types'
|
|
2
|
+
import type { Mark, SevTone } from './reachMarks'
|
|
3
|
+
import { routeMark, isSlow, formatLatency, declaredHosts, hostMatches, routeHostOf, originRouteEvidence, routeForOrigin, traceInClusterRunnable } from './reachMarks'
|
|
4
|
+
import { originOf, type Origin } from './reachOrigins'
|
|
5
|
+
import { podProbeKey } from './podReach'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Layout constants. Positions are COMPUTED from content, never hand-placed:
|
|
9
|
+
* node boxes size to their text, so fixed coordinates collide the moment a name
|
|
10
|
+
* or a tag runs long. Columns are laid out left to right with a guaranteed
|
|
11
|
+
* gutter wide enough to hold an edge pill without touching either neighbour.
|
|
12
|
+
*/
|
|
13
|
+
// Chains can reach five columns (origin -> Gateway -> Route -> Service -> Pods),
|
|
14
|
+
// so the gutter is tighter than a 3-column layout would allow.
|
|
15
|
+
const GUTTER = 98
|
|
16
|
+
/** The workload is worth having on the path and worth LESS width than a real
|
|
17
|
+
* network hop - its column and both its gutters are tightened. Still wide
|
|
18
|
+
* enough for the short edge pills that sit in them ("selects", "runs"). */
|
|
19
|
+
const WORKLOAD_GUTTER = 72
|
|
20
|
+
const WORKLOAD_W = 160
|
|
21
|
+
const ROW_GAP = 14
|
|
22
|
+
const LANE_PAD = { x: 14, top: 22, bottom: 14 }
|
|
23
|
+
const LANE_GAP = 20
|
|
24
|
+
/** Pills wrap to two lines, so the cap is what fits on two - not on one.
|
|
25
|
+
* At 16 it cut "HTTP 404 - reached" down to "HTTP 404...", which reports the
|
|
26
|
+
* status code and hides the only word that says what it meant. */
|
|
27
|
+
export const PILL_MAX_CHARS = 26
|
|
28
|
+
/** Hard cap on a pill's rendered width. MUST stay under GUTTER, or a pill
|
|
29
|
+
* overruns its gutter and lands on the node beside it. */
|
|
30
|
+
export const PILL_MAX_PX = 88
|
|
31
|
+
|
|
32
|
+
/** Columns are assigned in chain order. Every node belongs to exactly one,
|
|
33
|
+
* which is what guarantees left-to-right reading order and non-overlap. */
|
|
34
|
+
/** Width per column. The last column (Pods) is wider because it carries rows. */
|
|
35
|
+
const COL_W = { origin: 172, hop: 180, pods: 216 }
|
|
36
|
+
|
|
37
|
+
export interface GraphNode {
|
|
38
|
+
id: string
|
|
39
|
+
x: number
|
|
40
|
+
y: number
|
|
41
|
+
w: number
|
|
42
|
+
h: number
|
|
43
|
+
/** Small-caps type line, e.g. "SERVICE · PORT". */
|
|
44
|
+
kind: string
|
|
45
|
+
name: string
|
|
46
|
+
sub: string
|
|
47
|
+
/** The resource's OWN health. Never the path's truth - that lives on edges. */
|
|
48
|
+
tone: SevTone
|
|
49
|
+
tag?: string
|
|
50
|
+
/** The hop's OWN findings, headline-first. The graph used to consume these
|
|
51
|
+
* only to pick a dot colour, discarding the cause, action and remediation
|
|
52
|
+
* the backend had already produced - so the one sentence that answers
|
|
53
|
+
* "what is wrong with this hop" was a click away behind a coloured pixel. */
|
|
54
|
+
notes?: HopNote[]
|
|
55
|
+
anomalies?: { mark: Mark; text: string; title?: string }[]
|
|
56
|
+
/** Per-endpoint delivery results, rendered as rows inside the node.
|
|
57
|
+
* A column of pod boxes cost more width than the whole rest of the path and
|
|
58
|
+
* fit fewer of them; rows carry the same per-pod truth in less space. */
|
|
59
|
+
podRows?: PodRow[]
|
|
60
|
+
/** Endpoints beyond the row cap, named rather than silently dropped. */
|
|
61
|
+
moreRows?: number
|
|
62
|
+
dim?: boolean
|
|
63
|
+
ref?: ResourceRef
|
|
64
|
+
hop?: Hop
|
|
65
|
+
isOrigin?: boolean
|
|
66
|
+
/** An action offered ON the node, when the node IS the thing that would
|
|
67
|
+
* produce the missing evidence. Deliberately a button and never a
|
|
68
|
+
* click-the-capsule gesture: selecting a vantage is free, and this one
|
|
69
|
+
* creates Pods in the user's cluster. */
|
|
70
|
+
action?: { text: string; kind: 'run-in-cluster'; disabledReason?: string }
|
|
71
|
+
lane: 'control' | 'data'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface HopNote {
|
|
75
|
+
/** Resource-health severity, NOT a traffic Mark: findings describe the object,
|
|
76
|
+
* marks describe what happened to a request. Keeping the two vocabularies
|
|
77
|
+
* apart is why the dot and the edge never mean the same thing. */
|
|
78
|
+
severity: 'critical' | 'warning' | 'info'
|
|
79
|
+
/** Short headline - the parsed cause when there is one. */
|
|
80
|
+
text: string
|
|
81
|
+
/** Everything the row could not fit, for the hover. */
|
|
82
|
+
detail: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface PodRow {
|
|
86
|
+
name: string
|
|
87
|
+
mark: Mark
|
|
88
|
+
detail: string
|
|
89
|
+
ref: ResourceRef
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Rows shown before collapsing into a "+N more" line. */
|
|
93
|
+
export const POD_ROW_MAX = 6
|
|
94
|
+
|
|
95
|
+
/** Above this many sibling branches, only the selected one and the ones with
|
|
96
|
+
* findings stay expanded - the quiet remainder collapses to a single row. */
|
|
97
|
+
export const FAN_EXPANDED_MAX = 4
|
|
98
|
+
|
|
99
|
+
export interface GraphEdge {
|
|
100
|
+
id: string
|
|
101
|
+
/** SVG path data. */
|
|
102
|
+
d: string
|
|
103
|
+
mark: Mark
|
|
104
|
+
/** Set on the edges of a BOUNDARY span (a Service→Pods routing failure whose
|
|
105
|
+
* segment the workload node happens to sit inside): 'start' carries the one
|
|
106
|
+
* observed break; 'continuation' extends the same break across the span
|
|
107
|
+
* without claiming a second observation (rendered dashed, no pill). Break
|
|
108
|
+
* detection skips both - a boundary anchors to the exit of its source node
|
|
109
|
+
* (GraphModel.breakAtExitOf), never to a node halo. */
|
|
110
|
+
boundary?: 'start' | 'continuation'
|
|
111
|
+
label: string
|
|
112
|
+
/** The untruncated label. ALWAYS set: the pill has a pixel cap as well as a
|
|
113
|
+
* character cap, so text can be visually cut without `label` being shortened,
|
|
114
|
+
* and a hover that only appears sometimes is worse than one that always does. */
|
|
115
|
+
title: string
|
|
116
|
+
/** Pill centre, in canvas coordinates. Always inside a gutter. */
|
|
117
|
+
px: number
|
|
118
|
+
py: number
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface LaneBox {
|
|
122
|
+
x: number
|
|
123
|
+
y: number
|
|
124
|
+
w: number
|
|
125
|
+
h: number
|
|
126
|
+
label: string
|
|
127
|
+
/** Hover text. The band label is the most prominent word in the graph and had
|
|
128
|
+
* no explanation anywhere. */
|
|
129
|
+
help: string
|
|
130
|
+
/** Lane tint + label colour. */
|
|
131
|
+
color: string
|
|
132
|
+
dashed?: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface GraphModel {
|
|
136
|
+
nodes: GraphNode[]
|
|
137
|
+
edges: GraphEdge[]
|
|
138
|
+
/** The node the first FAILED edge lands on - the first place a request is
|
|
139
|
+
* known to have stopped. Derived from edge marks (path truth), never from
|
|
140
|
+
* node tone (resource health), because those are different questions, and
|
|
141
|
+
* only from the SELECTED vantage's edges. */
|
|
142
|
+
breakNodeId?: string
|
|
143
|
+
/** A Service-routing boundary break: the node whose EXIT the request never
|
|
144
|
+
* got past. Set instead of breakNodeId for this boundary - the failure is
|
|
145
|
+
* the segment between the Service and its Pods, and a halo on either node
|
|
146
|
+
* (or on the workload that merely sits inside the span) would blame a
|
|
147
|
+
* resource for its edge's failure. */
|
|
148
|
+
breakAtExitOf?: string
|
|
149
|
+
/** Nodes rendered inline that are NOT network hops (the workload that runs
|
|
150
|
+
* the Pods). Consumers must never give them journey states - a workload is
|
|
151
|
+
* neither "reached" nor a place a request stops. */
|
|
152
|
+
nonNetworkNodeIds?: string[]
|
|
153
|
+
/** Configured-but-bypassed resources: entries the selected vantage's request
|
|
154
|
+
* does not traverse (a relay went past them), or parallel entries the
|
|
155
|
+
* selected route does not use. Rendered as CONTEXT, never as the journey. */
|
|
156
|
+
contextNodeIds?: string[]
|
|
157
|
+
/** Non-network nodes to splice into the sidebar's display after a journey
|
|
158
|
+
* hop - the workload after its Service. Display order without polluting the
|
|
159
|
+
* journey list. */
|
|
160
|
+
interleave?: { id: string; afterId: string }[]
|
|
161
|
+
/** When >1, the journey's entry hops are PARALLEL members of one entry
|
|
162
|
+
* stage - consumers must say so rather than implying a sequence. */
|
|
163
|
+
entryParallelCount?: number
|
|
164
|
+
/** The journey's entry-hop node ids (subset of pathNodeIds). */
|
|
165
|
+
journeyEntryNodeIds?: string[]
|
|
166
|
+
/** The selected route's own chain, in traversal order. Consumers must read
|
|
167
|
+
* this rather than sorting rendered nodes by position: sorting flattens
|
|
168
|
+
* PARALLEL backends into what looks like a serial path, and sweeps in
|
|
169
|
+
* branches the selected route never touches. */
|
|
170
|
+
pathNodeIds: string[]
|
|
171
|
+
brackets: { d: string }[]
|
|
172
|
+
originIsControl: boolean
|
|
173
|
+
canvas: { w: number; h: number }
|
|
174
|
+
/** Lanes bound only their own nodes, so an unused half of the dataplane does
|
|
175
|
+
* not render as a large empty rectangle. */
|
|
176
|
+
laneControl?: LaneBox
|
|
177
|
+
laneData?: LaneBox
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const refId = (r?: ResourceRef): string => (r ? `${r.kind}/${r.namespace ?? ''}/${r.name || 'pods'}` : '')
|
|
181
|
+
|
|
182
|
+
const isPodsHop = (h: Hop): boolean => h.resource?.kind === 'Pods' || /pods/i.test(h.edge ?? '')
|
|
183
|
+
|
|
184
|
+
function truncate(s: string, n = PILL_MAX_CHARS): string {
|
|
185
|
+
return s.length <= n ? s : `${s.slice(0, n - 1)}…`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The headline clause of a probe's evidence.
|
|
190
|
+
*
|
|
191
|
+
* The producer writes full explanations - "Connection refused. Nothing is
|
|
192
|
+
* listening on the port." - which are right for a hover and far too long for an
|
|
193
|
+
* edge pill or a node row: the pill truncated mid-word to "Connectior
|
|
194
|
+
* refused...." and the row ran outside its box. Only the FIRST sentence is
|
|
195
|
+
* taken, and only at a real sentence break, so a "·"-separated detail like
|
|
196
|
+
* "HTTP 200 · 41 ms" survives intact. The untruncated text always stays on the
|
|
197
|
+
* hover.
|
|
198
|
+
*/
|
|
199
|
+
export function shortEvidence(s: string | undefined): string {
|
|
200
|
+
const t = (s ?? '').trim()
|
|
201
|
+
const cut = t.search(/\.\s/)
|
|
202
|
+
return (cut > 0 ? t.slice(0, cut) : t).replace(/\.$/, '').trim()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Estimated rendered height of a node box. The renderer uses fixed type sizes,
|
|
206
|
+
* so this stays accurate without a measure pass. */
|
|
207
|
+
function estHeight(n: {
|
|
208
|
+
anomalies?: unknown[]
|
|
209
|
+
notes?: { text: string }[]
|
|
210
|
+
podRows?: unknown[]
|
|
211
|
+
moreRows?: number
|
|
212
|
+
isOrigin?: boolean
|
|
213
|
+
sub?: string
|
|
214
|
+
action?: unknown
|
|
215
|
+
}): number {
|
|
216
|
+
const base = n.isOrigin ? 66 : 60
|
|
217
|
+
const anomalies = n.anomalies?.length ?? 0
|
|
218
|
+
// The action either shares the single status row (an origin capsule - the
|
|
219
|
+
// row grows a little) or stacks as its own full-width button. Uncounted, the
|
|
220
|
+
// rendered capsule grew straight out the bottom of its lane box.
|
|
221
|
+
const actionH = n.action ? (n.isOrigin && anomalies === 1 ? 7 : 28) : 0
|
|
222
|
+
const rows = n.podRows?.length ?? 0
|
|
223
|
+
// Long sub-lines wrap; approximate at ~34 characters per line.
|
|
224
|
+
const subLines = Math.max(1, Math.ceil((n.sub?.length ?? 0) / 34))
|
|
225
|
+
// Notes wrap too, and a wrapped note that the layout did not reserve room for
|
|
226
|
+
// is exactly how a node grows into the row beneath it.
|
|
227
|
+
const noteLines = (n.notes ?? []).reduce((sum, x) => sum + Math.max(1, Math.ceil(x.text.length / 26)), 0)
|
|
228
|
+
return (
|
|
229
|
+
base +
|
|
230
|
+
actionH +
|
|
231
|
+
(subLines - 1) * 13 +
|
|
232
|
+
(noteLines > 0 ? 8 + noteLines * 13 : 0) +
|
|
233
|
+
(anomalies > 0 ? 8 + anomalies * 17 : 0) +
|
|
234
|
+
(rows > 0 ? 8 + rows * 17 + (n.moreRows ? 15 : 0) : 0)
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A graph edge's cubic, kept so a pill can be re-placed anywhere along it.
|
|
239
|
+
* cy1/cy2 are the control-point heights - equal to the endpoints for an
|
|
240
|
+
* ordinary edge, pulled aside for one that has to route around a node. */
|
|
241
|
+
interface Cubic {
|
|
242
|
+
x1: number
|
|
243
|
+
y1: number
|
|
244
|
+
x2: number
|
|
245
|
+
y2: number
|
|
246
|
+
dx: number
|
|
247
|
+
cy1: number
|
|
248
|
+
cy2: number
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const cubicPath = (c: Cubic): string => `M${c.x1},${c.y1} C${c.x1 + c.dx},${c.cy1} ${c.x2 - c.dx},${c.cy2} ${c.x2},${c.y2}`
|
|
252
|
+
|
|
253
|
+
/** The point at fraction t along an edge's curve. Evaluates the SAME cubic the
|
|
254
|
+
* path draws, so a pill is on the line by construction. */
|
|
255
|
+
function bezierAt(c: Cubic, t: number): { x: number; y: number } {
|
|
256
|
+
const u = 1 - t
|
|
257
|
+
const f = (p0: number, p1: number, p2: number, p3: number) =>
|
|
258
|
+
u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
|
|
259
|
+
return { x: f(c.x1, c.x1 + c.dx, c.x2 - c.dx, c.x2), y: f(c.y1, c.cy1, c.cy2, c.y2) }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* What ONE origin actually observed at ONE hop.
|
|
264
|
+
*
|
|
265
|
+
* Every probe on the wire already names the hop it hit, the vantage that sent
|
|
266
|
+
* it and the mechanism it used - so an entry edge never needs to borrow the
|
|
267
|
+
* route's rollup to know how it went. It used to: a laptop that dialled a public
|
|
268
|
+
* Ingress and got an answer was drawn as "not tested", because the route is
|
|
269
|
+
* built from the SERVICE hop's probes and the Ingress dials never reach it.
|
|
270
|
+
*
|
|
271
|
+
* Returns undefined when this origin sent nothing here, so callers keep their
|
|
272
|
+
* existing fallback rather than inventing a verdict.
|
|
273
|
+
*/
|
|
274
|
+
export function hopEvidenceFor(
|
|
275
|
+
hop: Hop | undefined,
|
|
276
|
+
origin: Pick<Origin, 'id'>,
|
|
277
|
+
runVantage?: string,
|
|
278
|
+
opts: { stale?: boolean; running?: boolean } = {},
|
|
279
|
+
): { mark: Mark; label: string; title?: string } | undefined {
|
|
280
|
+
const live = probesFromOrigin(hop?.probes ?? [], origin, runVantage).filter((p) => !p.skipped)
|
|
281
|
+
if (live.length === 0) return undefined
|
|
282
|
+
if (opts.running) return { mark: 'running', label: 'testing now' }
|
|
283
|
+
if (opts.stale) return { mark: 'stale', label: 'out of date' }
|
|
284
|
+
const worst =
|
|
285
|
+
live.find((p) => !p.ok || p.tone === 'unhealthy') ??
|
|
286
|
+
live.find((p) => p.tone === 'degraded') ??
|
|
287
|
+
// The most specific layer that succeeded is the most informative: an HTTP
|
|
288
|
+
// answer says more than the TCP dial underneath it.
|
|
289
|
+
[...live].sort((a, b) => LAYER_RANK.indexOf(b.layer) - LAYER_RANK.indexOf(a.layer))[0]
|
|
290
|
+
const relayed = worst.path === 'apiserver'
|
|
291
|
+
// Tone 'reached' is the producer saying "answered, but with a 3xx/4xx/app-5xx"
|
|
292
|
+
// - real evidence the port serves, NOT a clean pass. Painting it the same
|
|
293
|
+
// solid green as a 2xx made 'HTTP 404 · reached' read as healthy at a glance;
|
|
294
|
+
// it is exactly the vocabulary's 'answered, but not with what we asked for'.
|
|
295
|
+
const mark: Mark =
|
|
296
|
+
!worst.ok || worst.tone === 'unhealthy'
|
|
297
|
+
? 'failed'
|
|
298
|
+
: worst.tone === 'degraded' || worst.tone === 'reached'
|
|
299
|
+
? 'answered'
|
|
300
|
+
: relayed
|
|
301
|
+
? 'proxied'
|
|
302
|
+
: 'proved'
|
|
303
|
+
const detail = shortEvidence(worst.detail) || shortEvidence(worst.reason)
|
|
304
|
+
return { mark, label: detail || (relayed ? 'relayed by Kubernetes' : 'request'), title: worst.detail || worst.reason }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const LAYER_RANK = ['dns', 'tcp', 'tls', 'http']
|
|
308
|
+
|
|
309
|
+
/** The most telling thing one origin saw anywhere along the path - a failure if
|
|
310
|
+
* there is one, else the furthest it got. Used when the ROUTE has no row for
|
|
311
|
+
* this vantage but its probes plainly did something. */
|
|
312
|
+
function bestHopEvidence(trace: Trace, o: Origin, opts: { stale?: boolean; running?: boolean }) {
|
|
313
|
+
const hops = [...(trace.upstreams ?? []), ...(trace.downstream ?? [])]
|
|
314
|
+
const seen = hops.map((h) => hopEvidenceFor(h, o, trace.runVantage, opts)).filter((x): x is NonNullable<typeof x> => !!x)
|
|
315
|
+
return seen.find((x) => x.mark === 'failed') ?? seen[0]
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Clearance between a detouring edge and the node it routes around. */
|
|
319
|
+
const DETOUR_GAP = 22
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Slides overlapping pills along their own curves until they stop colliding.
|
|
323
|
+
*
|
|
324
|
+
* Several edges fanning into one node genuinely share a midpoint, so no fixed
|
|
325
|
+
* offset separates them - but the curves are furthest apart near their SOURCES,
|
|
326
|
+
* where each still hugs its own origin. Pulling a colliding pill back toward its
|
|
327
|
+
* source therefore both separates it and keeps it on its line, which putting the
|
|
328
|
+
* text on the origin capsule instead did not: that duplicated the edge and cost
|
|
329
|
+
* a row of height per vantage.
|
|
330
|
+
*/
|
|
331
|
+
function separatePills(edges: GraphEdge[], geom: Map<string, Cubic>): void {
|
|
332
|
+
const W = PILL_MAX_PX + 14
|
|
333
|
+
const H = 26
|
|
334
|
+
const t = new Map<string, number>()
|
|
335
|
+
const withText = edges.filter((e) => e.label)
|
|
336
|
+
for (let pass = 0; pass < 6; pass++) {
|
|
337
|
+
let moved = false
|
|
338
|
+
for (let i = 0; i < withText.length; i++) {
|
|
339
|
+
for (let j = i + 1; j < withText.length; j++) {
|
|
340
|
+
const a = withText[i]
|
|
341
|
+
const b = withText[j]
|
|
342
|
+
if (Math.abs(a.px - b.px) >= W || Math.abs(a.py - b.py) >= H) continue
|
|
343
|
+
const c = geom.get(b.id)
|
|
344
|
+
if (!c) continue
|
|
345
|
+
// Direction-aware: edges leaving the SAME capsule are closest near
|
|
346
|
+
// their source, so always pulling backward walked the pill INTO the
|
|
347
|
+
// collision. Try both directions and keep whichever separates more.
|
|
348
|
+
const cur = t.get(b.id) ?? 0.5
|
|
349
|
+
const back = Math.max(0.16, cur - 0.09)
|
|
350
|
+
const fwd = Math.min(0.84, cur + 0.09)
|
|
351
|
+
const sep = (pt: { x: number; y: number }) => Math.max(Math.abs(pt.x - a.px) / W, Math.abs(pt.y - a.py) / H)
|
|
352
|
+
const pB = bezierAt(c, back)
|
|
353
|
+
const pF = bezierAt(c, fwd)
|
|
354
|
+
const next = sep(pF) > sep(pB) ? fwd : back
|
|
355
|
+
if (next === cur) continue
|
|
356
|
+
t.set(b.id, next)
|
|
357
|
+
const p = bezierAt(c, next)
|
|
358
|
+
b.px = p.x
|
|
359
|
+
b.py = p.y
|
|
360
|
+
moved = true
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (!moved) break
|
|
364
|
+
}
|
|
365
|
+
// Edges leaving the SAME capsule can run nearly coincident for their whole
|
|
366
|
+
// span - no point on either curve separates them. Residual colliders stack
|
|
367
|
+
// vertically as a last resort: slightly off the line beats unreadable.
|
|
368
|
+
for (let i = 0; i < withText.length; i++) {
|
|
369
|
+
for (let j = i + 1; j < withText.length; j++) {
|
|
370
|
+
const a = withText[i]
|
|
371
|
+
const b = withText[j]
|
|
372
|
+
if (Math.abs(a.px - b.px) >= W || Math.abs(a.py - b.py) >= H) continue
|
|
373
|
+
b.py = a.py + H + 2
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Rendered per node before collapsing; the rest stay one click away. */
|
|
379
|
+
const HOP_NOTE_MAX = 2
|
|
380
|
+
|
|
381
|
+
/** Longest a note may be before it stops being a headline. */
|
|
382
|
+
const NOTE_MAX_CHARS = 46
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The headline of a finding, for a box the size of a node.
|
|
386
|
+
*
|
|
387
|
+
* Producer messages are written to be read in full - "Accepted:
|
|
388
|
+
* NoMatchingListenerHostname - Gateway x/y listeners http, https: There were no
|
|
389
|
+
* hostname intersections..." is nine lines inside a graph node, which buries the
|
|
390
|
+
* path it is drawn on. Cut at the first real clause break so the CODE survives
|
|
391
|
+
* ("Accepted: NoMatchingListenerHostname"), and let the hover carry the rest.
|
|
392
|
+
*/
|
|
393
|
+
/** Plain language for the Gateway API condition reasons an operator actually
|
|
394
|
+
* hits. "Accepted: NoMatchingListenerHostname" is precise condition data and
|
|
395
|
+
* user-hostile prose - it reads as if the route WAS accepted. The full raw
|
|
396
|
+
* message stays on the hover/detail for experts. */
|
|
397
|
+
const KNOWN_ROUTE_REASONS: Record<string, string> = {
|
|
398
|
+
NoMatchingListenerHostname: 'Not attached: no listener matches its hosts',
|
|
399
|
+
NoMatchingParent: 'Not attached: the Gateway has no matching listener',
|
|
400
|
+
NotAllowedByListeners: 'Not attached: Gateway disallows this namespace',
|
|
401
|
+
RefNotPermitted: 'Backend ref not permitted — missing ReferenceGrant',
|
|
402
|
+
BackendNotFound: 'Backend not found',
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function noteHeadline(msg: string): string {
|
|
406
|
+
const t = (msg ?? '').trim()
|
|
407
|
+
// " - " and ". " separate a headline from its explanation; ":" does not - it
|
|
408
|
+
// usually introduces the very thing worth naming.
|
|
409
|
+
const cut = t.search(/\s[-–—]\s|\.\s/)
|
|
410
|
+
let head = (cut > 0 ? t.slice(0, cut) : t).replace(/[.:]$/, '').trim()
|
|
411
|
+
const known = head.match(/^(?:Accepted|ResolvedRefs|Programmed):\s*(\w+)$/)
|
|
412
|
+
if (known && KNOWN_ROUTE_REASONS[known[1]]) head = KNOWN_ROUTE_REASONS[known[1]]
|
|
413
|
+
return head.length <= NOTE_MAX_CHARS ? head : `${head.slice(0, NOTE_MAX_CHARS - 1)}…`
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* A hop's findings as node rows: the parsed `cause` when the detector produced
|
|
418
|
+
* one (it is written to be short), else the raw message. The full text - and the
|
|
419
|
+
* remediation - stay in the inspector, so the graph carries the headline and
|
|
420
|
+
* never becomes the place you read paragraphs.
|
|
421
|
+
*/
|
|
422
|
+
function hopNotes(hop?: Hop): HopNote[] {
|
|
423
|
+
const findings = hop?.findings ?? []
|
|
424
|
+
if (findings.length === 0) return []
|
|
425
|
+
const rank = { critical: 0, warning: 1, info: 2 } as const
|
|
426
|
+
const ordered = [...findings].sort((a, b) => (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3))
|
|
427
|
+
const notes = ordered.slice(0, HOP_NOTE_MAX).map((f) => ({
|
|
428
|
+
severity: f.severity,
|
|
429
|
+
text: noteHeadline(f.cause || f.message),
|
|
430
|
+
// The WHOLE message, always, however short the headline got.
|
|
431
|
+
detail: [f.message, f.action || f.remediation].filter(Boolean).join(' — '),
|
|
432
|
+
}))
|
|
433
|
+
const hidden = ordered.length - notes.length
|
|
434
|
+
if (hidden > 0) {
|
|
435
|
+
notes.push({ severity: ordered[notes.length].severity, text: `+${hidden} more`, detail: 'Select this resource to read the rest.' })
|
|
436
|
+
}
|
|
437
|
+
return notes
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Health from a hop's findings alone - the same rule the topology view uses:
|
|
441
|
+
* a critical finding is red only when nothing is serving. */
|
|
442
|
+
function hopTone(hop: Hop | undefined): SevTone {
|
|
443
|
+
if (!hop) return 'unknown'
|
|
444
|
+
const findings = hop.findings ?? []
|
|
445
|
+
const ready = typeof hop.meta?.ready === 'number' ? (hop.meta.ready as number) : undefined
|
|
446
|
+
const selected = typeof hop.meta?.selected === 'number' ? (hop.meta.selected as number) : undefined
|
|
447
|
+
const serving = typeof ready === 'number' && ready > 0
|
|
448
|
+
if (findings.some((f) => f.severity === 'critical')) return serving ? 'degraded' : 'unhealthy'
|
|
449
|
+
if (findings.some((f) => f.severity === 'warning')) return 'degraded'
|
|
450
|
+
// Nothing ready to serve is resource health even without a finding - the one
|
|
451
|
+
// state the old probe fallthrough was masking. publishNotReadyAddresses
|
|
452
|
+
// deliberately serves not-ready Pods, so it is not unhealthy there.
|
|
453
|
+
if (ready === 0 && (selected ?? 0) > 0 && !hop.meta?.publishNotReadyAddresses) return 'unhealthy'
|
|
454
|
+
// FINDINGS ONLY past this point - never probe outcomes. The dot is the
|
|
455
|
+
// resource's own health and must not change when the selected vantage does;
|
|
456
|
+
// what a request experienced lives on edges and rows, which are
|
|
457
|
+
// origin-scoped on purpose. A readable resource with no findings is healthy.
|
|
458
|
+
return 'healthy'
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function probesForPod(pod: PodStatus, probes: ProbeResult[]): ProbeResult[] {
|
|
462
|
+
return probes.filter((p) => {
|
|
463
|
+
const key = podProbeKey(p.target)
|
|
464
|
+
return key === pod.name || (!!pod.ip && key === pod.ip)
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Marks that mean this origin actually produced evidence for the scenario.
|
|
470
|
+
* Anything else (untested / denied / blocked / excluded / config) means it did
|
|
471
|
+
* not run, and must not inherit another origin's result.
|
|
472
|
+
*/
|
|
473
|
+
const EVIDENCE_MARKS: Mark[] = ['proved', 'failed', 'answered', 'proxied', 'stale', 'running', 'slow']
|
|
474
|
+
|
|
475
|
+
export function originProducedEvidence(origin: Origin): boolean {
|
|
476
|
+
return EVIDENCE_MARKS.includes(origin.mark)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* A hop's probes belong to whichever origin produced them. The graph renders
|
|
481
|
+
* ONE origin at a time, so it must read only that origin's probes - otherwise a
|
|
482
|
+
* vantage that never ran inherits another's result and a laptop's success is
|
|
483
|
+
* painted as a solid proved line inside the dataplane lane.
|
|
484
|
+
*/
|
|
485
|
+
function probesFromOrigin(probes: ProbeResult[], origin: Pick<Origin, 'id'>, runVantage?: string): ProbeResult[] {
|
|
486
|
+
return probes.filter((p) => originOf(p, runVantage) === origin.id)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Anomalies held out of a population aggregate. Averaging them away is exactly
|
|
491
|
+
* how a single refusing endpoint that real users hit becomes invisible, so each
|
|
492
|
+
* category is counted and named rather than folded into a percentage.
|
|
493
|
+
*/
|
|
494
|
+
function populationAnomalies(roster: PodStatus[], total: number, probes: ProbeResult[], publishNotReady: boolean): { mark: Mark; text: string }[] {
|
|
495
|
+
const out: { mark: Mark; text: string }[] = []
|
|
496
|
+
const failing = roster.filter((p) => p.ready && probesForPod(p, probes).some((x) => !x.skipped && (!x.ok || x.tone === 'unhealthy')))
|
|
497
|
+
if (failing.length > 0) {
|
|
498
|
+
out.push({ mark: 'failed', text: `${failing.length} endpoint${failing.length > 1 ? 's' : ''} refused the connection` })
|
|
499
|
+
}
|
|
500
|
+
const slow = roster.filter((p) => p.ready && probesForPod(p, probes).some((x) => !x.skipped && x.ok && isSlow(x)))
|
|
501
|
+
if (slow.length > 0) {
|
|
502
|
+
const worst = slow
|
|
503
|
+
.flatMap((p) => probesForPod(p, probes))
|
|
504
|
+
.filter((x) => !x.skipped && isSlow(x))
|
|
505
|
+
.sort((a, b) => (b.latencyNs ?? 0) - (a.latencyNs ?? 0))[0]
|
|
506
|
+
out.push({ mark: 'slow', text: `${slow.length} slow · ${formatLatency(worst?.latencyNs)}` })
|
|
507
|
+
}
|
|
508
|
+
// With publishNotReadyAddresses the dataplane routes to NotReady Pods too, so
|
|
509
|
+
// they are eligible endpoints - calling them "excluded, never routed to" is
|
|
510
|
+
// false precisely when someone is debugging a not-ready Pod that IS serving.
|
|
511
|
+
const notReady = publishNotReady ? [] : roster.filter((p) => !p.ready)
|
|
512
|
+
if (notReady.length > 0) {
|
|
513
|
+
out.push({ mark: 'excluded', text: `${notReady.length} NotReady — never routed to` })
|
|
514
|
+
}
|
|
515
|
+
// The roster is capped for payload size; anything past it is eligible but
|
|
516
|
+
// unobserved. Unprobed is not proven, so it must be stated, not implied.
|
|
517
|
+
const omitted = total - roster.length
|
|
518
|
+
if (omitted > 0) out.push({ mark: 'untested', text: `${omitted} eligible, not probed` })
|
|
519
|
+
return out
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Short forms of each vantage's mechanism, for the capsule only.
|
|
524
|
+
*
|
|
525
|
+
* `sub` renders at 10px in a 172px box - about 28 characters per line - and
|
|
526
|
+
* every full mech string is 33-46, so each capsule wrapped to two lines and paid
|
|
527
|
+
* a row of height for it. The full wording still renders in the inspector under
|
|
528
|
+
* MECHANISM, where there is room for it.
|
|
529
|
+
*/
|
|
530
|
+
const SHORT_MECH: Record<string, string> = {
|
|
531
|
+
local: 'your machine, your network',
|
|
532
|
+
apiserver: 'relayed by Kubernetes',
|
|
533
|
+
incluster: 'a throwaway Pod, in-cluster',
|
|
534
|
+
'radar-incluster': 'Radar’s own Pod',
|
|
535
|
+
caller: 'your app’s own Pod',
|
|
536
|
+
external: 'from the public internet',
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Short forms for the origin capsule's tag - the full wording would overrun
|
|
540
|
+
* the box and collide with the node beside it. */
|
|
541
|
+
const SHORT_KIND_TAG: Record<string, string> = {
|
|
542
|
+
SYNTHETIC: 'SYNTHETIC',
|
|
543
|
+
'REAL CLIENT': 'REAL CLIENT',
|
|
544
|
+
'AS A CLIENT': 'AS A CLIENT',
|
|
545
|
+
'REAL CALLER': 'REAL CALLER',
|
|
546
|
+
'RELAYED · NOT A CALLER': 'RELAYED',
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
interface Placed {
|
|
550
|
+
col: number
|
|
551
|
+
row: number
|
|
552
|
+
w: number
|
|
553
|
+
node: Omit<GraphNode, 'x' | 'y' | 'w' | 'h'>
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
interface BuildOpts {
|
|
557
|
+
trace: Trace
|
|
558
|
+
route?: RouteResult
|
|
559
|
+
origin: Origin
|
|
560
|
+
/** Every vantage that can actually run, so all of them are drawn at once and
|
|
561
|
+
* the comparison between them is visible rather than reconstructed across
|
|
562
|
+
* clicks. Defaults to just the selected one. */
|
|
563
|
+
origins?: Origin[]
|
|
564
|
+
stale?: boolean
|
|
565
|
+
running?: boolean
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The words for an origin that produced no usable evidence for the scenario.
|
|
570
|
+
* ONE helper for the capsule and every edge pill - two call sites drifted into
|
|
571
|
+
* telling simultaneous different stories ("not routable" on the capsule beside
|
|
572
|
+
* "couldn't test" on the edge). "not routable" itself is retired: it read as a
|
|
573
|
+
* NETWORK verdict about the target, the one claim a vantage whose dials all
|
|
574
|
+
* skipped has no standing to make. 'blocked' means tried-and-couldn't - say
|
|
575
|
+
* that, and let the skip reason on hover carry the why. The in-cluster ATTEMPT
|
|
576
|
+
* whose probe never started keeps its own words: execution failure is its own
|
|
577
|
+
* state, and "not tested" would erase the attempt.
|
|
578
|
+
*/
|
|
579
|
+
export function originNoEvidenceLabel(o: Pick<Origin, 'id' | 'mark' | 'unavailable'>): string {
|
|
580
|
+
if (o.mark === 'inconclusive') return 'ran — kept informational'
|
|
581
|
+
if (o.mark === 'denied') return 'not permitted'
|
|
582
|
+
if (o.id === 'incluster' && o.mark === 'blocked' && o.unavailable) return 'test couldn’t run'
|
|
583
|
+
if (o.mark === 'blocked') return 'couldn’t test'
|
|
584
|
+
return 'not tested'
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** One origin's own verdict on the selected route - the mark for its entry edge
|
|
588
|
+
* and the words for its pill. Computed per origin so a vantage that never ran
|
|
589
|
+
* is drawn as never-run beside one that succeeded, instead of the whole graph
|
|
590
|
+
* speaking for whichever is selected. */
|
|
591
|
+
export function originEntryEvidence(
|
|
592
|
+
trace: Trace,
|
|
593
|
+
route: RouteResult | undefined,
|
|
594
|
+
o: Origin,
|
|
595
|
+
opts: { stale?: boolean; running?: boolean } = {},
|
|
596
|
+
): { mark: Mark; label: string; title?: string } {
|
|
597
|
+
// A vantage mid-run has no evidence YET, and the evidence gate below would
|
|
598
|
+
// swallow that - so being in flight is stated first. It is the whole truth
|
|
599
|
+
// about this origin right now.
|
|
600
|
+
if (opts.running) return { mark: 'running', label: 'testing now' }
|
|
601
|
+
const ev = originRouteEvidence(route, o.id, trace.runVantage)
|
|
602
|
+
const asSeen = ev.kind === 'none' ? undefined : ev.result
|
|
603
|
+
const fromConfig = ev.kind === 'config'
|
|
604
|
+
const hasEvidence = ev.kind === 'own' || (ev.kind === 'rollup' && originProducedEvidence(o))
|
|
605
|
+
const rm: Mark = asSeen ? routeMark(asSeen, opts) : 'untested'
|
|
606
|
+
// Only the API-server RELAY may downgrade a proved result. A laptop is also
|
|
607
|
+
// outside the cluster, but a request it sends to a real Ingress address goes
|
|
608
|
+
// over the real network and exercises the real path from the ingress inward -
|
|
609
|
+
// calling that "proxied" (which means "bypassed the dataplane") was false.
|
|
610
|
+
const relayed = o.id === 'apiserver'
|
|
611
|
+
// No row for this route does not mean this vantage did nothing - it may have
|
|
612
|
+
// dialled the front door. Report what it actually reached, so the capsule's
|
|
613
|
+
// words agree with its glyph instead of showing a proved dot beside the
|
|
614
|
+
// phrase "not tested".
|
|
615
|
+
const alongPath = !hasEvidence && !fromConfig ? bestHopEvidence(trace, o, opts) : undefined
|
|
616
|
+
const noEvidenceLabel = originNoEvidenceLabel(o)
|
|
617
|
+
const mark: Mark = fromConfig
|
|
618
|
+
? 'config'
|
|
619
|
+
: !hasEvidence
|
|
620
|
+
? alongPath?.mark ?? (o.mark === 'denied' || o.mark === 'blocked' ? o.mark : 'untested')
|
|
621
|
+
: relayed && rm === 'proved'
|
|
622
|
+
? 'proxied'
|
|
623
|
+
: rm
|
|
624
|
+
const label = fromConfig
|
|
625
|
+
? 'broken as configured'
|
|
626
|
+
: !hasEvidence
|
|
627
|
+
? alongPath?.label ?? noEvidenceLabel
|
|
628
|
+
: shortEvidence(asSeen?.evidence) || (relayed ? 'relayed by Kubernetes' : 'request')
|
|
629
|
+
return { mark, label, title: hasEvidence || fromConfig ? asSeen?.evidence : alongPath?.title }
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Builds the laned graph for ONE scenario seen from ONE origin.
|
|
634
|
+
*
|
|
635
|
+
* Selecting a different origin genuinely re-routes the graph rather than
|
|
636
|
+
* relabelling it: a control-plane origin enters the subject from the upper lane
|
|
637
|
+
* through an apiserver node, having bypassed kube-proxy, NetworkPolicy and the
|
|
638
|
+
* mesh entirely. Drawing both the same way would be the central lie this view
|
|
639
|
+
* exists to prevent.
|
|
640
|
+
*/
|
|
641
|
+
export function buildGraph({ trace, route, origin, origins, stale, running }: BuildOpts): GraphModel {
|
|
642
|
+
const placed: Placed[] = []
|
|
643
|
+
const originIsControl = origin.lane === 'control'
|
|
644
|
+
|
|
645
|
+
const subjectId = refId(trace.subject)
|
|
646
|
+
const subjectNodeId = `n:${subjectId}`
|
|
647
|
+
const downstream = trace.downstream ?? []
|
|
648
|
+
const subjectHop = downstream.find((h) => refId(h.resource) === subjectId)
|
|
649
|
+
const upstreams = trace.upstreams ?? []
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* Upstreams are PARALLEL entry points, and a subject can have several
|
|
653
|
+
* backends each with their own Pods. Laying all of that out as one series
|
|
654
|
+
* invented a path that does not exist - two Ingresses read as
|
|
655
|
+
* "Ingress A then Ingress B", and only the first backend's Pods survived.
|
|
656
|
+
* Parentage follows the same rule the topology converter uses.
|
|
657
|
+
*/
|
|
658
|
+
const backends: { hop: Hop; id: string }[] = []
|
|
659
|
+
const podGroups: { hop: Hop; id: string; parentId: string }[] = []
|
|
660
|
+
let lastServiceId = subjectNodeId
|
|
661
|
+
for (const dn of downstream) {
|
|
662
|
+
const base = refId(dn.resource)
|
|
663
|
+
if (base === subjectId) {
|
|
664
|
+
lastServiceId = subjectNodeId
|
|
665
|
+
continue
|
|
666
|
+
}
|
|
667
|
+
if (isPodsHop(dn)) {
|
|
668
|
+
// An unnamed Pods hop's id collapses to the same value for every backend,
|
|
669
|
+
// so it must be scoped to its owning Service or later groups are dropped.
|
|
670
|
+
podGroups.push({ hop: dn, id: dn.resource?.name ? `n:${base}` : `${lastServiceId}::pods`, parentId: lastServiceId })
|
|
671
|
+
} else {
|
|
672
|
+
const id = `n:${base}`
|
|
673
|
+
backends.push({ hop: dn, id })
|
|
674
|
+
lastServiceId = id
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** The scenario names a host; only the entries serving it are on this path.
|
|
679
|
+
* Shares its matcher with scenario grouping so dimming and grouping can never
|
|
680
|
+
* disagree about which front door serves a host. */
|
|
681
|
+
const routeHost = routeHostOf(route?.route ?? '')
|
|
682
|
+
const servesRoute = (h: Hop): boolean => !!routeHost && declaredHosts(h).some((d) => hostMatches(d, routeHost))
|
|
683
|
+
// Prefer this origin's OWN result over the merged rollup. Without it the graph
|
|
684
|
+
// painted whatever the worst vantage saw under the selected vantage's name -
|
|
685
|
+
// the central misattribution this view exists to prevent. originProducedEvidence
|
|
686
|
+
// remains the fallback gate for traces carrying no per-vantage breakdown.
|
|
687
|
+
const ev = originRouteEvidence(route, origin.id)
|
|
688
|
+
const asSeen = ev.kind === 'none' ? undefined : ev.result
|
|
689
|
+
|
|
690
|
+
const matched = upstreams.filter(servesRoute)
|
|
691
|
+
const activeUpstreams = matched.length > 0 ? matched : upstreams
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Fan-out branches are peers, and until now every one of them rendered
|
|
695
|
+
* identically - so on a Gateway with seven attached routes, changing the
|
|
696
|
+
* selected scenario changed nothing on screen and there was no way to tell
|
|
697
|
+
* which branch you were diagnosing. Only entry points were ever dimmed.
|
|
698
|
+
*
|
|
699
|
+
* A branch is on the selected path when it serves the scenario's host or
|
|
700
|
+
* carries its backend. When NOTHING matches we dim nothing: a graph where
|
|
701
|
+
* every branch is greyed out says "none of this is relevant", which is worse
|
|
702
|
+
* than saying nothing at all.
|
|
703
|
+
*/
|
|
704
|
+
const routeTarget = (route?.target ?? '').split(':')[0].trim().toLowerCase()
|
|
705
|
+
// Namespace is part of the identity: a Gateway API backendRef can point at a
|
|
706
|
+
// same-named Service in another namespace, and matching on name alone would
|
|
707
|
+
// focus the wrong branch.
|
|
708
|
+
const routeTargetNs = (route?.targetNamespace || trace.subject.namespace || '').toLowerCase()
|
|
709
|
+
const onSelectedPath = (h: Hop): boolean => {
|
|
710
|
+
if (servesRoute(h)) return true
|
|
711
|
+
const name = (h.resource?.name ?? '').toLowerCase()
|
|
712
|
+
if (!routeTarget || name !== routeTarget) return false
|
|
713
|
+
const ns = (h.resource?.namespace || '').toLowerCase()
|
|
714
|
+
return !ns || !routeTargetNs || ns === routeTargetNs
|
|
715
|
+
}
|
|
716
|
+
const matchedBackends = backends.filter((b) => onSelectedPath(b.hop))
|
|
717
|
+
const focusBranches = matchedBackends.length > 0 && matchedBackends.length < backends.length
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Exception-first. A Gateway with seven attached routes rendered seven equally
|
|
721
|
+
* loud cards that overflowed the pane, and six of them were fine. What the
|
|
722
|
+
* operator needs is the selected branch and anything WRONG; the quiet
|
|
723
|
+
* remainder is context and can be one line.
|
|
724
|
+
*
|
|
725
|
+
* Collapsing is by relevance, never by position - dropping "the last three"
|
|
726
|
+
* would hide whichever route happened to sort late.
|
|
727
|
+
*/
|
|
728
|
+
const worthExpanding = (b: { hop: Hop }): boolean =>
|
|
729
|
+
onSelectedPath(b.hop) || (b.hop.findings ?? []).length > 0
|
|
730
|
+
const expanded = backends.length > FAN_EXPANDED_MAX ? backends.filter(worthExpanding) : backends
|
|
731
|
+
const collapsed = backends.filter((b) => !expanded.includes(b))
|
|
732
|
+
|
|
733
|
+
const hopSub = (h: Hop): string => {
|
|
734
|
+
const c = h.config
|
|
735
|
+
if (c?.clusterIP) return `ClusterIP ${c.clusterIP}`
|
|
736
|
+
if (c?.addresses?.length) return c.addresses.join(', ')
|
|
737
|
+
if (c?.hostnames?.length) return c.hostnames.join(', ')
|
|
738
|
+
if (c?.serviceType) return c.serviceType
|
|
739
|
+
return h.resource?.namespace ?? ''
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ---- columns follow depth in the branch, not position in a list ----
|
|
743
|
+
const COL_ORIGIN = 0
|
|
744
|
+
const COL_ENTRY = 1
|
|
745
|
+
const colSubject = upstreams.length > 0 ? COL_ENTRY + 1 : COL_ENTRY
|
|
746
|
+
const colBackend = colSubject + 1
|
|
747
|
+
// The workload sits between the Service and its Pods, the way the topology
|
|
748
|
+
// view draws it. It routes nothing - both its edges are declared, not observed
|
|
749
|
+
// - but it is what the reader opened, and a network view that omits it makes
|
|
750
|
+
// them hold the connection in their head. Only in the single-chain case: a
|
|
751
|
+
// multi-backend entry has no single workload to place.
|
|
752
|
+
// Only the vantage actually being exercised reads as running. Passing the
|
|
753
|
+
// flag to every origin made all of them animate during one probe run - the
|
|
754
|
+
// same "one vantage's state shown under another's name" this view exists to
|
|
755
|
+
// prevent, in motion.
|
|
756
|
+
const isRunning = (id: string) => !!running && id === 'incluster'
|
|
757
|
+
|
|
758
|
+
const workloadNodeId = 'n:workload'
|
|
759
|
+
// The producer resolves this from the Pods' owner chain, so it is present for
|
|
760
|
+
// ANY subject - a Service traced on its own gets its Deployment too, not just
|
|
761
|
+
// the workload-scoped tab. Its absence is a statement, not a gap: the producer
|
|
762
|
+
// returns nil when the selected Pods have no single owner, and substituting
|
|
763
|
+
// the workload the reader happened to open would claim another workload's
|
|
764
|
+
// Pods for it.
|
|
765
|
+
const theWorkload = downstream.find(isPodsHop)?.config?.workload
|
|
766
|
+
const showWorkload = !!theWorkload && backends.length === 0
|
|
767
|
+
const colWorkload = showWorkload ? colSubject + 1 : -1
|
|
768
|
+
const colPods = backends.length > 0 ? colBackend + 1 : showWorkload ? colSubject + 2 : colSubject + 1
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Two mechanisms never touch the front door, so drawing them through it is a
|
|
772
|
+
* lie the picture tells before any label is read:
|
|
773
|
+
*
|
|
774
|
+
* - the API-server proxy dials the Service/Pod subresource directly;
|
|
775
|
+
* - the in-cluster Job dials the BACKEND Service - internal/trace's own
|
|
776
|
+
* ProbeRequest doc says the in-cluster dial bypasses the front door, and
|
|
777
|
+
* the server only stamps its results onto downstream hops.
|
|
778
|
+
*
|
|
779
|
+
* Only the workstation vantage actually requests the declared hostname. When
|
|
780
|
+
* the origin bypasses, it enters beside the declared entries rather than
|
|
781
|
+
* through them: both then point at the subject, which is the truth - one
|
|
782
|
+
* exercised, one merely configured.
|
|
783
|
+
*/
|
|
784
|
+
// Route kinds carry no address; a probe reaches their backends, never them.
|
|
785
|
+
const subjectIsAddressable = trace.subject.kind !== 'HTTPRoute' && trace.subject.kind !== 'GRPCRoute'
|
|
786
|
+
const skipsEntries = (id: string) => upstreams.length > 0 && (id === 'apiserver' || id === 'incluster')
|
|
787
|
+
const originSkipsEntries = skipsEntries(origin.id)
|
|
788
|
+
|
|
789
|
+
// Every runnable vantage is drawn, in ONE stable order that does not depend on
|
|
790
|
+
// which is selected. Placing the selected one first made the capsules swap
|
|
791
|
+
// places on every click, so the thing you just clicked moved out from under
|
|
792
|
+
// the cursor and the rail's spatial memory was lost.
|
|
793
|
+
const liveOrigins = (origins?.length ? origins : [origin]).filter((o) => !o.unsupported)
|
|
794
|
+
const rowInCol = new Map<number, number>([[COL_ENTRY, upstreams.length]])
|
|
795
|
+
const originPlacement = new Map<string, { col: number; skips: boolean }>()
|
|
796
|
+
for (const o of liveOrigins) {
|
|
797
|
+
const skips = skipsEntries(o.id)
|
|
798
|
+
const col = skips ? COL_ENTRY : COL_ORIGIN
|
|
799
|
+
const row = rowInCol.get(col) ?? 0
|
|
800
|
+
rowInCol.set(col, row + 1)
|
|
801
|
+
originPlacement.set(o.id, { col, skips })
|
|
802
|
+
// The verdict rides on the capsule rather than on the edge: several origins
|
|
803
|
+
// fan into one node, so their edge pills genuinely want the same point, and
|
|
804
|
+
// "what this vantage found" is a property of the vantage anyway.
|
|
805
|
+
const verdict = originEntryEvidence(trace, route, o, { stale, running: isRunning(o.id) })
|
|
806
|
+
placed.push({
|
|
807
|
+
col,
|
|
808
|
+
row,
|
|
809
|
+
w: COL_W.origin,
|
|
810
|
+
node: {
|
|
811
|
+
id: `origin:${o.id}`,
|
|
812
|
+
kind: 'TESTED FROM',
|
|
813
|
+
name: o.name,
|
|
814
|
+
sub: SHORT_MECH[o.id] ?? o.mech,
|
|
815
|
+
// title carries the untruncated evidence - the row truncates visually
|
|
816
|
+
// and a cut "HTTP 308 · reached, redirect…" with no hover hid the
|
|
817
|
+
// destination it redirected to. The vantage's own dial count rides
|
|
818
|
+
// along: zero pixels, and the hover says how much work stands behind
|
|
819
|
+
// the one-line verdict.
|
|
820
|
+
anomalies: [
|
|
821
|
+
{
|
|
822
|
+
mark: verdict.mark,
|
|
823
|
+
text: verdict.label,
|
|
824
|
+
title: (() => {
|
|
825
|
+
const base = verdict.title || verdict.label
|
|
826
|
+
const n = [...(trace.upstreams ?? []), ...(trace.downstream ?? [])]
|
|
827
|
+
.flatMap((h) => h.probes ?? [])
|
|
828
|
+
.filter((pr) => !pr.skipped && originOf(pr, trace.runVantage) === o.id).length
|
|
829
|
+
return n > 0 ? `${base} — ${n} check${n === 1 ? '' : 's'} from this vantage` : base
|
|
830
|
+
})(),
|
|
831
|
+
},
|
|
832
|
+
],
|
|
833
|
+
// Offered where the gap is: this vantage says "not tested", and the
|
|
834
|
+
// control that would change that sits on it. Only when it has produced
|
|
835
|
+
// nothing yet - an origin with results has nothing to run.
|
|
836
|
+
action:
|
|
837
|
+
o.id === 'incluster' && !originProducedEvidence(o)
|
|
838
|
+
? {
|
|
839
|
+
text: 'Run this test',
|
|
840
|
+
kind: 'run-in-cluster' as const,
|
|
841
|
+
// Fail closed: permission alone isn't enough - with no runnable
|
|
842
|
+
// request the click is a guaranteed no-op (redis: every port is
|
|
843
|
+
// non-HTTP until native-protocol probing lands).
|
|
844
|
+
disabledReason:
|
|
845
|
+
o.unavailable ??
|
|
846
|
+
(traceInClusterRunnable(trace)
|
|
847
|
+
? undefined
|
|
848
|
+
: 'No path on this resource has a request Radar can send from inside the cluster.'),
|
|
849
|
+
}
|
|
850
|
+
: undefined,
|
|
851
|
+
tone: 'info',
|
|
852
|
+
tag: SHORT_KIND_TAG[o.kindTag] ?? o.kindTag,
|
|
853
|
+
isOrigin: true,
|
|
854
|
+
// Dimmed when it is not the one driving the rest of the graph. Its EDGE
|
|
855
|
+
// keeps full-strength marking - that mark is why it is drawn at all.
|
|
856
|
+
dim: o.id !== origin.id,
|
|
857
|
+
lane: o.lane === 'control' ? 'control' : 'data',
|
|
858
|
+
},
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
const others = liveOrigins.filter((o) => o.id !== origin.id)
|
|
862
|
+
|
|
863
|
+
upstreams.forEach((up, i) => {
|
|
864
|
+
const active = activeUpstreams.includes(up)
|
|
865
|
+
placed.push({
|
|
866
|
+
col: COL_ENTRY,
|
|
867
|
+
row: i,
|
|
868
|
+
w: COL_W.hop,
|
|
869
|
+
node: {
|
|
870
|
+
id: `n:${refId(up.resource)}`,
|
|
871
|
+
kind: (up.resource?.kind || 'ENTRY').toUpperCase(),
|
|
872
|
+
name: up.resource?.name ?? 'entry',
|
|
873
|
+
sub: active ? hopSub(up) : 'does not serve this host',
|
|
874
|
+
tone: hopTone(up),
|
|
875
|
+
notes: hopNotes(up),
|
|
876
|
+
// Entries that do not serve the selected host are shown, but dimmed -
|
|
877
|
+
// hiding them would misrepresent what is attached to this resource.
|
|
878
|
+
dim: !active,
|
|
879
|
+
ref: up.resource,
|
|
880
|
+
hop: up,
|
|
881
|
+
lane: 'data',
|
|
882
|
+
},
|
|
883
|
+
})
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
placed.push({
|
|
887
|
+
col: colSubject,
|
|
888
|
+
row: 0,
|
|
889
|
+
w: COL_W.hop,
|
|
890
|
+
node: {
|
|
891
|
+
id: subjectNodeId,
|
|
892
|
+
kind: (trace.subject.kind || 'SERVICE').toUpperCase(),
|
|
893
|
+
name: `${trace.subject.name}${route?.target ? ` ${route.target}` : ''}`,
|
|
894
|
+
sub: subjectHop ? hopSub(subjectHop) : trace.subject.namespace || '',
|
|
895
|
+
tone: hopTone(subjectHop),
|
|
896
|
+
notes: hopNotes(subjectHop),
|
|
897
|
+
ref: trace.subject,
|
|
898
|
+
hop: subjectHop,
|
|
899
|
+
lane: 'data',
|
|
900
|
+
},
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
expanded.forEach((b, i) => {
|
|
904
|
+
const onPath = !focusBranches || matchedBackends.includes(b)
|
|
905
|
+
placed.push({
|
|
906
|
+
col: colBackend,
|
|
907
|
+
row: i,
|
|
908
|
+
w: COL_W.hop,
|
|
909
|
+
node: {
|
|
910
|
+
id: b.id,
|
|
911
|
+
kind: (b.hop.resource?.kind || 'BACKEND').toUpperCase(),
|
|
912
|
+
name: b.hop.resource?.name ?? '',
|
|
913
|
+
sub: onPath ? hopSub(b.hop) : 'not on the selected path',
|
|
914
|
+
tone: hopTone(b.hop),
|
|
915
|
+
dim: !onPath,
|
|
916
|
+
notes: hopNotes(b.hop),
|
|
917
|
+
ref: b.hop.resource,
|
|
918
|
+
hop: b.hop,
|
|
919
|
+
lane: 'data',
|
|
920
|
+
},
|
|
921
|
+
})
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
if (collapsed.length > 0) {
|
|
925
|
+
placed.push({
|
|
926
|
+
col: colBackend,
|
|
927
|
+
row: expanded.length,
|
|
928
|
+
w: COL_W.hop,
|
|
929
|
+
node: {
|
|
930
|
+
id: 'collapsed:backends',
|
|
931
|
+
kind: `${collapsed.length} MORE`,
|
|
932
|
+
name: collapsed.length === 1 ? '1 more route' : `${collapsed.length} more routes`,
|
|
933
|
+
// Named so the row is a statement, not a truncation: these are quiet
|
|
934
|
+
// because nothing was found on them, not because they were dropped.
|
|
935
|
+
sub: 'nothing found · not on the selected path',
|
|
936
|
+
tone: 'unknown',
|
|
937
|
+
dim: true,
|
|
938
|
+
lane: 'data',
|
|
939
|
+
},
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const deliveryBlocked = (asSeen ? routeMark(asSeen, { stale, running }) : 'untested') === 'failed'
|
|
944
|
+
if (showWorkload && theWorkload) {
|
|
945
|
+
placed.push({
|
|
946
|
+
col: colWorkload,
|
|
947
|
+
row: 0,
|
|
948
|
+
w: WORKLOAD_W,
|
|
949
|
+
node: {
|
|
950
|
+
id: workloadNodeId,
|
|
951
|
+
kind: theWorkload.kind.toUpperCase(),
|
|
952
|
+
name: theWorkload.name,
|
|
953
|
+
ref: theWorkload,
|
|
954
|
+
// Deliberately thin. This is a network view; the workload's own health,
|
|
955
|
+
// replicas and rollout state live on its other tabs.
|
|
956
|
+
sub: 'runs these Pods',
|
|
957
|
+
tone: 'unknown',
|
|
958
|
+
lane: 'data',
|
|
959
|
+
},
|
|
960
|
+
})
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
podGroups.forEach((g, i) => {
|
|
964
|
+
const hop = g.hop
|
|
965
|
+
const roster = hop.config?.pods ?? []
|
|
966
|
+
const total = hop.config?.podTotal ?? roster.length
|
|
967
|
+
const ready = typeof hop.meta?.ready === 'number' ? (hop.meta.ready as number) : roster.filter((p) => p.ready).length
|
|
968
|
+
const selected = typeof hop.meta?.selected === 'number' ? (hop.meta.selected as number) : total
|
|
969
|
+
const publishNotReady = !!hop.meta?.publishNotReadyAddresses
|
|
970
|
+
const probes = probesFromOrigin(hop.probes ?? [], origin, trace.runVantage)
|
|
971
|
+
|
|
972
|
+
// Anomaly-first: keeping the FIRST six rows hid the failing or excluded Pod
|
|
973
|
+
// behind five healthy ones, which is exactly the row worth showing.
|
|
974
|
+
const rank = (x: PodStatus): number => {
|
|
975
|
+
const mine = probesForPod(x, probes).filter((q) => !q.skipped)
|
|
976
|
+
if (mine.some((q) => !q.ok || q.tone === 'unhealthy')) return 0
|
|
977
|
+
if (mine.some(isSlow)) return 1
|
|
978
|
+
if (!x.ready) return 2
|
|
979
|
+
if (mine.length === 0) return 3
|
|
980
|
+
return 4
|
|
981
|
+
}
|
|
982
|
+
const ordered = [...roster].sort((a, b) => rank(a) - rank(b))
|
|
983
|
+
const podRows: PodRow[] = []
|
|
984
|
+
for (const p of ordered.slice(0, POD_ROW_MAX)) {
|
|
985
|
+
const mine = probesForPod(p, probes).filter((x) => !x.skipped)
|
|
986
|
+
const failed = mine.find((x) => !x.ok || x.tone === 'unhealthy')
|
|
987
|
+
let mark: Mark
|
|
988
|
+
let detail: string
|
|
989
|
+
if (!p.ready && !publishNotReady) {
|
|
990
|
+
mark = 'excluded'
|
|
991
|
+
detail = 'not ready — nothing sent here'
|
|
992
|
+
} else if (failed) {
|
|
993
|
+
mark = 'failed'
|
|
994
|
+
detail = failed.detail || failed.error || 'refused'
|
|
995
|
+
} else if (mine.length === 0 && deliveryBlocked) {
|
|
996
|
+
mark = 'blocked'
|
|
997
|
+
detail = 'not reached — failed earlier'
|
|
998
|
+
} else if (mine.length === 0) {
|
|
999
|
+
mark = 'untested'
|
|
1000
|
+
detail = 'not tested'
|
|
1001
|
+
} else {
|
|
1002
|
+
const slowest = mine.filter(isSlow).sort((a, b) => (b.latencyNs ?? 0) - (a.latencyNs ?? 0))[0]
|
|
1003
|
+
mark = slowest ? 'slow' : 'proved'
|
|
1004
|
+
const best = mine.find((x) => x.layer === 'http') ?? mine[0]
|
|
1005
|
+
detail = slowest ? `slow · ${formatLatency(slowest.latencyNs)}` : shortEvidence(best?.detail) || 'reached'
|
|
1006
|
+
}
|
|
1007
|
+
if (!p.ready && publishNotReady) detail = `${detail} · not ready, sent traffic anyway`
|
|
1008
|
+
// The Pods hop carries its own namespace, which differs from the subject's
|
|
1009
|
+
// for a cross-namespace backend - navigating by the subject's would open a
|
|
1010
|
+
// Pod that isn't there.
|
|
1011
|
+
podRows.push({
|
|
1012
|
+
name: p.name,
|
|
1013
|
+
mark,
|
|
1014
|
+
detail,
|
|
1015
|
+
ref: { kind: 'Pod', name: p.name, namespace: hop.resource?.namespace || trace.subject.namespace },
|
|
1016
|
+
})
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
placed.push({
|
|
1020
|
+
col: colPods,
|
|
1021
|
+
row: i,
|
|
1022
|
+
w: COL_W.pods,
|
|
1023
|
+
node: {
|
|
1024
|
+
id: g.id,
|
|
1025
|
+
kind: 'PODS',
|
|
1026
|
+
// "taking traffic" claims observed delivery; readiness only establishes
|
|
1027
|
+
// ELIGIBILITY. The inspector already says "eligible" for this same
|
|
1028
|
+
// number - the graph must not contradict it.
|
|
1029
|
+
name: `${ready} of ${selected} eligible`,
|
|
1030
|
+
// The name line already counts eligibility - "every selected Pod is
|
|
1031
|
+
// eligible" under "1 of 1 eligible" said the same fact twice. The sub
|
|
1032
|
+
// only speaks when it adds something the count can't say.
|
|
1033
|
+
sub: publishNotReady
|
|
1034
|
+
? 'not-ready Pods are sent traffic too'
|
|
1035
|
+
: ready === selected
|
|
1036
|
+
? ''
|
|
1037
|
+
: `${selected - ready} not eligible`,
|
|
1038
|
+
tone: hopTone(hop),
|
|
1039
|
+
hop,
|
|
1040
|
+
notes: hopNotes(hop),
|
|
1041
|
+
anomalies: populationAnomalies(roster, total, probes, publishNotReady),
|
|
1042
|
+
podRows,
|
|
1043
|
+
moreRows: Math.max(0, roster.length - POD_ROW_MAX),
|
|
1044
|
+
lane: 'data',
|
|
1045
|
+
},
|
|
1046
|
+
})
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
// ---- resolve geometry ----
|
|
1050
|
+
const usedCols = [...new Set(placed.map((p) => p.col))].sort((a, b) => a - b)
|
|
1051
|
+
const colX = new Map<number, number>()
|
|
1052
|
+
const colW = new Map<number, number>()
|
|
1053
|
+
const wlCol = placed.find((p) => p.node.id === workloadNodeId)?.col
|
|
1054
|
+
let x = 0
|
|
1055
|
+
for (let i = 0; i < usedCols.length; i++) {
|
|
1056
|
+
const c = usedCols[i]
|
|
1057
|
+
const w = Math.max(...placed.filter((p) => p.col === c).map((p) => p.w))
|
|
1058
|
+
colW.set(c, w)
|
|
1059
|
+
colX.set(c, x)
|
|
1060
|
+
// Both gutters AROUND the workload column tighten - it is a less
|
|
1061
|
+
// interesting object for a network view and was stretching the
|
|
1062
|
+
// Service→Deployment→Pods stretch into dead space.
|
|
1063
|
+
const gutter = c === wlCol || usedCols[i + 1] === wlCol ? WORKLOAD_GUTTER : GUTTER
|
|
1064
|
+
x += w + gutter
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const heightOf = (p: Placed) => estHeight(p.node)
|
|
1068
|
+
|
|
1069
|
+
const controlPlaced = placed.filter((p) => p.node.lane === 'control')
|
|
1070
|
+
const dataPlaced = placed.filter((p) => p.node.lane === 'data')
|
|
1071
|
+
|
|
1072
|
+
const nodes: GraphNode[] = []
|
|
1073
|
+
const pos = new Map<string, GraphNode>()
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Stacks one lane's nodes by column and returns its height.
|
|
1077
|
+
*
|
|
1078
|
+
* The control lane used to pin every node to a single y, which was fine while
|
|
1079
|
+
* only one vantage was ever drawn - and put two on top of each other the
|
|
1080
|
+
* moment they all became visible at once.
|
|
1081
|
+
*/
|
|
1082
|
+
const layoutLane = (list: Placed[], top: number): number => {
|
|
1083
|
+
const byCol = new Map<number, Placed[]>()
|
|
1084
|
+
for (const p of list) {
|
|
1085
|
+
const arr = byCol.get(p.col) ?? []
|
|
1086
|
+
arr.push(p)
|
|
1087
|
+
byCol.set(p.col, arr)
|
|
1088
|
+
}
|
|
1089
|
+
const colHeights = new Map<number, number>()
|
|
1090
|
+
for (const [c, l] of byCol) {
|
|
1091
|
+
colHeights.set(c, l.reduce((sum, p) => sum + heightOf(p), 0) + (l.length - 1) * ROW_GAP)
|
|
1092
|
+
}
|
|
1093
|
+
const laneH = Math.max(0, ...colHeights.values())
|
|
1094
|
+
for (const [c, l] of byCol) {
|
|
1095
|
+
let y = top + (laneH - colHeights.get(c)!) / 2
|
|
1096
|
+
for (const p of [...l].sort((a, b) => a.row - b.row)) {
|
|
1097
|
+
const h = heightOf(p)
|
|
1098
|
+
const n: GraphNode = { ...p.node, x: colX.get(c)!, y, w: colW.get(c)!, h }
|
|
1099
|
+
nodes.push(n)
|
|
1100
|
+
pos.set(n.id, n)
|
|
1101
|
+
y += h + ROW_GAP
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return laneH
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const controlTop = controlPlaced.length > 0 ? LANE_PAD.top : 0
|
|
1108
|
+
const controlH = layoutLane(controlPlaced, controlTop)
|
|
1109
|
+
const dataTop = controlPlaced.length > 0 ? controlTop + controlH + LANE_PAD.bottom + LANE_GAP + LANE_PAD.top : LANE_PAD.top
|
|
1110
|
+
layoutLane(dataPlaced, dataTop)
|
|
1111
|
+
|
|
1112
|
+
// ---- edges ----
|
|
1113
|
+
const edges: GraphEdge[] = []
|
|
1114
|
+
const geom = new Map<string, Cubic>()
|
|
1115
|
+
const brackets: { d: string }[] = []
|
|
1116
|
+
const crossesLanes = new Set<string>()
|
|
1117
|
+
|
|
1118
|
+
// `full` is the untruncated text for the hover. Without it a shortened pill
|
|
1119
|
+
// label became the tooltip too, so the explanation was discarded rather than
|
|
1120
|
+
// moved somewhere it fits.
|
|
1121
|
+
/**
|
|
1122
|
+
* Where along an edge its pill sits, as a fraction of the curve.
|
|
1123
|
+
*
|
|
1124
|
+
* Every pill used to sit at the midpoint, which is fine for one edge and
|
|
1125
|
+
* collides the moment several fan into the same node: their midpoints are
|
|
1126
|
+
* genuinely the same point. Slotting moves them apart ALONG their own curve,
|
|
1127
|
+
* so each stays on the line it describes instead of floating beside it.
|
|
1128
|
+
*/
|
|
1129
|
+
const pillT = (slot: number): number => {
|
|
1130
|
+
if (slot <= 0) return 0.5
|
|
1131
|
+
const step = 0.13
|
|
1132
|
+
// Alternate above/below the midpoint so a fan spreads both ways.
|
|
1133
|
+
const dir = slot % 2 === 1 ? -1 : 1
|
|
1134
|
+
const mag = Math.ceil(slot / 2) * step
|
|
1135
|
+
return Math.min(0.78, Math.max(0.22, 0.5 + dir * mag))
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const edgeTo = new Map<string, string>()
|
|
1139
|
+
// Edges belonging to a vantage OTHER than the selected one. They are drawn so
|
|
1140
|
+
// the comparison is visible, but they must never speak for the selection -
|
|
1141
|
+
// reading a break off them let a workstation failure order the report while
|
|
1142
|
+
// the in-cluster vantage the reader had chosen was succeeding.
|
|
1143
|
+
const foreignEdges = new Set<string>()
|
|
1144
|
+
const connect = (id: string, fromId: string, toId: string, mark: Mark, label: string, full?: string, slot = 0, boundarySpan?: 'start' | 'continuation') => {
|
|
1145
|
+
const a = pos.get(fromId)
|
|
1146
|
+
const b = pos.get(toId)
|
|
1147
|
+
if (!a || !b) return
|
|
1148
|
+
if (a.lane !== b.lane) crossesLanes.add(id)
|
|
1149
|
+
const x1 = a.x + a.w
|
|
1150
|
+
const y1 = a.y + a.h / 2
|
|
1151
|
+
const x2 = b.x
|
|
1152
|
+
const y2 = b.y + b.h / 2
|
|
1153
|
+
const dx = Math.max(30, (x2 - x1) * 0.45)
|
|
1154
|
+
// An edge that SKIPS a column runs straight through whatever sits in it -
|
|
1155
|
+
// the in-cluster probe bypassing an HTTPRoute drew its line, and parked its
|
|
1156
|
+
// pill, on top of the very node it does not touch. Route around instead:
|
|
1157
|
+
// detour under the obstacles when the curve would cross them.
|
|
1158
|
+
const lo = Math.min(y1, y2)
|
|
1159
|
+
const hi = Math.max(y1, y2)
|
|
1160
|
+
const blocked = [...pos.values()].filter(
|
|
1161
|
+
(n) =>
|
|
1162
|
+
n.id !== fromId &&
|
|
1163
|
+
n.id !== toId &&
|
|
1164
|
+
n.x > Math.min(x1, x2) &&
|
|
1165
|
+
n.x + n.w < Math.max(x1, x2) &&
|
|
1166
|
+
n.y < hi + DETOUR_GAP &&
|
|
1167
|
+
n.y + n.h > lo - DETOUR_GAP,
|
|
1168
|
+
)
|
|
1169
|
+
// A cubic only travels ~3/4 of the way toward its control points, so setting
|
|
1170
|
+
// them AT the clearance line leaves the curve short of it - fine for a short
|
|
1171
|
+
// node, straight through a tall one. Solve for the control height that puts
|
|
1172
|
+
// the CURVE itself past the obstacle:
|
|
1173
|
+
// y(0.5) = (y1 + y2)/8 + (3/4)·D ⇒ D = (clear - (y1 + y2)/8) / 0.75
|
|
1174
|
+
//
|
|
1175
|
+
// The SIDE is chosen, never assumed. An edge dropping from the control lane
|
|
1176
|
+
// is already above the obstacle; forcing it under sends it swooping below
|
|
1177
|
+
// and back up, which is longer and reads as a detour to nowhere. Go round
|
|
1178
|
+
// whichever way the line is already passing.
|
|
1179
|
+
const controlFor = (clear: number) => (clear - (y1 + y2) / 8) / 0.75
|
|
1180
|
+
let cy1 = y1
|
|
1181
|
+
let cy2 = y2
|
|
1182
|
+
if (blocked.length > 0) {
|
|
1183
|
+
const mid = (y1 + y2) / 2
|
|
1184
|
+
const above = Math.min(...blocked.map((n) => n.y)) - DETOUR_GAP
|
|
1185
|
+
const below = Math.max(...blocked.map((n) => n.y + n.h)) + DETOUR_GAP
|
|
1186
|
+
const goAbove = Math.abs(mid - above) < Math.abs(mid - below)
|
|
1187
|
+
const overTop = goAbove && controlFor(above) > LANE_PAD.top
|
|
1188
|
+
|
|
1189
|
+
// Clearing the curve's MIDPOINT is not the same as clearing the node. An
|
|
1190
|
+
// asymmetric edge has already descended by the time it crosses the node's
|
|
1191
|
+
// far edge, so it grazed the corner while the midpoint sat comfortably
|
|
1192
|
+
// clear. Sample the span the node actually occupies and push the control
|
|
1193
|
+
// until every sampled point clears it.
|
|
1194
|
+
const worstOverlap = (control: number): number => {
|
|
1195
|
+
const c: Cubic = { x1, y1, x2, y2, dx, cy1: control, cy2: control }
|
|
1196
|
+
let worst = 0
|
|
1197
|
+
for (let i = 1; i < 20; i++) {
|
|
1198
|
+
const pt = bezierAt(c, i / 20)
|
|
1199
|
+
for (const n of blocked) {
|
|
1200
|
+
if (pt.x < n.x - 2 || pt.x > n.x + n.w + 2) continue
|
|
1201
|
+
const need = overTop ? n.y - DETOUR_GAP : n.y + n.h + DETOUR_GAP
|
|
1202
|
+
const over = overTop ? pt.y - need : need - pt.y
|
|
1203
|
+
if (over > worst) worst = over
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
return worst
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
let control = controlFor(overTop ? above : below)
|
|
1210
|
+
for (let i = 0; i < 8; i++) {
|
|
1211
|
+
const over = worstOverlap(control)
|
|
1212
|
+
if (over <= 0.5) break
|
|
1213
|
+
control += overTop ? -over : over
|
|
1214
|
+
}
|
|
1215
|
+
cy1 = control
|
|
1216
|
+
cy2 = control
|
|
1217
|
+
}
|
|
1218
|
+
const cubic: Cubic = { x1, y1, x2, y2, dx, cy1, cy2 }
|
|
1219
|
+
// Evaluate the SAME cubic the path draws, so the pill is on the line by
|
|
1220
|
+
// construction rather than by a midpoint approximation that only holds when
|
|
1221
|
+
// the curve happens to be symmetric.
|
|
1222
|
+
const t = pillT(slot)
|
|
1223
|
+
const p = bezierAt(cubic, t)
|
|
1224
|
+
geom.set(id, cubic)
|
|
1225
|
+
edgeTo.set(id, toId)
|
|
1226
|
+
edges.push({
|
|
1227
|
+
id,
|
|
1228
|
+
d: cubicPath(cubic),
|
|
1229
|
+
mark,
|
|
1230
|
+
boundary: boundarySpan,
|
|
1231
|
+
label: truncate(label),
|
|
1232
|
+
title: full || label,
|
|
1233
|
+
px: p.x,
|
|
1234
|
+
py: p.y,
|
|
1235
|
+
})
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const originNodeId = `origin:${origin.id}`
|
|
1239
|
+
// 'own' is this origin's result. 'none' means the producer told us it did not
|
|
1240
|
+
// test this route, which outranks anything it did on OTHER routes - so the
|
|
1241
|
+
// coarse pooled gate only applies to legacy traces with no breakdown at all.
|
|
1242
|
+
const hasEvidence = ev.kind === 'own' || (ev.kind === 'rollup' && originProducedEvidence(origin))
|
|
1243
|
+
// Known broken from configuration: true of every vantage, dialled by none.
|
|
1244
|
+
const fromConfig = ev.kind === 'config'
|
|
1245
|
+
const routeMarkNow: Mark = asSeen ? routeMark(asSeen, { stale, running: isRunning(origin.id) }) : 'untested'
|
|
1246
|
+
// A relay can never read as proof: it bypassed the real network path however
|
|
1247
|
+
// clean the response was.
|
|
1248
|
+
const entryMark: Mark = isRunning(origin.id)
|
|
1249
|
+
? 'running'
|
|
1250
|
+
: fromConfig
|
|
1251
|
+
? 'config'
|
|
1252
|
+
: !hasEvidence
|
|
1253
|
+
? origin.mark
|
|
1254
|
+
: origin.id === 'apiserver' && routeMarkNow === 'proved'
|
|
1255
|
+
? 'proxied'
|
|
1256
|
+
: routeMarkNow
|
|
1257
|
+
const originBlocked = !!origin.unavailable && origin.mark === 'blocked'
|
|
1258
|
+
const noEvidenceLabel = originNoEvidenceLabel(origin)
|
|
1259
|
+
const entryLabel = isRunning(origin.id)
|
|
1260
|
+
? 'testing now'
|
|
1261
|
+
: fromConfig
|
|
1262
|
+
? 'broken as configured'
|
|
1263
|
+
: !hasEvidence
|
|
1264
|
+
? noEvidenceLabel
|
|
1265
|
+
: shortEvidence(asSeen?.evidence) || (origin.id === 'apiserver' ? 'relayed by Kubernetes' : 'request')
|
|
1266
|
+
// The whole sentence, for the hover.
|
|
1267
|
+
const entryTitle = !hasEvidence && !fromConfig ? undefined : asSeen?.evidence || undefined
|
|
1268
|
+
|
|
1269
|
+
// Only the backends this route resolves to - shared by the selected origin's
|
|
1270
|
+
// bypass edge and by every other origin's.
|
|
1271
|
+
const bypassBackends = focusBranches ? matchedBackends : expanded
|
|
1272
|
+
// The request enters at the entry points that serve this host; everything
|
|
1273
|
+
// after is configuration, drawn dotted. There is no segment-local evidence to
|
|
1274
|
+
// claim otherwise.
|
|
1275
|
+
if (upstreams.length > 0 && !originSkipsEntries) {
|
|
1276
|
+
for (const up of upstreams) {
|
|
1277
|
+
const id = `n:${refId(up.resource)}`
|
|
1278
|
+
const active = activeUpstreams.includes(up)
|
|
1279
|
+
// This entry's OWN result for this origin, when it has one. The route
|
|
1280
|
+
// rollup is the fallback, not the source: it is built from the backend's
|
|
1281
|
+
// probes and cannot see a front-door dial.
|
|
1282
|
+
const own = active ? hopEvidenceFor(up, origin, trace.runVantage, { stale, running: isRunning(origin.id) }) : undefined
|
|
1283
|
+
connect(
|
|
1284
|
+
`e:origin-${id}`,
|
|
1285
|
+
originNodeId,
|
|
1286
|
+
id,
|
|
1287
|
+
originBlocked ? 'blocked' : active ? own?.mark ?? 'untested' : 'untested',
|
|
1288
|
+
active ? own?.label ?? 'not tested' : 'other host',
|
|
1289
|
+
active ? own?.title : undefined,
|
|
1290
|
+
)
|
|
1291
|
+
connect(`e:${id}-subject`, id, subjectNodeId, 'config', 'routes to')
|
|
1292
|
+
}
|
|
1293
|
+
} else {
|
|
1294
|
+
// The declared entries stay drawn, and stay CONFIG: they are how traffic is
|
|
1295
|
+
// meant to arrive, and this run did not use them.
|
|
1296
|
+
for (const up of upstreams) {
|
|
1297
|
+
connect(`e:${`n:${refId(up.resource)}`}-subject`, `n:${refId(up.resource)}`, subjectNodeId, 'config', 'routes to')
|
|
1298
|
+
}
|
|
1299
|
+
// An HTTPRoute/GRPCRoute has no address of its own - reachability lives on
|
|
1300
|
+
// the parent Gateway and the backend Service. A bypassing origin therefore
|
|
1301
|
+
// dials the BACKEND, and terminating its edge on the route object would draw
|
|
1302
|
+
// a request to something nothing can dial. Land it on the backends instead.
|
|
1303
|
+
// Only the backends this route actually resolves to. Landing the edge on
|
|
1304
|
+
// every expanded backend would spread ONE route's evidence across siblings
|
|
1305
|
+
// it says nothing about - the cross-backend leak the producer now refuses to
|
|
1306
|
+
// make, reintroduced a layer up.
|
|
1307
|
+
const bypassTargets = !subjectIsAddressable && originSkipsEntries ? bypassBackends.map((b) => b.id) : []
|
|
1308
|
+
if (bypassTargets.length > 0) {
|
|
1309
|
+
for (const id of bypassTargets) {
|
|
1310
|
+
connect(`e:origin-${id}`, originNodeId, id, originBlocked ? 'blocked' : entryMark, 'dialled directly')
|
|
1311
|
+
}
|
|
1312
|
+
} else {
|
|
1313
|
+
connect(
|
|
1314
|
+
'e:origin-subject',
|
|
1315
|
+
originNodeId,
|
|
1316
|
+
subjectNodeId,
|
|
1317
|
+
originBlocked ? 'blocked' : entryMark,
|
|
1318
|
+
originSkipsEntries ? 'direct to backend' : entryLabel,
|
|
1319
|
+
originSkipsEntries ? undefined : entryTitle,
|
|
1320
|
+
)
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
// Each unselected origin gets its own edge carrying ITS OWN mark AND its own
|
|
1324
|
+
// words. Without them the capsule is decoration and the comparison still costs
|
|
1325
|
+
// a click - which is the entire reason the vantages are drawn together. The
|
|
1326
|
+
// origins stack vertically, so their edge midpoints do too and the pills
|
|
1327
|
+
// separate on their own.
|
|
1328
|
+
others.forEach((o, oi) => {
|
|
1329
|
+
const p = originPlacement.get(o.id)
|
|
1330
|
+
const from = `origin:${o.id}`
|
|
1331
|
+
const mine = (id: string) => {
|
|
1332
|
+
foreignEdges.add(id)
|
|
1333
|
+
return id
|
|
1334
|
+
}
|
|
1335
|
+
const ev = originEntryEvidence(trace, route, o, { stale, running: isRunning(o.id) })
|
|
1336
|
+
const slot = oi + 1
|
|
1337
|
+
if (!p?.skips && upstreams.length > 0) {
|
|
1338
|
+
for (const up of activeUpstreams) {
|
|
1339
|
+
connect(mine(`e:origin-${o.id}-${refId(up.resource)}`), from, `n:${refId(up.resource)}`, ev.mark, '', ev.title || ev.label, slot)
|
|
1340
|
+
}
|
|
1341
|
+
return
|
|
1342
|
+
}
|
|
1343
|
+
if (!subjectIsAddressable && p?.skips) {
|
|
1344
|
+
for (const b of bypassBackends) connect(mine(`e:origin-${o.id}-${b.id}`), from, b.id, ev.mark, '', ev.title || ev.label, slot)
|
|
1345
|
+
return
|
|
1346
|
+
}
|
|
1347
|
+
connect(mine(`e:origin-${o.id}-subject`), from, subjectNodeId, ev.mark, '', ev.title || ev.label, slot)
|
|
1348
|
+
})
|
|
1349
|
+
|
|
1350
|
+
for (const b of expanded) {
|
|
1351
|
+
const onPath = !focusBranches || matchedBackends.includes(b)
|
|
1352
|
+
connect(`e:subject-${b.id}`, subjectNodeId, b.id, onPath ? 'config' : 'excluded', onPath ? 'sends to' : 'other host')
|
|
1353
|
+
}
|
|
1354
|
+
if (collapsed.length > 0) connect('e:subject-collapsed', subjectNodeId, 'collapsed:backends', 'config', 'also serves')
|
|
1355
|
+
// When the producer localized the break to the Service's own routing, the
|
|
1356
|
+
// Service->Pods edge is where it happened - packets reached the workload, so
|
|
1357
|
+
// what sits between them is what failed. Only ever drawn from a boundary the
|
|
1358
|
+
// producer actually established; an unlocalized failure colours nothing.
|
|
1359
|
+
const boundary = ev.kind === 'own' ? routeForOrigin(route, origin.id)?.failedBoundary : undefined
|
|
1360
|
+
// The producer establishes a boundary for ONE backend. Painting it on every
|
|
1361
|
+
// Service->Pods edge would condemn siblings whose pods were never probed -
|
|
1362
|
+
// the same cross-backend leak, one layer up from where it was just fixed.
|
|
1363
|
+
// Where the owning branch can't be identified, colour nothing.
|
|
1364
|
+
const boundaryParents =
|
|
1365
|
+
matchedBackends.length > 0
|
|
1366
|
+
? new Set(matchedBackends.map((b) => b.id))
|
|
1367
|
+
: backends.length === 0
|
|
1368
|
+
? new Set([subjectNodeId]) // single chain: the subject IS the Service
|
|
1369
|
+
: new Set<string>()
|
|
1370
|
+
let breakAtExitOf: string | undefined
|
|
1371
|
+
for (const g of podGroups) {
|
|
1372
|
+
const broke = boundary === 'service-routing' && boundaryParents.has(g.parentId)
|
|
1373
|
+
// A Service-routing break is a BOUNDARY, not a node: the request never got
|
|
1374
|
+
// past the Service's exit. The break anchors there (breakAtExitOf) with no
|
|
1375
|
+
// node halo - a halo on the workload blamed the Deployment for kube-proxy,
|
|
1376
|
+
// and one on the Pods blamed the endpoints for their Service's routing.
|
|
1377
|
+
if (broke) breakAtExitOf = g.parentId
|
|
1378
|
+
if (showWorkload) {
|
|
1379
|
+
const breakTitle =
|
|
1380
|
+
'The Service\u2019s routing to its Pods fails here. The Deployment sits between them as the thing that runs the Pods \u2014 it is not a network hop and not the culprit.'
|
|
1381
|
+
connect(
|
|
1382
|
+
`e:${g.id}-workload`,
|
|
1383
|
+
g.parentId,
|
|
1384
|
+
workloadNodeId,
|
|
1385
|
+
broke ? 'failed' : 'config',
|
|
1386
|
+
broke ? 'breaks here' : 'selects',
|
|
1387
|
+
broke ? breakTitle : undefined,
|
|
1388
|
+
0,
|
|
1389
|
+
broke ? 'start' : undefined,
|
|
1390
|
+
)
|
|
1391
|
+
// The continuation extends the SAME observed break across the span the
|
|
1392
|
+
// workload sits inside - dashed, no pill, never a second failed claim.
|
|
1393
|
+
connect(
|
|
1394
|
+
`e:${g.id}`,
|
|
1395
|
+
workloadNodeId,
|
|
1396
|
+
g.id,
|
|
1397
|
+
broke ? 'failed' : 'config',
|
|
1398
|
+
broke ? '' : 'runs',
|
|
1399
|
+
broke ? 'Continuation of the same break \u2014 the routing failure is between the Service and its Pods.' : undefined,
|
|
1400
|
+
0,
|
|
1401
|
+
broke ? 'continuation' : undefined,
|
|
1402
|
+
)
|
|
1403
|
+
continue
|
|
1404
|
+
}
|
|
1405
|
+
connect(
|
|
1406
|
+
`e:${g.id}`,
|
|
1407
|
+
g.parentId,
|
|
1408
|
+
g.id,
|
|
1409
|
+
broke ? 'failed' : 'config',
|
|
1410
|
+
broke ? 'breaks here' : 'selects',
|
|
1411
|
+
undefined,
|
|
1412
|
+
0,
|
|
1413
|
+
broke ? 'start' : undefined,
|
|
1414
|
+
)
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
separatePills(edges, geom)
|
|
1418
|
+
|
|
1419
|
+
// ---- lane boxes: bound only their own nodes ----
|
|
1420
|
+
const boxFor = (list: GraphNode[], label: string, help: string, color: string, dashed?: boolean): LaneBox | undefined => {
|
|
1421
|
+
if (list.length === 0) return undefined
|
|
1422
|
+
const x0 = Math.min(...list.map((n) => n.x)) - LANE_PAD.x
|
|
1423
|
+
const x1 = Math.max(...list.map((n) => n.x + n.w)) + LANE_PAD.x
|
|
1424
|
+
const y0 = Math.min(...list.map((n) => n.y)) - LANE_PAD.top
|
|
1425
|
+
const y1 = Math.max(...list.map((n) => n.y + n.h)) + LANE_PAD.bottom
|
|
1426
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0, label, help, color, dashed }
|
|
1427
|
+
}
|
|
1428
|
+
const laneControl = boxFor(
|
|
1429
|
+
nodes.filter((n) => n.lane === 'control'),
|
|
1430
|
+
'NOT FROM INSIDE THE CLUSTER',
|
|
1431
|
+
'Neither request originates inside the cluster’s dataplane, but they differ: a dial from your machine sends real traffic from outside - through a real entry it exercises the path from the entry inward (routing, NetworkPolicy, mesh included). A relayed request is carried by the Kubernetes control plane and bypasses the traffic path entirely.',
|
|
1432
|
+
'var(--color-info)',
|
|
1433
|
+
true,
|
|
1434
|
+
)
|
|
1435
|
+
const laneData = boxFor(
|
|
1436
|
+
nodes.filter((n) => n.lane === 'data'),
|
|
1437
|
+
'FROM INSIDE THE CLUSTER',
|
|
1438
|
+
'Started from a Pod inside the cluster, so the request goes through the cluster’s own routing, network policy and mesh. It dials the backend directly, so it says nothing about whether the front door works.',
|
|
1439
|
+
'var(--accent-text)',
|
|
1440
|
+
)
|
|
1441
|
+
|
|
1442
|
+
// A cross-lane edge's midpoint lands on the dataplane lane's top edge, which
|
|
1443
|
+
// is exactly where the lane label sits. Park those pills in the gap between
|
|
1444
|
+
// the lanes - otherwise empty - so neither covers the other. This runs AFTER
|
|
1445
|
+
// separatePills and can restack pills it had separated (every crossing edge
|
|
1446
|
+
// gets the same y), so colliders are re-spread here: the first keeps the
|
|
1447
|
+
// gap centre, later ones step outward alternating above/below - slightly
|
|
1448
|
+
// into a lane beats two pills on top of each other.
|
|
1449
|
+
if (laneControl && laneData) {
|
|
1450
|
+
const gapCentre = (laneControl.y + laneControl.h + laneData.y) / 2
|
|
1451
|
+
const parked: GraphEdge[] = []
|
|
1452
|
+
for (const e of edges) {
|
|
1453
|
+
if (!crossesLanes.has(e.id)) continue
|
|
1454
|
+
e.py = gapCentre
|
|
1455
|
+
for (const other of parked) {
|
|
1456
|
+
if (Math.abs(other.px - e.px) < PILL_MAX_PX + 14 && Math.abs(other.py - e.py) < 26) {
|
|
1457
|
+
const step = Math.ceil(parked.length / 2) * 28
|
|
1458
|
+
e.py = gapCentre + (parked.length % 2 === 1 ? step : -step)
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
parked.push(e)
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
// Detouring edges bow BELOW the nodes they route around, so the canvas has to
|
|
1466
|
+
// reserve room for the deepest of them or the curve clips at the bottom edge.
|
|
1467
|
+
const deepest = Math.max(0, ...edges.map((e) => e.py))
|
|
1468
|
+
const canvas = {
|
|
1469
|
+
w: Math.max(...nodes.map((n) => n.x + n.w), 300) + LANE_PAD.x + 4,
|
|
1470
|
+
h: Math.max(...nodes.map((n) => n.y + n.h), deepest, 200) + LANE_PAD.bottom + 4,
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Leftmost failed edge = the first place a request is known to have stopped.
|
|
1474
|
+
// Column order, not array order, so it does not depend on the sequence edges
|
|
1475
|
+
// happened to be built in.
|
|
1476
|
+
const failed = edges
|
|
1477
|
+
.filter((e) => e.mark === 'failed' && !e.boundary && !foreignEdges.has(e.id))
|
|
1478
|
+
.map((e) => pos.get(edgeTo.get(e.id) ?? ''))
|
|
1479
|
+
.filter((n): n is GraphNode => !!n)
|
|
1480
|
+
.sort((a, b) => a.x - b.x)
|
|
1481
|
+
const breakNodeId = failed[0]?.id
|
|
1482
|
+
|
|
1483
|
+
// Traversal order, not screen order: entries the route arrives through, the
|
|
1484
|
+
// subject, the workload behind it, only the backends this route resolves to,
|
|
1485
|
+
// and only those backends' Pods.
|
|
1486
|
+
const selectedBackends = focusBranches ? matchedBackends : expanded
|
|
1487
|
+
const selectedBackendIds = new Set(selectedBackends.map((b) => b.id))
|
|
1488
|
+
// The journey is what the selected vantage's request traverses. A bypassing
|
|
1489
|
+
// origin (apiserver relay, in-cluster Job) traverses NO entry; a matched host
|
|
1490
|
+
// traverses only ITS entries; parallel non-matched entries are context. The
|
|
1491
|
+
// workload is rendered inline but is not a hop - it interleaves in display,
|
|
1492
|
+
// never in the journey.
|
|
1493
|
+
const journeyUpstreams = originSkipsEntries ? [] : activeUpstreams
|
|
1494
|
+
const contextNodeIds = upstreams
|
|
1495
|
+
.filter((up) => !journeyUpstreams.includes(up))
|
|
1496
|
+
.map((up) => `n:${refId(up.resource)}`)
|
|
1497
|
+
const pathNodeIds = [
|
|
1498
|
+
...journeyUpstreams.map((up) => `n:${refId(up.resource)}`),
|
|
1499
|
+
subjectNodeId,
|
|
1500
|
+
...selectedBackends.map((b) => b.id),
|
|
1501
|
+
...podGroups.filter((g) => g.parentId === subjectNodeId || selectedBackendIds.has(g.parentId)).map((g) => g.id),
|
|
1502
|
+
].filter((id, i, all) => pos.has(id) && all.indexOf(id) === i)
|
|
1503
|
+
|
|
1504
|
+
return {
|
|
1505
|
+
nodes,
|
|
1506
|
+
edges,
|
|
1507
|
+
brackets,
|
|
1508
|
+
originIsControl,
|
|
1509
|
+
canvas,
|
|
1510
|
+
laneControl,
|
|
1511
|
+
laneData,
|
|
1512
|
+
breakNodeId,
|
|
1513
|
+
breakAtExitOf,
|
|
1514
|
+
nonNetworkNodeIds: showWorkload ? [workloadNodeId] : undefined,
|
|
1515
|
+
contextNodeIds: contextNodeIds.length ? contextNodeIds : undefined,
|
|
1516
|
+
interleave: showWorkload ? [{ id: workloadNodeId, afterId: subjectNodeId }] : undefined,
|
|
1517
|
+
entryParallelCount: journeyUpstreams.length > 1 ? journeyUpstreams.length : undefined,
|
|
1518
|
+
journeyEntryNodeIds: journeyUpstreams.length ? journeyUpstreams.map((up) => `n:${refId(up.resource)}`) : undefined,
|
|
1519
|
+
pathNodeIds,
|
|
1520
|
+
}
|
|
1521
|
+
}
|