@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,667 @@
|
|
|
1
|
+
import type { Trace, RouteResult, ResourceRef } from './types'
|
|
2
|
+
import type { Mark, SevTone } from './reachMarks'
|
|
3
|
+
import { routeMark, routeChip, routeTone, routeAsSeenFrom, originRouteEvidence, routeForOrigin, traceInClusterRunnable } from './reachMarks'
|
|
4
|
+
import type { Origin, OriginId } from './reachOrigins'
|
|
5
|
+
import { strongestGap, actionableGap, originSkipReason } from './reachOrigins'
|
|
6
|
+
import { hopEvidenceFor, originProducedEvidence, type GraphNode } from './reachGraphModel'
|
|
7
|
+
|
|
8
|
+
// 'run-probes' re-runs the reachability probes. It is deliberately NOT
|
|
9
|
+
// 'refresh': the panel that offers it promises fresh evidence, and a static
|
|
10
|
+
// refetch collects none.
|
|
11
|
+
export type InspectorAction = 'run-in-cluster' | 'run-probes' | 'open-resource' | 'copy-command'
|
|
12
|
+
|
|
13
|
+
export interface InspectorCTA {
|
|
14
|
+
text: string
|
|
15
|
+
primary?: boolean
|
|
16
|
+
action?: InspectorAction
|
|
17
|
+
ref?: ResourceRef
|
|
18
|
+
command?: string
|
|
19
|
+
/** Set when the CTA describes something Radar cannot do - rendered inert. */
|
|
20
|
+
disabledReason?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Only nodes are selectable now. Edges carry no segment-local evidence, so
|
|
24
|
+
* clicking one could only ever repeat the path result - see `Sidebar`. */
|
|
25
|
+
export type Selection = string | undefined
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What the sidebar shows.
|
|
29
|
+
*
|
|
30
|
+
* `path` is ALWAYS present: whether traffic got through, from where, with what
|
|
31
|
+
* caveats, and what to do next. That question must never require a click - it
|
|
32
|
+
* is the reason the tab exists. `resource` is ADDITIVE, appearing when a node
|
|
33
|
+
* is selected, and never replaces the diagnosis.
|
|
34
|
+
*/
|
|
35
|
+
export interface Sidebar {
|
|
36
|
+
path: {
|
|
37
|
+
chipTone: SevTone
|
|
38
|
+
chipText: string
|
|
39
|
+
title: string
|
|
40
|
+
/** The concrete path under test. Always visible: it was inside the collapsed
|
|
41
|
+
* details, so the panel described a result without ever naming what was
|
|
42
|
+
* requested. */
|
|
43
|
+
request?: string
|
|
44
|
+
body: string
|
|
45
|
+
scope: { k: string; v: string }[]
|
|
46
|
+
evidence: { mark: Mark; text: string }[]
|
|
47
|
+
notProve: string[]
|
|
48
|
+
next: { header: string; body: string; blocked?: string; ctas: InspectorCTA[]; quiet?: boolean }
|
|
49
|
+
}
|
|
50
|
+
/** Every hop on the selected path, in order - the whole story for this path
|
|
51
|
+
* seen from this vantage, rather than a summary plus whichever node was last
|
|
52
|
+
* clicked. Reading beat clicking: understanding a path used to take three
|
|
53
|
+
* clicks whose panel meant something different after each one. */
|
|
54
|
+
hops: HopReport[]
|
|
55
|
+
/** Configured-but-bypassed resources: on screen for orientation, outside the
|
|
56
|
+
* journey. The label says WHY they are not part of this vantage's path. */
|
|
57
|
+
context?: { label: string; hops: HopReport[] }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface HopDetail {
|
|
61
|
+
kind: string
|
|
62
|
+
name: string
|
|
63
|
+
chipTone: SevTone
|
|
64
|
+
chipText: string
|
|
65
|
+
body: string
|
|
66
|
+
facts: { k: string; v: string }[]
|
|
67
|
+
rows?: { mark: Mark; name: string; detail: string }[]
|
|
68
|
+
moreRows?: number
|
|
69
|
+
anomalies?: { mark: Mark; text: string }[]
|
|
70
|
+
notProve: string[]
|
|
71
|
+
openRef?: ResourceRef
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Configured-but-bypassed resources, grouped under one label naming WHY they
|
|
75
|
+
* are outside this vantage's journey. Their config sections stay readable;
|
|
76
|
+
* their journey-state words do not apply. */
|
|
77
|
+
function contextGroup(ctx: Ctx, byId: Map<string, GraphNode>): Sidebar['context'] {
|
|
78
|
+
const nodes = (ctx.contextNodeIds ?? []).map((id) => byId.get(id)).filter((n): n is GraphNode => !!n)
|
|
79
|
+
if (nodes.length === 0) return undefined
|
|
80
|
+
const label =
|
|
81
|
+
ctx.origin.id === 'apiserver'
|
|
82
|
+
? 'CONFIGURED ENTRY — KUBERNETES RELAYED THE REQUEST PAST THIS'
|
|
83
|
+
: ctx.origin.id === 'incluster' || ctx.origin.id === 'radar-incluster'
|
|
84
|
+
? 'CONFIGURED ENTRY — THE PROBE DIALS THE SERVICE DIRECTLY'
|
|
85
|
+
: 'PARALLEL ENTRY — NOT ON THIS ROUTE\u2019S PATH'
|
|
86
|
+
return {
|
|
87
|
+
label,
|
|
88
|
+
hops: nodes.map((n) => ({
|
|
89
|
+
...resourceSection(n),
|
|
90
|
+
id: n.id,
|
|
91
|
+
state: 'plain' as const,
|
|
92
|
+
chipText: '',
|
|
93
|
+
expanded: false,
|
|
94
|
+
})),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A hop's place in the story.
|
|
100
|
+
*
|
|
101
|
+
* - `break` where the request is known to have stopped. Expanded by default;
|
|
102
|
+
* it is the answer.
|
|
103
|
+
* - `before` reached before that. Collapsed - it worked, so it is context.
|
|
104
|
+
* - `after` never tried, because something earlier stopped. Collapsed, and
|
|
105
|
+
* labelled so its silence is not read as health.
|
|
106
|
+
* - `plain` no break to order around.
|
|
107
|
+
*/
|
|
108
|
+
export type HopState = 'before' | 'break' | 'after' | 'plain'
|
|
109
|
+
|
|
110
|
+
export interface HopReport extends HopDetail {
|
|
111
|
+
id: string
|
|
112
|
+
state: HopState
|
|
113
|
+
expanded: boolean
|
|
114
|
+
/** Set on entry hops when they are parallel members of one entry stage - the
|
|
115
|
+
* note reads "one of N parallel entry points" instead of implying sequence. */
|
|
116
|
+
parallelCount?: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const NOT_DATAPLANE = 'Nothing about the normal network path. Kubernetes relayed this for us, so routing, network policy and the mesh were all skipped.'
|
|
120
|
+
const SYNTHETIC_IDENTITY =
|
|
121
|
+
'That your app can reach it. This test ran from a throwaway Pod under the namespace’s default account with no token mounted — not as your application — so anything that checks who is calling may answer differently.'
|
|
122
|
+
|
|
123
|
+
function originScope(o: Origin, trace: Trace): { k: string; v: string }[] {
|
|
124
|
+
const runsIn: Record<OriginId, string> = {
|
|
125
|
+
incluster: `a throwaway Pod in ${trace.subject.namespace || 'the cluster'}`,
|
|
126
|
+
'radar-incluster': 'Radar\u2019s own Pod',
|
|
127
|
+
apiserver: 'the kube-apiserver process',
|
|
128
|
+
local: 'your workstation (outside the cluster)',
|
|
129
|
+
caller: 'the application workload’s own Pod',
|
|
130
|
+
external: 'a client on the public internet',
|
|
131
|
+
}
|
|
132
|
+
return [
|
|
133
|
+
{ k: 'TESTED FROM', v: o.name },
|
|
134
|
+
{ k: 'RUNS IN', v: runsIn[o.id] },
|
|
135
|
+
// The laptop row's content is where the dial came FROM ("you dialled a
|
|
136
|
+
// public address…"), not who dialled - identity and network position are
|
|
137
|
+
// different facts and must not share a label.
|
|
138
|
+
{ k: o.id === 'local' ? 'NETWORK POSITION' : 'IDENTITY', v: o.identity },
|
|
139
|
+
{ k: 'MECHANISM', v: o.mech },
|
|
140
|
+
]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The next-step prompt, driven by the strongest gap Radar can CLOSE. The
|
|
145
|
+
* unreachable ceiling is stated underneath as a caveat instead of being offered
|
|
146
|
+
* as an action - a button that can never be pressed is not a next step.
|
|
147
|
+
*/
|
|
148
|
+
function gapNext(
|
|
149
|
+
origins: Origin[],
|
|
150
|
+
current: Origin,
|
|
151
|
+
namespace?: string,
|
|
152
|
+
multiPath?: boolean,
|
|
153
|
+
inClusterRunnable = true,
|
|
154
|
+
): Sidebar['path']['next'] {
|
|
155
|
+
// The server can only test a route that carries a concrete in-cluster request.
|
|
156
|
+
// When none does, the run fails with "not supported for this subject" - so the
|
|
157
|
+
// panel must say that HERE, instead of recommending it as the strongest
|
|
158
|
+
// evidence available and letting the operator find out by clicking.
|
|
159
|
+
const notRunnable = 'No path on this resource has a request Radar can send from inside the cluster, so this test would have nothing to run.'
|
|
160
|
+
// No button here. The in-cluster run is offered ON the vantage capsule in the
|
|
161
|
+
// graph, which is the thing that would produce the missing evidence - and a
|
|
162
|
+
// third copy of the same control (header, panel, capsule) was one too many.
|
|
163
|
+
// The panel keeps the REASONING, which is what it is good at.
|
|
164
|
+
const inClusterCTA = (): InspectorCTA[] => []
|
|
165
|
+
const actionable = actionableGap(origins)
|
|
166
|
+
const ceiling = strongestGap(origins)
|
|
167
|
+
const ceilingNote = ceiling?.unsupported ? `Even then, ${ceiling.name.toLowerCase()} stays untested — ${ceiling.unavailable}` : undefined
|
|
168
|
+
const denied = origins.find((o) => o.mark === 'denied')
|
|
169
|
+
// A run is never scoped to the selected path - the server tests every declared
|
|
170
|
+
// path in one pass. Saying so here stops the picker above from reading as a
|
|
171
|
+
// filter on what the button will do.
|
|
172
|
+
const allPaths = multiPath ? ' The test covers every path on this resource, not only this one.' : ''
|
|
173
|
+
|
|
174
|
+
// Mid-run, a running origin is not a "gap" any more, so the no-gap branch
|
|
175
|
+
// below would claim Radar "already has the strongest evidence" BEFORE the
|
|
176
|
+
// result exists. Being in flight is its own state, and it is the whole truth
|
|
177
|
+
// about this panel right now.
|
|
178
|
+
const runningOrigin = origins.find((o) => o.mark === 'running')
|
|
179
|
+
if (runningOrigin) {
|
|
180
|
+
return {
|
|
181
|
+
header: 'TEST RUNNING',
|
|
182
|
+
body: `${runningOrigin.name} is testing now — the result lands here when it finishes.`,
|
|
183
|
+
ctas: [],
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!actionable && denied) {
|
|
188
|
+
const ns = namespace || '<namespace>'
|
|
189
|
+
return {
|
|
190
|
+
header: 'ASK FOR THIS PERMISSION',
|
|
191
|
+
body: `Running an in-cluster test needs \`create\` on \`jobs\` in ${ns}. Grant it, or run the check from a workload you already control.`,
|
|
192
|
+
blocked: denied.unavailable,
|
|
193
|
+
ctas: [{ text: 'Copy the permission check', action: 'copy-command', command: `kubectl auth can-i create jobs -n ${ns}` }],
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (actionable && actionable.id === current.id) {
|
|
197
|
+
// You are looking at the vantage that is itself the gap.
|
|
198
|
+
return {
|
|
199
|
+
header: 'RUN THIS NEXT',
|
|
200
|
+
body: inClusterRunnable
|
|
201
|
+
? `Nothing has been tested from ${actionable.name} yet, and it is the strongest evidence Radar can still collect.${allPaths}`
|
|
202
|
+
: `${notRunnable} Every declared path here was skipped before a request could be formed.`,
|
|
203
|
+
blocked: ceilingNote,
|
|
204
|
+
ctas: actionable.id === 'incluster' ? inClusterCTA() : [{ text: '⟳ Re-run', action: 'run-probes' }],
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!actionable) {
|
|
208
|
+
// Terminal state: there is nothing to do, and a call-to-action box whose
|
|
209
|
+
// message is "no action" shouts its own irrelevance. One quiet line of
|
|
210
|
+
// closure; no Re-run (the header already owns that control - the same
|
|
211
|
+
// one-copy rule as inClusterCTA), and no ceiling caveat (WHAT THIS
|
|
212
|
+
// DOESN'T PROVE states it on this same pane).
|
|
213
|
+
return {
|
|
214
|
+
header: 'NO STRONGER TEST AVAILABLE',
|
|
215
|
+
body: `This is the strongest evidence Radar can collect for this path.${allPaths}`,
|
|
216
|
+
ctas: [],
|
|
217
|
+
quiet: true,
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
header: 'RUN THIS NEXT',
|
|
222
|
+
body:
|
|
223
|
+
actionable.id === 'incluster' && !inClusterRunnable
|
|
224
|
+
? `${notRunnable} Every declared path here was skipped before a request could be formed.`
|
|
225
|
+
: `${actionable.name} has not been used for this path, and is the strongest evidence Radar can still collect.${allPaths}`,
|
|
226
|
+
blocked: ceilingNote,
|
|
227
|
+
ctas: actionable.id === 'incluster' ? inClusterCTA() : [{ text: '⟳ Re-run', action: 'run-probes' }],
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface Ctx {
|
|
232
|
+
trace: Trace
|
|
233
|
+
route?: RouteResult
|
|
234
|
+
origin: Origin
|
|
235
|
+
origins: Origin[]
|
|
236
|
+
nodes: GraphNode[]
|
|
237
|
+
/** Where the path first broke, from the graph model. */
|
|
238
|
+
breakNodeId?: string
|
|
239
|
+
/** A Service-routing boundary: the node whose EXIT the request never got
|
|
240
|
+
* past. The break renders on that hop with exit-phrased copy - no node is
|
|
241
|
+
* blamed. */
|
|
242
|
+
breakAtExitOf?: string
|
|
243
|
+
/** Inline nodes that are NOT network hops (the workload). Never given
|
|
244
|
+
* journey states - a workload is neither "reached" nor a stopping place. */
|
|
245
|
+
nonNetworkNodeIds?: string[]
|
|
246
|
+
/** Configured-but-bypassed entries - context, never the journey. */
|
|
247
|
+
contextNodeIds?: string[]
|
|
248
|
+
/** Non-network nodes spliced into the display after a journey hop. */
|
|
249
|
+
interleave?: { id: string; afterId: string }[]
|
|
250
|
+
/** >1 when the journey's entries are parallel members of one stage. */
|
|
251
|
+
entryParallelCount?: number
|
|
252
|
+
/** The journey's entry-hop node ids, from the graph model. */
|
|
253
|
+
journeyEntryNodeIds?: string[]
|
|
254
|
+
/** The selected route's chain in traversal order, from the graph model. */
|
|
255
|
+
pathNodeIds?: string[]
|
|
256
|
+
stale?: boolean
|
|
257
|
+
running?: boolean
|
|
258
|
+
/** More than one scenario is on screen, so scope has to be stated explicitly. */
|
|
259
|
+
multiPath?: boolean
|
|
260
|
+
/** The HTTP path the run requests, as chosen in "what to test". */
|
|
261
|
+
httpPath?: string
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Whether an in-cluster run has anything to send.
|
|
266
|
+
*
|
|
267
|
+
* The server tests a route only when it carries a concrete InClusterRequest; a
|
|
268
|
+
* route whose probes were all skipped never becomes one, so a subject can offer
|
|
269
|
+
* paths and still have nothing runnable. Knowing this from the trace is what
|
|
270
|
+
* lets the panel say so BEFORE the operator spends a Job on it.
|
|
271
|
+
*/
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
/** Whether the diagnosis says nothing the headline has not already said. Both
|
|
275
|
+
* are generated, so they collide whenever the producer falls back to the same
|
|
276
|
+
* generic sentence - and a banner repeating the line above it reads as a second
|
|
277
|
+
* problem rather than the same one. */
|
|
278
|
+
function restatesTitle(summary: string | undefined, title: string): boolean {
|
|
279
|
+
const norm = (x: string) => x.trim().toLowerCase().replace(/[.!]$/, '')
|
|
280
|
+
return !!summary && !!title && norm(summary) === norm(title)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The persistent diagnosis: did traffic get through, from where, and what next. */
|
|
284
|
+
function pathSection(ctx: Ctx): Sidebar['path'] {
|
|
285
|
+
const { trace, route, origin, origins } = ctx
|
|
286
|
+
// The route outcome is merged across origins. Without this gate the panel
|
|
287
|
+
// rendered another vantage's success under the selected vantage's name - a
|
|
288
|
+
// permanently unavailable origin could read "a real request went through"
|
|
289
|
+
// while the graph beside it said "not routable". Same lie the graph already
|
|
290
|
+
// guards against, in the surface users actually read.
|
|
291
|
+
// This origin's OWN result when the producer sent one; the coarse
|
|
292
|
+
// "did this origin produce anything" gate only remains as the fallback.
|
|
293
|
+
const ev = originRouteEvidence(route, origin.id)
|
|
294
|
+
const asSeen = ev.kind === 'none' ? undefined : ev.result
|
|
295
|
+
// A config-derived break is true of every vantage and observed by none, so it
|
|
296
|
+
// reads as a configuration fact - never as this origin's failed dial.
|
|
297
|
+
const fromConfig = ev.kind === 'config'
|
|
298
|
+
// Which KIND of derived break. Declared-config is broken whatever the cluster
|
|
299
|
+
// is doing; cluster-state is true right now and changes when the workload
|
|
300
|
+
// does. Calling the second one a configuration failure sends the reader to
|
|
301
|
+
// edit YAML when the fix is to get Pods ready.
|
|
302
|
+
const basis = ev.kind === 'config' ? ev.result.basis : undefined
|
|
303
|
+
const hasEvidence = ev.kind === 'own' || (ev.kind === 'rollup' && originProducedEvidence(origin))
|
|
304
|
+
const mark: Mark = fromConfig
|
|
305
|
+
? 'config'
|
|
306
|
+
: hasEvidence
|
|
307
|
+
? asSeen
|
|
308
|
+
? routeMark(asSeen, { stale: ctx.stale, running: ctx.running })
|
|
309
|
+
: 'untested'
|
|
310
|
+
: origin.mark
|
|
311
|
+
|
|
312
|
+
const notProve: string[] = []
|
|
313
|
+
if (origin.kind === 'synthetic') notProve.push(SYNTHETIC_IDENTITY)
|
|
314
|
+
if (origin.kind === 'relayed') notProve.push(NOT_DATAPLANE)
|
|
315
|
+
const hasFrontDoor = (trace.upstreams ?? []).length > 0
|
|
316
|
+
const external = origins.find((o) => o.id === 'external')
|
|
317
|
+
if (hasFrontDoor && external?.unsupported && origin.id !== 'external') {
|
|
318
|
+
// "No request has come in from outside" is only true until Radar's own
|
|
319
|
+
// machine dials the public entry and gets an answer - that request DID come
|
|
320
|
+
// from outside. What stays unproven then is narrower: a real user's request.
|
|
321
|
+
const outsideDialAnswered = (trace.upstreams ?? []).some((h) => {
|
|
322
|
+
const m = hopEvidenceFor(h, { id: 'local' }, trace.runVantage)?.mark
|
|
323
|
+
return m === 'proved' || m === 'answered'
|
|
324
|
+
})
|
|
325
|
+
notProve.push(
|
|
326
|
+
outsideDialAnswered
|
|
327
|
+
? 'That real users can reach it — Radar dialled the public entry from outside and got an answer, but no real user request has been observed.'
|
|
328
|
+
: 'That people on the internet can reach it — no request has come in from outside.',
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const evidence: { mark: Mark; text: string }[] = []
|
|
333
|
+
const seen = new Set<string>()
|
|
334
|
+
const add = (m: Mark, text: string) => {
|
|
335
|
+
const key = text.trim().toLowerCase()
|
|
336
|
+
if (!key || seen.has(key)) return
|
|
337
|
+
seen.add(key)
|
|
338
|
+
evidence.push({ mark: m, text })
|
|
339
|
+
}
|
|
340
|
+
if ((hasEvidence || fromConfig) && asSeen?.evidence) add(mark, asSeen.evidence)
|
|
341
|
+
// What this vantage saw at each HOP. The route is built from the backend's
|
|
342
|
+
// probes, so a laptop that dialled the front door and got an answer had all
|
|
343
|
+
// of it discarded and read as "no test has been run from here" - beside a
|
|
344
|
+
// graph drawing that very dial.
|
|
345
|
+
const hopSeen = ctx.nodes
|
|
346
|
+
.filter((n) => !n.isOrigin && n.hop)
|
|
347
|
+
.map((n) => ({ node: n, ev: hopEvidenceFor(n.hop, origin, trace.runVantage, { stale: ctx.stale, running: ctx.running }) }))
|
|
348
|
+
.filter((x): x is { node: GraphNode; ev: NonNullable<ReturnType<typeof hopEvidenceFor>> } => !!x.ev)
|
|
349
|
+
if (!hasEvidence && !fromConfig) {
|
|
350
|
+
for (const { node, ev: e } of hopSeen) add(e.mark, `${node.kind.toLowerCase()} ${node.name} — ${e.title || e.label}`)
|
|
351
|
+
}
|
|
352
|
+
// The one boundary two observations can establish. Stated as the reasoning
|
|
353
|
+
// that produced it, so it reads as evidence rather than as a verdict.
|
|
354
|
+
if (ev.kind === 'own' && routeForOrigin(route, origin.id)?.failedBoundary === 'service-routing') {
|
|
355
|
+
add('failed', 'the Pods answered directly, but the Service did not — so the Service’s own routing is what breaks')
|
|
356
|
+
}
|
|
357
|
+
// Localization facts are behind-the-gate (apiserver / direct-pod) evidence.
|
|
358
|
+
// They belong to the relayed origin, not to whichever origin is selected -
|
|
359
|
+
// listing them under the in-cluster probe credited it with observations it
|
|
360
|
+
// never made.
|
|
361
|
+
for (const f of origin.id === 'apiserver' && hasEvidence ? route?.localization ?? [] : []) {
|
|
362
|
+
const layer = f.layer.toUpperCase()
|
|
363
|
+
const detail = f.detail?.trim()
|
|
364
|
+
const body = !detail ? layer : detail.toUpperCase().startsWith(layer) ? detail : `${layer} · ${detail}`
|
|
365
|
+
add(f.ok ? 'proxied' : 'failed', `${body} — checked directly, past the entry point`)
|
|
366
|
+
}
|
|
367
|
+
if (evidence.length === 0) {
|
|
368
|
+
// A route the run SKIPPED still carries why. The reason is looked up from
|
|
369
|
+
// the skip rows by the exact identity that produced it, so it appears only
|
|
370
|
+
// under the vantage whose dial was skipped - never charging the in-cluster
|
|
371
|
+
// probe with the proxy's limits, or the laptop with the proxy's timeouts.
|
|
372
|
+
const skipReason = ev.kind === 'rollup' && asSeen?.outcome === 'not-tested' ? originSkipReason(trace, origin.id, route) : undefined
|
|
373
|
+
// An in-cluster run that was ATTEMPTED and never started is not "no test
|
|
374
|
+
// has been run" - the capsule preserves the attempt and this row must not
|
|
375
|
+
// erase it. The error itself is the observation.
|
|
376
|
+
const attemptError = origin.id === 'incluster' && origin.mark === 'blocked' ? origin.unavailable : undefined
|
|
377
|
+
add(
|
|
378
|
+
mark,
|
|
379
|
+
// The attempt error outranks skip rows: a prior Job's leftover skips
|
|
380
|
+
// would otherwise paint a fresh failed run (image pull, quota) with last
|
|
381
|
+
// run's reason, one pane from a capsule saying "test couldn't run".
|
|
382
|
+
attemptError ||
|
|
383
|
+
skipReason ||
|
|
384
|
+
(origin.unsupported
|
|
385
|
+
? 'Radar cannot test from here, so nothing has been learned this way'
|
|
386
|
+
: origin.mark === 'denied'
|
|
387
|
+
? 'not permitted to run this test'
|
|
388
|
+
: // A vantage that CANNOT be used states why (nothing dialable from
|
|
389
|
+
// the laptop, a skipped mechanism) - "no test has been run" reads
|
|
390
|
+
// as a test someone forgot, which is a different claim.
|
|
391
|
+
origin.unavailable || 'no test has been run from here'),
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const failed = mark === 'failed'
|
|
396
|
+
// Only when it is about THIS path. A trace carries one diagnosis but many
|
|
397
|
+
// routes, so an unattributed or sibling-owned cause must not be rendered as
|
|
398
|
+
// the selected path's - the route's own evidence is what remains true.
|
|
399
|
+
const rawDiagnosis = trace.diagnosis
|
|
400
|
+
const diagnosis = rawDiagnosis && (!rawDiagnosis.route || rawDiagnosis.route === route?.route) ? rawDiagnosis : undefined
|
|
401
|
+
const reachedSomething = !hasEvidence && !fromConfig && hopSeen.some((x) => x.ev.mark !== 'failed')
|
|
402
|
+
const body = basis === 'cluster-state'
|
|
403
|
+
? 'Nothing is ready to serve this path right now, so it cannot work from any vantage. No request was sent to establish that — it is read off the current state of the cluster, and it changes when the workload does.'
|
|
404
|
+
: fromConfig
|
|
405
|
+
? 'The configuration itself is broken, so this path cannot work from any vantage. No request was sent to establish that — it is read off what is declared.'
|
|
406
|
+
: reachedSomething
|
|
407
|
+
? 'This vantage did reach part of the path — see what it saw below. It has no result for this route as a whole, so the end-to-end journey from here is still unproven.'
|
|
408
|
+
: !hasEvidence
|
|
409
|
+
? (origin.unavailable || 'Nothing has been tested from here, so this says nothing about whether traffic gets through.') +
|
|
410
|
+
// When NOTHING was tested anywhere, the health dots are the only colour
|
|
411
|
+
// on the board and read as a passed test - the one state where the
|
|
412
|
+
// dot/line split needs saying out loud, not just in the caption.
|
|
413
|
+
((trace.coverage?.tested ?? 0) === 0 ? ' The dots on the graph show each resource’s own reported health — cluster state, not a test result.' : '')
|
|
414
|
+
: failed
|
|
415
|
+
? 'This is the first confirmed failure. Everything after it was never tried, so there is nothing to report past this point.'
|
|
416
|
+
: mark === 'proved'
|
|
417
|
+
? 'A real request went through and the target answered.'
|
|
418
|
+
: mark === 'proxied'
|
|
419
|
+
? 'Kubernetes relayed a request and the target answered — which shows something is serving, not that the normal path works.'
|
|
420
|
+
: mark === 'untested'
|
|
421
|
+
? 'Nothing has been tried from here yet. Configuration may look right, but that is intent, not proof.'
|
|
422
|
+
: mark === 'stale'
|
|
423
|
+
? 'This result predates a change to the cluster, so it is set aside rather than trusted.'
|
|
424
|
+
: mark === 'running'
|
|
425
|
+
? 'A test is running. Earlier results stay until new ones replace them.'
|
|
426
|
+
: // A proxy-only failure wears the same amber mark as a real answer,
|
|
427
|
+
// but nothing answered - saying "the target answered" here sent
|
|
428
|
+
// the reader to debug an application response that never existed.
|
|
429
|
+
asSeen?.outcome === 'unreachable' && asSeen?.confidence === 'indirect'
|
|
430
|
+
? 'The relayed dial failed. The proxy bypasses the real path, so this does not condemn it — but nothing answered, and the real path is still untested.'
|
|
431
|
+
: 'The target answered, but not with what was asked for.'
|
|
432
|
+
|
|
433
|
+
return {
|
|
434
|
+
chipTone: asSeen ? routeTone(asSeen, { stale: ctx.stale, running: ctx.running }) : 'unknown',
|
|
435
|
+
chipText: asSeen ? routeChip(asSeen, { stale: ctx.stale, running: ctx.running }) : 'not tested',
|
|
436
|
+
title: `${origin.name} → ${route?.target || trace.subject.name}`,
|
|
437
|
+
request: route ? `${route.route}${ctx.httpPath && ctx.httpPath !== '/' ? ` · HTTP path ${ctx.httpPath}` : ''}` : undefined,
|
|
438
|
+
body,
|
|
439
|
+
scope: [...originScope(origin, trace), ...(route ? [{ k: 'PATH', v: route.route }] : [])],
|
|
440
|
+
evidence,
|
|
441
|
+
notProve,
|
|
442
|
+
next:
|
|
443
|
+
failed && diagnosis
|
|
444
|
+
? {
|
|
445
|
+
header: 'LIKELY CAUSE',
|
|
446
|
+
body: diagnosis.summary + (diagnosis.nextAction ? ` ${diagnosis.nextAction}` : ''),
|
|
447
|
+
ctas: [
|
|
448
|
+
...(diagnosis.culpritResource ? [{ text: 'Open the culprit', primary: true, action: 'open-resource' as InspectorAction, ref: diagnosis.culpritResource }] : []),
|
|
449
|
+
...(diagnosis.command ? [{ text: 'Copy the command', action: 'copy-command' as InspectorAction, command: diagnosis.command }] : []),
|
|
450
|
+
],
|
|
451
|
+
}
|
|
452
|
+
: gapNext(origins, origin, trace.subject.namespace, ctx.multiPath, traceInClusterRunnable(trace)),
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** The additive detail for a selected node. Never replaces the diagnosis. */
|
|
457
|
+
function resourceSection(node: GraphNode): HopDetail {
|
|
458
|
+
const hop = node.hop
|
|
459
|
+
const findings = hop?.findings ?? []
|
|
460
|
+
|
|
461
|
+
if (node.podRows) {
|
|
462
|
+
const roster = hop?.config?.pods ?? []
|
|
463
|
+
const total = hop?.config?.podTotal ?? roster.length
|
|
464
|
+
const ready = typeof hop?.meta?.ready === 'number' ? (hop.meta.ready as number) : roster.filter((p) => p.ready).length
|
|
465
|
+
const selected = typeof hop?.meta?.selected === 'number' ? (hop.meta.selected as number) : total
|
|
466
|
+
const publishNotReady = !!hop?.meta?.publishNotReadyAddresses
|
|
467
|
+
const notReady = publishNotReady ? [] : roster.filter((p) => !p.ready)
|
|
468
|
+
const omitted = total - roster.length
|
|
469
|
+
const notProve: string[] = []
|
|
470
|
+
if (omitted > 0) notProve.push(`The ${omitted} Pods that were not tested. Untested is not proven.`)
|
|
471
|
+
if (notReady.length > 0) notProve.push(`The ${notReady.length} not-ready Pod${notReady.length > 1 ? 's' : ''} — nothing was sent to them, so nothing was learned.`)
|
|
472
|
+
return {
|
|
473
|
+
kind: 'PODS',
|
|
474
|
+
name: `${ready} of ${selected} eligible`,
|
|
475
|
+
chipTone: node.tone,
|
|
476
|
+
chipText: 'backends',
|
|
477
|
+
body: publishNotReady
|
|
478
|
+
? 'The Pods behind this Service. This Service is set to send traffic to Pods even before they report ready.'
|
|
479
|
+
: 'The Pods behind this Service. Kubernetes only sends traffic to the ones that report ready.',
|
|
480
|
+
facts: [
|
|
481
|
+
{ k: 'MATCHING PODS', v: `${selected}` },
|
|
482
|
+
// Derived from readiness, NOT from observed delivery - "taking traffic"
|
|
483
|
+
// claimed evidence we do not have.
|
|
484
|
+
{ k: 'ELIGIBLE', v: `${ready}` },
|
|
485
|
+
{
|
|
486
|
+
k: 'SITTING OUT',
|
|
487
|
+
v: publishNotReady ? 'none — not-ready Pods get traffic too' : notReady.length > 0 ? `${notReady.length} not ready` : 'none',
|
|
488
|
+
},
|
|
489
|
+
],
|
|
490
|
+
rows: node.podRows,
|
|
491
|
+
moreRows: node.moreRows,
|
|
492
|
+
anomalies: node.anomalies,
|
|
493
|
+
notProve,
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const c = hop?.config
|
|
498
|
+
const facts: { k: string; v: string }[] = []
|
|
499
|
+
if (c?.clusterIP) facts.push({ k: 'CLUSTER IP', v: c.clusterIP })
|
|
500
|
+
if (c?.serviceType) facts.push({ k: 'TYPE', v: c.serviceType })
|
|
501
|
+
if (c?.ports?.length) facts.push({ k: 'PORTS', v: c.ports.map((x) => `${x.port}→${x.targetPort ?? x.port}`).join(', ') })
|
|
502
|
+
if (c?.addresses?.length) facts.push({ k: 'ADDRESS', v: c.addresses.join(', ') })
|
|
503
|
+
if (c?.hostnames?.length) facts.push({ k: 'HOSTS', v: c.hostnames.join(', ') })
|
|
504
|
+
if (c?.selector) facts.push({ k: 'SELECTOR', v: Object.entries(c.selector).map(([k, v]) => `${k}=${v}`).join(', ') })
|
|
505
|
+
if (node.ref?.namespace) facts.push({ k: 'NAMESPACE', v: node.ref.namespace })
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
kind: node.kind,
|
|
509
|
+
name: node.name,
|
|
510
|
+
chipTone: node.tone,
|
|
511
|
+
chipText: node.dim ? 'not on this path' : '',
|
|
512
|
+
body: node.dim
|
|
513
|
+
? 'This entry point is attached to the resource but does not serve the host being tested.'
|
|
514
|
+
: findings.length > 0
|
|
515
|
+
? findings[0].cause || findings[0].message
|
|
516
|
+
: 'Configuration and health of this resource.',
|
|
517
|
+
facts,
|
|
518
|
+
notProve: [],
|
|
519
|
+
openRef: node.ref,
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Builds the sidebar. The diagnosis is always computed; a node selection only
|
|
525
|
+
* appends to it.
|
|
526
|
+
*/
|
|
527
|
+
export function buildSidebar(sel: Selection, ctx: Ctx): Sidebar {
|
|
528
|
+
const path = pathSection(ctx)
|
|
529
|
+
// The selected route's own chain, from the graph's traversal - NOT every node
|
|
530
|
+
// sorted by position. Sorting served siblings up as if they were sequential
|
|
531
|
+
// hops and included branches this route never touches.
|
|
532
|
+
const byId = new Map(ctx.nodes.map((n) => [n.id, n]))
|
|
533
|
+
const journey = (ctx.pathNodeIds ?? [])
|
|
534
|
+
.map((id) => byId.get(id))
|
|
535
|
+
.filter((n): n is GraphNode => !!n && !n.isOrigin)
|
|
536
|
+
// Display order interleaves non-network nodes (the workload) after their
|
|
537
|
+
// parent - present to read, absent from the journey's state machine.
|
|
538
|
+
const chain: GraphNode[] = []
|
|
539
|
+
for (const n of journey) {
|
|
540
|
+
chain.push(n)
|
|
541
|
+
for (const iv of ctx.interleave ?? []) {
|
|
542
|
+
if (iv.afterId === n.id) {
|
|
543
|
+
const wl = byId.get(iv.id)
|
|
544
|
+
if (wl) chain.push(wl)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const entryIds = new Set(ctx.journeyEntryNodeIds ?? [])
|
|
549
|
+
const nonNetwork = new Set(ctx.nonNetworkNodeIds ?? [])
|
|
550
|
+
// A boundary break anchors to the EXIT of its source hop (the Service);
|
|
551
|
+
// breakNodeId anchors to a landing node. Either way one index carries 'break'.
|
|
552
|
+
const anchorId = ctx.breakNodeId ?? ctx.breakAtExitOf
|
|
553
|
+
const breakIdx = anchorId ? chain.findIndex((n) => n.id === anchorId) : -1
|
|
554
|
+
// A break at ONE of N parallel entries orders nothing: a sibling entry is
|
|
555
|
+
// not "before" it, and the hops behind the stage were not necessarily
|
|
556
|
+
// stopped - the request may have gone through a sibling. Serial
|
|
557
|
+
// before/after semantics apply only when the break is outside the stage.
|
|
558
|
+
const breakInParallelStage =
|
|
559
|
+
breakIdx >= 0 && (ctx.entryParallelCount ?? 0) > 1 && entryIds.has(chain[breakIdx]?.id ?? '')
|
|
560
|
+
const stateOf = (i: number, id: string): HopState => {
|
|
561
|
+
// The workload is not a network hop: it is never "reached", never a place
|
|
562
|
+
// a request stops - whatever happens around it.
|
|
563
|
+
if (nonNetwork.has(id)) return 'plain'
|
|
564
|
+
if (breakIdx < 0) return 'plain'
|
|
565
|
+
if (breakInParallelStage) return i === breakIdx ? 'break' : 'plain'
|
|
566
|
+
if (i < breakIdx) return 'before'
|
|
567
|
+
return i === breakIdx ? 'break' : 'after'
|
|
568
|
+
}
|
|
569
|
+
// What to open when the reader has not chosen: the break if there is one,
|
|
570
|
+
// else the destination - the point of the path. Never everything at once,
|
|
571
|
+
// which is how a report becomes a wall.
|
|
572
|
+
const defaultOpen = breakIdx >= 0 ? breakIdx : chain.length - 1
|
|
573
|
+
return {
|
|
574
|
+
path,
|
|
575
|
+
hops: chain.map((n, i) => {
|
|
576
|
+
const section = resourceSection(n)
|
|
577
|
+
return {
|
|
578
|
+
...section,
|
|
579
|
+
id: n.id,
|
|
580
|
+
state: stateOf(i, n.id),
|
|
581
|
+
// Exit-phrased for a boundary break: the SERVICE hop carries it, and
|
|
582
|
+
// "stopped here" would blame the hop itself for its exit's failure.
|
|
583
|
+
chipText:
|
|
584
|
+
i === breakIdx && ctx.breakAtExitOf === n.id
|
|
585
|
+
? 'routing to its Pods breaks just past here'
|
|
586
|
+
: section.chipText,
|
|
587
|
+
parallelCount: entryIds.has(n.id) && (ctx.entryParallelCount ?? 0) > 1 ? ctx.entryParallelCount : undefined,
|
|
588
|
+
expanded: sel ? n.id === sel : i === defaultOpen,
|
|
589
|
+
}
|
|
590
|
+
}),
|
|
591
|
+
context: contextGroup(ctx, byId),
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** The headline verdict band. Derived from the selected scenario, never from an
|
|
596
|
+
* aggregate that could hide a failing route behind passing siblings. */
|
|
597
|
+
const VERDICT_TONE: Record<string, SevTone> = { healthy: 'healthy', degraded: 'degraded', broken: 'unhealthy', unknown: 'unknown' }
|
|
598
|
+
|
|
599
|
+
export function buildVerdict(
|
|
600
|
+
trace: Trace,
|
|
601
|
+
route: RouteResult | undefined,
|
|
602
|
+
opts: { stale?: boolean; running?: boolean; pathLabel?: string; originId?: string; originName?: string } = {},
|
|
603
|
+
): {
|
|
604
|
+
tone: SevTone
|
|
605
|
+
chipText: string
|
|
606
|
+
/** Set when the resource has more than one path, so the reader can tell the
|
|
607
|
+
* route-scoped badge apart from the resource-wide headline beside it. */
|
|
608
|
+
chipScope?: string
|
|
609
|
+
scopeLabel?: string
|
|
610
|
+
title: string
|
|
611
|
+
problem?: string
|
|
612
|
+
body: string
|
|
613
|
+
} {
|
|
614
|
+
// With no route there is nothing to derive a tone from, but the backend has
|
|
615
|
+
// still reached a verdict (e.g. a config fault found without probing). Falling
|
|
616
|
+
// through to 'unknown' showed a grey dot on a resource the tracer called
|
|
617
|
+
// degraded.
|
|
618
|
+
// The band sits directly above the inspector, which reads the SELECTED
|
|
619
|
+
// origin's own result. Leaving the band on the merged rollup let the two
|
|
620
|
+
// contradict each other in adjacent panes - "could not get through" over
|
|
621
|
+
// "got through" - on exactly the disagreeing traces per-vantage evidence
|
|
622
|
+
// exists to represent.
|
|
623
|
+
const seen = opts.originId ? routeAsSeenFrom(route, opts.originId) : route
|
|
624
|
+
// A route exists but this origin has no row: the badge says "not tested",
|
|
625
|
+
// and it must wear the NEUTRAL tone - painting it with the resource-wide
|
|
626
|
+
// verdict colour (amber on a degraded trace) dressed a vantage-scoped
|
|
627
|
+
// "not tested" as a warning about that vantage. The verdict fallback stays
|
|
628
|
+
// for traces with no routes at all (a config fault found without probing).
|
|
629
|
+
const tone: SevTone = opts.running ? 'info' : seen ? routeTone(seen, opts) : route ? 'unknown' : VERDICT_TONE[trace.verdict] ?? 'unknown'
|
|
630
|
+
return {
|
|
631
|
+
tone,
|
|
632
|
+
chipText: opts.running ? 'testing' : seen ? routeChip(seen, opts) : 'not tested',
|
|
633
|
+
// Title, problem and coverage describe the WHOLE resource; tone and chip
|
|
634
|
+
// follow the selected path. With several paths on screen that difference is
|
|
635
|
+
// invisible unless each side says which scope it speaks for.
|
|
636
|
+
// The badge now follows the selected path AND vantage while the headline,
|
|
637
|
+
// problem and coverage stay resource-wide. Both scopes are legitimate; what
|
|
638
|
+
// is not legitimate is leaving the reader to guess which is which, so the
|
|
639
|
+
// badge names its own scope in full.
|
|
640
|
+
// Prefixed here rather than at the render site: with no pathLabel the old
|
|
641
|
+
// template produced the visible "for from Radar on your machine".
|
|
642
|
+
chipScope: [opts.pathLabel ? `for ${opts.pathLabel}` : '', opts.originName ? `from ${opts.originName}` : '']
|
|
643
|
+
.filter(Boolean)
|
|
644
|
+
.join(' · ') || undefined,
|
|
645
|
+
scopeLabel: opts.pathLabel || opts.originName ? 'THIS RESOURCE' : undefined,
|
|
646
|
+
// A stale screen previously led with the old headline ("Reachable...") and
|
|
647
|
+
// then said underneath that the result was excluded. That is a contradiction,
|
|
648
|
+
// not an exclusion.
|
|
649
|
+
title: opts.stale
|
|
650
|
+
? 'This result is out of date — re-test'
|
|
651
|
+
: trace.headline || route?.route || `Reachability · ${trace.subject.name}`,
|
|
652
|
+
// The diagnosis is a named fault with a culprit and a next action - it
|
|
653
|
+
// answers "why not", where the headline only says how much was tested. It
|
|
654
|
+
// is called out rather than rendered as body prose, which made the more
|
|
655
|
+
// important fact read as an explanation of the less important one.
|
|
656
|
+
// A diagnosis that only restates the headline is not a second fact. The
|
|
657
|
+
// banner rendered "couldn't actively test any route from here" directly
|
|
658
|
+
// under a title saying exactly that.
|
|
659
|
+
problem: restatesTitle(
|
|
660
|
+
trace.diagnosis?.summary,
|
|
661
|
+
opts.stale ? '' : trace.headline || route?.route || `Reachability · ${trace.subject.name}`,
|
|
662
|
+
)
|
|
663
|
+
? undefined
|
|
664
|
+
: trace.diagnosis?.summary,
|
|
665
|
+
body: trace.diagnosis ? '' : trace.reason || '',
|
|
666
|
+
}
|
|
667
|
+
}
|