@skyhook-io/k8s-ui 1.10.4 → 1.10.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/components/applications/ApplicationDetail.test.tsx +46 -0
- package/src/components/applications/ApplicationDetail.tsx +14 -15
- package/src/components/topology/K8sResourceNode.tsx +5 -0
- package/src/components/topology/TopologyGraph.tsx +85 -5
- package/src/components/trace/ReachabilityGraph.tsx +503 -0
- package/src/components/trace/ReachabilityView.tsx +972 -0
- package/src/components/trace/TracePanel.test.ts +172 -0
- package/src/components/trace/TracePanel.tsx +558 -0
- package/src/components/trace/TraceSummary.test.ts +213 -0
- package/src/components/trace/TraceSummary.tsx +168 -0
- package/src/components/trace/inClusterSummary.test.ts +98 -0
- package/src/components/trace/inClusterSummary.ts +81 -0
- package/src/components/trace/index.ts +19 -0
- package/src/components/trace/podReach.test.ts +71 -0
- package/src/components/trace/podReach.ts +37 -0
- package/src/components/trace/probe-display.test.ts +107 -0
- package/src/components/trace/probe-display.ts +37 -0
- package/src/components/trace/reachFixtures.ts +261 -0
- package/src/components/trace/reachGraphModel.test.ts +1407 -0
- package/src/components/trace/reachGraphModel.ts +1521 -0
- package/src/components/trace/reachInspector.test.ts +669 -0
- package/src/components/trace/reachInspector.ts +667 -0
- package/src/components/trace/reachMarks.test.ts +667 -0
- package/src/components/trace/reachMarks.ts +579 -0
- package/src/components/trace/reachOrigins.test.ts +392 -0
- package/src/components/trace/reachOrigins.ts +437 -0
- package/src/components/trace/reachVerdict.test.ts +537 -0
- package/src/components/trace/reachVerdict.ts +601 -0
- package/src/components/trace/traceFingerprint.test.ts +214 -0
- package/src/components/trace/traceFingerprint.ts +122 -0
- package/src/components/trace/traceToSubgraph.test.ts +623 -0
- package/src/components/trace/traceToSubgraph.ts +463 -0
- package/src/components/trace/types.ts +389 -0
- package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
- package/src/components/ui/InClusterConsentDialog.tsx +141 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +58 -5
- package/src/components/workload/index.ts +1 -1
- package/src/components/workload/reachabilityTab.test.ts +47 -0
- package/src/index.ts +5 -0
- package/src/theme/components.css +38 -0
- package/src/theme/variables.css +5 -0
- package/src/types/core.ts +8 -0
- package/src/utils/application-history.test.ts +1 -1
- package/src/utils/applications.test.ts +63 -1
- package/src/utils/applications.ts +27 -0
- package/src/utils/inClusterConsent.test.ts +109 -0
- package/src/utils/inClusterConsent.ts +72 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/navigation.test.ts +61 -0
- package/src/utils/navigation.ts +11 -9
- package/src/utils/pluralize.test.ts +80 -1
- package/src/utils/pluralize.ts +34 -0
|
@@ -0,0 +1,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
|
+
}
|