@skyhook-io/k8s-ui 1.8.4 → 1.8.6

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 (55) hide show
  1. package/package.json +2 -2
  2. package/src/components/charts/PrometheusChartsView.tsx +3 -8
  3. package/src/components/gitops/GitOpsDetailLayout.tsx +2 -3
  4. package/src/components/gitops/GitOpsTableView.tsx +2 -4
  5. package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -6
  6. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +3 -7
  7. package/src/components/logs/LogCore.tsx +1 -1
  8. package/src/components/resources/ResourcesView.tsx +34 -5
  9. package/src/components/resources/get-pod-phase-display.test.ts +115 -3
  10. package/src/components/resources/renderers/CronJobRenderer.tsx +14 -7
  11. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +4 -3
  12. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +9 -15
  13. package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -11
  14. package/src/components/resources/renderers/GatewayRenderer.tsx +11 -35
  15. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +10 -16
  16. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -6
  17. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +24 -28
  18. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +19 -26
  19. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +20 -19
  20. package/src/components/resources/renderers/JobRenderer.tsx +6 -5
  21. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +9 -11
  22. package/src/components/resources/renderers/ServiceRenderer.tsx +1 -1
  23. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +6 -12
  24. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +11 -10
  25. package/src/components/resources/renderers/TraefikMiddlewareRenderer.tsx +185 -0
  26. package/src/components/resources/renderers/TraefikServersTransportRenderer.tsx +84 -0
  27. package/src/components/resources/renderers/TraefikServiceRenderer.tsx +118 -0
  28. package/src/components/resources/renderers/TraefikTLSOptionRenderer.tsx +62 -0
  29. package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -1
  30. package/src/components/resources/renderers/WorkloadRenderer.tsx +1 -1
  31. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +66 -0
  32. package/src/components/resources/renderers/contour-cells.tsx +6 -5
  33. package/src/components/resources/renderers/index.ts +4 -0
  34. package/src/components/resources/renderers/istio-cells.tsx +16 -25
  35. package/src/components/resources/resource-utils-cnpg.ts +1 -1
  36. package/src/components/resources/resource-utils-keda.ts +12 -6
  37. package/src/components/resources/resource-utils.ts +172 -57
  38. package/src/components/shared/ResourceActionsBar.tsx +1 -1
  39. package/src/components/shared/ResourceRendererDispatch.tsx +10 -0
  40. package/src/components/topology/TopologyControls.tsx +41 -9
  41. package/src/components/topology/TopologyGraph.tsx +20 -7
  42. package/src/components/topology/layout-elk-graph.test.ts +222 -0
  43. package/src/components/topology/layout.ts +96 -33
  44. package/src/components/topology/layout.worker.ts +18 -34
  45. package/src/components/ui/Badge.tsx +66 -3
  46. package/src/components/ui/ClusterName.tsx +12 -4
  47. package/src/components/ui/CodeViewer.tsx +1 -1
  48. package/src/components/ui/PaneLoader.tsx +6 -1
  49. package/src/components/ui/RestrictedState.tsx +152 -0
  50. package/src/components/ui/YamlEditor.tsx +1 -1
  51. package/src/components/ui/drawer-components.tsx +1 -1
  52. package/src/components/ui/index.ts +1 -0
  53. package/src/components/workload/WorkloadView.tsx +1 -1
  54. package/src/types/core.ts +37 -0
  55. package/src/utils/asset-url.ts +13 -0
