@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/utils/format.ts
CHANGED
|
@@ -126,12 +126,15 @@ export function parseMemoryToBytes(memString: string): number {
|
|
|
126
126
|
const num = parseFloat(match[1])
|
|
127
127
|
const suffix = match[2]
|
|
128
128
|
|
|
129
|
-
// Binary suffixes (powers of 1024)
|
|
129
|
+
// Binary suffixes (powers of 1024). Pi/Ei are real inputs at cluster scale —
|
|
130
|
+
// an aggregate of a thousand 1TiB nodes serializes as "1Pi".
|
|
130
131
|
const binarySuffixes: Record<string, number> = {
|
|
131
132
|
'Ki': 1024,
|
|
132
133
|
'Mi': 1024 ** 2,
|
|
133
134
|
'Gi': 1024 ** 3,
|
|
134
135
|
'Ti': 1024 ** 4,
|
|
136
|
+
'Pi': 1024 ** 5,
|
|
137
|
+
'Ei': 1024 ** 6,
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
// Decimal suffixes (powers of 1000)
|
|
@@ -141,6 +144,8 @@ export function parseMemoryToBytes(memString: string): number {
|
|
|
141
144
|
'M': 1000 ** 2,
|
|
142
145
|
'G': 1000 ** 3,
|
|
143
146
|
'T': 1000 ** 4,
|
|
147
|
+
'P': 1000 ** 5,
|
|
148
|
+
'E': 1000 ** 6,
|
|
144
149
|
}
|
|
145
150
|
|
|
146
151
|
if (suffix in binarySuffixes) {
|
|
@@ -164,6 +169,80 @@ export function formatMemoryString(memString: string): string {
|
|
|
164
169
|
return formatMemoryBytes(parseMemoryToBytes(memString))
|
|
165
170
|
}
|
|
166
171
|
|
|
172
|
+
// =============================================================================
|
|
173
|
+
// Quantity Parsing (counts)
|
|
174
|
+
// =============================================================================
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Every suffix a `resource.Quantity` can carry, in the API's own groupings:
|
|
178
|
+
* decimal SI in powers of 1000 (including the sub-unit n/u/m), and binary SI in
|
|
179
|
+
* powers of 1024. `K` is not canonical Kubernetes — only lowercase `k` is — but
|
|
180
|
+
* it is accepted here for the same leniency `parseMemoryToBytes` already has.
|
|
181
|
+
*/
|
|
182
|
+
const QUANTITY_SUFFIXES: Record<string, number> = {
|
|
183
|
+
n: 1e-9,
|
|
184
|
+
u: 1e-6,
|
|
185
|
+
m: 1e-3,
|
|
186
|
+
k: 1e3,
|
|
187
|
+
K: 1e3,
|
|
188
|
+
M: 1e6,
|
|
189
|
+
G: 1e9,
|
|
190
|
+
T: 1e12,
|
|
191
|
+
P: 1e15,
|
|
192
|
+
E: 1e18,
|
|
193
|
+
Ki: 1024,
|
|
194
|
+
Mi: 1024 ** 2,
|
|
195
|
+
Gi: 1024 ** 3,
|
|
196
|
+
Ti: 1024 ** 4,
|
|
197
|
+
Pi: 1024 ** 5,
|
|
198
|
+
Ei: 1024 ** 6,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Parse a Kubernetes `resource.Quantity` into a plain number.
|
|
203
|
+
*
|
|
204
|
+
* Counts are the surface that needs this. A Quantity's canonical serialization
|
|
205
|
+
* collapses trailing zeros into an SI suffix, so a node started with
|
|
206
|
+
* `--max-pods=1000` reports `"1k"` and not `"1000"` — read as a raw integer
|
|
207
|
+
* that is 1, so a usage bar divides by 1 instead of 1000. Any count that is
|
|
208
|
+
* a multiple of 1000 hits it.
|
|
209
|
+
*
|
|
210
|
+
* Unlike `parseMemoryToBytes`, an unrecognised suffix yields 0 rather than the
|
|
211
|
+
* bare number: a Quantity this cannot read is better reported as unknown by the
|
|
212
|
+
* caller than silently turned into a plausible-looking wrong number.
|
|
213
|
+
*
|
|
214
|
+
* @param quantity - K8s quantity like "1k", "216", "3000m", "2Gi", "1e3"
|
|
215
|
+
* @returns The value as a number, or 0 when it cannot be parsed
|
|
216
|
+
*/
|
|
217
|
+
export function parseQuantityToNumber(quantity: string | number | null | undefined): number {
|
|
218
|
+
if (typeof quantity === 'number') return Number.isFinite(quantity) ? quantity : 0
|
|
219
|
+
if (!quantity) return 0
|
|
220
|
+
|
|
221
|
+
const str = String(quantity).trim()
|
|
222
|
+
if (!str) return 0
|
|
223
|
+
|
|
224
|
+
// Decimal-exponent form. A Quantity's suffix is either an SI suffix or an
|
|
225
|
+
// exponent, never both, so this is decided before the suffix table — which
|
|
226
|
+
// also keeps "1E3" (1000) from being read as exa (1e18).
|
|
227
|
+
const exponent = str.match(/^([+-]?\d+(?:\.\d+)?)[eE]([+-]?\d+)$/)
|
|
228
|
+
if (exponent) {
|
|
229
|
+
const value = Number(`${exponent[1]}e${exponent[2]}`)
|
|
230
|
+
return Number.isFinite(value) ? value : 0
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const match = str.match(/^([+-]?\d+(?:\.\d+)?)\s*([A-Za-z]*)$/)
|
|
234
|
+
if (!match) return 0
|
|
235
|
+
|
|
236
|
+
const num = parseFloat(match[1])
|
|
237
|
+
if (!Number.isFinite(num)) return 0
|
|
238
|
+
|
|
239
|
+
const suffix = match[2]
|
|
240
|
+
if (!suffix) return num
|
|
241
|
+
|
|
242
|
+
const multiplier = QUANTITY_SUFFIXES[suffix]
|
|
243
|
+
return multiplier === undefined ? 0 : num * multiplier
|
|
244
|
+
}
|
|
245
|
+
|
|
167
246
|
// =============================================================================
|
|
168
247
|
// Dashboard Metrics Formatting (different units from metrics-server)
|
|
169
248
|
// =============================================================================
|
|
@@ -26,6 +26,15 @@ describe('inClusterConsent', () => {
|
|
|
26
26
|
expect(inClusterConsentGiven('prod')).toBe(false)
|
|
27
27
|
})
|
|
28
28
|
|
|
29
|
+
it('does not trust consent stored under the visible-name key format', () => {
|
|
30
|
+
const store: Record<string, string> = { 'radar.inClusterConsent.prod': '1' }
|
|
31
|
+
vi.stubGlobal('localStorage', {
|
|
32
|
+
getItem: (k: string) => (k in store ? store[k] : null),
|
|
33
|
+
setItem: (k: string, v: string) => { store[k] = v },
|
|
34
|
+
})
|
|
35
|
+
expect(inClusterConsentGiven('prod')).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
29
38
|
it('does not throw when localStorage is unavailable, and reports not-given', () => {
|
|
30
39
|
vi.stubGlobal('localStorage', {
|
|
31
40
|
getItem: () => { throw new Error('denied') },
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Consent memory for the mutating in-cluster reachability test (it creates a
|
|
2
|
-
// short-lived Job/pod). Keyed by
|
|
2
|
+
// short-lived Job/pod). Keyed by an opaque source identity so a "don't ask again" on one cluster
|
|
3
3
|
// never silently suppresses the confirm on another - the cluster the pod lands
|
|
4
4
|
// in is the whole point of asking. Switching kube-context re-prompts.
|
|
5
5
|
//
|
|
6
|
-
// No
|
|
6
|
+
// No source identity → no memory of ANY kind: the operator is asked on every
|
|
7
7
|
// run. localStorage is per-ORIGIN, and one origin (Radar Hub) can front many
|
|
8
8
|
// clusters, so a shared fallback key would let "don't ask again" on one cluster
|
|
9
9
|
// suppress the pod-creating confirm on all of them - and even an in-memory
|
|
@@ -11,22 +11,22 @@
|
|
|
11
11
|
// The dialog hides its "don't ask again" checkbox in that case, so nothing is
|
|
12
12
|
// promised that doesn't stick.
|
|
13
13
|
|
|
14
|
-
const key = (
|
|
14
|
+
const key = (clusterKey: string) => `radar.inClusterConsent.v2.${clusterKey}`
|
|
15
15
|
|
|
16
|
-
export function inClusterConsentGiven(
|
|
17
|
-
if (!
|
|
16
|
+
export function inClusterConsentGiven(clusterKey?: string): boolean {
|
|
17
|
+
if (!clusterKey) return false
|
|
18
18
|
try {
|
|
19
|
-
return localStorage.getItem(key(
|
|
19
|
+
return localStorage.getItem(key(clusterKey)) === '1'
|
|
20
20
|
} catch {
|
|
21
21
|
// localStorage unavailable (private mode / non-browser) - fail toward asking.
|
|
22
22
|
return false
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export function rememberInClusterConsent(
|
|
27
|
-
if (!
|
|
26
|
+
export function rememberInClusterConsent(clusterKey?: string): void {
|
|
27
|
+
if (!clusterKey) return
|
|
28
28
|
try {
|
|
29
|
-
localStorage.setItem(key(
|
|
29
|
+
localStorage.setItem(key(clusterKey), '1')
|
|
30
30
|
} catch {
|
|
31
31
|
// Non-fatal: if we can't persist, the user is simply asked again next time.
|
|
32
32
|
}
|
package/src/utils/index.ts
CHANGED
|
@@ -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,
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import type { WorkloadPodInfo } from '../types/core'
|
|
2
|
+
|
|
3
|
+
export type WorkloadRolloutPhase =
|
|
4
|
+
| 'idle'
|
|
5
|
+
| 'applying'
|
|
6
|
+
| 'progressing'
|
|
7
|
+
| 'waiting'
|
|
8
|
+
| 'paused'
|
|
9
|
+
| 'partition-reached'
|
|
10
|
+
| 'stalled'
|
|
11
|
+
|
|
12
|
+
export interface WorkloadRolloutActivity {
|
|
13
|
+
phase: WorkloadRolloutPhase
|
|
14
|
+
active: boolean
|
|
15
|
+
manual: boolean
|
|
16
|
+
label: string
|
|
17
|
+
detail?: string
|
|
18
|
+
desired: number
|
|
19
|
+
updated: number
|
|
20
|
+
ready: number
|
|
21
|
+
available: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isArgoRolloutResource(resource: any): boolean {
|
|
25
|
+
return String(resource?.apiVersion || '').startsWith('argoproj.io/')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function rolloutMayAdvanceAutomatically(activity: WorkloadRolloutActivity): boolean {
|
|
29
|
+
return activity.phase === 'applying' ||
|
|
30
|
+
activity.phase === 'progressing' ||
|
|
31
|
+
(activity.phase === 'waiting' && !activity.manual)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isRolloutActivityVisible(activity: WorkloadRolloutActivity): boolean {
|
|
35
|
+
return activity.active || activity.phase === 'stalled'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getObservedGeneration(resource: any): number {
|
|
39
|
+
const observed = resource?.status?.observedGeneration
|
|
40
|
+
if (typeof observed === 'number' && Number.isFinite(observed)) return observed
|
|
41
|
+
return typeof observed === 'string' && /^\d+$/.test(observed)
|
|
42
|
+
? Number.parseInt(observed, 10)
|
|
43
|
+
: 0
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getWorkloadRolloutActivity(
|
|
47
|
+
resource: any,
|
|
48
|
+
kind?: string,
|
|
49
|
+
workloadPods?: WorkloadPodInfo[],
|
|
50
|
+
): WorkloadRolloutActivity {
|
|
51
|
+
const normalized = (kind || resource?.kind || '').toLowerCase().replace(/s$/, '')
|
|
52
|
+
if (normalized === 'rollout' && !isArgoRolloutResource(resource)) {
|
|
53
|
+
return activity('idle', false, false, 'Stable', '', 0, 0, 0, 0)
|
|
54
|
+
}
|
|
55
|
+
let result: WorkloadRolloutActivity
|
|
56
|
+
switch (normalized) {
|
|
57
|
+
case 'deployment':
|
|
58
|
+
result = deploymentActivity(resource)
|
|
59
|
+
break
|
|
60
|
+
case 'statefulset':
|
|
61
|
+
result = statefulSetActivity(resource)
|
|
62
|
+
break
|
|
63
|
+
case 'daemonset':
|
|
64
|
+
result = daemonSetActivity(resource)
|
|
65
|
+
break
|
|
66
|
+
case 'rollout':
|
|
67
|
+
result = argoRolloutActivity(resource)
|
|
68
|
+
break
|
|
69
|
+
default:
|
|
70
|
+
result = activity('idle', false, false, 'Stable', '', 0, 0, 0, 0)
|
|
71
|
+
}
|
|
72
|
+
return withUpdatedRevisionFailure(result, workloadPods)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function deploymentActivity(resource: any): WorkloadRolloutActivity {
|
|
76
|
+
const spec = resource?.spec || {}
|
|
77
|
+
const status = resource?.status || {}
|
|
78
|
+
const desired = spec.replicas ?? 1
|
|
79
|
+
const updated = status.updatedReplicas ?? 0
|
|
80
|
+
const ready = status.readyReplicas ?? 0
|
|
81
|
+
const available = status.availableReplicas ?? 0
|
|
82
|
+
const base = activity('idle', false, false, 'Stable', '', desired, updated, ready, available)
|
|
83
|
+
const progress = (status.conditions || []).find((condition: any) => condition.type === 'Progressing')
|
|
84
|
+
|
|
85
|
+
if ((status.observedGeneration ?? 0) < (resource?.metadata?.generation ?? 0)) {
|
|
86
|
+
return merge(base, 'applying', true, 'Applying change', 'Waiting for the Deployment controller to observe generation')
|
|
87
|
+
}
|
|
88
|
+
if (progress?.status === 'False' || progress?.reason === 'ProgressDeadlineExceeded') {
|
|
89
|
+
return merge(base, 'stalled', false, 'Rollout stalled', progress.message || 'Progress deadline exceeded')
|
|
90
|
+
}
|
|
91
|
+
const old = Math.max(0, (status.replicas ?? 0) - updated)
|
|
92
|
+
const pending = updated < desired || old > 0
|
|
93
|
+
if (spec.paused) {
|
|
94
|
+
return merge(base, 'paused', pending, 'Rollout paused', pending ? `Paused at ${updated}/${desired} updated` : 'No rollout is pending')
|
|
95
|
+
}
|
|
96
|
+
if (desired === 0) return merge(base, 'idle', false, 'Scaled to zero', 'No replicas requested')
|
|
97
|
+
if (!pending) return merge(base, 'idle', false, 'Stable', `${available}/${desired} available`)
|
|
98
|
+
if (spec.strategy?.type === 'Recreate') {
|
|
99
|
+
if (updated === 0 && old > 0) {
|
|
100
|
+
return merge(base, 'waiting', true, 'Replacing replicas', `Waiting for old replicas to stop · ${available} available`)
|
|
101
|
+
}
|
|
102
|
+
return updated === 0
|
|
103
|
+
? merge(base, 'waiting', true, 'Waiting for new revision', replicaDetail(updated, desired, available))
|
|
104
|
+
: merge(base, 'progressing', true, 'Replacing replicas', replicaDetail(updated, desired, available))
|
|
105
|
+
}
|
|
106
|
+
if (updated === 0) return merge(base, 'waiting', true, 'Waiting for new revision', replicaDetail(updated, desired, available))
|
|
107
|
+
return old === 0 && updated === (status.replicas ?? 0)
|
|
108
|
+
? merge(base, 'progressing', true, 'Scaling', replicaDetail(updated, desired, available))
|
|
109
|
+
: merge(base, 'progressing', true, 'Rolling out', replicaDetail(updated, desired, available))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function statefulSetActivity(resource: any): WorkloadRolloutActivity {
|
|
113
|
+
const spec = resource?.spec || {}
|
|
114
|
+
const status = resource?.status || {}
|
|
115
|
+
const desired = spec.replicas ?? 1
|
|
116
|
+
const updated = status.updatedReplicas ?? 0
|
|
117
|
+
const ready = status.readyReplicas ?? 0
|
|
118
|
+
const base = activity('idle', false, false, 'Stable', '', desired, updated, ready, ready)
|
|
119
|
+
|
|
120
|
+
if ((status.observedGeneration ?? 0) < (resource?.metadata?.generation ?? 0)) {
|
|
121
|
+
return merge(base, 'applying', true, 'Applying change', 'Waiting for the StatefulSet controller to observe generation')
|
|
122
|
+
}
|
|
123
|
+
if (desired === 0) return merge(base, 'idle', false, 'Scaled to zero', 'No replicas requested')
|
|
124
|
+
if (spec.updateStrategy?.type === 'OnDelete') {
|
|
125
|
+
return updated === desired && ready === desired
|
|
126
|
+
? merge(base, 'idle', false, 'Stable', `${ready}/${desired} ready`)
|
|
127
|
+
: mergeManual(base, 'waiting', true, 'Waiting for Pod restart', `OnDelete strategy · ${updated}/${desired} updated`)
|
|
128
|
+
}
|
|
129
|
+
const partition = spec.updateStrategy?.rollingUpdate?.partition ?? 0
|
|
130
|
+
const target = Math.max(0, desired - partition)
|
|
131
|
+
if (updated >= target) {
|
|
132
|
+
const pendingRevision = Boolean(status.currentRevision && status.updateRevision && status.currentRevision !== status.updateRevision)
|
|
133
|
+
return partition > 0 && pendingRevision
|
|
134
|
+
? merge(base, 'partition-reached', false, 'Partition reached', `${partition}/${desired} Pods intentionally retained`)
|
|
135
|
+
: merge(base, 'idle', false, 'Stable', `${ready}/${desired} ready`)
|
|
136
|
+
}
|
|
137
|
+
const detail = `${updated}/${target} target Pods updated · ${ready}/${desired} ready`
|
|
138
|
+
return updated === 0
|
|
139
|
+
? merge(base, 'waiting', true, 'Waiting for new revision', detail)
|
|
140
|
+
: merge(base, 'progressing', true, 'Rolling out', detail)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function daemonSetActivity(resource: any): WorkloadRolloutActivity {
|
|
144
|
+
const spec = resource?.spec || {}
|
|
145
|
+
const status = resource?.status || {}
|
|
146
|
+
const desired = status.desiredNumberScheduled ?? 0
|
|
147
|
+
const updated = status.updatedNumberScheduled ?? 0
|
|
148
|
+
const ready = status.numberReady ?? 0
|
|
149
|
+
const available = status.numberAvailable ?? 0
|
|
150
|
+
const base = activity('idle', false, false, 'Stable', '', desired, updated, ready, available)
|
|
151
|
+
|
|
152
|
+
if ((status.observedGeneration ?? 0) < (resource?.metadata?.generation ?? 0)) {
|
|
153
|
+
return merge(base, 'applying', true, 'Applying change', 'Waiting for the DaemonSet controller to observe generation')
|
|
154
|
+
}
|
|
155
|
+
if (desired === 0) return merge(base, 'idle', false, 'No targets', 'Selector matches no nodes')
|
|
156
|
+
if (spec.updateStrategy?.type === 'OnDelete') {
|
|
157
|
+
return updated === desired && available === desired
|
|
158
|
+
? merge(base, 'idle', false, 'Stable', `${available}/${desired} available`)
|
|
159
|
+
: mergeManual(base, 'waiting', true, 'Waiting for Pod restart', `OnDelete strategy · ${updated}/${desired} updated`)
|
|
160
|
+
}
|
|
161
|
+
if (updated === desired) return merge(base, 'idle', false, 'Stable', `${available}/${desired} available`)
|
|
162
|
+
return updated === 0
|
|
163
|
+
? merge(base, 'waiting', true, 'Waiting for new revision', replicaDetail(updated, desired, available))
|
|
164
|
+
: merge(base, 'progressing', true, 'Rolling out', replicaDetail(updated, desired, available))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function argoRolloutActivity(resource: any): WorkloadRolloutActivity {
|
|
168
|
+
const spec = resource?.spec || {}
|
|
169
|
+
const status = resource?.status || {}
|
|
170
|
+
const desired = spec.replicas ?? ((status.replicas ?? 0) || 1)
|
|
171
|
+
const updated = status.updatedReplicas ?? 0
|
|
172
|
+
const ready = status.readyReplicas ?? 0
|
|
173
|
+
const available = status.availableReplicas ?? 0
|
|
174
|
+
const base = activity('idle', false, false, 'Stable', '', desired, updated, ready, available)
|
|
175
|
+
const detail = argoDetail(resource, updated, desired, available)
|
|
176
|
+
const observedGeneration = getObservedGeneration(resource)
|
|
177
|
+
if (observedGeneration > 0 && observedGeneration < (resource?.metadata?.generation ?? 0)) {
|
|
178
|
+
return merge(base, 'applying', true, 'Applying change', 'Waiting for the Rollout controller to observe generation')
|
|
179
|
+
}
|
|
180
|
+
const failedCondition = (status.conditions || []).find((condition: any) =>
|
|
181
|
+
(condition.type === 'InvalidSpec' && condition.status === 'True') ||
|
|
182
|
+
(condition.type === 'Progressing' && (condition.status === 'False' || condition.reason === 'ProgressDeadlineExceeded')),
|
|
183
|
+
)
|
|
184
|
+
if (failedCondition || status.abort) {
|
|
185
|
+
return merge(base, 'stalled', false, 'Rollout stalled', failedCondition?.message || failedCondition?.reason || status.message || (status.abort ? 'The Rollout was aborted' : '') || 'The Rollout controller reported a failed revision')
|
|
186
|
+
}
|
|
187
|
+
switch (String(status.phase || '').toLowerCase()) {
|
|
188
|
+
case 'degraded':
|
|
189
|
+
case 'error':
|
|
190
|
+
case 'failed':
|
|
191
|
+
case 'aborted':
|
|
192
|
+
return merge(base, 'stalled', false, 'Rollout stalled', status.message || 'The Rollout controller reported a failed revision')
|
|
193
|
+
case 'paused':
|
|
194
|
+
return merge(base, 'paused', true, 'Rollout paused', detail)
|
|
195
|
+
case 'progressing':
|
|
196
|
+
return updated === 0
|
|
197
|
+
? merge(base, 'waiting', true, 'Waiting for new revision', detail)
|
|
198
|
+
: merge(base, 'progressing', true, 'Rolling out', detail)
|
|
199
|
+
case 'healthy':
|
|
200
|
+
return merge(base, 'idle', false, 'Stable', `${available}/${desired} available`)
|
|
201
|
+
default:
|
|
202
|
+
return desired > 0 && (updated < desired || available < desired)
|
|
203
|
+
? merge(base, 'applying', true, 'Applying change', detail)
|
|
204
|
+
: merge(base, 'idle', false, 'Stable', status.message || '')
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function withUpdatedRevisionFailure(
|
|
209
|
+
rollout: WorkloadRolloutActivity,
|
|
210
|
+
pods?: WorkloadPodInfo[],
|
|
211
|
+
): WorkloadRolloutActivity {
|
|
212
|
+
if ((!rollout.active && rollout.phase !== 'stalled') || rollout.phase === 'applying' || !pods) return rollout
|
|
213
|
+
const failed = pods
|
|
214
|
+
.filter((pod) => pod.updatedRevision === true && pod.healthLevel === 'unhealthy')
|
|
215
|
+
.sort((a, b) => a.name.localeCompare(b.name))[0]
|
|
216
|
+
if (!failed) return rollout
|
|
217
|
+
const reason = failed.reason || failed.lastTerminationReason || failed.phase || 'Pod failed'
|
|
218
|
+
return merge(rollout, 'stalled', false, 'New revision cannot start', `${reason} · Pod ${failed.name}`)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function getArgoRolloutStepNumber(resource: any): number | null {
|
|
222
|
+
const steps = resource?.spec?.strategy?.canary?.steps || []
|
|
223
|
+
const currentIndex = resource?.status?.currentStepIndex
|
|
224
|
+
if (steps.length === 0 || typeof currentIndex !== 'number') return null
|
|
225
|
+
return Math.min(Math.max(currentIndex + 1, 1), steps.length)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function argoDetail(resource: any, updated: number, desired: number, available: number): string {
|
|
229
|
+
const stepNumber = getArgoRolloutStepNumber(resource)
|
|
230
|
+
const step = stepNumber === null ? '' : `Step ${stepNumber} · `
|
|
231
|
+
return `${step}${updated}/${desired} updated · ${available} available`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function replicaDetail(updated: number, desired: number, available: number): string {
|
|
235
|
+
return `${updated}/${desired} updated · ${available} available`
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function activity(
|
|
239
|
+
phase: WorkloadRolloutPhase,
|
|
240
|
+
active: boolean,
|
|
241
|
+
manual: boolean,
|
|
242
|
+
label: string,
|
|
243
|
+
detail: string,
|
|
244
|
+
desired: number,
|
|
245
|
+
updated: number,
|
|
246
|
+
ready: number,
|
|
247
|
+
available: number,
|
|
248
|
+
): WorkloadRolloutActivity {
|
|
249
|
+
return { phase, active, manual, label, detail, desired, updated, ready, available }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function merge(
|
|
253
|
+
base: WorkloadRolloutActivity,
|
|
254
|
+
phase: WorkloadRolloutPhase,
|
|
255
|
+
active: boolean,
|
|
256
|
+
label: string,
|
|
257
|
+
detail: string,
|
|
258
|
+
): WorkloadRolloutActivity {
|
|
259
|
+
return { ...base, phase, active, manual: false, label, detail }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function mergeManual(
|
|
263
|
+
base: WorkloadRolloutActivity,
|
|
264
|
+
phase: WorkloadRolloutPhase,
|
|
265
|
+
active: boolean,
|
|
266
|
+
label: string,
|
|
267
|
+
detail: string,
|
|
268
|
+
): WorkloadRolloutActivity {
|
|
269
|
+
return { ...base, phase, active, manual: true, label, detail }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function rolloutActivityLevel(activity: WorkloadRolloutActivity): 'healthy' | 'neutral' | 'degraded' | 'unhealthy' {
|
|
273
|
+
switch (activity.phase) {
|
|
274
|
+
case 'stalled':
|
|
275
|
+
return 'unhealthy'
|
|
276
|
+
case 'paused':
|
|
277
|
+
return 'degraded'
|
|
278
|
+
case 'waiting':
|
|
279
|
+
return activity.manual ? 'degraded' : 'neutral'
|
|
280
|
+
case 'applying':
|
|
281
|
+
case 'progressing':
|
|
282
|
+
case 'partition-reached':
|
|
283
|
+
return 'neutral'
|
|
284
|
+
default:
|
|
285
|
+
return activity.label === 'Stable' ? 'healthy' : 'neutral'
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function rolloutActivityBadge(activity: WorkloadRolloutActivity): { text: string; color: string; level: 'healthy' | 'neutral' | 'degraded' | 'unhealthy' } {
|
|
290
|
+
const level = rolloutActivityLevel(activity)
|
|
291
|
+
return { text: activity.label, color: `status-${level}`, level }
|
|
292
|
+
}
|