@skyhook-io/k8s-ui 1.7.11 → 1.7.12
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/dock/TerminalTab.tsx +4 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
- package/src/components/issues/IssuesView.tsx +64 -17
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +4 -4
- package/src/components/issues/severity.ts +5 -0
- package/src/components/issues/types.ts +43 -0
- package/src/components/logs/LogCore.tsx +13 -2
- package/src/components/logs/LogToolbarSelects.tsx +6 -2
- package/src/components/logs/LogsViewer.tsx +66 -10
- package/src/components/logs/WorkloadLogsViewer.tsx +60 -8
- package/src/components/logs/useLogStream.ts +41 -3
- package/src/components/resources/ResourcesView.tsx +550 -52
- package/src/components/resources/column-filter-serialization.test.ts +26 -0
- package/src/components/resources/get-default-container-name.test.ts +31 -0
- package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
- package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
- package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
- package/src/components/resources/renderers/PodRenderer.tsx +2 -2
- package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
- package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
- package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
- package/src/components/resources/renderers/dra-cells.tsx +80 -0
- package/src/components/resources/renderers/index.ts +8 -0
- package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
- package/src/components/resources/resource-utils-dra.ts +90 -0
- package/src/components/resources/resource-utils-nvidia.ts +63 -0
- package/src/components/resources/resource-utils.ts +62 -4
- package/src/components/shared/ResourceActionsBar.tsx +5 -4
- package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
- package/src/components/ui/ConfirmDialog.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +23 -1
- package/src/types/core.ts +1 -0
- package/src/utils/api-resources.ts +21 -0
- package/src/utils/custom-columns.test.ts +111 -0
- package/src/utils/custom-columns.ts +49 -0
- package/src/utils/extended-resources.test.ts +152 -0
- package/src/utils/extended-resources.ts +121 -0
- package/src/utils/index.ts +1 -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
|
+
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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'
|