@@ -0,0 +1,222 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest'
2
+ import { buildHierarchicalElkGraph, applyHierarchicalLayout, buildInterGroupEdges, setLayoutEngine, type GroupDisplayLevel } from './layout'
3
+ import type { TopologyNode, TopologyEdge } from '../../types'
4
+
5
+ // Collect every id ELK will see as a layoutable shape: top-level children plus
6
+ // the members of expanded groups. An edge endpoint outside this set is exactly
7
+ // what makes ELK throw "Referenced shape does not exist".
8
+ function validEndpointIds(elkGraph: { children: Array<{ id: string; children?: Array<{ id: string }> }> }): Set<string> {
9
+ const ids = new Set<string>()
10
+ for (const child of elkGraph.children) {
11
+ ids.add(child.id)
12
+ for (const c of child.children ?? []) ids.add(c.id)
13
+ }
14
+ return ids
15
+ }
16
+
17
+ function deployment(ns: string, name: string): TopologyNode {
18
+ return {
19
+ id: `deployment/${ns}/${name}`,
20
+ kind: 'Deployment',
21
+ name,
22
+ status: 'healthy',
23
+ data: { namespace: ns },
24
+ }
25
+ }
26
+
27
+ describe('buildHierarchicalElkGraph — collapse predicate consistency', () => {
28
+ // Reproduces the Resources→Traffic crash: smart-default chipped the namespaces
29
+ // present at the time (app1), then a view switch surfaced a namespace
30
+ // (skyhook-gateway) with no groupLevels entry. Node placement hid its members
31
+ // (treated as collapsed) but the old edge-redirect only fired for groups in
32
+ // collapsedGroups — leaving an edge pointed at a hidden plain node id.
33
+ it('redirects edges into a late-arriving (no-level) group so no edge dangles', () => {
34
+ const nodes: TopologyNode[] = [
35
+ deployment('app1', 'web'),
36
+ deployment('skyhook-gateway', 'skyhook-frpc'),
37
+ ]
38
+ const edges: TopologyEdge[] = [
39
+ { id: 'e1', source: 'deployment/app1/web', target: 'deployment/skyhook-gateway/skyhook-frpc', type: 'routes-to' },
40
+ ]
41
+
42
+ // Smart default chipped app1 only; skyhook-gateway appeared later → no entry.
43
+ const groupLevels = new Map<string, GroupDisplayLevel>([['group-namespace-app1', 'chip']])
44
+ // collapsedGroups mirrors TopologyGraph: only explicit non-'topology' levels.
45
+ const collapsedGroups = new Set<string>(['group-namespace-app1'])
46
+
47
+ // smartDefaultActive=true: the large-cluster chip pass ran, so the
48
+ // late-arriving skyhook-gateway defaults to collapsed.
49
+ const { elkGraph } = buildHierarchicalElkGraph(nodes, edges, 'namespace', collapsedGroups, groupLevels, true)
50
+
51
+ const valid = validEndpointIds(elkGraph)
52
+ for (const edge of elkGraph.edges) {
53
+ expect(valid.has(edge.sources[0]), `source ${edge.sources[0]} must exist`).toBe(true)
54
+ expect(valid.has(edge.targets[0]), `target ${edge.targets[0]} must exist`).toBe(true)
55
+ }
56
+
57
+ // The plain hidden member id must never survive as an endpoint.
58
+ const endpoints = elkGraph.edges.flatMap(e => [...e.sources, ...e.targets])
59
+ expect(endpoints).not.toContain('deployment/skyhook-gateway/skyhook-frpc')
60
+ expect(endpoints).toContain('group-namespace-skyhook-gateway')
61
+ })
62
+
63
+ // Without smart-default (small clusters, manual toggles), collapsing one group
64
+ // must NOT cascade-collapse untouched no-entry groups. app2 has no level entry;
65
+ // since smartDefaultActive is false it stays expanded and its member renders.
66
+ it('does not cascade-collapse no-entry groups when smart-default is inactive', () => {
67
+ const nodes: TopologyNode[] = [
68
+ deployment('app1', 'web'),
69
+ deployment('app2', 'api'),
70
+ ]
71
+ const edges: TopologyEdge[] = [
72
+ { id: 'e1', source: 'deployment/app1/web', target: 'deployment/app2/api', type: 'routes-to' },
73
+ ]
74
+ // User collapsed only app1; app2 untouched (no entry).
75
+ const groupLevels = new Map<string, GroupDisplayLevel>([['group-namespace-app1', 'chip']])
76
+ const collapsedGroups = new Set<string>(['group-namespace-app1'])
77
+
78
+ const { elkGraph } = buildHierarchicalElkGraph(nodes, edges, 'namespace', collapsedGroups, groupLevels, false)
79
+
80
+ // app2 stays expanded with its member as a child.
81
+ const app2 = elkGraph.children.find(c => c.id === 'group-namespace-app2')
82
+ expect(app2?.children?.some(c => c.id === 'deployment/app2/api')).toBe(true)
83
+
84
+ // Edge stays valid: app1 redirected to its chip, app2 member kept (it exists).
85
+ const valid = validEndpointIds(elkGraph)
86
+ for (const edge of elkGraph.edges) {
87
+ expect(valid.has(edge.sources[0])).toBe(true)
88
+ expect(valid.has(edge.targets[0])).toBe(true)
89
+ }
90
+ const endpoints = elkGraph.edges.flatMap(e => [...e.sources, ...e.targets])
91
+ expect(endpoints).toContain('group-namespace-app1')
92
+ expect(endpoints).toContain('deployment/app2/api')
93
+ })
94
+
95
+ it('keeps edges between expanded groups referencing plain member ids', () => {
96
+ const nodes: TopologyNode[] = [
97
+ deployment('app1', 'web'),
98
+ deployment('app2', 'api'),
99
+ ]
100
+ const edges: TopologyEdge[] = [
101
+ { id: 'e1', source: 'deployment/app1/web', target: 'deployment/app2/api', type: 'routes-to' },
102
+ ]
103
+ // Both groups explicitly expanded.
104
+ const groupLevels = new Map<string, GroupDisplayLevel>([
105
+ ['group-namespace-app1', 'topology'],
106
+ ['group-namespace-app2', 'topology'],
107
+ ])
108
+
109
+ const { elkGraph } = buildHierarchicalElkGraph(nodes, edges, 'namespace', new Set(), groupLevels)
110
+
111
+ const valid = validEndpointIds(elkGraph)
112
+ for (const edge of elkGraph.edges) {
113
+ expect(valid.has(edge.sources[0])).toBe(true)
114
+ expect(valid.has(edge.targets[0])).toBe(true)
115
+ }
116
+ const endpoints = elkGraph.edges.flatMap(e => [...e.sources, ...e.targets])
117
+ expect(endpoints).toContain('deployment/app1/web')
118
+ expect(endpoints).toContain('deployment/app2/api')
119
+ })
120
+
121
+ it('produces no dangling endpoints with no grouping', () => {
122
+ const nodes: TopologyNode[] = [deployment('app1', 'web'), deployment('app1', 'api')]
123
+ const edges: TopologyEdge[] = [
124
+ { id: 'e1', source: 'deployment/app1/web', target: 'deployment/app1/api', type: 'routes-to' },
125
+ ]
126
+ const { elkGraph } = buildHierarchicalElkGraph(nodes, edges, 'none', new Set(), new Map())
127
+ const valid = validEndpointIds(elkGraph)
128
+ for (const edge of elkGraph.edges) {
129
+ expect(valid.has(edge.sources[0])).toBe(true)
130
+ expect(valid.has(edge.targets[0])).toBe(true)
131
+ }
132
+ })
133
+ })
134
+
135
+ // Phase-2 meta layout: edges that position groups relative to each other must
136
+ // survive even when both endpoints are already meta nodes (two collapsed chips, or
137
+ // a chip and an ungrouped node). The old branching dropped those, so connected
138
+ // chips weren't pulled together.
139
+ describe('buildInterGroupEdges — meta-graph connectivity', () => {
140
+ const edge = (id: string, source: string, target: string) => ({ id, sources: [source], targets: [target] })
141
+
142
+ it('keeps chip↔chip edges (both endpoints already meta nodes)', () => {
143
+ const metaIds = new Set(['group-namespace-app1', 'group-namespace-app2'])
144
+ // Neither endpoint is an expanded-group member, so nodeToGroup is empty.
145
+ const out = buildInterGroupEdges([edge('e1', 'group-namespace-app1', 'group-namespace-app2')], new Map(), metaIds)
146
+ expect(out).toHaveLength(1)
147
+ expect(out[0]).toMatchObject({ sources: ['group-namespace-app1'], targets: ['group-namespace-app2'] })
148
+ })
149
+
150
+ it('keeps chip↔ungrouped edges', () => {
151
+ const metaIds = new Set(['group-namespace-app1', 'orphan-node'])
152
+ const out = buildInterGroupEdges([edge('e1', 'group-namespace-app1', 'orphan-node')], new Map(), metaIds)
153
+ expect(out).toHaveLength(1)
154
+ })
155
+
156
+ it('normalizes expanded-group members to their group', () => {
157
+ const nodeToGroup = new Map([
158
+ ['deployment/app1/web', 'group-namespace-app1'],
159
+ ['deployment/app2/api', 'group-namespace-app2'],
160
+ ])
161
+ const metaIds = new Set(['group-namespace-app1', 'group-namespace-app2'])
162
+ const out = buildInterGroupEdges([edge('e1', 'deployment/app1/web', 'deployment/app2/api')], nodeToGroup, metaIds)
163
+ expect(out[0]).toMatchObject({ sources: ['group-namespace-app1'], targets: ['group-namespace-app2'] })
164
+ })
165
+
166
+ it('drops intra-group edges and endpoints absent from the meta graph', () => {
167
+ const nodeToGroup = new Map([
168
+ ['deployment/app1/web', 'group-namespace-app1'],
169
+ ['deployment/app1/api', 'group-namespace-app1'],
170
+ ])
171
+ const metaIds = new Set(['group-namespace-app1'])
172
+ // Same group on both ends → intra, skip. And an edge to a non-meta id → skip.
173
+ const out = buildInterGroupEdges([
174
+ edge('e1', 'deployment/app1/web', 'deployment/app1/api'),
175
+ edge('e2', 'group-namespace-app1', 'ghost-node'),
176
+ ], nodeToGroup, metaIds)
177
+ expect(out).toHaveLength(0)
178
+ })
179
+
180
+ it('dedupes edges collapsing to the same meta pair', () => {
181
+ const nodeToGroup = new Map([
182
+ ['deployment/app1/web', 'group-namespace-app1'],
183
+ ['deployment/app1/api', 'group-namespace-app1'],
184
+ ['deployment/app2/x', 'group-namespace-app2'],
185
+ ])
186
+ const metaIds = new Set(['group-namespace-app1', 'group-namespace-app2'])
187
+ const out = buildInterGroupEdges([
188
+ edge('e1', 'deployment/app1/web', 'deployment/app2/x'),
189
+ edge('e2', 'deployment/app1/api', 'deployment/app2/x'),
190
+ ], nodeToGroup, metaIds)
191
+ expect(out).toHaveLength(1)
192
+ })
193
+ })
194
+
195
+ // Render layer: the GroupNode's displayLevel must agree with ELK placement, or a
196
+ // chip renders over its own laid-out children. group.isCollapsed (from the layout
197
+ // engine) is the single source of truth.
198
+ describe('applyHierarchicalLayout — rendered displayLevel matches placement', () => {
199
+ beforeAll(() => setLayoutEngine('main-thread'))
200
+
201
+ const noop = () => {}
202
+ const callbacks = { onSetLevel: noop, onCardClick: noop }
203
+
204
+ it('renders an untouched no-entry group as topology (not chip) on manual collapse', async () => {
205
+ const nodes: TopologyNode[] = [deployment('app1', 'web'), deployment('app2', 'api')]
206
+ const edges: TopologyEdge[] = [
207
+ { id: 'e1', source: 'deployment/app1/web', target: 'deployment/app2/api', type: 'routes-to' },
208
+ ]
209
+ // Small cluster, smart-default inactive: user collapsed only app1.
210
+ const groupLevels = new Map<string, GroupDisplayLevel>([['group-namespace-app1', 'chip']])
211
+ const collapsedGroups = new Set<string>(['group-namespace-app1'])
212
+
213
+ const { elkGraph, groupMap } = buildHierarchicalElkGraph(nodes, edges, 'namespace', collapsedGroups, groupLevels, false)
214
+ const { nodes: rendered } = await applyHierarchicalLayout(
215
+ elkGraph, nodes, edges, groupMap, 'namespace', collapsedGroups, callbacks, false, groupLevels,
216
+ )
217
+
218
+ const groupNode = (id: string) => rendered.find(n => n.id === id && n.type === 'group')
219
+ expect(groupNode('group-namespace-app1')?.data.displayLevel).toBe('chip')
220
+ expect(groupNode('group-namespace-app2')?.data.displayLevel).toBe('topology')
221
+ })
222
+ })
@@ -245,19 +245,11 @@ async function runLayoutOnMainThread(
245
245
 
246
246
  // Phase 2: Build meta-graph and position groups based on inter-group edges
247
247
  // (nodeToGroup was built once above).
248
- const interGroupEdges: ElkEdge[] = []
249
- const seen = new Set<string>()
250
- for (const edge of elkGraph.edges) {
251
- const sg = nodeToGroup.get(edge.sources[0])
252
- const tg = nodeToGroup.get(edge.targets[0])
253
- if (sg && tg && sg !== tg) {
254
- const key = `${sg}->${tg}`
255
- if (!seen.has(key)) { seen.add(key); interGroupEdges.push({ id: `inter-${key}`, sources: [sg], targets: [tg] }) }
256
- } else if ((!sg && tg) || (sg && !tg)) {
257
- const s = sg || edge.sources[0], t = tg || edge.targets[0], key = `${s}->${t}`
258
- if (!seen.has(key)) { seen.add(key); interGroupEdges.push({ id: `inter-${key}`, sources: [s], targets: [t] }) }
259
- }
260
- }
248
+ const metaIds = new Set<string>([
249
+ ...groupLayouts.map(g => g.groupId),
250
+ ...ungroupedNodes.map(n => n.id),
251
+ ])
252
+ const interGroupEdges = buildInterGroupEdges(elkGraph.edges, nodeToGroup, metaIds)
261
253
 
262
254
  const metaResult = await elk.layout({
263
255
  id: 'meta-root',
@@ -323,6 +315,36 @@ interface ElkGraph {
323
315
  edges: ElkEdge[]
324
316
  }
325
317
 
318
+ /**
319
+ * Build the meta-graph edges that position groups relative to each other (Phase 2).
320
+ * Each endpoint is normalized to its meta node: an expanded group's member maps to
321
+ * its group (via nodeToGroup); a collapsed-group chip id or an ungrouped node id is
322
+ * ALREADY a meta node and is used as-is. Edges between two already-meta nodes
323
+ * (chip↔chip, chip↔ungrouped) must still be included — dropping them loses the
324
+ * connectivity ELK uses to place connected groups near each other. Endpoints absent
325
+ * from metaIds are skipped (defensive — keeps the meta graph importable).
326
+ *
327
+ * Mirrored inline in layout.worker.ts, which is intentionally self-contained.
328
+ */
329
+ export function buildInterGroupEdges(
330
+ edges: ElkEdge[],
331
+ nodeToGroup: Map<string, string>,
332
+ metaIds: Set<string>,
333
+ ): ElkEdge[] {
334
+ const out: ElkEdge[] = []
335
+ const seen = new Set<string>()
336
+ for (const edge of edges) {
337
+ const s = nodeToGroup.get(edge.sources[0]) ?? edge.sources[0]
338
+ const t = nodeToGroup.get(edge.targets[0]) ?? edge.targets[0]
339
+ if (s === t || !metaIds.has(s) || !metaIds.has(t)) continue
340
+ const key = `${s}->${t}`
341
+ if (seen.has(key)) continue
342
+ seen.add(key)
343
+ out.push({ id: `inter-${key}`, sources: [s], targets: [t] })
344
+ }
345
+ return out
346
+ }
347
+
326
348
  // Get app label from a node (if it has one)
327
349
  function getAppLabel(node: TopologyNode): string | null {
328
350
  const labels = (node.data.labels as Record<string, string>) || {}
@@ -459,13 +481,41 @@ function pickGroupName(nodes: TopologyNode[]): string {
459
481
  return sorted[0].name
460
482
  }
461
483
 
484
+ /**
485
+ * Whether a group renders as a single collapsed chip/card (members hidden) vs an
486
+ * expanded container. This is the ONE predicate that node placement, ELK edge
487
+ * redirect, and ReactFlow edge building must all agree on — if they diverge, an
488
+ * edge can reference a member hidden inside a chip, which either crashes ELK
489
+ * ("Referenced shape does not exist") or silently drops the rendered edge.
490
+ *
491
+ * - Explicit chip/cardGrid levels are always collapsed (they're in collapsedGroups).
492
+ * - When the smart-default chip pass is active (large clusters), a group with no
493
+ * level entry is a late arrival and defaults to collapsed — e.g. a namespace
494
+ * that only surfaces after switching Resources↔Traffic. When it is NOT active
495
+ * (small clusters, manual per-group toggles), a no-entry group stays expanded,
496
+ * so collapsing one group never cascades to untouched groups.
497
+ */
498
+ export function isGroupEffectivelyCollapsed(
499
+ groupId: string,
500
+ collapsedGroups: Set<string>,
501
+ groupLevels: Map<string, GroupDisplayLevel> | undefined,
502
+ smartDefaultActive: boolean,
503
+ ): boolean {
504
+ if (collapsedGroups.has(groupId)) return true
505
+ if (smartDefaultActive && groupLevels?.get(groupId) !== 'topology') return true
506
+ return false
507
+ }
508
+
462
509
  // Build hierarchical ELK graph with groups containing children
463
510
  export function buildHierarchicalElkGraph(
464
511
  topologyNodes: TopologyNode[],
465
512
  edges: Array<{ id: string; source: string; target: string; type: string }>,
466
513
  groupingMode: GroupingMode,
467
514
  collapsedGroups: Set<string>,
468
- groupLevels?: Map<string, GroupDisplayLevel>
515
+ groupLevels?: Map<string, GroupDisplayLevel>,
516
+ /** True when the large-cluster smart-default chip pass has materialized levels;
517
+ * makes no-entry groups default to collapsed (see isGroupEffectivelyCollapsed). */
518
+ smartDefaultActive = false,
469
519
  ): { elkGraph: ElkGraph; groupMap: Map<string, string[]>; nodeToGroup: Map<string, string> } {
470
520
  const groupMap = new Map<string, string[]>()
471
521
  const nodeToGroup = new Map<string, string>()
@@ -496,6 +546,11 @@ export function buildHierarchicalElkGraph(
496
546
  }
497
547
  }
498
548
 
549
+ // Node placement and edge redirect MUST share this predicate — see
550
+ // isGroupEffectivelyCollapsed for why.
551
+ const isGroupCollapsed = (groupId: string): boolean =>
552
+ isGroupEffectivelyCollapsed(groupId, collapsedGroups, groupLevels, smartDefaultActive)
553
+
499
554
  const children: ElkNode[] = []
500
555
  const processedNodes = new Set<string>()
501
556
 
@@ -516,16 +571,7 @@ export function buildHierarchicalElkGraph(
516
571
  // Create group nodes with children
517
572
  for (const [groupKey, memberIds] of groupMap) {
518
573
  const groupId = `group-${groupingMode}-${groupKey}`
519
- // When groupLevels has entries for the CURRENT grouping mode, any group without
520
- // an explicit 'topology' level is collapsed. This prevents late-arriving namespaces
521
- // from defaulting to expanded and overlapping with collapsed chips.
522
- // Only apply when levels exist for the same grouping prefix — don't let namespace
523
- // levels leak into app/label grouping contexts.
524
- const groupPrefix = `group-${groupingMode}-`
525
- const hasLevelsForCurrentMode = groupLevels && groupLevels.size > 0 &&
526
- [...groupLevels.keys()].some(k => k.startsWith(groupPrefix))
527
- const isCollapsed = collapsedGroups.has(groupId) ||
528
- (hasLevelsForCurrentMode && groupLevels!.get(groupId) !== 'topology')
574
+ const isCollapsed = isGroupCollapsed(groupId)
529
575
 
530
576
  if (isCollapsed) {
531
577
  const displayLevel = groupLevels?.get(groupId) || 'chip'
@@ -623,6 +669,18 @@ export function buildHierarchicalElkGraph(
623
669
  }
624
670
  }
625
671
 
672
+ // Every ELK edge endpoint must reference a node present in the graph (a
673
+ // top-level child, or a member of an expanded group). A single dangling
674
+ // reference makes ELK reject the whole import, blanking the topology — so we
675
+ // drop the stray edge instead. With the unified isGroupCollapsed predicate
676
+ // this should never fire; it's insurance against future placement/redirect
677
+ // drift.
678
+ const validEndpointIds = new Set<string>()
679
+ for (const child of children) {
680
+ validEndpointIds.add(child.id)
681
+ if (child.children) for (const c of child.children) validEndpointIds.add(c.id)
682
+ }
683
+
626
684
  // Build edges, redirecting to groups when collapsed
627
685
  const elkEdges: ElkEdge[] = []
628
686
  const seenEdges = new Set<string>()
@@ -631,17 +689,21 @@ export function buildHierarchicalElkGraph(
631
689
  let source = edge.source
632
690
  let target = edge.target
633
691
 
634
- // Redirect edges to collapsed groups
692
+ // Redirect edges to collapsed groups — same predicate as node placement so
693
+ // an edge never references a member hidden inside a chip.
635
694
  const sourceGroup = nodeToGroup.get(source)
636
- if (sourceGroup && collapsedGroups.has(sourceGroup)) {
695
+ if (sourceGroup && isGroupCollapsed(sourceGroup)) {
637
696
  source = sourceGroup
638
697
  }
639
698
 
640
699
  const targetGroup = nodeToGroup.get(target)
641
- if (targetGroup && collapsedGroups.has(targetGroup)) {
700
+ if (targetGroup && isGroupCollapsed(targetGroup)) {
642
701
  target = targetGroup
643
702
  }
644
703
 
704
+ // Drop edges whose endpoints aren't in the graph (see validEndpointIds)
705
+ if (!validEndpointIds.has(source) || !validEndpointIds.has(target)) continue
706
+
645
707
  // Skip self-loops
646
708
  if (source === target) continue
647
709
 
@@ -865,12 +927,13 @@ export async function applyHierarchicalLayout(
865
927
  positions.set(group.groupId, pos)
866
928
 
867
929
  const { worstStatus, unhealthyCount } = computeGroupHealth(memberIds, nodeMap)
868
- // Default to 'chip' when groupLevels has entries for the current grouping mode,
869
- // so late-arriving namespaces don't render as expanded topology and overlap with chips.
870
- const hasLevelsForMode = groupLevels && groupLevels.size > 0 &&
871
- [...groupLevels.keys()].some(k => k.startsWith(`group-${groupingMode}-`))
872
- const defaultLevel: GroupDisplayLevel = hasLevelsForMode ? 'chip' : 'topology'
873
- const displayLevel: GroupDisplayLevel = groupLevels?.get(group.groupId) || (group.isCollapsed ? 'chip' : defaultLevel)
930
+ // Display level derives from the SAME source of truth as ELK node placement:
931
+ // group.isCollapsed reflects buildHierarchicalElkGraph's isGroupEffectivelyCollapsed
932
+ // decision (an expanded group was laid out with its children). Recomputing a
933
+ // separate "default" here would let the rendered chip disagree with the laid-out
934
+ // children — e.g. a manual single collapse rendering untouched groups as chips
935
+ // while their children are still placed. An explicit level (e.g. cardGrid) wins.
936
+ const displayLevel: GroupDisplayLevel = groupLevels?.get(group.groupId) || (group.isCollapsed ? 'chip' : 'topology')
874
937
 
875
938
  // Compute kind breakdown for collapsed chips
876
939
  const kindCounts: Record<string, number> = {}
@@ -198,44 +198,28 @@ self.onmessage = async (e: MessageEvent<LayoutRequest>) => {
198
198
  }
199
199
 
200
200
  // Phase 2: Build meta-graph and position groups (nodeToGroup built once above).
201
- // Find inter-group edges
202
- const interGroupEdges: ElkEdge[] = []
203
- const seenInterGroupEdges = new Set<string>()
204
-
205
- for (const edge of elkGraph.edges) {
206
- const sourceGroup = nodeToGroup.get(edge.sources[0])
207
- const targetGroup = nodeToGroup.get(edge.targets[0])
208
-
209
- if (sourceGroup && targetGroup && sourceGroup !== targetGroup) {
210
- const edgeKey = `${sourceGroup}->${targetGroup}`
211
- if (!seenInterGroupEdges.has(edgeKey)) {
212
- seenInterGroupEdges.add(edgeKey)
213
- interGroupEdges.push({
214
- id: `inter-${edgeKey}`,
215
- sources: [sourceGroup],
216
- targets: [targetGroup],
217
- })
218
- }
219
- } else if ((!sourceGroup && targetGroup) || (sourceGroup && !targetGroup)) {
220
- const source = sourceGroup || edge.sources[0]
221
- const target = targetGroup || edge.targets[0]
222
- const edgeKey = `${source}->${target}`
223
- if (!seenInterGroupEdges.has(edgeKey)) {
224
- seenInterGroupEdges.add(edgeKey)
225
- interGroupEdges.push({
226
- id: `inter-${edgeKey}`,
227
- sources: [source],
228
- targets: [target],
229
- })
230
- }
231
- }
232
- }
233
-
234
- // Build and layout meta-graph
201
+ // Canonical version: buildInterGroupEdges in layout.ts — kept inline here because
202
+ // the worker is intentionally self-contained. Normalize each endpoint to its meta
203
+ // node (expanded member → its group; a chip id or ungrouped id is already a meta
204
+ // node) and keep edges between two already-meta nodes (chip↔chip, chip↔ungrouped),
205
+ // which the old branching dropped — losing the connectivity used to place groups.
235
206
  const metaChildren: ElkNode[] = [
236
207
  ...groupLayouts.map(g => ({ id: g.groupId, width: g.width, height: g.height })),
237
208
  ...ungroupedNodes.map(n => ({ id: n.id, width: n.width, height: n.height })),
238
209
  ]
210
+ const metaIds = new Set<string>(metaChildren.map(c => c.id))
211
+
212
+ const interGroupEdges: ElkEdge[] = []
213
+ const seenInterGroupEdges = new Set<string>()
214
+ for (const edge of elkGraph.edges) {
215
+ const source = nodeToGroup.get(edge.sources[0]) ?? edge.sources[0]
216
+ const target = nodeToGroup.get(edge.targets[0]) ?? edge.targets[0]
217
+ if (source === target || !metaIds.has(source) || !metaIds.has(target)) continue
218
+ const edgeKey = `${source}->${target}`
219
+ if (seenInterGroupEdges.has(edgeKey)) continue
220
+ seenInterGroupEdges.add(edgeKey)
221
+ interGroupEdges.push({ id: `inter-${edgeKey}`, sources: [source], targets: [target] })
222
+ }
239
223
 
240
224
  const metaGraph: ElkGraph = {
241
225
  id: 'meta-root',
@@ -9,11 +9,31 @@ import type { ReactNode } from 'react'
9
9
  export type BadgeSeverity = 'success' | 'warning' | 'alert' | 'error' | 'info' | 'neutral'
10
10
  export type BadgeSize = 'sm' | 'default'
11
11
 
12
+ // Intent tokens beyond severity/kind. Named by PURPOSE, not hue: two tokens may
13
+ // share a color value today yet stay separate names so a future retune moves
14
+ // exactly the right ones. See DESIGN.md "Badge decision tree".
15
+ //
16
+ // - protocol: transport/scheme tags (HTTP/TCP/UDP/…). Its OWN family because it
17
+ // is a recurring, meaningful axis and must NOT collide with severity hues
18
+ // (e.g. a green "HTTPS" must not read as "success").
19
+ // - note: neutral attention/FYI markers (cross-namespace, wildcard, default,
20
+ // immutable). Distinct from severity-info — it flags "noteworthy", not a status.
21
+ // - accent1/2/3: LOCAL categorical distinction (rw/ro, spot/on-demand,
22
+ // control-plane/worker) where the hue carries no cross-screen meaning — just
23
+ // "tell these sibling options apart". The only place sharing is intended.
24
+ // - structural: ports/paths/hosts/weights/names — neutral data fragments, not a
25
+ // status. Its own name so it can diverge from severity-neutral later.
26
+ export type BadgeTone = 'note' | 'accent1' | 'accent2' | 'accent3' | 'structural'
27
+
12
28
  interface BadgeProps {
13
29
  /** Severity-based coloring (status badges) */
14
30
  severity?: BadgeSeverity
15
31
  /** K8s resource kind coloring (kind badges) */
16
32
  kind?: string
33
+ /** Transport protocol/scheme tag (http/https/tls/tcp/udp/grpc/h2) */
34
+ protocol?: string
35
+ /** Categorical/accent intent tone (see BadgeTone) */
36
+ tone?: BadgeTone
17
37
  /** Explicit color class override (bypasses severity/kind lookup) */
18
38
  colorClass?: string
19
39
  /** Size variant */
@@ -135,12 +155,49 @@ const KIND: Record<string, string> = {
135
155
 
136
156
  const DEFAULT_KIND_COLOR = 'bg-fuchsia-50 text-fuchsia-700 border-fuchsia-200 dark:bg-fuchsia-950/40 dark:text-fuchsia-400 dark:border-fuchsia-800/40'
137
157
 
158
+ // ---------------------------------------------------------------------------
159
+ // PROTOCOL COLORS — transport/scheme tags. Deliberately avoid severity hues
160
+ // (emerald/amber/orange/red/sky) so a protocol never reads as a status.
161
+ // ---------------------------------------------------------------------------
162
+ const PROTOCOL: Record<string, string> = {
163
+ http: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
164
+ http2: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
165
+ h2: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
166
+ h2c: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
167
+ https: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
168
+ tls: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
169
+ tcp: 'bg-indigo-100 text-indigo-700 border-indigo-300 dark:bg-indigo-950/50 dark:text-indigo-400 dark:border-indigo-700/40',
170
+ udp: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
171
+ grpc: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
172
+ 'grpc-web': 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
173
+ }
174
+ const DEFAULT_PROTOCOL_COLOR = 'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-950/50 dark:text-slate-400 dark:border-slate-700/40'
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // TONE COLORS — note / local-categorical accents / structural.
178
+ // ---------------------------------------------------------------------------
179
+ const TONE: Record<BadgeTone, string> = {
180
+ // attention/FYI — distinct from severity so it doesn't read as warning/info
181
+ note: 'bg-violet-100 text-violet-700 border-violet-300 dark:bg-violet-950/50 dark:text-violet-400 dark:border-violet-700/40',
182
+ // three visually-distinct accents for local "tell siblings apart" use
183
+ accent1: 'bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/50 dark:text-blue-400 dark:border-blue-700/40',
184
+ accent2: 'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-300 dark:bg-fuchsia-950/50 dark:text-fuchsia-400 dark:border-fuchsia-700/40',
185
+ accent3: 'bg-teal-100 text-teal-800 border-teal-300 dark:bg-teal-950/50 dark:text-teal-400 dark:border-teal-700/40',
186
+ // neutral data fragment (ports/paths/hosts/names)
187
+ structural: 'bg-theme-elevated text-theme-text-secondary border-theme-border',
188
+ }
189
+
138
190
  // Structure classes
139
191
  const SIZE_CLASSES: Record<BadgeSize, string> = {
140
192
  default: 'badge',
141
193
  sm: 'badge-sm',
142
194
  }
143
195
 
196
+ /** Resolve a protocol/scheme label (case-insensitive) to its color class. */
197
+ export function getProtocolColorClass(protocol: string): string {
198
+ return PROTOCOL[protocol.toLowerCase().trim()] ?? DEFAULT_PROTOCOL_COLOR
199
+ }
200
+
144
201
  /** Resolve kind color with fuzzy matching for plural/lowercase forms */
145
202
  export function getKindColorClass(kind: string): string {
146
203
  // Direct match
@@ -185,8 +242,14 @@ export function getSeverityColorClass(severity: BadgeSeverity): string {
185
242
  * Badge component — the ONE source of truth for badge rendering.
186
243
  * Use severity for status badges, kind for resource type badges, or colorClass for custom.
187
244
  */
188
- export function Badge({ severity, kind, colorClass, size = 'default', className, onClick, title, children }: BadgeProps) {
189
- const color = colorClass ?? (severity ? SEVERITY[severity] : kind ? getKindColorClass(kind) : '')
245
+ export function Badge({ severity, kind, protocol, tone, colorClass, size = 'default', className, onClick, title, children }: BadgeProps) {
246
+ const color =
247
+ colorClass ??
248
+ (severity ? SEVERITY[severity]
249
+ : protocol ? getProtocolColorClass(protocol)
250
+ : tone ? TONE[tone]
251
+ : kind ? getKindColorClass(kind)
252
+ : '')
190
253
  const cls = clsx(SIZE_CLASSES[size], color, className)
191
254
 
192
255
  if (onClick) {
@@ -196,4 +259,4 @@ export function Badge({ severity, kind, colorClass, size = 'default', className,
196
259
  }
197
260
 
198
261
  // Re-export the raw color maps for backwards compat (used by badge-colors.ts consumers)
199
- export { SEVERITY as BADGE_SEVERITY_COLORS, KIND as BADGE_KIND_COLORS }
262
+ export { SEVERITY as BADGE_SEVERITY_COLORS, KIND as BADGE_KIND_COLORS, PROTOCOL as BADGE_PROTOCOL_COLORS, TONE as BADGE_TONE_COLORS }
@@ -3,10 +3,18 @@ import { MiddleEllipsis } from './MiddleEllipsis'
3
3
  import { Tooltip } from './Tooltip'
4
4
  import { parseContextName } from '../../utils/context-name'
5
5
  import type { ParsedContextName } from '../../utils/context-name'
6
- import awsLogo from './provider-logos/aws.png'
7
- import awsLogoDark from './provider-logos/aws-dark.png'
8
- import gcpLogo from './provider-logos/gcp.png'
9
- import azureLogo from './provider-logos/azure.svg'
6
+ import { assetUrl } from '../../utils/asset-url'
7
+ import awsLogoAsset from './provider-logos/aws.png'
8
+ import awsLogoDarkAsset from './provider-logos/aws-dark.png'
9
+ import gcpLogoAsset from './provider-logos/gcp.png'
10
+ import azureLogoAsset from './provider-logos/azure.svg'
11
+
12
+ // assetUrl normalizes the bundler-specific asset-import type (string under Vite,
13
+ // StaticImageData under webpack/Next) to a URL string usable in `<img src>`.
14
+ const awsLogo = assetUrl(awsLogoAsset)
15
+ const awsLogoDark = assetUrl(awsLogoDarkAsset)
16
+ const gcpLogo = assetUrl(gcpLogoAsset)
17
+ const azureLogo = assetUrl(azureLogoAsset)
10
18
 
11
19
  // ClusterName renders a kubectl context string with the meaningful
12
20
  // cluster identity surfaced as primary text and provider/region pushed
@@ -391,7 +391,7 @@ export function CodeViewer({
391
391
  style={{ maxHeight }}
392
392
  >
393
393
  {highlighting ? (
394
- <div className="p-4 text-theme-text-tertiary text-sm font-mono">Loading...</div>
394
+ <div className="p-4 text-theme-text-tertiary text-sm font-mono">Loading…</div>
395
395
  ) : (
396
396
  <div
397
397
  ref={contentRef}
@@ -1,4 +1,9 @@
1
- import radarLoadingIcon from '../../assets/radar/radar-icon-loading.svg'
1
+ import { assetUrl } from '../../utils/asset-url'
2
+ import radarLoadingIconAsset from '../../assets/radar/radar-icon-loading.svg'
3
+
4
+ // assetUrl normalizes the bundler-specific asset-import type (string under Vite,
5
+ // StaticImageData under webpack/Next) to a URL string usable in `<img src>`.
6
+ const radarLoadingIcon = assetUrl(radarLoadingIconAsset)
2
7
 
3
8
  // PaneLoader — center-of-pane loading state. Animated radar icon stacked
4
9
  // above a label so swapping the label across the loading chain doesn't