@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.
- package/package.json +2 -2
- package/src/components/charts/PrometheusChartsView.tsx +3 -8
- package/src/components/gitops/GitOpsDetailLayout.tsx +2 -3
- package/src/components/gitops/GitOpsTableView.tsx +2 -4
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +3 -6
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +3 -7
- package/src/components/logs/LogCore.tsx +1 -1
- package/src/components/resources/ResourcesView.tsx +34 -5
- package/src/components/resources/get-pod-phase-display.test.ts +115 -3
- package/src/components/resources/renderers/CronJobRenderer.tsx +14 -7
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +4 -3
- package/src/components/resources/renderers/GRPCRouteRenderer.tsx +9 -15
- package/src/components/resources/renderers/GatewayClassRenderer.tsx +3 -11
- package/src/components/resources/renderers/GatewayRenderer.tsx +11 -35
- package/src/components/resources/renderers/HTTPRouteRenderer.tsx +10 -16
- package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +3 -6
- package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +24 -28
- package/src/components/resources/renderers/IstioGatewayRenderer.tsx +19 -26
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +20 -19
- package/src/components/resources/renderers/JobRenderer.tsx +6 -5
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +9 -11
- package/src/components/resources/renderers/ServiceRenderer.tsx +1 -1
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +6 -12
- package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +11 -10
- package/src/components/resources/renderers/TraefikMiddlewareRenderer.tsx +185 -0
- package/src/components/resources/renderers/TraefikServersTransportRenderer.tsx +84 -0
- package/src/components/resources/renderers/TraefikServiceRenderer.tsx +118 -0
- package/src/components/resources/renderers/TraefikTLSOptionRenderer.tsx +62 -0
- package/src/components/resources/renderers/WorkflowRenderer.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +1 -1
- package/src/components/resources/renderers/badge-no-handrolled.test.tsx +66 -0
- package/src/components/resources/renderers/contour-cells.tsx +6 -5
- package/src/components/resources/renderers/index.ts +4 -0
- package/src/components/resources/renderers/istio-cells.tsx +16 -25
- package/src/components/resources/resource-utils-cnpg.ts +1 -1
- package/src/components/resources/resource-utils-keda.ts +12 -6
- package/src/components/resources/resource-utils.ts +172 -57
- package/src/components/shared/ResourceActionsBar.tsx +1 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +10 -0
- package/src/components/topology/TopologyControls.tsx +41 -9
- package/src/components/topology/TopologyGraph.tsx +20 -7
- package/src/components/topology/layout-elk-graph.test.ts +222 -0
- package/src/components/topology/layout.ts +96 -33
- package/src/components/topology/layout.worker.ts +18 -34
- package/src/components/ui/Badge.tsx +66 -3
- package/src/components/ui/ClusterName.tsx +12 -4
- package/src/components/ui/CodeViewer.tsx +1 -1
- package/src/components/ui/PaneLoader.tsx +6 -1
- package/src/components/ui/RestrictedState.tsx +152 -0
- package/src/components/ui/YamlEditor.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +1 -1
- package/src/types/core.ts +37 -0
- 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
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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 &&
|
|
695
|
+
if (sourceGroup && isGroupCollapsed(sourceGroup)) {
|
|
637
696
|
source = sourceGroup
|
|
638
697
|
}
|
|
639
698
|
|
|
640
699
|
const targetGroup = nodeToGroup.get(target)
|
|
641
|
-
if (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
|
-
//
|
|
869
|
-
//
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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 =
|
|
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
|
|
7
|
-
import
|
|
8
|
-
import
|
|
9
|
-
import
|
|
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
|
|
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
|
|
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
|