@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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/applications/AppChips.tsx +109 -0
  3. package/src/components/applications/AppTooltips.tsx +199 -0
  4. package/src/components/applications/ApplicationDetail.tsx +671 -0
  5. package/src/components/applications/ApplicationsList.tsx +569 -0
  6. package/src/components/applications/ReadyBar.tsx +22 -0
  7. package/src/components/applications/index.ts +8 -0
  8. package/src/components/audit/AuditFindingsTable.tsx +3 -25
  9. package/src/components/logs/WorkloadLogsViewer.tsx +8 -5
  10. package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
  11. package/src/components/shared/DetailShell.tsx +14 -7
  12. package/src/components/shared/EditableYamlView.tsx +37 -17
  13. package/src/components/timeline/TimelineList.tsx +3 -32
  14. package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
  15. package/src/components/topology/K8sResourceNode.tsx +26 -5
  16. package/src/components/topology/TopologyGraph.tsx +102 -3
  17. package/src/components/topology/layout.ts +36 -11
  18. package/src/components/ui/CenteredEmpty.tsx +27 -0
  19. package/src/components/ui/SearchBox.tsx +85 -0
  20. package/src/components/ui/index.ts +1 -0
  21. package/src/components/workload/WorkloadView.tsx +167 -33
  22. package/src/components/workload/index.ts +1 -1
  23. package/src/hooks/useKeyboardShortcuts.tsx +3 -1
  24. package/src/index.ts +4 -0
  25. package/src/utils/applications.test.ts +207 -0
  26. package/src/utils/applications.ts +674 -0
  27. package/src/utils/format.ts +11 -0
  28. package/src/utils/index.ts +2 -0
  29. package/src/utils/topology-neighborhood.test.ts +185 -0
  30. package/src/utils/topology-neighborhood.ts +262 -0
  31. package/src/utils/workload-colors.ts +36 -0
@@ -20,3 +20,5 @@ export * from './gitops-owner'
20
20
  export * from './gitops-route'
21
21
  export * from './rbac-badges'
22
22
  export * from './git-provider-urls'
23
+ export * from './applications'
24
+ export * from './topology-neighborhood'
@@ -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
+ }