@skyhook-io/k8s-ui 1.7.10 → 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/GitOpsTableView.tsx +22 -2
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
- package/src/components/issues/IssuesView.tsx +169 -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 +74 -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/CreateResourceDialog.tsx +32 -3
- package/src/components/shared/EditableYamlView.tsx +31 -2
- 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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { ChevronRight, Copy, Check, Tag, AlertTriangle, CheckCircle, ExternalLink, Layers, X, Minus } from 'lucide-react'
|
|
3
3
|
import { clsx } from 'clsx'
|
|
4
|
-
import { formatAge, formatDuration } from '../resources/resource-utils'
|
|
4
|
+
import { formatAge, formatDuration, formatResources } from '../resources/resource-utils'
|
|
5
|
+
import { getEffectiveResources } from '../../utils/extended-resources'
|
|
5
6
|
import { Tooltip } from './Tooltip'
|
|
6
7
|
import { getKindColorClass } from '../ui/Badge'
|
|
7
8
|
|
|
@@ -489,6 +490,18 @@ export function MetadataSection({ data }: { data: any }) {
|
|
|
489
490
|
)
|
|
490
491
|
}
|
|
491
492
|
|
|
493
|
+
// Templates never get apiserver request-defaulting, so render the effective
|
|
494
|
+
// view (requests, falling back to limits) — limits-only GPU specs included.
|
|
495
|
+
function ContainerResourcesLine({ resources }: { resources: any }) {
|
|
496
|
+
const effective = getEffectiveResources(resources)
|
|
497
|
+
if (Object.keys(effective).length === 0) return null
|
|
498
|
+
return (
|
|
499
|
+
<div className="text-xs text-theme-text-tertiary mt-1" title="Effective requests (requests, falling back to limits)">
|
|
500
|
+
Resources: {formatResources(effective)}
|
|
501
|
+
</div>
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
|
|
492
505
|
export function PodTemplateSection({ template }: { template: any }) {
|
|
493
506
|
if (!template) return null
|
|
494
507
|
const initContainers = template.spec?.initContainers || []
|
|
@@ -508,6 +521,7 @@ export function PodTemplateSection({ template }: { template: any }) {
|
|
|
508
521
|
$ {[...(c.command || []), ...(c.args || [])].join(' ')}
|
|
509
522
|
</div>
|
|
510
523
|
)}
|
|
524
|
+
<ContainerResourcesLine resources={c.resources} />
|
|
511
525
|
</div>
|
|
512
526
|
))}
|
|
513
527
|
<div className="text-xs text-theme-text-tertiary font-medium uppercase tracking-wide mt-3">Containers</div>
|
|
@@ -522,6 +536,7 @@ export function PodTemplateSection({ template }: { template: any }) {
|
|
|
522
536
|
Ports: {c.ports.map((p: any) => `${p.name ? `${p.name}: ` : ''}${p.containerPort}/${p.protocol || 'TCP'}`).join(', ')}
|
|
523
537
|
</div>
|
|
524
538
|
)}
|
|
539
|
+
<ContainerResourcesLine resources={c.resources} />
|
|
525
540
|
</div>
|
|
526
541
|
))}
|
|
527
542
|
</div>
|
|
@@ -664,6 +679,9 @@ export function formatKindName(kind: string): string {
|
|
|
664
679
|
horizontalpodautoscalers: 'HPA', nodes: 'Node', namespaces: 'Namespace',
|
|
665
680
|
persistentvolumeclaims: 'PVC', persistentvolumes: 'PV',
|
|
666
681
|
httpproxies: 'HTTPProxy',
|
|
682
|
+
resourceclaims: 'ResourceClaim', resourceclaimtemplates: 'ResourceClaimTemplate',
|
|
683
|
+
deviceclasses: 'DeviceClass', resourceslices: 'ResourceSlice',
|
|
684
|
+
clusterpolicies: 'ClusterPolicy', nvidiadrivers: 'NVIDIADriver',
|
|
667
685
|
}
|
|
668
686
|
if (names[k]) return names[k]
|
|
669
687
|
|
|
@@ -726,6 +744,7 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
|
|
|
726
744
|
(relationships.scalers && relationships.scalers.length > 0) ||
|
|
727
745
|
(relationships.pdbs && relationships.pdbs.length > 0) ||
|
|
728
746
|
(relationships.networkPolicies && relationships.networkPolicies.length > 0) ||
|
|
747
|
+
(relationships.resourceClaims && relationships.resourceClaims.length > 0) ||
|
|
729
748
|
relationships.scaleTarget
|
|
730
749
|
|
|
731
750
|
if (!hasRelationships) return null
|
|
@@ -772,6 +791,9 @@ export function RelatedResourcesSection({ relationships, onNavigate }: RelatedRe
|
|
|
772
791
|
{relationships.networkPolicies && relationships.networkPolicies.length > 0 && (
|
|
773
792
|
<RelationshipGroup label="Network Policies" refs={dedupeRefs(relationships.networkPolicies)} onNavigate={onNavigate} />
|
|
774
793
|
)}
|
|
794
|
+
{relationships.resourceClaims && relationships.resourceClaims.length > 0 && (
|
|
795
|
+
<RelationshipGroup label="Resource Claims" refs={dedupeRefs(relationships.resourceClaims)} onNavigate={onNavigate} />
|
|
796
|
+
)}
|
|
775
797
|
{relationships.scaleTarget && (
|
|
776
798
|
<RelationshipGroup label="Scale Target" refs={[relationships.scaleTarget]} onNavigate={onNavigate} />
|
|
777
799
|
)}
|
package/src/types/core.ts
CHANGED
|
@@ -441,6 +441,7 @@ export interface Relationships {
|
|
|
441
441
|
pods?: ResourceRef[]
|
|
442
442
|
serviceAccount?: ResourceRef // For Pods: derived from pod.spec.serviceAccountName
|
|
443
443
|
node?: ResourceRef // For scheduled Pods: derived from pod.spec.nodeName
|
|
444
|
+
resourceClaims?: ResourceRef[] // For Pods: DRA ResourceClaims (direct + template-generated)
|
|
444
445
|
}
|
|
445
446
|
|
|
446
447
|
// Parsed X.509 certificate metadata (from backend cert parsing)
|
|
@@ -170,6 +170,27 @@ export function formatGroupName(group: string): string {
|
|
|
170
170
|
'sparkoperator.k8s.io': 'Spark',
|
|
171
171
|
'kubeflow.org': 'Kubeflow',
|
|
172
172
|
'snapshot.storage.k8s.io': 'Snapshots',
|
|
173
|
+
'karpenter.sh': 'Karpenter',
|
|
174
|
+
'karpenter.k8s.aws': 'Karpenter',
|
|
175
|
+
'karpenter.azure.com': 'Karpenter',
|
|
176
|
+
'karpenter.k8s.gcp': 'Karpenter',
|
|
177
|
+
'resource.k8s.io': 'Dynamic Resource Allocation',
|
|
178
|
+
'kueue.x-k8s.io': 'Kueue',
|
|
179
|
+
'autoscaling.x-k8s.io': 'Cluster Autoscaler',
|
|
180
|
+
'serving.kserve.io': 'KServe',
|
|
181
|
+
'ray.io': 'KubeRay',
|
|
182
|
+
'leaderworkerset.x-k8s.io': 'LeaderWorkerSet',
|
|
183
|
+
'jobset.x-k8s.io': 'JobSet',
|
|
184
|
+
'inference.networking.k8s.io': 'Inference Gateway',
|
|
185
|
+
'inference.networking.x-k8s.io': 'Inference Gateway',
|
|
186
|
+
'nvidia.com': 'NVIDIA GPU Operator',
|
|
187
|
+
'scheduling.run.ai': 'KAI Scheduler',
|
|
188
|
+
'kai.scheduler': 'KAI Scheduler',
|
|
189
|
+
'kaito.sh': 'KAITO',
|
|
190
|
+
'batch.volcano.sh': 'Volcano',
|
|
191
|
+
'scheduling.volcano.sh': 'Volcano',
|
|
192
|
+
'flow.volcano.sh': 'Volcano',
|
|
193
|
+
'bus.volcano.sh': 'Volcano',
|
|
173
194
|
}
|
|
174
195
|
if (knownGroups[group]) return knownGroups[group]
|
|
175
196
|
// Suffix rules — for unbounded provider group sets (Crossplane providers ship
|
|
@@ -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'
|