@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.
- package/package.json +1 -1
- package/src/components/applications/AppTooltips.tsx +13 -1
- package/src/components/applications/ApplicationDetail.test.tsx +25 -0
- package/src/components/applications/ApplicationDetail.tsx +39 -7
- package/src/components/applications/ApplicationsView.tsx +18 -3
- package/src/components/applications/ReadyBar.tsx +3 -2
- package/src/components/applications/applications-view.test.tsx +91 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +40 -31
- package/src/components/issues/ResourceIssuesSection.tsx +3 -2
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +123 -4
- package/src/components/issues/issues.test.ts +221 -10
- package/src/components/issues/types.ts +31 -5
- package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
- package/src/components/resources/ResourcesView.tsx +964 -96
- package/src/components/resources/column-settings-key.test.ts +98 -0
- package/src/components/resources/column-visibility-merge.test.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +236 -0
- package/src/components/resources/gateway-policy-status.test.ts +291 -0
- package/src/components/resources/gateway-policy-status.ts +237 -0
- package/src/components/resources/generic-status.test.ts +199 -0
- package/src/components/resources/generic-status.ts +171 -0
- package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
- package/src/components/resources/index.ts +9 -1
- package/src/components/resources/istio-gateway-filter.test.ts +97 -0
- package/src/components/resources/printer-columns.test.ts +298 -0
- package/src/components/resources/printer-columns.ts +249 -0
- package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -5
- package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
- package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
- package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
- package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
- package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
- package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
- package/src/components/resources/renderers/kai-cells.tsx +67 -0
- package/src/components/resources/renderers/kaito-cells.tsx +62 -0
- package/src/components/resources/renderers/kserve-cells.tsx +145 -0
- package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
- package/src/components/resources/renderers/kueue-cells.tsx +176 -0
- package/src/components/resources/renderers/nim-cells.tsx +81 -0
- package/src/components/resources/renderers/ray-cells.tsx +124 -0
- package/src/components/resources/renderers/volcano-cells.tsx +140 -0
- package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
- package/src/components/resources/resource-utils-hpa.test.ts +4 -0
- package/src/components/resources/resource-utils-hpa.ts +6 -2
- package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
- package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
- package/src/components/resources/resource-utils-kai.ts +105 -0
- package/src/components/resources/resource-utils-kaito.ts +114 -0
- package/src/components/resources/resource-utils-kserve.ts +186 -0
- package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
- package/src/components/resources/resource-utils-kueue.ts +198 -0
- package/src/components/resources/resource-utils-nim.ts +109 -0
- package/src/components/resources/resource-utils-ray.ts +214 -0
- package/src/components/resources/resource-utils-rollout.test.ts +183 -0
- package/src/components/resources/resource-utils-velero.test.ts +30 -0
- package/src/components/resources/resource-utils-volcano.ts +176 -0
- package/src/components/resources/resource-utils.ts +156 -47
- package/src/components/resources/resources-column-filter.test.ts +27 -1
- package/src/components/resources/workload-rollout-golden.test.ts +55 -0
- package/src/components/shared/CreateResourceDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.tsx +4 -2
- package/src/components/shared/ResourceActionsBar.tsx +170 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
- package/src/components/shared/SetImageDialog.test.ts +129 -0
- package/src/components/shared/SetImageDialog.tsx +577 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/shared/revision-role-badges.test.ts +56 -4
- package/src/components/timeline/TimelineList.tsx +19 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
- package/src/components/trace/ReachabilityView.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +12 -0
- package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
- package/src/components/workload/WorkloadView.tsx +144 -81
- package/src/hooks/useProgressiveRefresh.ts +37 -0
- package/src/source-hygiene.test.ts +28 -0
- package/src/theme/tailwind-theme.css +4 -0
- package/src/types/core.ts +55 -0
- package/src/utils/api-resources.test.ts +40 -1
- package/src/utils/api-resources.ts +11 -1
- package/src/utils/applications.ts +50 -0
- package/src/utils/bulk-workload-actions.test.ts +20 -0
- package/src/utils/bulk-workload-actions.ts +15 -0
- package/src/utils/navigation.test.ts +31 -1
- package/src/utils/navigation.ts +21 -2
- 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',
|
package/src/utils/navigation.ts
CHANGED
|
@@ -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:
|
|
173
|
+
kind: kindToPluralWithGroup(ref.kind, ref.group ?? ''),
|
|
155
174
|
namespace: ref.namespace ?? '',
|
|
156
175
|
name: ref.name,
|
|
157
176
|
group: ref.group,
|