@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.
Files changed (57) 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/topology/layout.worker.ts +2 -1
  7. package/src/components/trace/ReachabilityGraph.tsx +503 -0
  8. package/src/components/trace/ReachabilityView.tsx +972 -0
  9. package/src/components/trace/TracePanel.test.ts +172 -0
  10. package/src/components/trace/TracePanel.tsx +558 -0
  11. package/src/components/trace/TraceSummary.test.ts +213 -0
  12. package/src/components/trace/TraceSummary.tsx +168 -0
  13. package/src/components/trace/inClusterSummary.test.ts +98 -0
  14. package/src/components/trace/inClusterSummary.ts +81 -0
  15. package/src/components/trace/index.ts +19 -0
  16. package/src/components/trace/podReach.test.ts +71 -0
  17. package/src/components/trace/podReach.ts +37 -0
  18. package/src/components/trace/probe-display.test.ts +107 -0
  19. package/src/components/trace/probe-display.ts +37 -0
  20. package/src/components/trace/reachFixtures.ts +261 -0
  21. package/src/components/trace/reachGraphModel.test.ts +1407 -0
  22. package/src/components/trace/reachGraphModel.ts +1521 -0
  23. package/src/components/trace/reachInspector.test.ts +669 -0
  24. package/src/components/trace/reachInspector.ts +667 -0
  25. package/src/components/trace/reachMarks.test.ts +667 -0
  26. package/src/components/trace/reachMarks.ts +579 -0
  27. package/src/components/trace/reachOrigins.test.ts +392 -0
  28. package/src/components/trace/reachOrigins.ts +437 -0
  29. package/src/components/trace/reachVerdict.test.ts +537 -0
  30. package/src/components/trace/reachVerdict.ts +601 -0
  31. package/src/components/trace/traceFingerprint.test.ts +214 -0
  32. package/src/components/trace/traceFingerprint.ts +122 -0
  33. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  34. package/src/components/trace/traceToSubgraph.ts +463 -0
  35. package/src/components/trace/types.ts +389 -0
  36. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  37. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  38. package/src/components/ui/index.ts +1 -0
  39. package/src/components/workload/WorkloadView.tsx +58 -5
  40. package/src/components/workload/index.ts +1 -1
  41. package/src/components/workload/reachabilityTab.test.ts +47 -0
  42. package/src/index.ts +5 -0
  43. package/src/theme/components.css +38 -0
  44. package/src/theme/variables.css +5 -0
  45. package/src/types/core.ts +23 -0
  46. package/src/utils/application-history.test.ts +1 -1
  47. package/src/utils/applications.test.ts +63 -1
  48. package/src/utils/applications.ts +27 -0
  49. package/src/utils/asset-url.test.ts +113 -0
  50. package/src/utils/asset-url.ts +33 -1
  51. package/src/utils/inClusterConsent.test.ts +109 -0
  52. package/src/utils/inClusterConsent.ts +72 -0
  53. package/src/utils/index.ts +2 -0
  54. package/src/utils/navigation.test.ts +61 -0
  55. package/src/utils/navigation.ts +11 -9
  56. package/src/utils/pluralize.test.ts +80 -1
  57. 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",
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.17",
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.24.0",
101
+ "lucide-react": "^1.28.0",
102
102
  "monaco-editor": "0.55.1",
103
- "react": "^19.2.7",
104
- "react-dom": "^19.2.7",
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. `null` means
126
- * the application itself is selected; a key (see `workloadKey`) selects a
127
- * workload scope. */
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 implicitSingleWorkloadKey =
411
- workloads.length === 1 ? workloadKey(workloads[0]) : null;
412
- const rawSelected =
413
- selectedWorkloadKey !== undefined
414
- ? selectedWorkloadKey
415
- : (internalSelected ?? implicitSingleWorkloadKey);
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, selectedWorkload, onSelectWorkload]);
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
- const edgeId = `${source}-${target}-${edge.type}`
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 edgeColor = getEdgeColor(edge.type, isTrafficView)
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
- const animated = enableAnimations && isTrafficView && isTrafficEdge
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