@skyhook-io/k8s-ui 1.10.4 → 1.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/topology/K8sResourceNode.tsx +5 -0
  5. package/src/components/topology/TopologyGraph.tsx +85 -5
  6. package/src/components/trace/ReachabilityGraph.tsx +503 -0
  7. package/src/components/trace/ReachabilityView.tsx +972 -0
  8. package/src/components/trace/TracePanel.test.ts +172 -0
  9. package/src/components/trace/TracePanel.tsx +558 -0
  10. package/src/components/trace/TraceSummary.test.ts +213 -0
  11. package/src/components/trace/TraceSummary.tsx +168 -0
  12. package/src/components/trace/inClusterSummary.test.ts +98 -0
  13. package/src/components/trace/inClusterSummary.ts +81 -0
  14. package/src/components/trace/index.ts +19 -0
  15. package/src/components/trace/podReach.test.ts +71 -0
  16. package/src/components/trace/podReach.ts +37 -0
  17. package/src/components/trace/probe-display.test.ts +107 -0
  18. package/src/components/trace/probe-display.ts +37 -0
  19. package/src/components/trace/reachFixtures.ts +261 -0
  20. package/src/components/trace/reachGraphModel.test.ts +1407 -0
  21. package/src/components/trace/reachGraphModel.ts +1521 -0
  22. package/src/components/trace/reachInspector.test.ts +669 -0
  23. package/src/components/trace/reachInspector.ts +667 -0
  24. package/src/components/trace/reachMarks.test.ts +667 -0
  25. package/src/components/trace/reachMarks.ts +579 -0
  26. package/src/components/trace/reachOrigins.test.ts +392 -0
  27. package/src/components/trace/reachOrigins.ts +437 -0
  28. package/src/components/trace/reachVerdict.test.ts +537 -0
  29. package/src/components/trace/reachVerdict.ts +601 -0
  30. package/src/components/trace/traceFingerprint.test.ts +214 -0
  31. package/src/components/trace/traceFingerprint.ts +122 -0
  32. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  33. package/src/components/trace/traceToSubgraph.ts +463 -0
  34. package/src/components/trace/types.ts +389 -0
  35. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  36. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  37. package/src/components/ui/index.ts +1 -0
  38. package/src/components/workload/WorkloadView.tsx +58 -5
  39. package/src/components/workload/index.ts +1 -1
  40. package/src/components/workload/reachabilityTab.test.ts +47 -0
  41. package/src/index.ts +5 -0
  42. package/src/theme/components.css +38 -0
  43. package/src/theme/variables.css +5 -0
  44. package/src/types/core.ts +8 -0
  45. package/src/utils/application-history.test.ts +1 -1
  46. package/src/utils/applications.test.ts +63 -1
  47. package/src/utils/applications.ts +27 -0
  48. package/src/utils/inClusterConsent.test.ts +109 -0
  49. package/src/utils/inClusterConsent.ts +72 -0
  50. package/src/utils/index.ts +1 -0
  51. package/src/utils/navigation.test.ts +61 -0
  52. package/src/utils/navigation.ts +11 -9
  53. package/src/utils/pluralize.test.ts +80 -1
  54. package/src/utils/pluralize.ts +34 -0
