@skyhook-io/k8s-ui 1.7.11 → 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 (70) 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/dock/TerminalTab.tsx +4 -2
  10. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
  11. package/src/components/issues/IssuesView.tsx +64 -17
  12. package/src/components/issues/index.ts +1 -1
  13. package/src/components/issues/issues.test.ts +4 -4
  14. package/src/components/issues/severity.ts +5 -0
  15. package/src/components/issues/types.ts +43 -0
  16. package/src/components/logs/LogCore.tsx +13 -2
  17. package/src/components/logs/LogToolbarSelects.tsx +6 -2
  18. package/src/components/logs/LogsViewer.tsx +66 -10
  19. package/src/components/logs/WorkloadLogsViewer.tsx +68 -13
  20. package/src/components/logs/useLogStream.ts +41 -3
  21. package/src/components/resources/ResourcesView.tsx +550 -52
  22. package/src/components/resources/column-filter-serialization.test.ts +26 -0
  23. package/src/components/resources/get-default-container-name.test.ts +31 -0
  24. package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
  25. package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
  26. package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
  27. package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
  28. package/src/components/resources/renderers/PodRenderer.tsx +2 -2
  29. package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
  30. package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
  31. package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
  32. package/src/components/resources/renderers/WorkloadRenderer.tsx +5 -4
  33. package/src/components/resources/renderers/dra-cells.tsx +80 -0
  34. package/src/components/resources/renderers/index.ts +8 -0
  35. package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
  36. package/src/components/resources/resource-utils-dra.ts +90 -0
  37. package/src/components/resources/resource-utils-nvidia.ts +63 -0
  38. package/src/components/resources/resource-utils.ts +62 -4
  39. package/src/components/shared/DetailShell.tsx +14 -7
  40. package/src/components/shared/EditableYamlView.tsx +37 -17
  41. package/src/components/shared/ResourceActionsBar.tsx +5 -4
  42. package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
  43. package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
  44. package/src/components/timeline/TimelineList.tsx +3 -32
  45. package/src/components/timeline/TimelineSwimlanes.tsx +3 -31
  46. package/src/components/topology/K8sResourceNode.tsx +26 -5
  47. package/src/components/topology/TopologyGraph.tsx +102 -3
  48. package/src/components/topology/layout.ts +36 -11
  49. package/src/components/ui/CenteredEmpty.tsx +27 -0
  50. package/src/components/ui/ConfirmDialog.tsx +1 -1
  51. package/src/components/ui/SearchBox.tsx +85 -0
  52. package/src/components/ui/drawer-components.tsx +23 -1
  53. package/src/components/ui/index.ts +1 -0
  54. package/src/components/workload/WorkloadView.tsx +167 -33
  55. package/src/components/workload/index.ts +1 -1
  56. package/src/hooks/useKeyboardShortcuts.tsx +3 -1
  57. package/src/index.ts +4 -0
  58. package/src/types/core.ts +1 -0
  59. package/src/utils/api-resources.ts +21 -0
  60. package/src/utils/applications.test.ts +207 -0
  61. package/src/utils/applications.ts +674 -0
  62. package/src/utils/custom-columns.test.ts +111 -0
  63. package/src/utils/custom-columns.ts +49 -0
  64. package/src/utils/extended-resources.test.ts +152 -0
  65. package/src/utils/extended-resources.ts +121 -0
  66. package/src/utils/format.ts +11 -0
  67. package/src/utils/index.ts +3 -0
  68. package/src/utils/topology-neighborhood.test.ts +185 -0
  69. package/src/utils/topology-neighborhood.ts +262 -0
  70. package/src/utils/workload-colors.ts +36 -0
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from './custom-columns'
3
+
4
+ describe('customColumnKey', () => {
5
+ it('encodes source and path', () => {
6
+ expect(customColumnKey({ source: 'label', path: 'topology.kubernetes.io/zone' })).toBe('label:topology.kubernetes.io/zone')
7
+ expect(customColumnKey({ source: 'annotation', path: 'foo/bar' })).toBe('annotation:foo/bar')
8
+ })
9
+
10
+ it('produces equal keys for same source+path (dedupe contract)', () => {
11
+ const a = customColumnKey({ source: 'label', path: 'x' })
12
+ const b = customColumnKey({ source: 'label', path: 'x' })
13
+ expect(a).toBe(b)
14
+ })
15
+
16
+ it('distinguishes source for the same path', () => {
17
+ expect(customColumnKey({ source: 'label', path: 'x' }))
18
+ .not.toBe(customColumnKey({ source: 'annotation', path: 'x' }))
19
+ })
20
+ })
21
+
22
+ describe('readCustomColumnValue', () => {
23
+ const res = (meta: any) => ({ metadata: meta })
24
+
25
+ it('reads a label value', () => {
26
+ expect(readCustomColumnValue(res({ labels: { zone: 'us-east-1a' } }), { source: 'label', path: 'zone' })).toBe('us-east-1a')
27
+ })
28
+
29
+ it('reads an annotation value', () => {
30
+ expect(readCustomColumnValue(res({ annotations: { team: 'infra' } }), { source: 'annotation', path: 'team' })).toBe('infra')
31
+ })
32
+
33
+ it('does not cross labels and annotations', () => {
34
+ const r = res({ labels: { k: 'fromLabel' }, annotations: { k: 'fromAnnotation' } })
35
+ expect(readCustomColumnValue(r, { source: 'label', path: 'k' })).toBe('fromLabel')
36
+ expect(readCustomColumnValue(r, { source: 'annotation', path: 'k' })).toBe('fromAnnotation')
37
+ })
38
+
39
+ it('returns empty string for a missing key', () => {
40
+ expect(readCustomColumnValue(res({ labels: { a: '1' } }), { source: 'label', path: 'b' })).toBe('')
41
+ })
42
+
43
+ it('returns empty string when metadata/bag is absent', () => {
44
+ expect(readCustomColumnValue({}, { source: 'label', path: 'b' })).toBe('')
45
+ expect(readCustomColumnValue(res({}), { source: 'annotation', path: 'b' })).toBe('')
46
+ expect(readCustomColumnValue(undefined, { source: 'label', path: 'b' })).toBe('')
47
+ })
48
+
49
+ it('coerces non-string values, but maps null/undefined to empty (not the literal "null")', () => {
50
+ expect(readCustomColumnValue(res({ labels: { n: 5 as any } }), { source: 'label', path: 'n' })).toBe('5')
51
+ expect(readCustomColumnValue(res({ labels: { n: null as any } }), { source: 'label', path: 'n' })).toBe('')
52
+ })
53
+ })
54
+
55
+ describe('sanitizeCustomColumnDefs', () => {
56
+ it('returns [] for non-array input', () => {
57
+ expect(sanitizeCustomColumnDefs(undefined)).toEqual([])
58
+ expect(sanitizeCustomColumnDefs(null)).toEqual([])
59
+ expect(sanitizeCustomColumnDefs({ source: 'label', path: 'x' })).toEqual([])
60
+ expect(sanitizeCustomColumnDefs('label:x')).toEqual([])
61
+ })
62
+
63
+ it('keeps well-formed defs', () => {
64
+ const defs = [
65
+ { source: 'label', path: 'zone' },
66
+ { source: 'annotation', path: 'team' },
67
+ ]
68
+ expect(sanitizeCustomColumnDefs(defs)).toEqual(defs)
69
+ })
70
+
71
+ it('drops entries with invalid source, missing or blank path', () => {
72
+ const raw = [
73
+ { source: 'label', path: 'good' },
74
+ { source: 'lable', path: 'typo-source' },
75
+ { source: 'label', path: '' },
76
+ { source: 'label', path: ' ' },
77
+ { source: 'annotation' },
78
+ null,
79
+ 'label:x',
80
+ ]
81
+ expect(sanitizeCustomColumnDefs(raw)).toEqual([{ source: 'label', path: 'good' }])
82
+ })
83
+
84
+ it('drops a non-string path without throwing (predicate short-circuits before trim)', () => {
85
+ const raw = [
86
+ { source: 'label', path: 42 },
87
+ { source: 'label', path: { nested: true } },
88
+ { source: 'annotation', path: 'ok' },
89
+ ]
90
+ expect(() => sanitizeCustomColumnDefs(raw)).not.toThrow()
91
+ expect(sanitizeCustomColumnDefs(raw)).toEqual([{ source: 'annotation', path: 'ok' }])
92
+ })
93
+
94
+ it('trims paths so the load path matches the add path', () => {
95
+ expect(sanitizeCustomColumnDefs([{ source: 'label', path: ' zone ' }]))
96
+ .toEqual([{ source: 'label', path: 'zone' }])
97
+ })
98
+
99
+ it('dedupes by key, keeping the first occurrence', () => {
100
+ const raw = [
101
+ { source: 'label', path: 'zone' },
102
+ { source: 'label', path: 'zone' },
103
+ { source: 'label', path: ' zone ' },
104
+ { source: 'annotation', path: 'zone' },
105
+ ]
106
+ expect(sanitizeCustomColumnDefs(raw)).toEqual([
107
+ { source: 'label', path: 'zone' },
108
+ { source: 'annotation', path: 'zone' },
109
+ ])
110
+ })
111
+ })
@@ -0,0 +1,49 @@
1
+ // User-defined table columns sourcing a metadata.labels[path] or
2
+ // metadata.annotations[path] value. Persisted per-kind in localStorage and
3
+ // materialized (in ResourcesView) into a self-contained ExtraColumn so they
4
+ // ride the existing render/sort/filter override rails.
5
+
6
+ export type CustomColumnSource = 'label' | 'annotation'
7
+
8
+ export interface CustomColumnDef {
9
+ source: CustomColumnSource
10
+ path: string
11
+ }
12
+
13
+ // Stable identity used as the React key, ExtraColumn.key, visibility-set
14
+ // membership, and dedupe key. Built-in column keys never use a `:` prefix, so
15
+ // `label:`/`annotation:` can't collide with them. Build-only — never parsed
16
+ // back into source+path, so a colon inside `path` is harmless.
17
+ export function customColumnKey(d: CustomColumnDef): string {
18
+ return `${d.source}:${d.path}`
19
+ }
20
+
21
+ export function readCustomColumnValue(resource: any, d: CustomColumnDef): string {
22
+ const bag = d.source === 'label' ? resource?.metadata?.labels : resource?.metadata?.annotations
23
+ const v = bag?.[d.path]
24
+ return typeof v === 'string' ? v : v == null ? '' : String(v)
25
+ }
26
+
27
+ // Drop anything that isn't a well-formed def — guards the localStorage load
28
+ // boundary where a corrupted/hand-edited blob could otherwise be a non-array
29
+ // (crashing a later .map) or carry empty/invalid entries (dead columns).
30
+ // TypeScript's guarantees stop at JSON.parse, so this is the one place the
31
+ // (source, non-empty path) invariant must be enforced at runtime. Paths are
32
+ // trimmed and deduped by key so the load path produces the same defs the add
33
+ // path would — a hand-edited blob can't yield a padded key or two columns
34
+ // sharing one key.
35
+ export function sanitizeCustomColumnDefs(raw: unknown): CustomColumnDef[] {
36
+ if (!Array.isArray(raw)) return []
37
+ const seen = new Set<string>()
38
+ const out: CustomColumnDef[] = []
39
+ for (const c of raw) {
40
+ if (!c || (c.source !== 'label' && c.source !== 'annotation') || typeof c.path !== 'string') continue
41
+ const def: CustomColumnDef = { source: c.source, path: c.path.trim() }
42
+ if (def.path === '') continue
43
+ const key = customColumnKey(def)
44
+ if (seen.has(key)) continue
45
+ seen.add(key)
46
+ out.push(def)
47
+ }
48
+ return out
49
+ }
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ isGpuResourceKey,
4
+ isGpuCountKey,
5
+ getEffectiveResourceValue,
6
+ getEffectiveResources,
7
+ getPodGpuCount,
8
+ getNodeGpuCount,
9
+ getExtendedCapacityRows,
10
+ } from './extended-resources'
11
+
12
+ describe('isGpuResourceKey', () => {
13
+ it('matches vendor whole-GPU keys', () => {
14
+ expect(isGpuResourceKey('nvidia.com/gpu')).toBe(true)
15
+ expect(isGpuResourceKey('amd.com/gpu')).toBe(true)
16
+ })
17
+ it('matches NVIDIA MIG slices', () => {
18
+ expect(isGpuResourceKey('nvidia.com/mig-1g.5gb')).toBe(true)
19
+ })
20
+ it('matches the Intel gpu.intel.com family', () => {
21
+ expect(isGpuResourceKey('gpu.intel.com/i915')).toBe(true)
22
+ expect(isGpuResourceKey('gpu.intel.com/millicores')).toBe(true)
23
+ })
24
+ it('rejects non-GPU keys', () => {
25
+ expect(isGpuResourceKey('cpu')).toBe(false)
26
+ expect(isGpuResourceKey('hugepages-2Mi')).toBe(false)
27
+ expect(isGpuResourceKey('example.com/fpga')).toBe(false)
28
+ })
29
+ })
30
+
31
+ describe('isGpuCountKey', () => {
32
+ it('excludes fractional Intel dimensions', () => {
33
+ expect(isGpuCountKey('gpu.intel.com/millicores')).toBe(false)
34
+ expect(isGpuCountKey('gpu.intel.com/memory.max')).toBe(false)
35
+ expect(isGpuCountKey('gpu.intel.com/tiles')).toBe(false)
36
+ })
37
+ it('includes count-like keys', () => {
38
+ expect(isGpuCountKey('nvidia.com/gpu')).toBe(true)
39
+ expect(isGpuCountKey('nvidia.com/mig-3g.20gb')).toBe(true)
40
+ expect(isGpuCountKey('gpu.intel.com/i915')).toBe(true)
41
+ })
42
+ })
43
+
44
+ describe('getEffectiveResourceValue', () => {
45
+ it('falls back to limits when requests omit the key (canonical GPU form)', () => {
46
+ const resources = { limits: { 'nvidia.com/gpu': '2' } }
47
+ expect(getEffectiveResourceValue(resources, 'nvidia.com/gpu')).toBe('2')
48
+ })
49
+ it('prefers an explicit request', () => {
50
+ const resources = { requests: { 'nvidia.com/gpu': '1' }, limits: { 'nvidia.com/gpu': '1' } }
51
+ expect(getEffectiveResourceValue(resources, 'nvidia.com/gpu')).toBe('1')
52
+ })
53
+ it('returns undefined when absent', () => {
54
+ expect(getEffectiveResourceValue({}, 'nvidia.com/gpu')).toBeUndefined()
55
+ expect(getEffectiveResourceValue(undefined, 'nvidia.com/gpu')).toBeUndefined()
56
+ })
57
+ })
58
+
59
+ describe('getPodGpuCount', () => {
60
+ it('sums limits-only GPU containers', () => {
61
+ const pod = {
62
+ spec: {
63
+ containers: [
64
+ { resources: { limits: { 'nvidia.com/gpu': '2' } } },
65
+ { resources: { limits: { 'nvidia.com/gpu': '1' } } },
66
+ ],
67
+ },
68
+ }
69
+ expect(getPodGpuCount(pod)).toBe(3)
70
+ })
71
+ it('applies max(sum(containers), max(initContainers))', () => {
72
+ const pod = {
73
+ spec: {
74
+ containers: [{ resources: { limits: { 'nvidia.com/gpu': '1' } } }],
75
+ initContainers: [{ resources: { limits: { 'nvidia.com/gpu': '4' } } }],
76
+ },
77
+ }
78
+ expect(getPodGpuCount(pod)).toBe(4)
79
+ })
80
+ it('counts MIG slices but not fractional Intel dimensions', () => {
81
+ const pod = {
82
+ spec: {
83
+ containers: [
84
+ { resources: { limits: { 'nvidia.com/mig-1g.5gb': '2', 'gpu.intel.com/millicores': '500' } } },
85
+ ],
86
+ },
87
+ }
88
+ expect(getPodGpuCount(pod)).toBe(2)
89
+ })
90
+ it('returns 0 for GPU-free pods and malformed values', () => {
91
+ expect(getPodGpuCount({ spec: { containers: [{ resources: { limits: { cpu: '1' } } }] } })).toBe(0)
92
+ expect(getPodGpuCount({ spec: { containers: [{ resources: { limits: { 'nvidia.com/gpu': 'x' } } }] } })).toBe(0)
93
+ expect(getPodGpuCount({})).toBe(0)
94
+ })
95
+ it('parses K8s quantity suffixes (3000m = 3, 3k = 3000)', () => {
96
+ expect(getPodGpuCount({ spec: { containers: [{ resources: { limits: { 'nvidia.com/gpu': '3000m' } } }] } })).toBe(3)
97
+ expect(getPodGpuCount({ spec: { containers: [{ resources: { limits: { 'nvidia.com/gpu': '3k' } } }] } })).toBe(3000)
98
+ })
99
+ })
100
+
101
+ describe('getEffectiveResources', () => {
102
+ it('merges limits-only keys with explicit requests', () => {
103
+ const resources = {
104
+ requests: { cpu: '100m' },
105
+ limits: { cpu: '200m', 'nvidia.com/gpu': '1' },
106
+ }
107
+ expect(getEffectiveResources(resources)).toEqual({ cpu: '100m', 'nvidia.com/gpu': '1' })
108
+ })
109
+ it('returns empty for missing blocks', () => {
110
+ expect(getEffectiveResources(undefined)).toEqual({})
111
+ expect(getEffectiveResources({})).toEqual({})
112
+ })
113
+ })
114
+
115
+ describe('getNodeGpuCount', () => {
116
+ it('sums allocatable count-like GPU keys', () => {
117
+ const node = { status: { allocatable: { 'nvidia.com/gpu': '8', cpu: '64' } } }
118
+ expect(getNodeGpuCount(node)).toBe(8)
119
+ })
120
+ it('returns 0 without GPU keys', () => {
121
+ expect(getNodeGpuCount({ status: { allocatable: { cpu: '64' } } })).toBe(0)
122
+ expect(getNodeGpuCount({})).toBe(0)
123
+ })
124
+ })
125
+
126
+ describe('getExtendedCapacityRows', () => {
127
+ it('excludes the curated standard keys and sorts GPU rows first', () => {
128
+ const capacity = {
129
+ cpu: '64',
130
+ memory: '256Gi',
131
+ pods: '110',
132
+ 'ephemeral-storage': '1Ti',
133
+ 'hugepages-1Gi': '2Gi',
134
+ 'nvidia.com/gpu': '8',
135
+ }
136
+ const allocatable = { ...capacity, 'nvidia.com/gpu': '7' }
137
+ const rows = getExtendedCapacityRows(capacity, allocatable)
138
+ expect(rows.map(r => r.key)).toEqual(['nvidia.com/gpu', 'hugepages-1Gi'])
139
+ expect(rows[0]).toMatchObject({ capacity: '8', allocatable: '7', isGpu: true })
140
+ })
141
+ it('drops all-zero rows (every node advertises hugepages-* as 0)', () => {
142
+ const capacity = { cpu: '4', 'hugepages-2Mi': '0', 'hugepages-1Gi': '0' }
143
+ expect(getExtendedCapacityRows(capacity, capacity)).toEqual([])
144
+ })
145
+ it('handles keys present on only one side', () => {
146
+ const rows = getExtendedCapacityRows({ 'amd.com/gpu': '4' }, {})
147
+ expect(rows).toEqual([{ key: 'amd.com/gpu', capacity: '4', allocatable: undefined, isGpu: true }])
148
+ })
149
+ it('returns empty for nodes with only standard resources', () => {
150
+ expect(getExtendedCapacityRows({ cpu: '4', memory: '8Gi' }, { cpu: '4' })).toEqual([])
151
+ })
152
+ })
@@ -0,0 +1,121 @@
1
+ // Extended-resource (GPU etc.) helpers shared by table cells, renderers, and
2
+ // the GPU views. Keep all effective-request math here — K8s semantics for
3
+ // extended resources are subtle and must not be reimplemented per call site.
4
+
5
+ import { parseMemoryToBytes } from './format'
6
+
7
+ const STANDARD_NODE_RESOURCES = new Set(['cpu', 'memory', 'pods', 'ephemeral-storage'])
8
+
9
+ // Broad GPU detection for badging/labeling: whole-GPU keys (nvidia.com/gpu,
10
+ // amd.com/gpu), NVIDIA MIG slices, and Intel's gpu.intel.com family
11
+ // (i915/xe/millicores/memory.max/tiles).
12
+ export function isGpuResourceKey(key: string): boolean {
13
+ return key.endsWith('/gpu') || key.startsWith('nvidia.com/mig-') || key.startsWith('gpu.intel.com/')
14
+ }
15
+
16
+ // Count-like GPU keys only — values that mean "N devices/slices". Excludes
17
+ // fractional dimensions (gpu.intel.com/millicores, memory.max, tiles) whose
18
+ // numbers would distort a device count.
19
+ export function isGpuCountKey(key: string): boolean {
20
+ return (
21
+ key.endsWith('/gpu') ||
22
+ key.startsWith('nvidia.com/mig-') ||
23
+ key === 'gpu.intel.com/i915' ||
24
+ key === 'gpu.intel.com/xe'
25
+ )
26
+ }
27
+
28
+ // GPUs are canonically specified in limits only: the request defaults to the
29
+ // limit, and the two must be equal when both are set (no overcommit).
30
+ export function getEffectiveResourceValue(resources: any, key: string): string | undefined {
31
+ return resources?.requests?.[key] ?? resources?.limits?.[key]
32
+ }
33
+
34
+ // Merged per-key effective view of a container resources block. Needed for pod
35
+ // TEMPLATES (workload detail): apiserver defaulting copies limits→requests only
36
+ // on live Pods, never on templates, so limits-only GPU templates have no requests.
37
+ export function getEffectiveResources(resources: any): Record<string, string> {
38
+ const keys = new Set([
39
+ ...Object.keys(resources?.requests || {}),
40
+ ...Object.keys(resources?.limits || {}),
41
+ ])
42
+ const out: Record<string, string> = {}
43
+ for (const key of keys) {
44
+ const value = getEffectiveResourceValue(resources, key)
45
+ if (value !== undefined) out[key] = value
46
+ }
47
+ return out
48
+ }
49
+
50
+ // K8s quantity → number. Counts are usually plain integers, but quantities may
51
+ // legally carry suffixes: "3000m" is 3, "3k" is 3000.
52
+ function asCount(value: unknown): number {
53
+ if (typeof value === 'number') return Number.isFinite(value) ? value : 0
54
+ const s = String(value ?? '').trim()
55
+ if (!s) return 0
56
+ if (/^\d+(\.\d+)?m$/.test(s)) return Number(s.slice(0, -1)) / 1000
57
+ const n = parseMemoryToBytes(s)
58
+ return Number.isFinite(n) ? n : 0
59
+ }
60
+
61
+ function containerGpuCount(container: any): number {
62
+ const resources = container?.resources
63
+ const keys = new Set([
64
+ ...Object.keys(resources?.requests || {}),
65
+ ...Object.keys(resources?.limits || {}),
66
+ ])
67
+ let total = 0
68
+ for (const key of keys) {
69
+ if (isGpuCountKey(key)) total += asCount(getEffectiveResourceValue(resources, key))
70
+ }
71
+ return total
72
+ }
73
+
74
+ // Effective pod GPU count per scheduling semantics:
75
+ // max(sum of regular containers, largest init container).
76
+ export function getPodGpuCount(pod: any): number {
77
+ const containers: any[] = pod?.spec?.containers || []
78
+ const initContainers: any[] = pod?.spec?.initContainers || []
79
+ const regular = containers.reduce((sum, c) => sum + containerGpuCount(c), 0)
80
+ const init = initContainers.reduce((max, c) => Math.max(max, containerGpuCount(c)), 0)
81
+ return Math.max(regular, init)
82
+ }
83
+
84
+ export function getNodeGpuCount(node: any): number {
85
+ const allocatable = node?.status?.allocatable || {}
86
+ let total = 0
87
+ for (const [key, value] of Object.entries(allocatable)) {
88
+ if (isGpuCountKey(key)) total += asCount(value)
89
+ }
90
+ return total
91
+ }
92
+
93
+ export interface ExtendedCapacityRow {
94
+ key: string
95
+ capacity?: string
96
+ allocatable?: string
97
+ isGpu: boolean
98
+ }
99
+
100
+ // Node capacity/allocatable keys beyond the curated cpu/memory/pods/ephemeral-storage
101
+ // rows (hugepages, attachable-volumes, vendor GPU keys, ...). GPU keys sort first.
102
+ // All-zero rows are dropped — every node advertises hugepages-* as "0".
103
+ export function getExtendedCapacityRows(capacity: any, allocatable: any): ExtendedCapacityRow[] {
104
+ const keys = new Set([...Object.keys(capacity || {}), ...Object.keys(allocatable || {})])
105
+ const rows: ExtendedCapacityRow[] = []
106
+ for (const key of keys) {
107
+ if (STANDARD_NODE_RESOURCES.has(key)) continue
108
+ if ((capacity?.[key] ?? '0') === '0' && (allocatable?.[key] ?? '0') === '0') continue
109
+ rows.push({
110
+ key,
111
+ capacity: capacity?.[key],
112
+ allocatable: allocatable?.[key],
113
+ isGpu: isGpuResourceKey(key),
114
+ })
115
+ }
116
+ rows.sort((a, b) => {
117
+ if (a.isGpu !== b.isGpu) return a.isGpu ? -1 : 1
118
+ return a.key.localeCompare(b.key)
119
+ })
120
+ return rows
121
+ }
@@ -259,3 +259,14 @@ export function formatBytes(bytes: number): string {
259
259
  const i = Math.floor(Math.log(bytes) / Math.log(k))
260
260
  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
261
261
  }
262
+
263
+ /** Middle-ellipsis for long identifiers (image tags, pod names): keeps the
264
+ * start and the differentiating suffix. Returns the input when it fits. */
265
+ export function midTruncate(s: string, max = 24): string {
266
+ if (s.length <= max) return s
267
+ if (max <= 1) return '…'.slice(0, Math.max(0, max))
268
+ if (max <= 3) return `${s.slice(0, max - 1)}…`
269
+ const tail = Math.min(10, Math.floor(max / 2) - 1)
270
+ const head = max - tail - 1
271
+ return `${s.slice(0, head)}…${s.slice(-tail)}`
272
+ }
@@ -9,6 +9,7 @@ export * from './resource-hierarchy'
9
9
  export * from './log-format'
10
10
  export * from './download'
11
11
  export * from './env-from'
12
+ export * from './extended-resources'
12
13
  export * from './api-resources'
13
14
  export * from './skeleton-yaml'
14
15
  export * from './k8s-errors'
@@ -19,3 +20,5 @@ export * from './gitops-owner'
19
20
  export * from './gitops-route'
20
21
  export * from './rbac-badges'
21
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
+ })