@skyhook-io/k8s-ui 1.13.0 → 1.13.4

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 (110) hide show
  1. package/package.json +2 -2
  2. package/src/components/applications/AppTooltips.tsx +13 -1
  3. package/src/components/applications/ApplicationDetail.test.tsx +25 -0
  4. package/src/components/applications/ApplicationDetail.tsx +41 -9
  5. package/src/components/applications/ApplicationsView.tsx +18 -3
  6. package/src/components/applications/ReadyBar.tsx +3 -2
  7. package/src/components/applications/applications-view.test.tsx +91 -0
  8. package/src/components/cluster-switcher/ClusterSwitcher.tsx +35 -7
  9. package/src/components/issues/IssuesView.tsx +40 -31
  10. package/src/components/issues/ResourceIssuesSection.tsx +3 -2
  11. package/src/components/issues/index.ts +3 -1
  12. package/src/components/issues/issue-timing.ts +123 -4
  13. package/src/components/issues/issues.test.ts +221 -10
  14. package/src/components/issues/types.ts +31 -5
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
  16. package/src/components/resources/ResourcesView.tsx +973 -100
  17. package/src/components/resources/column-settings-key.test.ts +98 -0
  18. package/src/components/resources/column-visibility-merge.test.ts +64 -0
  19. package/src/components/resources/curated-column-ownership.test.ts +236 -0
  20. package/src/components/resources/gateway-policy-status.test.ts +291 -0
  21. package/src/components/resources/gateway-policy-status.ts +237 -0
  22. package/src/components/resources/generic-status.test.ts +199 -0
  23. package/src/components/resources/generic-status.ts +171 -0
  24. package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
  25. package/src/components/resources/index.ts +9 -1
  26. package/src/components/resources/istio-gateway-filter.test.ts +97 -0
  27. package/src/components/resources/printer-columns.test.ts +298 -0
  28. package/src/components/resources/printer-columns.ts +249 -0
  29. package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
  30. package/src/components/resources/renderers/EventRenderer.tsx +3 -5
  31. package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
  32. package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
  33. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  34. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
  35. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  36. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  37. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  38. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  39. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  40. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  41. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  43. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  44. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  45. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  46. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  47. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  48. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  49. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  50. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  51. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  52. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  53. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  54. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  55. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  56. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  57. package/src/components/resources/resource-utils-hpa.ts +6 -2
  58. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  59. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  60. package/src/components/resources/resource-utils-kai.ts +105 -0
  61. package/src/components/resources/resource-utils-kaito.ts +114 -0
  62. package/src/components/resources/resource-utils-kserve.ts +186 -0
  63. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  64. package/src/components/resources/resource-utils-kueue.ts +198 -0
  65. package/src/components/resources/resource-utils-nim.ts +109 -0
  66. package/src/components/resources/resource-utils-ray.ts +214 -0
  67. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  68. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  69. package/src/components/resources/resource-utils-volcano.ts +176 -0
  70. package/src/components/resources/resource-utils.ts +156 -47
  71. package/src/components/resources/resources-column-filter.test.ts +27 -1
  72. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  73. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  74. package/src/components/shared/EditableYamlView.tsx +4 -2
  75. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  76. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  77. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  78. package/src/components/shared/SetImageDialog.test.ts +129 -0
  79. package/src/components/shared/SetImageDialog.tsx +577 -0
  80. package/src/components/shared/index.ts +1 -0
  81. package/src/components/shared/revision-role-badges.test.ts +56 -4
  82. package/src/components/timeline/TimelineList.tsx +19 -3
  83. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  84. package/src/components/trace/ReachabilityView.tsx +1 -1
  85. package/src/components/trace/TracePanel.tsx +1 -0
  86. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  87. package/src/components/ui/SelectMenu.tsx +202 -28
  88. package/src/components/ui/drawer-components.tsx +12 -0
  89. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  90. package/src/components/workload/WorkloadView.tsx +146 -83
  91. package/src/hooks/useProgressiveRefresh.ts +37 -0
  92. package/src/source-hygiene.test.ts +28 -0
  93. package/src/theme/tailwind-theme.css +4 -0
  94. package/src/types/core.ts +62 -4
  95. package/src/utils/api-resources.test.ts +40 -1
  96. package/src/utils/api-resources.ts +11 -1
  97. package/src/utils/applications.ts +50 -0
  98. package/src/utils/bulk-workload-actions.test.ts +20 -0
  99. package/src/utils/bulk-workload-actions.ts +15 -0
  100. package/src/utils/clipboard.test.ts +86 -0
  101. package/src/utils/clipboard.ts +50 -0
  102. package/src/utils/extended-resources.ts +4 -7
  103. package/src/utils/format.test.ts +83 -1
  104. package/src/utils/format.ts +80 -1
  105. package/src/utils/inClusterConsent.test.ts +9 -0
  106. package/src/utils/inClusterConsent.ts +9 -9
  107. package/src/utils/index.ts +1 -0
  108. package/src/utils/navigation.test.ts +31 -1
  109. package/src/utils/navigation.ts +21 -2
  110. package/src/utils/workload-rollout.ts +292 -0