@@ -0,0 +1,503 @@
1
+ import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
2
+ import { PILL_MAX_PX, type GraphModel, type GraphNode, type GraphEdge, type LaneBox } from './reachGraphModel'
3
+ import { markStyle, glyphStyle, markHelp, SEV_COLOR, MARK_LEGEND, MARK_CATEGORY_LABEL, type Mark, type MarkCategory } from './reachMarks'
4
+ import { Tooltip } from '../ui/Tooltip'
5
+
6
+ /** Finding severity -> the shared health tones. Findings describe the OBJECT;
7
+ * Marks describe what happened to a request. The two vocabularies stay apart. */
8
+ const FINDING_TONE = { critical: 'unhealthy', warning: 'degraded', info: 'info' } as const
9
+
10
+ /** Upper bound on scale-up. Past this the graph stops reading as a diagram and
11
+ * starts reading as zoomed-in text. */
12
+ const MAX_SCALE = 1.5
13
+ /** Lower bound on scale-down. The graph's smallest type is 8.5px by design, so
14
+ * every point of scale-down comes straight off legibility - at 0.82 it landed
15
+ * near 7px. Below this the canvas keeps its size and the column scrolls
16
+ * instead, which is the lesser evil. */
17
+ const MIN_SCALE = 0.96
18
+ /** Overflow below this is left to the scrollbar - see `clipped` in useFit. */
19
+ const CLIP_HINT_MIN_PX = 48
20
+
21
+ /**
22
+ * Fits the fixed design canvas to whatever width the column actually has.
23
+ *
24
+ * The geometry is hand-laid and must stay proportional - reflowing node
25
+ * positions per breakpoint would destroy the spatial mapping the view teaches.
26
+ * Scaling the whole canvas keeps that mapping while still using the full
27
+ * column, in both directions: narrow columns shrink it instead of showing a
28
+ * scrollbar, wide ones grow it instead of stranding dead space.
29
+ */
30
+ function useFit(canvasW: number, canvasH: number): [React.RefObject<HTMLDivElement | null>, number, number, boolean] {
31
+ const ref = useRef<HTMLDivElement | null>(null)
32
+ const [box, setBox] = useState({ w: 0, h: 0 })
33
+ useLayoutEffect(() => {
34
+ const el = ref.current
35
+ if (!el) return
36
+ // Subtract the inset the canvas is positioned at, so a scaled-up graph does
37
+ // not run under the column's edges.
38
+ const measure = () => setBox({ w: el.clientWidth - 16, h: el.clientHeight - 12 })
39
+ measure()
40
+ const ro = new ResizeObserver(measure)
41
+ ro.observe(el)
42
+ return () => ro.disconnect()
43
+ }, [])
44
+ // Fit to whichever axis binds, so a tall pane grows the graph instead of
45
+ // leaving the space empty.
46
+ const raw = box.w > 0 && box.h > 0 ? Math.min(box.w / canvasW, box.h / canvasH) : 1
47
+ const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, raw))
48
+ // A chain long enough to hit the floor no longer fits, and the node that runs
49
+ // off the right is the DESTINATION - the one the whole diagram is about. The
50
+ // pane scrolls, but a scrollbar alone does not read as "the path continues".
51
+ //
52
+ // Only worth saying when a meaningful amount is hidden: the fade itself
53
+ // obscures content, so firing it for the last few pixels of a node's padding
54
+ // would hide more than it reveals.
55
+ const clipped = box.w > 0 && canvasW * scale - box.w > CLIP_HINT_MIN_PX
56
+ return [ref, scale, box.h > 0 ? box.h / scale : canvasH, clipped]
57
+ }
58
+
59
+ /**
60
+ * The laned canvas. Nodes are absolutely positioned HTML over an SVG edge layer
61
+ * so they can carry real typography and pinned anomalies - an all-SVG graph
62
+ * would force text metrics we cannot control across themes.
63
+ *
64
+ * The canvas is a fixed design size inside a scroll container rather than a
65
+ * fluid one: the layout encodes reading order (origin left, delivery right) and
66
+ * a responsive reflow would break the very spatial mapping it exists to teach.
67
+ */
68
+ export function ReachabilityGraph({
69
+ model,
70
+ selected,
71
+ onSelect,
72
+ onAction,
73
+ }: {
74
+ model: GraphModel
75
+ selected?: string
76
+ onSelect: (id: string) => void
77
+ onAction?: (a: NonNullable<GraphNode['action']>) => void
78
+ }) {
79
+ const { canvas, laneControl, laneData } = model
80
+ const [fitRef, scale, availH, clipped] = useFit(canvas.w, canvas.h)
81
+ // A lane is "these nodes", not "this region" - stretching it to the pane left
82
+ // a tall empty box with the path pinned to its top edge. The card fills the
83
+ // height; the graph is optically centred within its column instead.
84
+ void availH
85
+ return (
86
+ <div className="flex h-full min-w-0 flex-col bg-theme-base">
87
+ {/* The path is wide and short, so the space is used by SCALING the graph
88
+ up to whichever axis binds - not by stretching the lane, which just
89
+ floats the content in an empty band. Whatever height is left over is
90
+ split evenly by centring.
91
+
92
+ No minHeight on the measured element: the ResizeObserver watches it,
93
+ so sizing it from a scale derived from its own height was a feedback
94
+ loop that ratcheted the graph up to the cap. flex-1 inside a
95
+ full-height column already gives it a definite height.
96
+
97
+ The scrollbar gutter is reserved for the same reason: a horizontal
98
+ scrollbar appearing here would shrink clientHeight, change the scale,
99
+ and oscillate. */}
100
+ <div className="relative flex min-h-0 min-w-0 flex-1">
101
+ <div
102
+ ref={fitRef}
103
+ // `safe center` on BOTH axes, not plain centring. Plain `center` in a
104
+ // scroll container pushes the leading edge outside the padding box when
105
+ // the content overflows, and no amount of scrolling brings it back -
106
+ // which is why the origin capsule was clipped off the top of a wide
107
+ // fan-out. `safe` falls back to start exactly when that would happen.
108
+ className="flex min-h-0 min-w-0 flex-1 overflow-auto px-2 py-1.5 [align-items:safe_center] [justify-content:safe_center] [scrollbar-gutter:stable]"
109
+ >
110
+ <div className="relative shrink-0" style={{ width: canvas.w * scale, height: canvas.h * scale }}>
111
+ <div
112
+ className="absolute left-0 top-0"
113
+ style={{ width: canvas.w, height: canvas.h, transform: `scale(${scale})`, transformOrigin: 'top left' }}
114
+ >
115
+ {laneControl && <Lane box={laneControl} />}
116
+ {laneData && <Lane box={laneData} />}
117
+ <div>
118
+ <svg width={canvas.w} height={canvas.h} viewBox={`0 0 ${canvas.w} ${canvas.h}`} className="absolute inset-0" style={{ overflow: 'visible' }}>
119
+ {model.brackets.map((b, i) => (
120
+ <path key={i} d={b.d} fill="none" stroke="var(--border-default)" strokeWidth={1} strokeDasharray="3 3" />
121
+ ))}
122
+ {model.edges.map((e) => {
123
+ const s = markStyle(e.mark)
124
+ // A boundary CONTINUATION extends one observed break across its
125
+ // span - same colour so the span reads as one failure, dashed and
126
+ // lighter so it never claims a second observation.
127
+ const continuation = e.boundary === 'continuation'
128
+ return (
129
+ <path
130
+ key={e.id}
131
+ d={e.d}
132
+ fill="none"
133
+ stroke={s.color}
134
+ strokeWidth={selected === e.id ? s.strokeWidth + 1 : s.strokeWidth}
135
+ strokeDasharray={continuation ? '6 5' : s.dash}
136
+ strokeOpacity={continuation ? 0.6 : s.strokeOpacity}
137
+ strokeLinecap="round"
138
+ className={e.mark === 'running' ? 'reach-edge-testing' : undefined}
139
+ />
140
+ )
141
+ })}
142
+ </svg>
143
+ {/* An edge with no words carries its evidence in its line style alone -
144
+ drawing an empty capsule for it put a bare glyph on top of whichever
145
+ real pill shared that point. */}
146
+ {model.edges
147
+ .filter((e) => !!e.label)
148
+ .map((e) => (
149
+ <EdgePill key={e.id} edge={e} />
150
+ ))}
151
+ {model.nodes.map((n) => (
152
+ <Node key={n.id} node={n} selected={selected === n.id} onSelect={onSelect} onAction={onAction} />
153
+ ))}
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ {clipped && (
159
+ <>
160
+ <div
161
+ aria-hidden
162
+ className="pointer-events-none absolute inset-y-0 right-0 w-10"
163
+ // Not `transparent`: it interpolates through transparent BLACK, which
164
+ // smears grey across a light background. Fade the surface to itself.
165
+ style={{
166
+ background:
167
+ 'linear-gradient(to right, color-mix(in srgb, var(--bg-base) 0%, transparent), var(--bg-base))',
168
+ }}
169
+ />
170
+ <div className="pointer-events-none absolute bottom-1.5 right-2 rounded-full border border-theme-border bg-theme-surface px-1.5 py-0.5 text-[9px] font-semibold text-theme-text-tertiary">
171
+ scroll for the rest of the path →
172
+ </div>
173
+ </>
174
+ )}
175
+ </div>
176
+ <Legend model={model} />
177
+ </div>
178
+ )
179
+ }
180
+
181
+ function Lane({ box }: { box: LaneBox }) {
182
+ return (
183
+ <>
184
+ <div
185
+ className="absolute rounded-[10px]"
186
+ style={{
187
+ left: box.x,
188
+ top: box.y,
189
+ width: box.w,
190
+ height: box.h,
191
+ border: box.dashed ? '1px dashed var(--border-default)' : '1px solid var(--border-subtle)',
192
+ background: `color-mix(in srgb, ${box.color} 4%, transparent)`,
193
+ zIndex: 0,
194
+ }}
195
+ />
196
+ {/* Sibling of the box, not a child: a lane that bounds one narrow node is
197
+ far narrower than its label, and as a child the label was clipped by
198
+ the box. */}
199
+ <Tooltip
200
+ content={box.help}
201
+ wrapperClassName="absolute cursor-help"
202
+ wrapperStyle={{ left: box.x + 12, top: box.y - 8, zIndex: 4 }}
203
+ >
204
+ <span
205
+ className="whitespace-nowrap px-1.5 text-[9px] font-bold tracking-[0.06em] bg-theme-base"
206
+ style={{ color: box.color }}
207
+ >
208
+ {box.label}
209
+ </span>
210
+ </Tooltip>
211
+ </>
212
+ )
213
+ }
214
+
215
+ /** Passive. There is no segment-local evidence behind an edge, so clicking one
216
+ * could only repeat the path result the sidebar already shows permanently. */
217
+ function EdgePill({ edge }: { edge: GraphEdge }) {
218
+ const s = markStyle(edge.mark)
219
+ return (
220
+ <Tooltip
221
+ content={
222
+ <>
223
+ <span className="font-semibold">{edge.title}</span>
224
+ <span className="text-theme-text-tertiary"> — {markHelp(edge.mark)}</span>
225
+ </>
226
+ }
227
+ wrapperClassName="absolute cursor-help"
228
+ wrapperStyle={{ left: edge.px, top: edge.py, transform: 'translate(-50%,-50%)', zIndex: 3, maxWidth: PILL_MAX_PX }}
229
+ >
230
+ {/* Two lines, not one: the pill is capped to fit inside a gutter, which
231
+ leaves room for about ten characters per line. On one line that turned
232
+ every phrase into its first word plus an ellipsis. */}
233
+ <div
234
+ className="flex max-w-full items-start gap-1 rounded-full border border-theme-border bg-theme-surface px-2 py-0.5 text-[10px] font-semibold leading-[1.25] text-theme-text-secondary"
235
+ style={{ boxShadow: '0 1px 2px rgba(0,0,0,.05)' }}
236
+ >
237
+ <span className="mt-px" style={glyphStyle(edge.mark)}>
238
+ {s.glyph}
239
+ </span>
240
+ <span className="line-clamp-2 min-w-0">{edge.label}</span>
241
+ </div>
242
+ </Tooltip>
243
+ )
244
+ }
245
+
246
+ function Node({
247
+ node,
248
+ selected,
249
+ onSelect,
250
+ onAction,
251
+ }: {
252
+ node: GraphNode
253
+ selected: boolean
254
+ onSelect: (id: string) => void
255
+ onAction?: (a: NonNullable<GraphNode['action']>) => void
256
+ }) {
257
+ const isOrigin = node.isOrigin
258
+ // An origin capsule carries exactly one status row; its action shares that
259
+ // line instead of adding a full-width row beneath it.
260
+ const inlineAction = !!(isOrigin && node.action && node.anomalies?.length === 1)
261
+ const style: CSSProperties = {
262
+ left: node.x,
263
+ top: node.y,
264
+ width: node.w,
265
+ // Pin to the height the layout reserved, so a box can never grow past its
266
+ // slot and collide with the row beneath it.
267
+ minHeight: node.h,
268
+ zIndex: isOrigin ? 3 : 2,
269
+ background: isOrigin ? 'var(--bg-elevated)' : 'var(--bg-surface)',
270
+ // The origin is a vantage point, not a Kubernetes object. The dashed
271
+ // capsule is what keeps those two categories from reading as one kind.
272
+ borderRadius: isOrigin ? 22 : 9,
273
+ border: selected
274
+ ? '1.5px solid var(--accent)'
275
+ : isOrigin
276
+ ? `1.5px dashed ${node.lane === 'control' ? 'var(--color-info)' : 'var(--accent)'}`
277
+ : '1px solid var(--border-default)',
278
+ boxShadow: selected ? '0 0 0 3px var(--accent-muted)' : isOrigin ? 'none' : '0 1px 2px rgba(0,0,0,.05)',
279
+ opacity: node.dim ? 0.5 : 1,
280
+ }
281
+ return (
282
+ <div
283
+ role="button"
284
+ tabIndex={0}
285
+ // The visible text is kind + name in separate spans a screen reader joins
286
+ // unpredictably; one explicit name says what this is and (for a vantage
287
+ // capsule) what selecting it does.
288
+ aria-label={isOrigin ? `Vantage: ${node.name} — show what was tested from here` : `${node.kind} ${node.name}`}
289
+ aria-pressed={selected}
290
+ onClick={() => onSelect(node.id)}
291
+ onKeyDown={(e) => {
292
+ if (e.key === 'Enter' || e.key === ' ') {
293
+ e.preventDefault()
294
+ onSelect(node.id)
295
+ }
296
+ }}
297
+ className="absolute cursor-pointer px-2.5 py-1.5 text-left"
298
+ style={style}
299
+ >
300
+ <div className="flex items-center gap-1.5">
301
+ <span className="min-w-0 flex-1 truncate font-mono text-[8.5px] font-bold tracking-[0.06em] text-theme-text-tertiary">{node.kind}</span>
302
+ {node.tag && <TinyTag text={node.tag} tone={node.lane === 'control' ? 'var(--color-info)' : 'var(--color-warning-dark)'} />}
303
+ {!isOrigin && (
304
+ <span
305
+ className="inline-block shrink-0 rounded-full"
306
+ style={{ width: 8, height: 8, background: SEV_COLOR[node.tone] }}
307
+ aria-label={`health ${node.tone}`}
308
+ />
309
+ )}
310
+ </div>
311
+ <Tooltip content={node.name} wrapperClassName="block min-w-0">
312
+ <div className="mt-0.5 truncate font-mono text-[11.5px] font-semibold text-theme-text-primary">{node.name}</div>
313
+ </Tooltip>
314
+ <div className="mt-px text-[10px] leading-[1.35] text-theme-text-tertiary">{node.sub}</div>
315
+ {/* What is actually WRONG with this hop, on the hop. The backend already
316
+ produces a parsed cause per finding; the graph previously spent it on a
317
+ dot colour and made the reader click to learn the rest. Deliberately a
318
+ headline only - the message, action and remediation stay in the
319
+ inspector so this never becomes a place you read paragraphs. */}
320
+ {node.notes && node.notes.length > 0 && (
321
+ <div className="mt-1.5 flex flex-col gap-0.5 border-t border-theme-border-subtle pt-1.5">
322
+ {node.notes.map((n, i) => (
323
+ <Tooltip key={i} content={n.detail || n.text} wrapperClassName="w-full cursor-help">
324
+ <div className="flex w-full items-start gap-1.5">
325
+ <span
326
+ className="mt-1 inline-block shrink-0 rounded-full"
327
+ style={{ width: 6, height: 6, background: SEV_COLOR[FINDING_TONE[n.severity]] }}
328
+ />
329
+ {/* Hard guard: whatever the producer writes, a node note may
330
+ never grow past two lines and push the graph around. */}
331
+ <span className="line-clamp-2 min-w-0 flex-1 text-left text-[9.5px] leading-[1.35] text-theme-text-secondary">
332
+ {n.text}
333
+ </span>
334
+ </div>
335
+ </Tooltip>
336
+ ))}
337
+ </div>
338
+ )}
339
+ {node.anomalies && node.anomalies.length > 0 && (
340
+ <div className="mt-1.5 flex flex-col gap-0.5 border-t border-theme-border-subtle pt-1.5">
341
+ {node.anomalies.map((a, i) => (
342
+ <div key={i} className={`flex gap-1.5 ${inlineAction && i === 0 ? 'items-center' : 'items-baseline'}`}>
343
+ <MarkGlyph mark={a.mark} />
344
+ {/* Rows truncate visually; the full sentence must always be a
345
+ hover away - a cut "reached, redirect…" hid its destination. */}
346
+ <Tooltip content={a.title || a.text} wrapperClassName="min-w-0 flex-1">
347
+ <span className="block truncate text-[9.5px] leading-[1.35] text-theme-text-secondary">{a.text}</span>
348
+ </Tooltip>
349
+ {/* The capsule's single status row and its action share the line -
350
+ a stacked full-width button grew the capsule past the height
351
+ the layout reserved, out the bottom of its lane box. */}
352
+ {inlineAction && i === 0 && (
353
+ <button
354
+ type="button"
355
+ disabled={!!node.action!.disabledReason}
356
+ title={node.action!.disabledReason || node.action!.text}
357
+ onClick={(e) => {
358
+ e.stopPropagation()
359
+ if (!node.action?.disabledReason) onAction?.(node.action!)
360
+ }}
361
+ className="shrink-0 whitespace-nowrap rounded border border-theme-border bg-theme-surface px-1.5 py-0.5 text-[9px] font-semibold text-theme-text-primary transition-colors hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-50"
362
+ >
363
+ ⚗ Run now
364
+ </button>
365
+ )}
366
+ </div>
367
+ ))}
368
+ </div>
369
+ )}
370
+ {/* Per-endpoint delivery, as rows rather than a column of boxes - same
371
+ truth, a fraction of the width, and more of them visible at once. */}
372
+ {node.podRows && node.podRows.length > 0 && (
373
+ <div className="mt-1.5 flex flex-col gap-0.5 border-t border-theme-border-subtle pt-1.5">
374
+ {node.podRows.map((r) => (
375
+ <Tooltip
376
+ key={r.name}
377
+ content={
378
+ <>
379
+ <span className="font-mono font-semibold">{r.name}</span>
380
+ <span className="text-theme-text-tertiary"> — {r.detail} · {markHelp(r.mark)}</span>
381
+ </>
382
+ }
383
+ wrapperClassName="w-full cursor-help"
384
+ >
385
+ <div className="flex w-full items-baseline gap-1.5">
386
+ <span style={glyphStyle(r.mark)}>{markStyle(r.mark).glyph}</span>
387
+ <span className="min-w-0 flex-[2] truncate font-mono text-[9.5px] text-theme-text-secondary">{r.name}</span>
388
+ {/* Was shrink-0, so a long detail could not give way and ran
389
+ straight out of the node. Both sides truncate now; the full
390
+ text is on the hover either way. */}
391
+ <span className="min-w-0 flex-1 truncate text-right text-[9px] text-theme-text-tertiary">{r.detail}</span>
392
+ </div>
393
+ </Tooltip>
394
+ ))}
395
+ {!!node.moreRows && <div className="text-[9px] text-theme-text-tertiary">+{node.moreRows} more not shown</div>}
396
+ </div>
397
+ )}
398
+ {/* Offered where the gap is. Deliberately its own button rather than a
399
+ click on the capsule: selecting a vantage is free, and this creates
400
+ Pods in the user's cluster. Full-width form only when the status row
401
+ could not host it inline. */}
402
+ {node.action && !inlineAction && (
403
+ <button
404
+ type="button"
405
+ disabled={!!node.action.disabledReason}
406
+ title={node.action.disabledReason}
407
+ onClick={(e) => {
408
+ e.stopPropagation()
409
+ if (!node.action?.disabledReason) onAction?.(node.action!)
410
+ }}
411
+ className="mt-1.5 w-full rounded border border-theme-border bg-theme-surface px-2 py-1 text-[9.5px] font-semibold text-theme-text-primary transition-colors hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-50"
412
+ >
413
+ ⚗ {node.action.text}
414
+ </button>
415
+ )}
416
+ </div>
417
+ )
418
+ }
419
+
420
+ export function TinyTag({ text, tone, title }: { text: string; tone: string; title?: string }) {
421
+ const tag = (
422
+ <span
423
+ className="whitespace-nowrap rounded-full px-1.5 py-px text-[8.5px] font-bold tracking-[0.05em]"
424
+ style={{
425
+ color: tone,
426
+ border: `1px solid ${tone}`,
427
+ // Opaque, not tinted-transparent: the origin capsule's dashed border ran
428
+ // straight through the tag when this let the backdrop show.
429
+ background: `color-mix(in srgb, ${tone} 10%, var(--bg-elevated))`,
430
+ }}
431
+ >
432
+ {text}
433
+ </span>
434
+ )
435
+ if (!title) return tag
436
+ return (
437
+ <Tooltip content={title} wrapperClassName="cursor-help">
438
+ {tag}
439
+ </Tooltip>
440
+ )
441
+ }
442
+
443
+ /** A mark's symbol with its meaning on hover. The symbol set is the graph's
444
+ * whole evidence vocabulary and appears in six places; without one component
445
+ * each site had to remember to attach the decoder, and some did not. */
446
+ export function MarkGlyph({ mark }: { mark: Mark }) {
447
+ return (
448
+ <Tooltip content={markHelp(mark)} wrapperClassName="cursor-help">
449
+ <span style={glyphStyle(mark)}>{markStyle(mark).glyph}</span>
450
+ </Tooltip>
451
+ )
452
+ }
453
+
454
+ /**
455
+ * One line, not a symbol table.
456
+ *
457
+ * Every glyph on screen already sits beside its own words - an edge pill, a
458
+ * capsule verdict, a Pod row - and every one carries markHelp on hover. Spelling
459
+ * the vocabulary out again cost a permanent band to repeat what the thing itself
460
+ * says. What is NOT discoverable anywhere is the GRAMMAR: that a dot and a line
461
+ * answer different questions. That stays, with the vocabulary on hover for
462
+ * anyone who wants the whole set.
463
+ */
464
+ function Legend({ model }: { model: GraphModel }) {
465
+ const present = new Set<Mark>([
466
+ ...model.edges.map((e) => e.mark),
467
+ ...model.nodes.flatMap((n) => [...(n.anomalies ?? []).map((a) => a.mark), ...(n.podRows ?? []).map((r) => r.mark)]),
468
+ ])
469
+ const shown = MARK_LEGEND.filter((l) => present.has(l.mark))
470
+ if (shown.length === 0) return null
471
+ // Grouped, not flat: a flat list teaches the vocabulary as one axis, and it
472
+ // is not - what happened, how it was tested, and why nothing ran are
473
+ // different questions.
474
+ const categories = [...new Set(shown.map((l) => l.category))] as MarkCategory[]
475
+ return (
476
+ <div className="mt-0.5 flex items-center justify-end border-t border-theme-border-subtle px-3.5 py-1.5 text-[10.5px] text-theme-text-tertiary">
477
+ <Tooltip
478
+ content={
479
+ <span className="flex flex-col gap-1">
480
+ {categories.map((c) => (
481
+ <span key={c} className="flex flex-col gap-0.5">
482
+ <span className="text-[9px] font-bold uppercase tracking-[0.06em] opacity-70">{MARK_CATEGORY_LABEL[c]}</span>
483
+ {shown
484
+ .filter((l) => l.category === c)
485
+ .map((l) => (
486
+ <span key={l.mark} className="inline-flex items-baseline gap-1.5">
487
+ <span style={glyphStyle(l.mark as Mark)}>{markStyle(l.mark as Mark).glyph}</span>
488
+ {l.text}
489
+ </span>
490
+ ))}
491
+ </span>
492
+ ))}
493
+ </span>
494
+ }
495
+ wrapperClassName="cursor-help"
496
+ >
497
+ {/* The dot IS resource health (findings + readiness), never probe
498
+ outcomes - it must not change when the selected vantage does. */}
499
+ <span className="italic">dot = how this resource is doing · line = did traffic get through, from the selected vantage</span>
500
+ </Tooltip>
501
+ </div>
502
+ )
503
+ }