@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.
- package/package.json +2 -2
- package/src/components/applications/AppTooltips.tsx +13 -1
- package/src/components/applications/ApplicationDetail.test.tsx +25 -0
- package/src/components/applications/ApplicationDetail.tsx +41 -9
- 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 +35 -7
- 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 +973 -100
- 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/NamespaceRenderer.test.ts +30 -0
- package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
- 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/trace/TracePanel.tsx +1 -0
- package/src/components/ui/InClusterConsentDialog.tsx +5 -4
- package/src/components/ui/SelectMenu.tsx +202 -28
- package/src/components/ui/drawer-components.tsx +12 -0
- package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
- package/src/components/workload/WorkloadView.tsx +146 -83
- 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 +62 -4
- 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/clipboard.test.ts +86 -0
- package/src/utils/clipboard.ts +50 -0
- package/src/utils/extended-resources.ts +4 -7
- package/src/utils/format.test.ts +83 -1
- package/src/utils/format.ts +80 -1
- package/src/utils/inClusterConsent.test.ts +9 -0
- package/src/utils/inClusterConsent.ts +9 -9
- package/src/utils/index.ts +1 -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'
|
|
@@ -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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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 {
|
package/src/utils/format.test.ts
CHANGED
|
@@ -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
|
})
|