@skyhook-io/k8s-ui 1.13.3 → 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 (97) hide show
  1. package/package.json +1 -1
  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 +39 -7
  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 +4 -1
  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 +964 -96
  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/PVCRenderer.test.tsx +44 -0
  36. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  37. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  39. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  40. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  41. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  42. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  43. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  44. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  45. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  46. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  47. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  48. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  49. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  50. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  51. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  52. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  53. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  54. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  55. package/src/components/resources/resource-utils-hpa.ts +6 -2
  56. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  57. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  58. package/src/components/resources/resource-utils-kai.ts +105 -0
  59. package/src/components/resources/resource-utils-kaito.ts +114 -0
  60. package/src/components/resources/resource-utils-kserve.ts +186 -0
  61. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  62. package/src/components/resources/resource-utils-kueue.ts +198 -0
  63. package/src/components/resources/resource-utils-nim.ts +109 -0
  64. package/src/components/resources/resource-utils-ray.ts +214 -0
  65. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  66. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  67. package/src/components/resources/resource-utils-volcano.ts +176 -0
  68. package/src/components/resources/resource-utils.ts +156 -47
  69. package/src/components/resources/resources-column-filter.test.ts +27 -1
  70. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  71. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  72. package/src/components/shared/EditableYamlView.tsx +4 -2
  73. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  74. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  75. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  76. package/src/components/shared/SetImageDialog.test.ts +129 -0
  77. package/src/components/shared/SetImageDialog.tsx +577 -0
  78. package/src/components/shared/index.ts +1 -0
  79. package/src/components/shared/revision-role-badges.test.ts +56 -4
  80. package/src/components/timeline/TimelineList.tsx +19 -3
  81. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  82. package/src/components/trace/ReachabilityView.tsx +1 -1
  83. package/src/components/ui/drawer-components.tsx +12 -0
  84. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  85. package/src/components/workload/WorkloadView.tsx +144 -81
  86. package/src/hooks/useProgressiveRefresh.ts +37 -0
  87. package/src/source-hygiene.test.ts +28 -0
  88. package/src/theme/tailwind-theme.css +4 -0
  89. package/src/types/core.ts +55 -0
  90. package/src/utils/api-resources.test.ts +40 -1
  91. package/src/utils/api-resources.ts +11 -1
  92. package/src/utils/applications.ts +50 -0
  93. package/src/utils/bulk-workload-actions.test.ts +20 -0
  94. package/src/utils/bulk-workload-actions.ts +15 -0
  95. package/src/utils/navigation.test.ts +31 -1
  96. package/src/utils/navigation.ts +21 -2
  97. 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'
@@ -625,6 +676,7 @@ export type HPADiagnosisState =
625
676
  | 'metrics_unavailable'
626
677
  | 'metrics_incomplete'
627
678
  | 'unable_to_scale'
679
+ | 'scaled_to_zero'
628
680
  | 'disabled'
629
681
  | 'pinned'
630
682
  | 'stale'
@@ -683,6 +735,7 @@ export interface APIResource {
683
735
  name: string // Plural name (e.g., "deployments")
684
736
  namespaced: boolean
685
737
  isCrd: boolean
738
+ featured?: boolean
686
739
  verbs: string[]
687
740
  }
688
741
 
@@ -1434,6 +1487,8 @@ export interface WorkloadPodInfo {
1434
1487
  stepID?: string
1435
1488
  stepName?: string
1436
1489
  stepPhase?: string
1490
+ revisionIdentity?: string
1491
+ updatedRevision?: boolean
1437
1492
  }
1438
1493
 
1439
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,
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect, afterEach } from 'vitest'
2
2
  import { englishPlural } from './pluralize'
3
- import { kindToPlural, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
3
+ import { kindToPlural, kindToPluralWithGroup, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
4
4
 
5
5
  afterEach(() => {
6
6
  resetNavigationMap()
@@ -224,6 +224,24 @@ describe('initNavigationMap', () => {
224
224
  ])
225
225
  expect(pluralToKind('pods')).toBe('Pod')
226
226
  })
227
+
228
+ test('keeps the virtual PodGroup alias distinct from scheduling PodGroup', () => {
229
+ initNavigationMap([
230
+ { group: 'scheduling.k8s.io', version: 'v1beta1', kind: 'PodGroup', name: 'podgroups', namespaced: true, isCrd: false, verbs: ['get', 'list'] },
231
+ { group: 'example.io', version: 'v1', kind: 'PodGroup', name: 'custompodgroups', namespaced: true, isCrd: true, verbs: ['get', 'list'] },
232
+ ])
233
+
234
+ expect(kindToPlural('PodGroup')).toBe('pods')
235
+ expect(kindToPluralWithGroup('PodGroup', 'scheduling.k8s.io')).toBe('podgroups')
236
+ expect(kindToPluralWithGroup('PodGroup', 'example.io')).toBe('custompodgroups')
237
+ expect(kindToPluralWithGroup('podgroups', 'scheduling.k8s.io')).toBe('podgroups')
238
+ })
239
+
240
+ test('preserves aliases and plural slugs before group discovery is initialized', () => {
241
+ expect(kindToPluralWithGroup('PodGroup', 'scheduling.k8s.io')).toBe('podgroups')
242
+ expect(kindToPluralWithGroup('CalicoGlobalNetworkPolicy', 'projectcalico.org')).toBe('globalnetworkpolicies')
243
+ expect(kindToPluralWithGroup('schedules', 'velero.io')).toBe('schedules')
244
+ })
227
245
  })
228
246
 
