@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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/components/dock/TerminalTab.tsx +4 -2
  3. package/src/components/gitops/GitOpsTableView.tsx +22 -2
  4. package/src/components/gitops/insights/GitOpsInsightViews.tsx +1 -0
  5. package/src/components/issues/IssuesView.tsx +169 -17
  6. package/src/components/issues/index.ts +1 -1
  7. package/src/components/issues/issues.test.ts +4 -4
  8. package/src/components/issues/severity.ts +5 -0
  9. package/src/components/issues/types.ts +74 -0
  10. package/src/components/logs/LogCore.tsx +13 -2
  11. package/src/components/logs/LogToolbarSelects.tsx +6 -2
  12. package/src/components/logs/LogsViewer.tsx +66 -10
  13. package/src/components/logs/WorkloadLogsViewer.tsx +60 -8
  14. package/src/components/logs/useLogStream.ts +41 -3
  15. package/src/components/resources/ResourcesView.tsx +550 -52
  16. package/src/components/resources/column-filter-serialization.test.ts +26 -0
  17. package/src/components/resources/get-default-container-name.test.ts +31 -0
  18. package/src/components/resources/renderers/DeviceClassRenderer.tsx +49 -0
  19. package/src/components/resources/renderers/NodeRenderer.tsx +7 -0
  20. package/src/components/resources/renderers/NvidiaClusterPolicyRenderer.tsx +57 -0
  21. package/src/components/resources/renderers/NvidiaDriverRenderer.tsx +47 -0
  22. package/src/components/resources/renderers/PodRenderer.tsx +2 -2
  23. package/src/components/resources/renderers/ResourceClaimRenderer.tsx +118 -0
  24. package/src/components/resources/renderers/ResourceClaimTemplateRenderer.tsx +39 -0
  25. package/src/components/resources/renderers/ResourceSliceRenderer.tsx +72 -0
  26. package/src/components/resources/renderers/dra-cells.tsx +80 -0
  27. package/src/components/resources/renderers/index.ts +8 -0
  28. package/src/components/resources/renderers/nvidia-cells.tsx +43 -0
  29. package/src/components/resources/resource-utils-dra.ts +90 -0
  30. package/src/components/resources/resource-utils-nvidia.ts +63 -0
  31. package/src/components/resources/resource-utils.ts +62 -4
  32. package/src/components/shared/CreateResourceDialog.tsx +32 -3
  33. package/src/components/shared/EditableYamlView.tsx +31 -2
  34. package/src/components/shared/ResourceActionsBar.tsx +5 -4
  35. package/src/components/shared/ResourceRendererDispatch.test.tsx +103 -0
  36. package/src/components/shared/ResourceRendererDispatch.tsx +27 -2
  37. package/src/components/ui/ConfirmDialog.tsx +1 -1
  38. package/src/components/ui/drawer-components.tsx +23 -1
  39. package/src/types/core.ts +1 -0
  40. package/src/utils/api-resources.ts +21 -0
  41. package/src/utils/custom-columns.test.ts +111 -0
  42. package/src/utils/custom-columns.ts +49 -0
  43. package/src/utils/extended-resources.test.ts +152 -0
  44. package/src/utils/extended-resources.ts +121 -0
  45. 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
+ }
@@ -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'