@skyhook-io/k8s-ui 1.7.12 → 1.7.13
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 +1 -1
- package/src/components/applications/AppChips.tsx +109 -0
- package/src/components/applications/AppTooltips.tsx +199 -0
- package/src/components/applications/ApplicationDetail.tsx +671 -0
- package/src/components/applications/ApplicationsList.tsx +569 -0
- package/src/components/applications/ReadyBar.tsx +22 -0
- package/src/components/applications/index.ts +8 -0
- package/src/components/audit/AuditFindingsTable.tsx +3 -25
- package/src/components/logs/WorkloadLogsViewer.tsx +8 -5
- package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
- package/src/components/shared/DetailShell.tsx +14 -7
- package/src/components/shared/EditableYamlView.tsx +37 -17
- package/src/components/timeline/TimelineList.tsx +3 -32
- package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
- package/src/components/topology/K8sResourceNode.tsx +26 -5
- package/src/components/topology/TopologyGraph.tsx +102 -3
- package/src/components/topology/layout.ts +36 -11
- package/src/components/ui/CenteredEmpty.tsx +27 -0
- package/src/components/ui/SearchBox.tsx +85 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/WorkloadView.tsx +167 -33
- package/src/components/workload/index.ts +1 -1
- package/src/hooks/useKeyboardShortcuts.tsx +3 -1
- package/src/index.ts +4 -0
- package/src/utils/applications.test.ts +207 -0
- package/src/utils/applications.ts +674 -0
- package/src/utils/format.ts +11 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +185 -0
- package/src/utils/topology-neighborhood.ts +262 -0
- package/src/utils/workload-colors.ts +36 -0
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { neighborhoodFor, tagWorkloadOwnership } from './topology-neighborhood'
|
|
3
|
+
import type { Topology, NodeKind, EdgeType } from '../types/core'
|
|
4
|
+
|
|
5
|
+
function node(id: string, kind: string, ns: string, name: string): Topology['nodes'][number] {
|
|
6
|
+
return { id, kind: kind as NodeKind, name, status: 'healthy' as Topology['nodes'][number]['status'], data: { namespace: ns } }
|
|
7
|
+
}
|
|
8
|
+
function edge(source: string, target: string, type: EdgeType): Topology['edges'][number] {
|
|
9
|
+
return { id: `${source}->${target}`, source, target, type }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('neighborhoodFor', () => {
|
|
13
|
+
// Deployment → ReplicaSet → Pod (manages), plus a Service exposing it and a
|
|
14
|
+
// ConfigMap configuring it. All of it is the workload's neighborhood.
|
|
15
|
+
it('includes the ownership chain + attached context', () => {
|
|
16
|
+
const topo: Topology = {
|
|
17
|
+
nodes: [
|
|
18
|
+
node('dep', 'Deployment', 'app', 'web'),
|
|
19
|
+
node('rs', 'ReplicaSet', 'app', 'web-abc'),
|
|
20
|
+
node('pod', 'Pod', 'app', 'web-abc-1'),
|
|
21
|
+
node('svc', 'Service', 'app', 'web'),
|
|
22
|
+
node('cm', 'ConfigMap', 'app', 'web-config'),
|
|
23
|
+
],
|
|
24
|
+
edges: [
|
|
25
|
+
edge('dep', 'rs', 'manages'),
|
|
26
|
+
edge('rs', 'pod', 'manages'),
|
|
27
|
+
edge('svc', 'dep', 'exposes'),
|
|
28
|
+
edge('cm', 'dep', 'configures'),
|
|
29
|
+
],
|
|
30
|
+
}
|
|
31
|
+
const out = neighborhoodFor(topo, [{ kind: 'Deployment', namespace: 'app', name: 'web' }])
|
|
32
|
+
expect(new Set(out.nodes.map((n) => n.id))).toEqual(new Set(['dep', 'rs', 'pod', 'svc', 'cm']))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// The leaf rule: a ConfigMap shared by two unrelated Deployments must NOT
|
|
36
|
+
// bridge the second Deployment into the first's neighborhood.
|
|
37
|
+
it('does not bleed through a shared ConfigMap', () => {
|
|
38
|
+
const topo: Topology = {
|
|
39
|
+
nodes: [
|
|
40
|
+
node('depA', 'Deployment', 'app', 'a'),
|
|
41
|
+
node('depB', 'Deployment', 'app', 'b'),
|
|
42
|
+
node('cm', 'ConfigMap', 'app', 'shared'),
|
|
43
|
+
],
|
|
44
|
+
edges: [
|
|
45
|
+
edge('cm', 'depA', 'configures'),
|
|
46
|
+
edge('cm', 'depB', 'configures'),
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
const out = neighborhoodFor(topo, [{ kind: 'Deployment', namespace: 'app', name: 'a' }])
|
|
50
|
+
const ids = new Set(out.nodes.map((n) => n.id))
|
|
51
|
+
expect(ids.has('depA')).toBe(true)
|
|
52
|
+
expect(ids.has('cm')).toBe(true) // the shared ConfigMap IS shown (context)
|
|
53
|
+
expect(ids.has('depB')).toBe(false) // …but it doesn't drag in the other app
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// A GitOps manager reached upward is a leaf: "managed by" is shown, but its
|
|
57
|
+
// sibling workloads are not pulled in.
|
|
58
|
+
it('does not expand through a GitOps manager to its siblings', () => {
|
|
59
|
+
const topo: Topology = {
|
|
60
|
+
nodes: [
|
|
61
|
+
node('ks', 'Kustomization', 'flux-system', 'apps'),
|
|
62
|
+
node('depA', 'Deployment', 'app', 'a'),
|
|
63
|
+
node('depB', 'Deployment', 'app', 'b'),
|
|
64
|
+
],
|
|
65
|
+
edges: [
|
|
66
|
+
edge('ks', 'depA', 'manages'),
|
|
67
|
+
edge('ks', 'depB', 'manages'),
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
const out = neighborhoodFor(topo, [{ kind: 'Deployment', namespace: 'app', name: 'a' }])
|
|
71
|
+
const ids = new Set(out.nodes.map((n) => n.id))
|
|
72
|
+
expect(ids.has('depA')).toBe(true)
|
|
73
|
+
expect(ids.has('ks')).toBe(true) // the managing Kustomization is shown
|
|
74
|
+
expect(ids.has('depB')).toBe(false) // …but not the Kustomization's other app
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Upward manages is context for ANY manager kind, not just GitOps: a seed
|
|
78
|
+
// Job shows its CronJob ("managed by") without dragging in sibling Jobs.
|
|
79
|
+
it('does not expand upward through a CronJob to sibling Jobs', () => {
|
|
80
|
+
const topo: Topology = {
|
|
81
|
+
nodes: [
|
|
82
|
+
node('cj', 'CronJob', 'app', 'nightly'),
|
|
83
|
+
node('job1', 'Job', 'app', 'nightly-001'),
|
|
84
|
+
node('job2', 'Job', 'app', 'nightly-002'),
|
|
85
|
+
node('pod2', 'Pod', 'app', 'nightly-002-x'),
|
|
86
|
+
],
|
|
87
|
+
edges: [
|
|
88
|
+
edge('cj', 'job1', 'manages'),
|
|
89
|
+
edge('cj', 'job2', 'manages'),
|
|
90
|
+
edge('job2', 'pod2', 'manages'),
|
|
91
|
+
],
|
|
92
|
+
}
|
|
93
|
+
const out = neighborhoodFor(topo, [{ kind: 'Job', namespace: 'app', name: 'nightly-001' }])
|
|
94
|
+
const ids = new Set(out.nodes.map((n) => n.id))
|
|
95
|
+
expect(ids.has('job1')).toBe(true)
|
|
96
|
+
expect(ids.has('cj')).toBe(true) // the managing CronJob is shown…
|
|
97
|
+
expect(ids.has('job2')).toBe(false) // …but its sibling Jobs are not
|
|
98
|
+
expect(ids.has('pod2')).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// The degree guard targets shared infra (routing/context), not ownership: a
|
|
102
|
+
// workload with more than K pods must still keep every pod — the ReplicaSet
|
|
103
|
+
// in between must not be leafed for high manages-fan-out.
|
|
104
|
+
it('keeps all pods of a large workload (degree guard exempts ownership)', () => {
|
|
105
|
+
const pods = Array.from({ length: 10 }, (_, i) => node(`pod${i}`, 'Pod', 'app', `web-${i}`))
|
|
106
|
+
const topo: Topology = {
|
|
107
|
+
nodes: [node('dep', 'Deployment', 'app', 'web'), node('rs', 'ReplicaSet', 'app', 'web-abc'), ...pods],
|
|
108
|
+
edges: [edge('dep', 'rs', 'manages'), ...pods.map((p) => edge('rs', p.id, 'manages'))],
|
|
109
|
+
}
|
|
110
|
+
const out = neighborhoodFor(topo, [{ kind: 'Deployment', namespace: 'app', name: 'web' }])
|
|
111
|
+
const ids = new Set(out.nodes.map((n) => n.id))
|
|
112
|
+
expect(ids.has('rs')).toBe(true)
|
|
113
|
+
for (const p of pods) expect(ids.has(p.id)).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('returns an empty graph with a warning when no seed matches', () => {
|
|
117
|
+
const topo: Topology = { nodes: [node('dep', 'Deployment', 'app', 'web')], edges: [] }
|
|
118
|
+
const out = neighborhoodFor(topo, [{ kind: 'Deployment', namespace: 'app', name: 'missing' }])
|
|
119
|
+
expect(out.nodes).toHaveLength(0)
|
|
120
|
+
expect(out.warnings?.some((w) => w.includes('No topology nodes matched'))).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('tagWorkloadOwnership', () => {
|
|
125
|
+
const dataOf = (t: Topology, id: string) => t.nodes.find((n) => n.id === id)!.data as Record<string, unknown>
|
|
126
|
+
|
|
127
|
+
// Two workloads, each with its own Service + Pod, plus one shared ConfigMap.
|
|
128
|
+
// Each workload owns its exclusive satellites; the shared ConfigMap is neutral.
|
|
129
|
+
it('tags exclusive satellites + pods with their workload, shared as neutral', () => {
|
|
130
|
+
const topo: Topology = {
|
|
131
|
+
nodes: [
|
|
132
|
+
node('depA', 'Deployment', 'app', 'a'),
|
|
133
|
+
node('podA', 'Pod', 'app', 'a-1'),
|
|
134
|
+
node('svcA', 'Service', 'app', 'a'),
|
|
135
|
+
node('depB', 'Deployment', 'app', 'b'),
|
|
136
|
+
node('podB', 'Pod', 'app', 'b-1'),
|
|
137
|
+
node('shared', 'ConfigMap', 'app', 'shared'),
|
|
138
|
+
],
|
|
139
|
+
edges: [
|
|
140
|
+
edge('depA', 'podA', 'manages'),
|
|
141
|
+
edge('svcA', 'depA', 'exposes'),
|
|
142
|
+
edge('depB', 'podB', 'manages'),
|
|
143
|
+
edge('shared', 'depA', 'configures'),
|
|
144
|
+
edge('shared', 'depB', 'configures'),
|
|
145
|
+
],
|
|
146
|
+
}
|
|
147
|
+
const { topology, colorByWorkload } = tagWorkloadOwnership(topo, [
|
|
148
|
+
{ kind: 'Deployment', namespace: 'app', name: 'a' },
|
|
149
|
+
{ kind: 'Deployment', namespace: 'app', name: 'b' },
|
|
150
|
+
])
|
|
151
|
+
const a = colorByWorkload.get('Deployment/app/a')
|
|
152
|
+
const b = colorByWorkload.get('Deployment/app/b')
|
|
153
|
+
expect(a).not.toBe(b)
|
|
154
|
+
// a's core + its exclusive Service carry a's color; its pod inherits it.
|
|
155
|
+
expect(dataOf(topology, 'depA').ownerWorkloadId).toBe('Deployment/app/a')
|
|
156
|
+
expect(dataOf(topology, 'podA').ownerColorIndex).toBe(a)
|
|
157
|
+
expect(dataOf(topology, 'svcA').ownerColorIndex).toBe(a)
|
|
158
|
+
expect(dataOf(topology, 'podB').ownerColorIndex).toBe(b)
|
|
159
|
+
// the ConfigMap touches both workloads → neutral color…
|
|
160
|
+
expect(dataOf(topology, 'shared').ownerWorkloadId).toBeNull()
|
|
161
|
+
expect(dataOf(topology, 'shared').ownerColorIndex).toBeNull()
|
|
162
|
+
// …but its focus set includes BOTH, so focusing either lights it up.
|
|
163
|
+
expect(new Set(dataOf(topology, 'shared').focusWorkloadIds as string[])).toEqual(
|
|
164
|
+
new Set(['Deployment/app/a', 'Deployment/app/b']),
|
|
165
|
+
)
|
|
166
|
+
// an exclusive satellite's focus set is just its own workload.
|
|
167
|
+
expect(dataOf(topology, 'svcA').focusWorkloadIds).toEqual(['Deployment/app/a'])
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// A GitOps manager is context, not membership — it never claims a color even
|
|
171
|
+
// when it manages a single workload in the neighborhood.
|
|
172
|
+
it('leaves a GitOps manager neutral', () => {
|
|
173
|
+
const topo: Topology = {
|
|
174
|
+
nodes: [
|
|
175
|
+
node('ks', 'Kustomization', 'flux-system', 'apps'),
|
|
176
|
+
node('dep', 'Deployment', 'app', 'web'),
|
|
177
|
+
node('pod', 'Pod', 'app', 'web-1'),
|
|
178
|
+
],
|
|
179
|
+
edges: [edge('ks', 'dep', 'manages'), edge('dep', 'pod', 'manages')],
|
|
180
|
+
}
|
|
181
|
+
const { topology } = tagWorkloadOwnership(topo, [{ kind: 'Deployment', namespace: 'app', name: 'web' }])
|
|
182
|
+
expect(dataOf(topology, 'ks').ownerWorkloadId).toBeNull()
|
|
183
|
+
expect(dataOf(topology, 'pod').ownerWorkloadId).toBe('Deployment/app/web')
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { Topology, TopologyNode, TopologyEdge, EdgeType, NodeKind } from '../types/core'
|
|
2
|
+
|
|
3
|
+
// Seeded neighborhood query — the shared primitive behind the WorkloadView
|
|
4
|
+
// Topology tab (seed = one workload) and the Application topology (seed = the
|
|
5
|
+
// app's workloads). Given a full topology and a seed set, it returns the
|
|
6
|
+
// subgraph "everything relevant to these seeds": the seeds' ownership cores plus
|
|
7
|
+
// their attached context (Services, config, autoscalers, policies) — without
|
|
8
|
+
// letting a shared resource bridge in unrelated workloads.
|
|
9
|
+
//
|
|
10
|
+
// The traversal is the load-bearing part. Edges fall into three classes:
|
|
11
|
+
//
|
|
12
|
+
// identity (`manages`) — the ownerRef / controller chain
|
|
13
|
+
// (Deployment→ReplicaSet→Pod). Walk it: a
|
|
14
|
+
// workload's pods ARE the workload.
|
|
15
|
+
// routing (`exposes`,`routes-to`)— a Service/Ingress/Route in front of the
|
|
16
|
+
// workload. INCLUDE it, but as a LEAF: a
|
|
17
|
+
// shared Ingress fronts many unrelated apps,
|
|
18
|
+
// so we don't expand THROUGH it.
|
|
19
|
+
// context (`configures`,`uses`, — a ConfigMap/Secret/HPA/PDB attached to the
|
|
20
|
+
// `protects`) workload. INCLUDE as a LEAF: a shared
|
|
21
|
+
// ConfigMap mounted by two apps must not glue
|
|
22
|
+
// them into one neighborhood.
|
|
23
|
+
//
|
|
24
|
+
// One more rule keeps it honest: managers reached UPWARD (a CronJob over a
|
|
25
|
+
// seed Job, a GitOps controller over a workload) are LEAVES — we show "managed
|
|
26
|
+
// by X" but never expand down to X's OTHER children (the same over-merge the
|
|
27
|
+
// app resolver's structuralRoot fix prevents, in graph form).
|
|
28
|
+
//
|
|
29
|
+
// The result is the raw subgraph; the caller hands it to <TopologyGraph/>.
|
|
30
|
+
|
|
31
|
+
export interface NeighborhoodSeed {
|
|
32
|
+
kind: string
|
|
33
|
+
namespace: string
|
|
34
|
+
name: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const IDENTITY_EDGES = new Set<EdgeType>(['manages'])
|
|
38
|
+
const ROUTING_EDGES = new Set<EdgeType>(['exposes', 'routes-to'])
|
|
39
|
+
// context: 'configures' | 'uses' | 'protects' — everything else is leaf-attached.
|
|
40
|
+
|
|
41
|
+
// GitOps managers: included as context ("managed by"), never expanded through.
|
|
42
|
+
const GITOPS_MANAGER_KINDS = new Set<NodeKind>([
|
|
43
|
+
'Application',
|
|
44
|
+
'Kustomization',
|
|
45
|
+
'HelmRelease',
|
|
46
|
+
'GitRepository',
|
|
47
|
+
] as NodeKind[])
|
|
48
|
+
|
|
49
|
+
function nodeNamespace(node: TopologyNode): string {
|
|
50
|
+
const ns = node.data?.namespace
|
|
51
|
+
return typeof ns === 'string' ? ns : ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The identity string for a workload/seed — `kind/namespace/name`. This format
|
|
55
|
+
* is a cross-module contract: rail rows, the `?workload=` URL param, hover
|
|
56
|
+
* focus, and the ownership stamp all compare these strings. Always construct
|
|
57
|
+
* through here; never inline the template. (Unambiguous: K8s kinds and
|
|
58
|
+
* DNS-1123 names cannot contain `/`.) */
|
|
59
|
+
export function workloadKey(ref: NeighborhoodSeed): string {
|
|
60
|
+
return `${ref.kind}/${ref.namespace}/${ref.name}`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function matchSeedNode(node: TopologyNode, seeds: NeighborhoodSeed[]): boolean {
|
|
64
|
+
return seeds.some((s) => s.kind === node.kind && s.name === node.name && s.namespace === nodeNamespace(node))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Filter a topology to the neighborhood of `seeds`. Returns the subgraph; an
|
|
68
|
+
* empty graph (with a warning) when no seed node matches. */
|
|
69
|
+
export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]): Topology {
|
|
70
|
+
const nodeById = new Map<string, TopologyNode>()
|
|
71
|
+
for (const n of topology.nodes) nodeById.set(n.id, n)
|
|
72
|
+
|
|
73
|
+
const seedIds = new Set<string>()
|
|
74
|
+
for (const n of topology.nodes) {
|
|
75
|
+
if (matchSeedNode(n, seeds)) seedIds.add(n.id)
|
|
76
|
+
}
|
|
77
|
+
if (seedIds.size === 0) {
|
|
78
|
+
return {
|
|
79
|
+
...topology,
|
|
80
|
+
nodes: [],
|
|
81
|
+
edges: [],
|
|
82
|
+
warnings: [...(topology.warnings ?? []), 'No topology nodes matched this selection.'],
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// adjacency (both directions) for the bounded walk.
|
|
87
|
+
const adjacency = new Map<string, TopologyEdge[]>()
|
|
88
|
+
for (const e of topology.edges) {
|
|
89
|
+
for (const id of [e.source, e.target]) {
|
|
90
|
+
if (!adjacency.has(id)) adjacency.set(id, [])
|
|
91
|
+
adjacency.get(id)!.push(e)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const keep = new Set(seedIds)
|
|
96
|
+
// Nodes included for context but never expanded THROUGH.
|
|
97
|
+
const leaf = new Set<string>()
|
|
98
|
+
const queue: string[] = Array.from(seedIds)
|
|
99
|
+
|
|
100
|
+
while (queue.length) {
|
|
101
|
+
const id = queue.shift()!
|
|
102
|
+
if (leaf.has(id)) continue // a leaf is a dead end — don't traverse out of it
|
|
103
|
+
for (const e of adjacency.get(id) ?? []) {
|
|
104
|
+
const nextId = e.source === id ? e.target : e.source
|
|
105
|
+
const nextNode = nodeById.get(nextId)
|
|
106
|
+
if (!nextNode) continue
|
|
107
|
+
|
|
108
|
+
let asLeaf: boolean
|
|
109
|
+
if (IDENTITY_EDGES.has(e.type)) {
|
|
110
|
+
// ownerRef chain: DOWNWARD (owner → child) is identity — a workload's
|
|
111
|
+
// pods ARE the workload. UPWARD (child → its manager: a CronJob, a
|
|
112
|
+
// GitOps controller) is context — include "managed by X" as a leaf,
|
|
113
|
+
// never fan out to X's other children (a seed Job must not drag in
|
|
114
|
+
// every sibling Job its CronJob owns).
|
|
115
|
+
asLeaf = nextId === e.source
|
|
116
|
+
} else if (ROUTING_EDGES.has(e.type)) {
|
|
117
|
+
asLeaf = true // a Service/Ingress in front of the workload — leaf
|
|
118
|
+
} else {
|
|
119
|
+
asLeaf = true // configures / uses / protects — leaf
|
|
120
|
+
}
|
|
121
|
+
if (!keep.has(nextId)) {
|
|
122
|
+
keep.add(nextId)
|
|
123
|
+
if (asLeaf) leaf.add(nextId)
|
|
124
|
+
queue.push(nextId)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
...topology,
|
|
131
|
+
nodes: topology.nodes.filter((n) => keep.has(n.id)),
|
|
132
|
+
edges: topology.edges.filter((e) => keep.has(e.source) && keep.has(e.target)),
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ─── Workload ownership tagging ──────────────────────────────────────────────
|
|
137
|
+
//
|
|
138
|
+
// For the application graph (seeds = the app's workloads) we want to show which
|
|
139
|
+
// resources belong to which workload. A resource is "owned" by a workload when
|
|
140
|
+
// it belongs to that workload ALONE — its pods (manages-descendants), and the
|
|
141
|
+
// Service/config/policy attached to exactly one workload. Anything attached to
|
|
142
|
+
// two or more workloads (a shared ConfigMap, a GitOps manager) stays NEUTRAL, as
|
|
143
|
+
// does anything attached to none. This is the visual twin of the leaf rule: the
|
|
144
|
+
// graph already refuses to bridge through shared resources, and here they
|
|
145
|
+
// refuse to claim a color.
|
|
146
|
+
|
|
147
|
+
/** What `tagWorkloadOwnership` stamps into each node's `data`:
|
|
148
|
+
* - `ownerWorkloadId` + `ownerColorIndex` — the EXCLUSIVE owner, for the color
|
|
149
|
+
* wash. Shared nodes are null (neutral).
|
|
150
|
+
* - `focusWorkloadIds` — every workload whose neighborhood includes this node,
|
|
151
|
+
* for hover-focus. A shared ConfigMap belongs to all workloads that use it,
|
|
152
|
+
* so focusing any of them lights it up (matching the single-workload
|
|
153
|
+
* topology), even though it stays neutral-colored.
|
|
154
|
+
* Consumers MUST read via `ownershipOf` — never cast the raw data keys. */
|
|
155
|
+
export interface OwnershipStamp {
|
|
156
|
+
ownerWorkloadId: string | null
|
|
157
|
+
ownerColorIndex: number | null
|
|
158
|
+
focusWorkloadIds: string[]
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The single audited reader for the ownership stamp. Tolerates untagged nodes
|
|
162
|
+
* (plain topologies) by returning the neutral stamp. */
|
|
163
|
+
export function ownershipOf(data: Record<string, unknown> | undefined): OwnershipStamp {
|
|
164
|
+
return {
|
|
165
|
+
ownerWorkloadId: typeof data?.ownerWorkloadId === 'string' ? data.ownerWorkloadId : null,
|
|
166
|
+
ownerColorIndex: typeof data?.ownerColorIndex === 'number' ? data.ownerColorIndex : null,
|
|
167
|
+
focusWorkloadIds: Array.isArray(data?.focusWorkloadIds) ? (data.focusWorkloadIds as string[]) : [],
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface WorkloadOwnership {
|
|
172
|
+
/** The neighborhood subgraph, each node's `data` carrying an OwnershipStamp. */
|
|
173
|
+
topology: Topology
|
|
174
|
+
/** Color index per workload key (see `workloadKey`) — for rail swatches. */
|
|
175
|
+
colorByWorkload: Map<string, number>
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Run the neighborhood query for `seeds`, then tag each node with the workload
|
|
179
|
+
* that exclusively owns it (or neutral). Returns the tagged subgraph plus the
|
|
180
|
+
* color + node-ownership maps the application rail needs. */
|
|
181
|
+
export function tagWorkloadOwnership(topology: Topology, seeds: NeighborhoodSeed[]): WorkloadOwnership {
|
|
182
|
+
const sub = neighborhoodFor(topology, seeds)
|
|
183
|
+
|
|
184
|
+
// Stable color per workload: order of `seeds` (matches the rail's order).
|
|
185
|
+
const colorByWorkload = new Map<string, number>()
|
|
186
|
+
for (const s of seeds) {
|
|
187
|
+
const k = workloadKey(s)
|
|
188
|
+
if (!colorByWorkload.has(k)) colorByWorkload.set(k, colorByWorkload.size)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The seed nodes present in the subgraph, by their workload key.
|
|
192
|
+
const seedKeyById = new Map<string, string>()
|
|
193
|
+
for (const n of sub.nodes) {
|
|
194
|
+
if (matchSeedNode(n, seeds)) {
|
|
195
|
+
seedKeyById.set(n.id, workloadKey({ kind: n.kind, namespace: nodeNamespace(n), name: n.name }))
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// manages-DOWN children (source manages target) + undirected neighbors.
|
|
200
|
+
const downChildren = new Map<string, string[]>()
|
|
201
|
+
const neighbors = new Map<string, Set<string>>()
|
|
202
|
+
for (const e of sub.edges) {
|
|
203
|
+
if (e.type === 'manages') {
|
|
204
|
+
if (!downChildren.has(e.source)) downChildren.set(e.source, [])
|
|
205
|
+
downChildren.get(e.source)!.push(e.target)
|
|
206
|
+
}
|
|
207
|
+
for (const [a, b] of [[e.source, e.target], [e.target, e.source]] as const) {
|
|
208
|
+
if (!neighbors.has(a)) neighbors.set(a, new Set())
|
|
209
|
+
neighbors.get(a)!.add(b)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Core = each seed plus everything reachable DOWN the manages chain from it
|
|
214
|
+
// (its ReplicaSets, Pods). Exclusive by construction — a pod has one controller.
|
|
215
|
+
const coreOwner = new Map<string, string>()
|
|
216
|
+
for (const [seedId, key] of seedKeyById) {
|
|
217
|
+
const queue = [seedId]
|
|
218
|
+
while (queue.length) {
|
|
219
|
+
const id = queue.shift()!
|
|
220
|
+
if (coreOwner.has(id)) continue
|
|
221
|
+
coreOwner.set(id, key)
|
|
222
|
+
for (const c of downChildren.get(id) ?? []) if (!coreOwner.has(c)) queue.push(c)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// For each node, figure out which workloads it belongs to. A core node (the
|
|
227
|
+
// workload itself + its manages-descendants) belongs to its own workload. Any
|
|
228
|
+
// other node belongs to every workload-core it touches: that's its focus set.
|
|
229
|
+
// The color owner is the EXCLUSIVE case only — a node touching exactly one
|
|
230
|
+
// workload and not a GitOps manager (managers are context, never owned).
|
|
231
|
+
const nodes = sub.nodes.map((n) => {
|
|
232
|
+
const core = coreOwner.get(n.id) ?? null
|
|
233
|
+
let focusWorkloadIds: string[]
|
|
234
|
+
let owner: string | null
|
|
235
|
+
if (core) {
|
|
236
|
+
focusWorkloadIds = [core]
|
|
237
|
+
owner = core
|
|
238
|
+
} else {
|
|
239
|
+
const related = new Set<string>()
|
|
240
|
+
for (const nb of neighbors.get(n.id) ?? []) {
|
|
241
|
+
const o = coreOwner.get(nb)
|
|
242
|
+
if (o) related.add(o)
|
|
243
|
+
}
|
|
244
|
+
focusWorkloadIds = [...related]
|
|
245
|
+
owner = related.size === 1 && !GITOPS_MANAGER_KINDS.has(n.kind) ? [...related][0] : null
|
|
246
|
+
}
|
|
247
|
+
const stamp: OwnershipStamp = {
|
|
248
|
+
ownerWorkloadId: owner,
|
|
249
|
+
ownerColorIndex: owner ? colorByWorkload.get(owner) ?? null : null,
|
|
250
|
+
focusWorkloadIds,
|
|
251
|
+
}
|
|
252
|
+
return { ...n, data: { ...n.data, ...stamp } }
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
return { topology: { ...sub, nodes }, colorByWorkload }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The set of node IDs that are the seeds themselves — handy for the caller to
|
|
259
|
+
* pass `focusNodeId` (pan/zoom to the workload) into <TopologyGraph/>. */
|
|
260
|
+
export function seedNodeIds(topology: Topology, seeds: NeighborhoodSeed[]): string[] {
|
|
261
|
+
return topology.nodes.filter((n) => matchSeedNode(n, seeds)).map((n) => n.id)
|
|
262
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { seriesColor, seriesFill } from '../components/charts/colors'
|
|
2
|
+
|
|
3
|
+
// Per-workload color encoding for the application topology graph. A workload's
|
|
4
|
+
// exclusive satellites (its Service, config, pods) carry its hue; shared and
|
|
5
|
+
// unattached resources stay neutral. Reuses SERIES_COLORS — the codebase's
|
|
6
|
+
// categorical palette for multi-series charts (10 well-separated 500-level
|
|
7
|
+
// shades, vetted on both themes) — so workload colors match the rest of the UI.
|
|
8
|
+
//
|
|
9
|
+
// Solid swatch for the rail legend; faint fill for the node card background. The
|
|
10
|
+
// node wash is applied only to healthy/unknown cards (see K8sResourceNode), so a
|
|
11
|
+
// warm hue here never competes with the red/amber a degraded card owns.
|
|
12
|
+
|
|
13
|
+
export interface WorkloadHue {
|
|
14
|
+
/** Solid — the rail legend chip. */
|
|
15
|
+
swatch: string
|
|
16
|
+
/** Faint fill (~13% alpha) — the node card tint, layered over the surface. */
|
|
17
|
+
wash: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Sentinel owner for shared / unattached nodes — they get no hue (neutral).
|
|
21
|
+
* Collision-proof: real workload keys always contain two `/`. */
|
|
22
|
+
export const NEUTRAL_OWNER = '__neutral__'
|
|
23
|
+
|
|
24
|
+
/** The hover-focus channel's three states: `null` = no focus (everything lit),
|
|
25
|
+
* `NEUTRAL_OWNER` = focus the shared/unscoped bucket, any other string = a
|
|
26
|
+
* workload key (see `workloadKey`) whose neighborhood stays lit. */
|
|
27
|
+
export type WorkloadFocus = string | null
|
|
28
|
+
|
|
29
|
+
const NEUTRAL_FALLBACK = '#64748b' // slate-500
|
|
30
|
+
|
|
31
|
+
export function workloadHue(index: number): WorkloadHue {
|
|
32
|
+
return {
|
|
33
|
+
swatch: seriesColor(index, NEUTRAL_FALLBACK),
|
|
34
|
+
wash: seriesFill(index, NEUTRAL_FALLBACK),
|
|
35
|
+
}
|
|
36
|
+
}
|