package/src/types/core.ts CHANGED
@@ -59,6 +59,55 @@ export interface WorkloadWritePermissions {
59
59
  rollouts: boolean
60
60
  }
61
61
 
62
+ export type WorkloadContainerType = 'container' | 'initContainer'
63
+
64
+ export interface WorkloadContainerImage {
65
+ type: WorkloadContainerType
66
+ name: string
67
+ image: string
68
+ }
69
+
70
+ export interface WorkloadImageTarget {
71
+ group: string
72
+ resource: string
73
+ kind: string
74
+ namespace: string
75
+ name: string
76
+ }
77
+
78
+ export type WorkloadUpdateBehaviorType =
79
+ | 'rolling'
80
+ | 'recreate'
81
+ | 'paused'
82
+ | 'onDelete'
83
+ | 'partitioned'
84
+ | 'canary'
85
+ | 'blueGreen'
86
+
87
+ export interface WorkloadUpdateBehavior {
88
+ type: WorkloadUpdateBehaviorType
89
+ partition?: number
90
+ autoPromote?: boolean
91
+ gated?: boolean
92
+ }
93
+
94
+ export interface WorkloadImageInventory {
95
+ target: WorkloadImageTarget
96
+ containers: WorkloadContainerImage[]
97
+ behavior: WorkloadUpdateBehavior
98
+ }
99
+
100
+ export interface WorkloadImageUpdate {
101
+ type: WorkloadContainerType
102
+ name: string
103
+ previousImage: string
104
+ image: string
105
+ }
106
+
107
+ export interface SetWorkloadImagesResult extends WorkloadImageInventory {
108
+ object: Record<string, unknown>
109
+ }
110
+
62
111
  export interface IntegrationCapability {
63
112
  state: CapacityIntegrationState
64
113
  reasonCode?: string
@@ -112,6 +161,7 @@ export interface CloudConnectCapability {
112
161
  export interface FeatureCapabilities {
113
162
  yamlReview?: boolean
114
163
  yamlSchemas?: boolean
164
+ workloadImages?: boolean
115
165
  }
116
166
 
117
167
  // DeploymentMode is the closed set of topologies Radar can run in.
@@ -304,6 +354,7 @@ export interface PodSummary {
304
354
  // K8s Event (from SSE stream)
305
355
  export interface K8sEvent {
306
356
  kind: string
357
+ group?: string
307
358
  namespace: string
308
359
  name: string
309
360
  operation: 'add' | 'update' | 'delete'
@@ -455,12 +506,15 @@ export interface ClusterInfo {
455
506
  // Context info for context switching
456
507
  export interface ContextInfo {
457
508
  name: string
509
+ /** Original context name inside its source file. Set for isolated sources
510
+ * so display code need not infer backend-added collision qualifiers. */
511
+ originalName?: string
458
512
  cluster: string
459
513
  user: string
460
514
  namespace: string
461
515
  isCurrent: boolean
462
- /** Source kubeconfig label (e.g. "kube-cluster-paris"). Set by backend
463
- * only when 2+ kubeconfig files are loaded; empty otherwise. */
516
+ /** Source kubeconfig label (e.g. "kube-cluster-paris"). Set by the backend
517
+ * for contexts loaded through the isolated source registry. */
464
518
  source?: string
465
519
  }
466
520
 
@@ -622,6 +676,7 @@ export type HPADiagnosisState =
622
676
  | 'metrics_unavailable'
623
677
  | 'metrics_incomplete'
624
678
  | 'unable_to_scale'
679
+ | 'scaled_to_zero'
625
680
  | 'disabled'
626
681
  | 'pinned'
627
682
  | 'stale'
@@ -680,6 +735,7 @@ export interface APIResource {
680
735
  name: string // Plural name (e.g., "deployments")
681
736
  namespaced: boolean
682
737
  isCrd: boolean
738
+ featured?: boolean
683
739
  verbs: string[]
684
740
  }
685
741
 
@@ -1273,8 +1329,8 @@ export interface AggregatedFlow {
1273
1329
 
1274
1330
  // Cluster info for traffic detection
1275
1331
  export interface TrafficClusterInfo {
1276
- platform: string // gke, eks, aks, generic
1277
- cni: string // cilium, calico, flannel, vpc-cni, azure-cni
1332
+ platform: string // rke2, gke, eks, aks, minikube, kind, docker-desktop, openshift, rancher, generic
1333
+ cni: string // cilium, canal, calico, flannel, vpc-cni, azure-cni, gke-native, unknown
1278
1334
  dataplaneV2: boolean
1279
1335
  clusterName?: string
1280
1336
  k8sVersion?: string
@@ -1431,6 +1487,8 @@ export interface WorkloadPodInfo {
1431
1487
  stepID?: string
1432
1488
  stepName?: string
1433
1489
  stepPhase?: string
1490
+ revisionIdentity?: string
1491
+ updatedRevision?: boolean
1434
1492
  }
1435
1493
 
1436
1494
  // SSE event types for workload log streaming
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { categorizeResources, findAPIResourceForRoute, formatGroupName, shortenGroupName } from './api-resources'
2
+ import { categorizeResources, findAPIResourceForRoute, formatGroupName, isCoreBatchJob, shortenGroupName } from './api-resources'
3
3
 
4
4
  describe('findAPIResourceForRoute', () => {
5
5
  const resources = [
@@ -45,6 +45,7 @@ describe('formatGroupName', () => {
45
45
  expect(formatGroupName('sql.cnrm.cloud.google.com')).toBe('Config Connector')
46
46
  expect(formatGroupName('crd.k8s.amazonaws.com')).toBe('AWS VPC CNI')
47
47
  expect(formatGroupName('acid.zalan.do')).toBe('Zalando Postgres')
48
+ expect(formatGroupName('llm-d.ai')).toBe('llm-d')
48
49
  })
49
50
 
50
51
  it('formats unmapped API groups without exposing raw domain strings', () => {
@@ -86,4 +87,42 @@ describe('categorizeResources', () => {
86
87
  expect(networking?.resources.some(r => r.kind === 'WidgetRoute')).toBe(false)
87
88
  expect(networkingAPIs?.resources.map(r => r.kind)).toEqual(['WidgetRoute'])
88
89
  })
90
+
91
+ it('surfaces only the discovered Kubernetes 1.37 APIs in the generic category', () => {
92
+ const resources = [
93
+ { group: 'scheduling.k8s.io', version: 'v1beta1', kind: 'Workload', name: 'workloads', namespaced: true, isCrd: false, featured: true, verbs: ['get', 'list', 'watch'] },
94
+ { group: 'scheduling.k8s.io', version: 'v1beta1', kind: 'PodGroup', name: 'podgroups', namespaced: true, isCrd: false, featured: true, verbs: ['get', 'list', 'watch'] },
95
+ { group: 'scheduling.k8s.io', version: 'v1alpha3', kind: 'CompositePodGroup', name: 'compositepodgroups', namespaced: true, isCrd: false, featured: true, verbs: ['get', 'list', 'watch'] },
96
+ { group: 'certificates.k8s.io', version: 'v1', kind: 'PodCertificateRequest', name: 'podcertificaterequests', namespaced: true, isCrd: false, featured: true, verbs: ['get', 'list', 'watch'] },
97
+ { group: 'certificates.k8s.io', version: 'v1', kind: 'ClusterTrustBundle', name: 'clustertrustbundles', namespaced: false, isCrd: false, featured: true, verbs: ['get', 'list', 'watch'] },
98
+ { group: 'authentication.k8s.io', version: 'v1', kind: 'TokenReview', name: 'tokenreviews', namespaced: false, isCrd: false, verbs: ['list'] },
99
+ ]
100
+
101
+ const category = categorizeResources(resources).find(c => c.name === 'Other Kubernetes APIs')
102
+ expect(category?.resources.map((resource) => resource.kind)).toEqual([
103
+ 'ClusterTrustBundle',
104
+ 'CompositePodGroup',
105
+ 'PodCertificateRequest',
106
+ 'PodGroup',
107
+ 'Workload',
108
+ ])
109
+ expect(category?.resources.some(resource => resource.kind === 'TokenReview')).toBe(false)
110
+ })
111
+
112
+ it('does not invent disabled 1.37 APIs or merge colliding CRDs', () => {
113
+ const categories = categorizeResources([
114
+ { group: 'kueue.x-k8s.io', version: 'v1beta1', kind: 'Workload', name: 'workloads', namespaced: true, isCrd: true, verbs: ['list'] },
115
+ ])
116
+
117
+ expect(categories.find(c => c.name === 'Other Kubernetes APIs')).toBeUndefined()
118
+ expect(categories.find(c => c.name === 'Kueue')?.resources.map(resource => resource.kind)).toEqual(['Workload'])
119
+ })
120
+ })
121
+
122
+ describe('isCoreBatchJob', () => {
123
+ it('distinguishes Kubernetes Jobs from same-kind custom resources', () => {
124
+ expect(isCoreBatchJob('Job', 'batch')).toBe(true)
125
+ expect(isCoreBatchJob('jobs')).toBe(true)
126
+ expect(isCoreBatchJob('Job', 'batch.volcano.sh')).toBe(false)
127
+ })
89
128
  })
@@ -14,7 +14,6 @@ const STORAGE_KINDS = ['PersistentVolumeClaim', 'PersistentVolume', 'StorageClas
14
14
  const ACCESS_CONTROL_KINDS = ['ServiceAccount', 'Role', 'ClusterRole', 'RoleBinding', 'ClusterRoleBinding']
15
15
  const CLUSTER_KINDS = ['Node', 'Namespace', 'Event']
16
16
  const CORE_CATEGORY_NAMES = new Set(['Workloads', 'Networking', 'Configuration', 'Storage', 'Access Control', 'Cluster'])
17
-
18
17
  // Core resources that must always be present (fallback if API discovery misses them)
19
18
  export const CORE_RESOURCES: APIResource[] = [
20
19
  { group: '', version: 'v1', kind: 'Pod', name: 'pods', namespaced: true, isCrd: false, verbs: ['list', 'get', 'watch'] },
@@ -66,6 +65,11 @@ export function findAPIResourceForRoute(
66
65
  ?? resources?.find(matchesRoute)
67
66
  }
68
67
 
68
+ export function isCoreBatchJob(kind: string, group?: string): boolean {
69
+ const normalizedKind = kind.toLowerCase()
70
+ return (normalizedKind === 'job' || normalizedKind === 'jobs') && (!group || group === 'batch')
71
+ }
72
+
69
73
  // Resources that should be hidden from the sidebar
70
74
  const HIDDEN_KINDS = ['PodMetrics', 'NodeMetrics']
71
75
 
@@ -100,6 +104,7 @@ export function categorizeResources(resources: APIResource[]): ResourceCategory[
100
104
  const storage = coreResources.filter(r => STORAGE_KINDS.includes(r.kind))
101
105
  const accessControl = coreResources.filter(r => ACCESS_CONTROL_KINDS.includes(r.kind))
102
106
  const cluster = coreResources.filter(r => CLUSTER_KINDS.includes(r.kind))
107
+ const otherKubernetesAPIs = coreResources.filter(r => r.featured)
103
108
 
104
109
  const crds = uniqueResources.filter(r => r.isCrd)
105
110
  const crdGroups = new Map<string, APIResource[]>()
@@ -115,6 +120,7 @@ export function categorizeResources(resources: APIResource[]): ResourceCategory[
115
120
  addToCategory('Storage', storage)
116
121
  addToCategory('Access Control', accessControl)
117
122
  addToCategory('Cluster', cluster)
123
+ addToCategory('Other Kubernetes APIs', otherKubernetesAPIs)
118
124
 
119
125
  for (const [group, groupResources] of crdGroups) {
120
126
  addToCategory(formatGroupName(group), groupResources)
@@ -215,7 +221,11 @@ export function formatGroupName(group: string): string {
215
221
  'jobset.x-k8s.io': 'JobSet',
216
222
  'inference.networking.k8s.io': 'Inference Gateway',
217
223
  'inference.networking.x-k8s.io': 'Inference Gateway',
224
+ 'llm-d.ai': 'llm-d',
218
225
  'nvidia.com': 'NVIDIA GPU Operator',
226
+ 'apps.nvidia.com': 'NVIDIA NIM',
227
+ 'amd.com': 'AMD GPU Operator',
228
+ 'trainer.kubeflow.org': 'Kubeflow',
219
229
  'scheduling.run.ai': 'KAI Scheduler',
220
230
  'kai.scheduler': 'KAI Scheduler',
221
231
  'kaito.sh': 'KAITO',
@@ -1,4 +1,5 @@
1
1
  import type { ResourceRef } from '../types/core'
2
+ import { rolloutActivityLevel, type WorkloadRolloutActivity } from './workload-rollout'
2
3
 
3
4
  // Shared model for the Applications surface — host-agnostic. The OSS single-
4
5
  // cluster view and (eventually) the Cloud fleet view both build on these types
@@ -26,9 +27,58 @@ export interface AppWorkload {
26
27
  desired: number;
27
28
  restarts: number;
28
29
  reason?: string;
30
+ rollout?: WorkloadRolloutActivity;
29
31
  batch?: AppBatchSummary;
30
32
  }
31
33
 
34
+ export interface AppRolloutSummary {
35
+ count: number
36
+ label: string
37
+ detail: string
38
+ details: string[]
39
+ health: AppHealth
40
+ }
41
+
42
+ export function rolloutDisplayHealth(activity: WorkloadRolloutActivity, workloadHealth: string): AppHealth {
43
+ const rolloutHealth: AppHealth = rolloutActivityLevel(activity)
44
+ const servingHealth = healthOf(workloadHealth)
45
+ return servingHealth === 'healthy' || servingHealth === 'neutral'
46
+ ? rolloutHealth
47
+ : worstHealth([rolloutHealth, servingHealth])
48
+ }
49
+
50
+ export function rolloutSummaryForApps(apps: AppRow[]): AppRolloutSummary | null {
51
+ const entries = apps.flatMap((app) => app.workloads
52
+ .filter((workload) => workload.rollout && (workload.rollout.active || workload.rollout.phase === 'stalled'))
53
+ .map((workload) => ({ app, workload, rollout: workload.rollout! })))
54
+ if (entries.length === 0) return null
55
+ const rank = (phase: WorkloadRolloutActivity['phase']) => {
56
+ if (phase === 'stalled') return 3
57
+ if (phase === 'paused') return 2
58
+ return 1
59
+ }
60
+ const worst = entries.reduce((current, entry) => rank(entry.rollout.phase) > rank(current.rollout.phase) ? entry : current)
61
+ const health = entries.reduce<AppHealth>(
62
+ (current, entry) => worstHealth([current, rolloutDisplayHealth(entry.rollout, entry.workload.health)]),
63
+ 'neutral',
64
+ )
65
+ const stalled = entries.filter((entry) => entry.rollout.phase === 'stalled').length
66
+ const label = entries.length === 1
67
+ ? worst.rollout.label
68
+ : stalled > 0
69
+ ? stalled === entries.length
70
+ ? `${stalled} stalled`
71
+ : `${stalled} stalled · ${entries.length - stalled} waiting`
72
+ : `${entries.length} updating`
73
+ const details = entries.map(({ app, workload, rollout }) => {
74
+ const prefix = apps.length > 1 ? `${app.name} / ` : ''
75
+ return `${prefix}${workload.name}: ${rollout.label}${rollout.detail ? ` — ${rollout.detail}` : ''}`
76
+ })
77
+ const visibleDetails = details.slice(0, 8)
78
+ const detail = `${visibleDetails.join(' · ')}${details.length > visibleDetails.length ? ` · +${details.length - visibleDetails.length} more` : ''}`
79
+ return { count: entries.length, label, detail, details, health }
80
+ }
81
+
32
82
  export interface AppBatchSummary {
33
83
  schedule?: string;
34
84
  suspended?: boolean;
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
2
2
  import {
3
3
  canBulkRestartKind,
4
4
  canBulkScaleKind,
5
+ canPatchWorkloadKind,
6
+ canSetWorkloadImages,
5
7
  intersectWorkloadWrites,
6
8
  } from './bulk-workload-actions'
7
9
  import type { Capabilities, WorkloadWritePermissions } from '../types/core'
@@ -28,6 +30,24 @@ describe('bulk workload action gating', () => {
28
30
  expect(canBulkRestartKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(true)
29
31
  })
30
32
 
33
+ it('shares exact group and kind checks with detail patch actions', () => {
34
+ const all = writes({ deployments: true, daemonSets: true, statefulSets: true, rollouts: true })
35
+
36
+ expect(canPatchWorkloadKind({ name: 'deployments', group: 'apps' }, all)).toBe(true)
37
+ expect(canPatchWorkloadKind({ name: 'rollouts', group: 'argoproj.io' }, all)).toBe(true)
38
+ expect(canPatchWorkloadKind({ name: 'deployments', group: 'example.com' }, all)).toBe(false)
39
+ expect(canPatchWorkloadKind({ name: 'services', group: '' }, all)).toBe(false)
40
+ expect(canPatchWorkloadKind({ name: 'deployments', group: 'apps' }, undefined)).toBe(false)
41
+ })
42
+
43
+ it('requires an explicit server advertisement for image updates', () => {
44
+ const all = writes({ deployments: true })
45
+
46
+ expect(canSetWorkloadImages({ name: 'deployments', group: 'apps' }, all, true)).toBe(true)
47
+ expect(canSetWorkloadImages({ name: 'deployments', group: 'apps' }, all, false)).toBe(false)
48
+ expect(canSetWorkloadImages({ name: 'deployments', group: 'apps' }, undefined, true)).toBe(false)
49
+ })
50
+
31
51
  it('requires the apps group for built-in workload restart', () => {
32
52
  const all = writes({ deployments: true, daemonSets: true, statefulSets: true })
33
53
 
@@ -8,6 +8,13 @@ export type BulkWorkloadKindInfo = {
8
8
  export function canBulkRestartKind(
9
9
  kind: BulkWorkloadKindInfo | null | undefined,
10
10
  writes: WorkloadWritePermissions | undefined,
11
+ ): boolean {
12
+ return canPatchWorkloadKind(kind, writes)
13
+ }
14
+
15
+ export function canPatchWorkloadKind(
16
+ kind: BulkWorkloadKindInfo | null | undefined,
17
+ writes: WorkloadWritePermissions | undefined,
11
18
  ): boolean {
12
19
  switch (kind?.name.toLowerCase()) {
13
20
  case 'deployments':
@@ -23,6 +30,14 @@ export function canBulkRestartKind(
23
30
  }
24
31
  }
25
32
 
33
+ export function canSetWorkloadImages(
34
+ kind: BulkWorkloadKindInfo | null | undefined,
35
+ writes: WorkloadWritePermissions | undefined,
36
+ supported: boolean,
37
+ ): boolean {
38
+ return supported && canPatchWorkloadKind(kind, writes)
39
+ }
40
+
26
41
  export function canBulkScaleKind(
27
42
  kind: BulkWorkloadKindInfo | null | undefined,
28
43
  writes: WorkloadWritePermissions | undefined,
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import { copyText } from './clipboard'
4
+
5
+ function fallbackDocument(copied: boolean) {
6
+ const document = {
7
+ createElement: vi.fn(() => textarea),
8
+ body: { appendChild: vi.fn() },
9
+ execCommand: vi.fn(() => copied),
10
+ activeElement: null as unknown,
11
+ }
12
+ const textarea = {
13
+ value: '',
14
+ style: {} as CSSStyleDeclaration,
15
+ setAttribute: vi.fn(),
16
+ select: vi.fn(() => { document.activeElement = textarea }),
17
+ setSelectionRange: vi.fn(),
18
+ remove: vi.fn(),
19
+ }
20
+ return { document, textarea }
21
+ }
22
+
23
+ describe('copyText', () => {
24
+ it('uses the synchronous fallback when the Clipboard API is unavailable on HTTP', async () => {
25
+ const { document } = fallbackDocument(true)
26
+
27
+ await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)
28
+
29
+ expect(document.execCommand).toHaveBeenCalledWith('copy')
30
+ })
31
+
32
+ it('falls back to a synchronous copy command when the Clipboard API rejects', async () => {
33
+ const clipboard = { writeText: vi.fn().mockRejectedValue(new DOMException('Blocked', 'NotAllowedError')) }
34
+ const { document, textarea } = fallbackDocument(true)
35
+
36
+ await expect(copyText('busybox', clipboard, document as unknown as Document)).resolves.toBe(true)
37
+
38
+ expect(clipboard.writeText).toHaveBeenCalledWith('busybox')
39
+ expect(document.body.appendChild).toHaveBeenCalledWith(textarea)
40
+ expect(textarea.value).toBe('busybox')
41
+ expect(textarea.select).toHaveBeenCalledOnce()
42
+ expect(document.execCommand).toHaveBeenCalledWith('copy')
43
+ expect(textarea.remove).toHaveBeenCalledOnce()
44
+ })
45
+
46
+ it('restores focus to the previously active element without scrolling', async () => {
47
+ const { document } = fallbackDocument(true)
48
+ const previouslyFocused = { focus: vi.fn(), isConnected: true }
49
+ document.activeElement = previouslyFocused
50
+
51
+ await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)
52
+
53
+ expect(previouslyFocused.focus).toHaveBeenCalledWith({ preventScroll: true })
54
+ })
55
+
56
+ it('leaves focus alone when a copy handler focused something else', async () => {
57
+ const { document, textarea } = fallbackDocument(true)
58
+ const previouslyFocused = { focus: vi.fn(), isConnected: true }
59
+ document.activeElement = previouslyFocused
60
+ const somethingElse = {}
61
+ textarea.select.mockImplementation(() => { document.activeElement = somethingElse })
62
+
63
+ await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)
64
+
65
+ expect(previouslyFocused.focus).not.toHaveBeenCalled()
66
+ })
67
+
68
+ it('does not refocus an element that left the DOM during the copy', async () => {
69
+ const { document } = fallbackDocument(true)
70
+ const previouslyFocused = { focus: vi.fn(), isConnected: false }
71
+ document.activeElement = previouslyFocused
72
+
73
+ await expect(copyText('busybox', undefined, document as unknown as Document)).resolves.toBe(true)
74
+
75
+ expect(previouslyFocused.focus).not.toHaveBeenCalled()
76
+ })
77
+
78
+ it('does not create a fallback element when the Clipboard API succeeds', async () => {
79
+ const clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }
80
+ const { document } = fallbackDocument(true)
81
+
82
+ await expect(copyText('busybox', clipboard, document as unknown as Document)).resolves.toBe(true)
83
+
84
+ expect(document.createElement).not.toHaveBeenCalled()
85
+ })
86
+ })
@@ -0,0 +1,50 @@
1
+ type ClipboardWriter = Pick<Clipboard, 'writeText'>
2
+
3
+ /**
4
+ * Copy text from a user gesture, including pages served from an insecure origin.
5
+ * The async Clipboard API is unavailable or rejects on plain HTTP, while the
6
+ * legacy command remains permitted when it runs synchronously from the click.
7
+ */
8
+ export async function copyText(
9
+ text: string,
10
+ clipboard: ClipboardWriter | undefined = typeof navigator === 'undefined' ? undefined : navigator.clipboard,
11
+ doc: Document | undefined = typeof document === 'undefined' ? undefined : document,
12
+ ): Promise<boolean> {
13
+ if (clipboard?.writeText) {
14
+ try {
15
+ await clipboard.writeText(text)
16
+ return true
17
+ } catch {
18
+ // Fall through for insecure origins and denied Clipboard API access.
19
+ }
20
+ }
21
+
22
+ if (!doc?.body) return false
23
+
24
+ const previouslyFocused = doc.activeElement as { focus?: (options?: FocusOptions) => void; isConnected?: boolean } | null
25
+ const textarea = doc.createElement('textarea')
26
+ textarea.value = text
27
+ textarea.readOnly = true
28
+ textarea.setAttribute('aria-hidden', 'true')
29
+ textarea.style.position = 'fixed'
30
+ textarea.style.opacity = '0'
31
+ textarea.style.pointerEvents = 'none'
32
+
33
+ try {
34
+ doc.body.appendChild(textarea)
35
+ textarea.select()
36
+ textarea.setSelectionRange(0, text.length)
37
+ return doc.execCommand('copy')
38
+ } catch {
39
+ return false
40
+ } finally {
41
+ // Restore focus only when the textarea still owns it — a copy handler may
42
+ // have legitimately focused something else — and without scrolling a
43
+ // now-offscreen element back into view.
44
+ const textareaOwnsFocus = doc.activeElement === (textarea as unknown as Element)
45
+ textarea.remove()
46
+ if (textareaOwnsFocus && previouslyFocused?.isConnected !== false) {
47
+ previouslyFocused?.focus?.({ preventScroll: true })
48
+ }
49
+ }
50
+ }
@@ -2,7 +2,7 @@
2
2
  // the GPU views. Keep all effective-request math here — K8s semantics for
3
3
  // extended resources are subtle and must not be reimplemented per call site.
4
4
 
5
- import { parseMemoryToBytes } from './format'
5
+ import { parseQuantityToNumber } from './format'
6
6
 
7
7
  const STANDARD_NODE_RESOURCES = new Set(['cpu', 'memory', 'pods', 'ephemeral-storage'])
8
8
 
@@ -48,14 +48,11 @@ export function getEffectiveResources(resources: any): Record<string, string> {
48
48
  }
49
49
 
50
50
  // K8s quantity → number. Counts are usually plain integers, but quantities may
51
- // legally carry suffixes: "3000m" is 3, "3k" is 3000.
51
+ // legally carry suffixes: "3000m" is 3, "3k" is 3000. The shared quantity
52
+ // parser is the one place that knows the suffix table.
52
53
  function asCount(value: unknown): number {
53
54
  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
55
+ return parseQuantityToNumber(String(value ?? ''))
59
56
  }
60
57
 
61
58
  function containerGpuCount(container: any): number {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { formatCPUString, formatMemoryString, parseMemoryToBytes } from './format'
2
+ import { formatCPUString, formatMemoryString, parseMemoryToBytes, parseQuantityToNumber } from './format'
3
3
 
4
4
  describe('formatCPUString', () => {
5
5
  it('renders exact zero as zero, not a fabricated near-zero', () => {
@@ -32,4 +32,86 @@ describe('parseMemoryToBytes', () => {
32
32
  expect(parseMemoryToBytes('-1Ki')).toBe(-1024)
33
33
  expect(parseMemoryToBytes('1Ki')).toBe(1024)
34
34
  })
35
+
36
+ // Aggregate cluster memory reaches suffixes a single node never carries —
37
+ // "1Pi" must not fall through to the bare-number fallback and read as 1 byte.
38
+ it('parses the large suffixes an aggregate capacity can carry', () => {
39
+ expect(parseMemoryToBytes('1Pi')).toBe(1024 ** 5)
40
+ expect(parseMemoryToBytes('1Ei')).toBe(1024 ** 6)
41
+ expect(parseMemoryToBytes('1P')).toBe(1000 ** 5)
42
+ expect(parseMemoryToBytes('1E')).toBe(1000 ** 6)
43
+ })
44
+ })
45
+
46
+ describe('parseQuantityToNumber', () => {
47
+ // The reported bug. A Quantity's canonical form collapses trailing zeros into
48
+ // an SI suffix, so a node started with --max-pods=1000 reports "1k". Read as
49
+ // a raw integer that is 1, and a node running 216 pods rendered as 216 / 1
50
+ // with the usage bar pegged at 21600%.
51
+ it('reads a pod capacity of 1000 as 1000, not 1', () => {
52
+ expect(parseQuantityToNumber('1k')).toBe(1000)
53
+ expect((216 / parseQuantityToNumber('1k')) * 100).toBeCloseTo(21.6)
54
+ })
55
+
56
+ it('only collapses zeros in groups of three, so nearby counts are unchanged', () => {
57
+ expect(parseQuantityToNumber('2k')).toBe(2000)
58
+ expect(parseQuantityToNumber('1020')).toBe(1020)
59
+ expect(parseQuantityToNumber('990')).toBe(990)
60
+ expect(parseQuantityToNumber('216')).toBe(216)
61
+ })
62
+
63
+ it('handles the sub-unit decimal suffixes a count can legally carry', () => {
64
+ expect(parseQuantityToNumber('3000m')).toBe(3)
65
+ expect(parseQuantityToNumber('1500m')).toBe(1.5)
66
+ expect(parseQuantityToNumber('1000000u')).toBe(1)
67
+ expect(parseQuantityToNumber('1000000000n')).toBe(1)
68
+ })
69
+
70
+ it('handles the whole decimal SI range', () => {
71
+ expect(parseQuantityToNumber('5M')).toBe(5e6)
72
+ expect(parseQuantityToNumber('5G')).toBe(5e9)
73
+ expect(parseQuantityToNumber('5T')).toBe(5e12)
74
+ expect(parseQuantityToNumber('5P')).toBe(5e15)
75
+ expect(parseQuantityToNumber('5E')).toBe(5e18)
76
+ })
77
+
78
+ it('handles binary suffixes in powers of 1024', () => {
79
+ expect(parseQuantityToNumber('1Ki')).toBe(1024)
80
+ expect(parseQuantityToNumber('2Gi')).toBe(2 * 1024 ** 3)
81
+ expect(parseQuantityToNumber('1Ei')).toBe(1024 ** 6)
82
+ })
83
+
84
+ // "1E3" is the exponent form (1000), while "1E" is exa. Deciding the exponent
85
+ // first is what keeps the two apart.
86
+ it('reads the decimal-exponent form without confusing it for exa', () => {
87
+ expect(parseQuantityToNumber('1e3')).toBe(1000)
88
+ expect(parseQuantityToNumber('1E3')).toBe(1000)
89
+ expect(parseQuantityToNumber('1.5e3')).toBe(1500)
90
+ expect(parseQuantityToNumber('1e-3')).toBe(0.001)
91
+ expect(parseQuantityToNumber('1E')).toBe(1e18)
92
+ })
93
+
94
+ it('preserves the sign', () => {
95
+ expect(parseQuantityToNumber('-1k')).toBe(-1000)
96
+ expect(parseQuantityToNumber('+1k')).toBe(1000)
97
+ })
98
+
99
+ // A number this cannot read is reported as unknown so the caller can fall back,
100
+ // rather than becoming a plausible-looking wrong number on screen.
101
+ it('yields zero for input it cannot parse rather than guessing', () => {
102
+ expect(parseQuantityToNumber('1z')).toBe(0)
103
+ expect(parseQuantityToNumber('1KB')).toBe(0)
104
+ expect(parseQuantityToNumber('abc')).toBe(0)
105
+ expect(parseQuantityToNumber('')).toBe(0)
106
+ expect(parseQuantityToNumber(' ')).toBe(0)
107
+ expect(parseQuantityToNumber(null)).toBe(0)
108
+ expect(parseQuantityToNumber(undefined)).toBe(0)
109
+ })
110
+
111
+ it('passes through numbers the API already decoded, and rejects non-finite ones', () => {
112
+ expect(parseQuantityToNumber(216)).toBe(216)
113
+ expect(parseQuantityToNumber(0)).toBe(0)
114
+ expect(parseQuantityToNumber(Number.NaN)).toBe(0)
115
+ expect(parseQuantityToNumber(Number.POSITIVE_INFINITY)).toBe(0)
116
+ })
35
117
  })