@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.5",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/skyhook-io/radar",
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
},
|
|
90
90
|
"devDependencies": {
|
|
91
91
|
"@types/node": "^26.1.1",
|
|
92
|
-
"@types/react": "^19.2.
|
|
92
|
+
"@types/react": "^19.2.18",
|
|
93
93
|
"@types/react-dom": "^19.2.3",
|
|
94
94
|
"@xterm/addon-fit": "^0.11.0",
|
|
95
95
|
"@xterm/addon-web-links": "^0.12.0",
|
|
@@ -98,10 +98,10 @@
|
|
|
98
98
|
"clsx": "^2.1.1",
|
|
99
99
|
"diff": "^9.0.0",
|
|
100
100
|
"elkjs": "^0.11.1",
|
|
101
|
-
"lucide-react": "^1.
|
|
101
|
+
"lucide-react": "^1.28.0",
|
|
102
102
|
"monaco-editor": "0.55.1",
|
|
103
|
-
"react": "^19.2.
|
|
104
|
-
"react-dom": "^19.2.
|
|
103
|
+
"react": "^19.2.8",
|
|
104
|
+
"react-dom": "^19.2.8",
|
|
105
105
|
"typescript": "^6.0.2",
|
|
106
106
|
"vitest": "^4.1.10",
|
|
107
107
|
"yaml": "^2.9.0"
|
|
@@ -207,6 +207,52 @@ describe("ApplicationDetail shell", () => {
|
|
|
207
207
|
expect(html).not.toContain("Application views");
|
|
208
208
|
});
|
|
209
209
|
|
|
210
|
+
// A controlled host's only way to say "no workload in the URL" is null, so
|
|
211
|
+
// without this the application scope a single-workload app must not have
|
|
212
|
+
// becomes the default for every URL-driven host.
|
|
213
|
+
it("opens the workload directly for controlled single-workload apps with no workload in the URL", () => {
|
|
214
|
+
const singleApp: AppRow = {
|
|
215
|
+
...app,
|
|
216
|
+
workloads: [app.workloads[0]],
|
|
217
|
+
};
|
|
218
|
+
const html = renderDetail({
|
|
219
|
+
app: singleApp,
|
|
220
|
+
selectedWorkloadKey: null,
|
|
221
|
+
onSelectWorkload: () => {},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(html).toContain("Runtime for");
|
|
225
|
+
expect(html).toContain("checkout-api");
|
|
226
|
+
expect(html).not.toContain("Application views");
|
|
227
|
+
expect(html).not.toContain('aria-haspopup="listbox"');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("keeps workload scope for a controlled single-workload app whose URL names a workload it no longer has", () => {
|
|
231
|
+
const singleApp: AppRow = {
|
|
232
|
+
...app,
|
|
233
|
+
workloads: [app.workloads[0]],
|
|
234
|
+
};
|
|
235
|
+
const html = renderDetail({
|
|
236
|
+
app: singleApp,
|
|
237
|
+
selectedWorkloadKey: workloadKey(app.workloads[1]),
|
|
238
|
+
onSelectWorkload: () => {},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(html).toContain("Runtime for");
|
|
242
|
+
expect(html).toContain("checkout-api");
|
|
243
|
+
expect(html).not.toContain("Application views");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("keeps application scope for a controlled multi-workload app with no workload in the URL", () => {
|
|
247
|
+
const html = renderDetail({
|
|
248
|
+
selectedWorkloadKey: null,
|
|
249
|
+
onSelectWorkload: () => {},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
expect(html).toContain("Application views");
|
|
253
|
+
expect(html).not.toContain("Runtime for");
|
|
254
|
+
});
|
|
255
|
+
|
|
210
256
|
it("renders one filtered application chronology with workload and source actions", () => {
|
|
211
257
|
const html = renderDetail({
|
|
212
258
|
selectedView: "history",
|
|
@@ -71,6 +71,7 @@ import {
|
|
|
71
71
|
sourceReportedHealth,
|
|
72
72
|
sourceSyncHealth,
|
|
73
73
|
servingReadiness,
|
|
74
|
+
resolveAppWorkloadSelection,
|
|
74
75
|
worstHealth,
|
|
75
76
|
appGroupLagMessage,
|
|
76
77
|
compareVersions,
|
|
@@ -122,9 +123,10 @@ export interface AppIdentityInstance {
|
|
|
122
123
|
|
|
123
124
|
/** Workload selection is either fully controlled (key + callback, the host
|
|
124
125
|
* wires it to the URL so back/forward works) or fully internal — providing
|
|
125
|
-
* only half silently freezes the selector, so the types forbid it.
|
|
126
|
-
*
|
|
127
|
-
* workload
|
|
126
|
+
* only half silently freezes the selector, so the types forbid it. A key (see
|
|
127
|
+
* `workloadKey`) selects a workload scope; `null` asks for application scope,
|
|
128
|
+
* which a single-workload app does not have — there the component resolves to
|
|
129
|
+
* the sole workload regardless. See `rawSelected`. */
|
|
128
130
|
type SelectionProps =
|
|
129
131
|
| {
|
|
130
132
|
selectedWorkloadKey: string | null;
|
|
@@ -407,12 +409,13 @@ export function ApplicationDetail({
|
|
|
407
409
|
}, [app.key, selectedView]);
|
|
408
410
|
|
|
409
411
|
const [internalSelected, setInternalSelected] = useState<string | null>(null);
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
const rawSelected =
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
412
|
+
const hostSelected =
|
|
413
|
+
selectedWorkloadKey !== undefined ? selectedWorkloadKey : internalSelected;
|
|
414
|
+
const { selected: rawSelected, hostKeyIsStale } = useMemo(
|
|
415
|
+
() =>
|
|
416
|
+
resolveAppWorkloadSelection(workloads.map(workloadKey), hostSelected),
|
|
417
|
+
[workloads, hostSelected],
|
|
418
|
+
);
|
|
416
419
|
const setSelected = useCallback(
|
|
417
420
|
(key: string | null, options?: AppWorkloadSelectionOptions) =>
|
|
418
421
|
onSelectWorkload
|
|
@@ -425,14 +428,10 @@ export function ApplicationDetail({
|
|
|
425
428
|
: undefined;
|
|
426
429
|
const singleWorkloadScope = workloads.length === 1 && !!selectedWorkload;
|
|
427
430
|
useEffect(() => {
|
|
428
|
-
if (
|
|
429
|
-
selectedWorkloadKey !== undefined &&
|
|
430
|
-
selectedWorkloadKey !== null &&
|
|
431
|
-
!selectedWorkload
|
|
432
|
-
) {
|
|
431
|
+
if (selectedWorkloadKey !== undefined && hostKeyIsStale) {
|
|
433
432
|
onSelectWorkload?.(null);
|
|
434
433
|
}
|
|
435
|
-
}, [selectedWorkloadKey,
|
|
434
|
+
}, [selectedWorkloadKey, hostKeyIsStale, onSelectWorkload]);
|
|
436
435
|
useEffect(() => {
|
|
437
436
|
if (selectedWorkloadKey === undefined) setInternalSelected(null);
|
|
438
437
|
}, [app.key, selectedWorkloadKey]);
|
|
@@ -282,6 +282,11 @@ function getSubtitle(
|
|
|
282
282
|
kind: NodeKind,
|
|
283
283
|
nodeData: Record<string, unknown>,
|
|
284
284
|
): string {
|
|
285
|
+
// An explicit override wins over the per-kind computed subtitle: the Reachability
|
|
286
|
+
// diagram precomputes a functional one-liner (port→targetPort, readiness) the
|
|
287
|
+
// generic per-kind logic can't know. '' is a deliberate "no subtitle", honored as
|
|
288
|
+
// such. App-topology nodes never set this, so their behavior is unchanged.
|
|
289
|
+
if (typeof nodeData.subtitleOverride === 'string') return nodeData.subtitleOverride;
|
|
285
290
|
const base = baseSubtitle(kind, nodeData);
|
|
286
291
|
const ps = nodeData.podSummary as PodSummary | undefined;
|
|
287
292
|
if (ps && SUMMARY_POD_KINDS.has(kind)) {
|
|
@@ -83,6 +83,31 @@ function getEdgeStyle(type: string, isTrafficView: boolean, isTrafficEdge: boole
|
|
|
83
83
|
return style
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Reachability outcome → edge color/dash, set by the Reachability view via
|
|
87
|
+
// TopologyEdge.reachOutcome (distinct from policyEffect, a NetworkPolicy concept).
|
|
88
|
+
// Honest + DISTINCT: "blocked" (a CONSEQUENCE - downstream of a real break) is a gray
|
|
89
|
+
// DASH; "not-tested" (ABSENCE of probe data) is a lighter DOTTED line. Neither is ever
|
|
90
|
+
// a flowing/green edge that implies traffic crosses a break.
|
|
91
|
+
const REACH_COLORS: Record<string, string> = {
|
|
92
|
+
verified: '#22c55e',
|
|
93
|
+
reached: '#22c55e',
|
|
94
|
+
unreachable: '#ef4444',
|
|
95
|
+
blocked: '#94a3b8',
|
|
96
|
+
'not-tested': '#cbd5e1',
|
|
97
|
+
}
|
|
98
|
+
const REACH_DASH: Record<string, string | undefined> = {
|
|
99
|
+
reached: '5 4', // dashed green - reached, but only via the proxy (not real-traffic verified)
|
|
100
|
+
blocked: '6 3', // dashed gray - a consequence of an upstream break
|
|
101
|
+
'not-tested': '2 4', // dotted - we have no probe data, not a failure
|
|
102
|
+
}
|
|
103
|
+
function reachEdgeStyle(o: string): React.CSSProperties {
|
|
104
|
+
return {
|
|
105
|
+
stroke: REACH_COLORS[o] || '#94a3b8',
|
|
106
|
+
strokeWidth: 2,
|
|
107
|
+
strokeDasharray: REACH_DASH[o],
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
86
111
|
// Threshold for disabling edge animations (performance optimization)
|
|
87
112
|
const EDGE_ANIMATION_THRESHOLD = 200
|
|
88
113
|
|
|
@@ -139,14 +164,19 @@ function buildEdges(
|
|
|
139
164
|
// Skip self-loops (both ends in same collapsed group)
|
|
140
165
|
if (source === target) continue
|
|
141
166
|
|
|
142
|
-
// Skip duplicate edges (O(1) with Set)
|
|
143
|
-
|
|
167
|
+
// Skip duplicate edges (O(1) with Set). Include the reachability outcome and
|
|
168
|
+
// label in the key: two route edges between the same source/target/type but
|
|
169
|
+
// with different outcomes (one verified, one unreachable) are DISTINCT routes -
|
|
170
|
+
// collapsing them would drop the failing route from the diagram.
|
|
171
|
+
const edgeId = `${source}-${target}-${edge.type}${edge.reachOutcome ? `-${edge.reachOutcome}` : ''}${edge.label ? `-${edge.label}` : ''}`
|
|
144
172
|
if (seenEdgeIds.has(edgeId)) continue
|
|
145
173
|
seenEdgeIds.add(edgeId)
|
|
146
174
|
|
|
147
|
-
const
|
|
175
|
+
const reach = edge.reachOutcome
|
|
176
|
+
const edgeColor = reach ? (REACH_COLORS[reach] || '#94a3b8') : getEdgeColor(edge.type, isTrafficView)
|
|
148
177
|
const isTrafficEdge = edge.type === 'routes-to' || edge.type === 'exposes'
|
|
149
|
-
|
|
178
|
+
// A reachability edge never animates (a dashed "blocked" must not look like flow).
|
|
179
|
+
const animated = enableAnimations && isTrafficView && isTrafficEdge && !reach
|
|
150
180
|
|
|
151
181
|
edges.push({
|
|
152
182
|
id: edgeId,
|
|
@@ -154,13 +184,19 @@ function buildEdges(
|
|
|
154
184
|
target,
|
|
155
185
|
type: 'smoothstep',
|
|
156
186
|
animated,
|
|
187
|
+
// labelTitle (declared route when the label shows an overridden tested
|
|
188
|
+
// path) renders as a native SVG <title> tooltip inside React Flow's edge
|
|
189
|
+
// text - keeps the declared path available without overwriting the label.
|
|
190
|
+
label: edge.labelTitle
|
|
191
|
+
? (<>{edge.label}<title>{edge.labelTitle}</title></>)
|
|
192
|
+
: (edge.label || undefined),
|
|
157
193
|
markerEnd: {
|
|
158
194
|
type: MarkerType.ArrowClosed,
|
|
159
195
|
color: edgeColor,
|
|
160
196
|
width: 12,
|
|
161
197
|
height: 12,
|
|
162
198
|
},
|
|
163
|
-
style: getEdgeStyle(edge.type, isTrafficView, isTrafficEdge, animated),
|
|
199
|
+
style: reach ? reachEdgeStyle(reach) : getEdgeStyle(edge.type, isTrafficView, isTrafficEdge, animated),
|
|
164
200
|
})
|
|
165
201
|
}
|
|
166
202
|
|
|
@@ -232,6 +268,9 @@ export function TopologyGraph({
|
|
|
232
268
|
const isTrafficView = viewMode === 'traffic'
|
|
233
269
|
const [nodes, setNodes, onNodesChangeBase] = useNodesState([] as Node[])
|
|
234
270
|
const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[])
|
|
271
|
+
// Bumped each time an ELK layout lands; the visual-sync effect depends on it so a
|
|
272
|
+
// probe that arrives mid-layout still gets its fresh styles re-applied afterward.
|
|
273
|
+
const [layoutEpoch, setLayoutEpoch] = useState(0)
|
|
235
274
|
|
|
236
275
|
// Wrap onNodesChange to track user-dragged positions so they survive re-layouts.
|
|
237
276
|
const onNodesChange = useCallback((changes: NodeChange<Node>[]) => {
|
|
@@ -736,6 +775,7 @@ export function TopologyGraph({
|
|
|
736
775
|
)
|
|
737
776
|
setEdges(builtEdges)
|
|
738
777
|
}
|
|
778
|
+
if (groupingMode === 'none') setLayoutEpoch(e => e + 1)
|
|
739
779
|
}).catch((err) => {
|
|
740
780
|
console.error('[TopologyGraph] Layout post-processing error:', err)
|
|
741
781
|
setLayoutError(err instanceof Error ? err.message : String(err))
|
|
@@ -746,6 +786,46 @@ export function TopologyGraph({
|
|
|
746
786
|
// when the actual structure hasn't changed
|
|
747
787
|
}, [workingNodes, workingEdges, structureKey, groupingMode, hideGroupHeader, collapsedGroups, groupLevels, handleSetLevel, handleCardClick, onMaximizeNamespace, isTrafficView, expandedPodGroups, handleExpandPodGroup, handleCollapsePodGroup, onToggleReplicaSets, setNodes, setEdges, layoutRetryCount])
|
|
748
788
|
|
|
789
|
+
// Visual-only sync - no relayout. The layout effect above is guarded by
|
|
790
|
+
// structureKey, which DELIBERATELY ignores node status + edge reachOutcome (a
|
|
791
|
+
// recolor shouldn't pay for an ELK pass). But the Reachability diagram mutates
|
|
792
|
+
// exactly those: clicking Run recolors an edge (e.g. not-tested → verified)
|
|
793
|
+
// WITHOUT changing structure, so the layout effect skips and the new outcome
|
|
794
|
+
// never reaches the canvas. Re-apply the visual attributes here, in place,
|
|
795
|
+
// preserving ELK positions. Scoped to groupingMode 'none' (the reachability
|
|
796
|
+
// view) - the grouped app topology rebuilds structure on each data refresh and
|
|
797
|
+
// never relies on in-place restyle, so it stays untouched.
|
|
798
|
+
const visualKey = useMemo(() => {
|
|
799
|
+
if (groupingMode !== 'none') return ''
|
|
800
|
+
const nodeStatus = foldHash(workingNodes, n => `${n.id}:${n.status}:${(n.data as { subtitleOverride?: unknown })?.subtitleOverride ?? ''}`)
|
|
801
|
+
const edgeReach = foldHash(workingEdges, e => `${e.source}->${e.target}:${e.reachOutcome ?? ''}:${e.label ?? ''}`)
|
|
802
|
+
return `${nodeStatus}|${edgeReach}`
|
|
803
|
+
}, [groupingMode, workingNodes, workingEdges])
|
|
804
|
+
|
|
805
|
+
useEffect(() => {
|
|
806
|
+
if (groupingMode !== 'none' || prevStructureRef.current === '') return
|
|
807
|
+
const byId = new Map(workingNodes.map(n => [n.id, n]))
|
|
808
|
+
setNodes(prev => {
|
|
809
|
+
let changed = false
|
|
810
|
+
const next = prev.map(node => {
|
|
811
|
+
const wn = byId.get(node.id)
|
|
812
|
+
if (!wn) return node
|
|
813
|
+
// Re-apply BOTH the status AND the latest node payload (nodeData) so a probe
|
|
814
|
+
// updates the on-canvas subtitle (e.g. "not reached - break upstream") too,
|
|
815
|
+
// not just the dot color. The detail panel reads the topology node directly,
|
|
816
|
+
// but the rendered card reads node.data.nodeData - keep it fresh.
|
|
817
|
+
if (node.data?.status === wn.status && node.data?.nodeData === wn.data) return node
|
|
818
|
+
changed = true
|
|
819
|
+
return { ...node, data: { ...node.data, status: wn.status, nodeData: wn.data } }
|
|
820
|
+
})
|
|
821
|
+
return changed ? next : prev
|
|
822
|
+
})
|
|
823
|
+
setEdges(prev => (prev.length === 0 ? prev : buildEdges(workingEdges, collapsedGroups, groupMapRef.current ?? new Map(), groupingMode, isTrafficView, undefined, prev.length, groupLevels, false)))
|
|
824
|
+
// layoutEpoch is a dep so this re-applies AFTER any in-flight ELK layout lands -
|
|
825
|
+
// a stale layout closure can't leave the canvas painted with pre-probe styles.
|
|
826
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
827
|
+
}, [visualKey, layoutEpoch])
|
|
828
|
+
|
|
749
829
|
// Handle node click
|
|
750
830
|
const handleNodeClick = useCallback(
|
|
751
831
|
(_event: React.MouseEvent, node: Node) => {
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import ELK from 'elkjs/lib/elk-api.js'
|
|
9
9
|
import elkWorkerAlgorithm from 'elkjs/lib/elk-worker.min.js?url'
|
|
10
|
+
import { assetUrl } from '../../utils/asset-url'
|
|
10
11
|
|
|
11
12
|
// Create ELK with explicit worker URL (runs the algorithm in our worker context)
|
|
12
13
|
const elk = new ELK({
|
|
13
|
-
workerUrl: elkWorkerAlgorithm,
|
|
14
|
+
workerUrl: assetUrl(elkWorkerAlgorithm),
|
|
14
15
|
})
|
|
15
16
|
|
|
16
17
|
// ELK options for laying out nodes within a single group
|