229
247
  describe('refToSelectedResource', () => {
@@ -241,6 +259,18 @@ describe('refToSelectedResource', () => {
241
259
  })
242
260
  })
243
261
 
262
+ test('uses the API group when a real resource collides with a virtual kind', () => {
263
+ initNavigationMap([
264
+ { group: 'scheduling.k8s.io', version: 'v1beta1', kind: 'PodGroup', name: 'podgroups', namespaced: true, isCrd: false, verbs: ['get', 'list'] },
265
+ ])
266
+ expect(refToSelectedResource({ kind: 'PodGroup', name: 'batch', namespace: 'default', group: 'scheduling.k8s.io' })).toEqual({
267
+ kind: 'podgroups',
268
+ name: 'batch',
269
+ namespace: 'default',
270
+ group: 'scheduling.k8s.io',
271
+ })
272
+ })
273
+
244
274
  test('preserves group field', () => {
245
275
  const result = refToSelectedResource({
246
276
  kind: 'Certificate',
@@ -42,10 +42,15 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
42
42
  networkpolicies: 'NetworkPolicy',
43
43
  }
44
44
 
45
+ const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
46
+ 'scheduling.k8s.io/podgroup': 'podgroups',
47
+ }
48
+
45
49
  // Dynamic map built from API discovery — populated by initNavigationMap().
46
50
  // Once populated, this is the source of truth for all kind↔plural lookups.
47
51
  let discoveredPluralToKind: Record<string, string> | null = null
48
52
  let discoveredKindToPlural: Record<string, string> | null = null
53
+ let discoveredGroupKindToPlural: Record<string, string> | null = null
49
54
 
50
55
  /**
51
56
  * Initialize navigation maps from discovered API resources.
@@ -55,6 +60,7 @@ let discoveredKindToPlural: Record<string, string> | null = null
55
60
  export function initNavigationMap(resources: APIResource[]) {
56
61
  const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
57
62
  const k2p: Record<string, string> = {}
63
+ const gk2p: Record<string, string> = {}
58
64
  for (const r of resources) {
59
65
  const plural = r.name.toLowerCase()
60
66
  // First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
@@ -62,15 +68,18 @@ export function initNavigationMap(resources: APIResource[]) {
62
68
  // "pods" with kind "PodMetrics") cannot hijack the core mapping.
63
69
  if (!(plural in p2k)) p2k[plural] = r.kind
64
70
  k2p[r.kind.toLowerCase()] = plural
71
+ gk2p[`${r.group}/${r.kind.toLowerCase()}`] = plural
65
72
  }
66
73
  discoveredPluralToKind = p2k
67
74
  discoveredKindToPlural = k2p
75
+ discoveredGroupKindToPlural = gk2p
68
76
  }
69
77
 
70
78
  /** Reset navigation maps to builtin-only state. For testing. */
71
79
  export function resetNavigationMap() {
72
80
  discoveredPluralToKind = null
73
81
  discoveredKindToPlural = null
82
+ discoveredGroupKindToPlural = null
74
83
  }
75
84
 
76
85
  function getPluralToKind(): Record<string, string> {
@@ -90,6 +99,8 @@ export function kindToPlural(kind: string): string {
90
99
  // Already a known plural — return as-is to prevent double-pluralization
91
100
  if (kindLower in pluralToKindMap) return kindLower
92
101
 
102
+ if (kindLower === 'podgroup') return 'pods'
103
+
93
104
  // Lookup from discovered API resources (singular kind → plural name)
94
105
  if (discoveredKindToPlural && kindLower in discoveredKindToPlural) {
95
106
  return discoveredKindToPlural[kindLower]
@@ -99,7 +110,6 @@ export function kindToPlural(kind: string): string {
99
110
  const aliases: Record<string, string> = {
100
111
  horizontalpodautoscaler: 'horizontalpodautoscalers',
101
112
  pvc: 'persistentvolumeclaims',
102
- podgroup: 'pods',
103
113
  caliconetworkpolicy: 'networkpolicies',
104
114
  calicoglobalnetworkpolicy: 'globalnetworkpolicies',
105
115
  calicostagednetworkpolicy: 'stagednetworkpolicies',
@@ -122,6 +132,15 @@ export function kindToPlural(kind: string): string {
122
132
  return englishPlural(kindLower)
123
133
  }
124
134
 
135
+ export function kindToPluralWithGroup(kind: string, group: string): string {
136
+ if (!group) return kindToPlural(kind)
137
+ const kindLower = kind.toLowerCase()
138
+ const pluralToKindMap = getPluralToKind()
139
+ if (kindLower in pluralToKindMap) return kindLower
140
+ const groupKind = `${group}/${kindLower}`
141
+ return discoveredGroupKindToPlural?.[groupKind] ?? BUILTIN_GROUP_KIND_TO_PLURAL[groupKind] ?? kindToPlural(kind)
142
+ }
143
+
125
144
  /**
126
145
  * Convert a plural API resource name (e.g., "deployments") back to singular PascalCase kind (e.g., "Deployment").
127
146
  * Inverse of kindToPlural. Converts plural API resource names from URLs back to
@@ -151,7 +170,7 @@ export function refToSelectedResource(
151
170
  ref: Pick<ResourceRef, 'kind' | 'name' | 'group'> & { namespace?: string },
152
171
  ): SelectedResource {
153
172
  return {
154
- kind: kindToPlural(ref.kind),
173
+ kind: kindToPluralWithGroup(ref.kind, ref.group ?? ''),
155
174
  namespace: ref.namespace ?? '',
156
175
  name: ref.name,
157
176
  group: ref.group,