@skyhook-io/k8s-ui 1.13.0 → 1.13.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/package.json +2 -2
  2. package/src/components/applications/AppTooltips.tsx +13 -1
  3. package/src/components/applications/ApplicationDetail.test.tsx +25 -0
  4. package/src/components/applications/ApplicationDetail.tsx +41 -9
  5. package/src/components/applications/ApplicationsView.tsx +18 -3
  6. package/src/components/applications/ReadyBar.tsx +3 -2
  7. package/src/components/applications/applications-view.test.tsx +91 -0
  8. package/src/components/cluster-switcher/ClusterSwitcher.tsx +35 -7
  9. package/src/components/issues/IssuesView.tsx +40 -31
  10. package/src/components/issues/ResourceIssuesSection.tsx +3 -2
  11. package/src/components/issues/index.ts +3 -1
  12. package/src/components/issues/issue-timing.ts +123 -4
  13. package/src/components/issues/issues.test.ts +221 -10
  14. package/src/components/issues/types.ts +31 -5
  15. package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
  16. package/src/components/resources/ResourcesView.tsx +973 -100
  17. package/src/components/resources/column-settings-key.test.ts +98 -0
  18. package/src/components/resources/column-visibility-merge.test.ts +64 -0
  19. package/src/components/resources/curated-column-ownership.test.ts +236 -0
  20. package/src/components/resources/gateway-policy-status.test.ts +291 -0
  21. package/src/components/resources/gateway-policy-status.ts +237 -0
  22. package/src/components/resources/generic-status.test.ts +199 -0
  23. package/src/components/resources/generic-status.ts +171 -0
  24. package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
  25. package/src/components/resources/index.ts +9 -1
  26. package/src/components/resources/istio-gateway-filter.test.ts +97 -0
  27. package/src/components/resources/printer-columns.test.ts +298 -0
  28. package/src/components/resources/printer-columns.ts +249 -0
  29. package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
  30. package/src/components/resources/renderers/EventRenderer.tsx +3 -5
  31. package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
  32. package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
  33. package/src/components/resources/renderers/HPARenderer.tsx +6 -1
  34. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
  35. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  36. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  37. package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
  38. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  39. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  40. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  41. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  42. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  43. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  44. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  45. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  46. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  47. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  48. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  49. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  50. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  51. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  52. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  53. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  54. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  55. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  56. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  57. package/src/components/resources/resource-utils-hpa.ts +6 -2
  58. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  59. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  60. package/src/components/resources/resource-utils-kai.ts +105 -0
  61. package/src/components/resources/resource-utils-kaito.ts +114 -0
  62. package/src/components/resources/resource-utils-kserve.ts +186 -0
  63. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  64. package/src/components/resources/resource-utils-kueue.ts +198 -0
  65. package/src/components/resources/resource-utils-nim.ts +109 -0
  66. package/src/components/resources/resource-utils-ray.ts +214 -0
  67. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  68. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  69. package/src/components/resources/resource-utils-volcano.ts +176 -0
  70. package/src/components/resources/resource-utils.ts +156 -47
  71. package/src/components/resources/resources-column-filter.test.ts +27 -1
  72. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  73. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  74. package/src/components/shared/EditableYamlView.tsx +4 -2
  75. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  76. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  77. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  78. package/src/components/shared/SetImageDialog.test.ts +129 -0
  79. package/src/components/shared/SetImageDialog.tsx +577 -0
  80. package/src/components/shared/index.ts +1 -0
  81. package/src/components/shared/revision-role-badges.test.ts +56 -4
  82. package/src/components/timeline/TimelineList.tsx +19 -3
  83. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  84. package/src/components/trace/ReachabilityView.tsx +1 -1
  85. package/src/components/trace/TracePanel.tsx +1 -0
  86. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  87. package/src/components/ui/SelectMenu.tsx +202 -28
  88. package/src/components/ui/drawer-components.tsx +12 -0
  89. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  90. package/src/components/workload/WorkloadView.tsx +146 -83
  91. package/src/hooks/useProgressiveRefresh.ts +37 -0
  92. package/src/source-hygiene.test.ts +28 -0
  93. package/src/theme/tailwind-theme.css +4 -0
  94. package/src/types/core.ts +62 -4
  95. package/src/utils/api-resources.test.ts +40 -1
  96. package/src/utils/api-resources.ts +11 -1
  97. package/src/utils/applications.ts +50 -0
  98. package/src/utils/bulk-workload-actions.test.ts +20 -0
  99. package/src/utils/bulk-workload-actions.ts +15 -0
  100. package/src/utils/clipboard.test.ts +86 -0
  101. package/src/utils/clipboard.ts +50 -0
  102. package/src/utils/extended-resources.ts +4 -7
  103. package/src/utils/format.test.ts +83 -1
  104. package/src/utils/format.ts +80 -1
  105. package/src/utils/inClusterConsent.test.ts +9 -0
  106. package/src/utils/inClusterConsent.ts +9 -9
  107. package/src/utils/index.ts +1 -0
  108. package/src/utils/navigation.test.ts +31 -1
  109. package/src/utils/navigation.ts +21 -2
  110. package/src/utils/workload-rollout.ts +292 -0
@@ -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 CLUSTER so a "don't ask again" on one cluster
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 cluster identity → no memory of ANY kind: the operator is asked on every
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 = (cluster: string) => `radar.inClusterConsent.${cluster}`
14
+ const key = (clusterKey: string) => `radar.inClusterConsent.v2.${clusterKey}`
15
15
 
16
- export function inClusterConsentGiven(cluster?: string): boolean {
17
- if (!cluster) return false
16
+ export function inClusterConsentGiven(clusterKey?: string): boolean {
17
+ if (!clusterKey) return false
18
18
  try {
19
- return localStorage.getItem(key(cluster)) === '1'
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(cluster?: string): void {
27
- if (!cluster) return
26
+ export function rememberInClusterConsent(clusterKey?: string): void {
27
+ if (!clusterKey) return
28
28
  try {
29
- localStorage.setItem(key(cluster), '1')
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
  }
@@ -9,6 +9,7 @@ export * from './animation'
9
9
  export * from './resource-hierarchy'
10
10
  export * from './log-format'
11
11
  export * from './download'
12
+ export * from './clipboard'
12
13
  export * from './env-from'
13
14
  export * from './extended-resources'
14
15
  export * from './api-resources'
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect, afterEach } from 'vitest'
2
2
  import { englishPlural } from './pluralize'
3
- import { kindToPlural, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
3
+ import { kindToPlural, kindToPluralWithGroup, pluralToKind, refToSelectedResource, initNavigationMap, resetNavigationMap, laneId, laneResourceKey, groupQualifiesLaneId, parseLaneId } from './navigation'
4
4
 
5
5
  afterEach(() => {
6
6
  resetNavigationMap()
@@ -224,6 +224,24 @@ describe('initNavigationMap', () => {
224
224
  ])
225
225
  expect(pluralToKind('pods')).toBe('Pod')
226
226
  })
227
+
228
+ test('keeps the virtual PodGroup alias distinct from scheduling PodGroup', () => {
229
+ initNavigationMap([
230
+ { group: 'scheduling.k8s.io', version: 'v1beta1', kind: 'PodGroup', name: 'podgroups', namespaced: true, isCrd: false, verbs: ['get', 'list'] },
231
+ { group: 'example.io', version: 'v1', kind: 'PodGroup', name: 'custompodgroups', namespaced: true, isCrd: true, verbs: ['get', 'list'] },
232
+ ])
233
+
234
+ expect(kindToPlural('PodGroup')).toBe('pods')
235
+ expect(kindToPluralWithGroup('PodGroup', 'scheduling.k8s.io')).toBe('podgroups')
236
+ expect(kindToPluralWithGroup('PodGroup', 'example.io')).toBe('custompodgroups')
237
+ expect(kindToPluralWithGroup('podgroups', 'scheduling.k8s.io')).toBe('podgroups')
238
+ })
239
+
240
+ test('preserves aliases and plural slugs before group discovery is initialized', () => {
241
+ expect(kindToPluralWithGroup('PodGroup', 'scheduling.k8s.io')).toBe('podgroups')
242
+ expect(kindToPluralWithGroup('CalicoGlobalNetworkPolicy', 'projectcalico.org')).toBe('globalnetworkpolicies')
243
+ expect(kindToPluralWithGroup('schedules', 'velero.io')).toBe('schedules')
244
+ })
227
245
  })
228
246
 
229
247
  describe('refToSelectedResource', () => {
@@ -241,6 +259,18 @@ describe('refToSelectedResource', () => {
241
259
  })
242
260
  })
243
261
 
262
+ test('uses the API group when a real resource collides with a virtual kind', () => {
263
+ initNavigationMap([
264
+ { group: 'scheduling.k8s.io', version: 'v1beta1', kind: 'PodGroup', name: 'podgroups', namespaced: true, isCrd: false, verbs: ['get', 'list'] },
265
+ ])
266
+ expect(refToSelectedResource({ kind: 'PodGroup', name: 'batch', namespace: 'default', group: 'scheduling.k8s.io' })).toEqual({
267
+ kind: 'podgroups',
268
+ name: 'batch',
269
+ namespace: 'default',
270
+ group: 'scheduling.k8s.io',
271
+ })
272
+ })
273
+
244
274
  test('preserves group field', () => {
245
275
  const result = refToSelectedResource({
246
276
  kind: 'Certificate',
@@ -42,10 +42,15 @@ const BUILTIN_PLURAL_TO_KIND: Record<string, string> = {
42
42
  networkpolicies: 'NetworkPolicy',
43
43
  }
44
44
 
45
+ const BUILTIN_GROUP_KIND_TO_PLURAL: Record<string, string> = {
46
+ 'scheduling.k8s.io/podgroup': 'podgroups',
47
+ }
48
+
45
49
  // Dynamic map built from API discovery — populated by initNavigationMap().
46
50
  // Once populated, this is the source of truth for all kind↔plural lookups.
47
51
  let discoveredPluralToKind: Record<string, string> | null = null
48
52
  let discoveredKindToPlural: Record<string, string> | null = null
53
+ let discoveredGroupKindToPlural: Record<string, string> | null = null
49
54
 
50
55
  /**
51
56
  * Initialize navigation maps from discovered API resources.
@@ -55,6 +60,7 @@ let discoveredKindToPlural: Record<string, string> | null = null
55
60
  export function initNavigationMap(resources: APIResource[]) {
56
61
  const p2k: Record<string, string> = { ...BUILTIN_PLURAL_TO_KIND }
57
62
  const k2p: Record<string, string> = {}
63
+ const gk2p: Record<string, string> = {}
58
64
  for (const r of resources) {
59
65
  const plural = r.name.toLowerCase()
60
66
  // First-wins on plurals: BUILTIN_PLURAL_TO_KIND seeds canonical core mappings
@@ -62,15 +68,18 @@ export function initNavigationMap(resources: APIResource[]) {
62
68
  // "pods" with kind "PodMetrics") cannot hijack the core mapping.
63
69
  if (!(plural in p2k)) p2k[plural] = r.kind
64
70
  k2p[r.kind.toLowerCase()] = plural
71
+ gk2p[`${r.group}/${r.kind.toLowerCase()}`] = plural
65
72
  }
66
73
  discoveredPluralToKind = p2k
67
74
  discoveredKindToPlural = k2p
75
+ discoveredGroupKindToPlural = gk2p
68
76
  }
69
77
 
70
78
  /** Reset navigation maps to builtin-only state. For testing. */
71
79
  export function resetNavigationMap() {
72
80
  discoveredPluralToKind = null
73
81
  discoveredKindToPlural = null
82
+ discoveredGroupKindToPlural = null
74
83
  }
75
84
 
76
85
  function getPluralToKind(): Record<string, string> {
@@ -90,6 +99,8 @@ export function kindToPlural(kind: string): string {
90
99
  // Already a known plural — return as-is to prevent double-pluralization
91
100
  if (kindLower in pluralToKindMap) return kindLower
92
101
 
102
+ if (kindLower === 'podgroup') return 'pods'
103
+
93
104
  // Lookup from discovered API resources (singular kind → plural name)
94
105
  if (discoveredKindToPlural && kindLower in discoveredKindToPlural) {
95
106
  return discoveredKindToPlural[kindLower]
@@ -99,7 +110,6 @@ export function kindToPlural(kind: string): string {
99
110
  const aliases: Record<string, string> = {
100
111
  horizontalpodautoscaler: 'horizontalpodautoscalers',
101
112
  pvc: 'persistentvolumeclaims',
102
- podgroup: 'pods',
103
113
  caliconetworkpolicy: 'networkpolicies',
104
114
  calicoglobalnetworkpolicy: 'globalnetworkpolicies',
105
115
  calicostagednetworkpolicy: 'stagednetworkpolicies',
@@ -122,6 +132,15 @@ export function kindToPlural(kind: string): string {
122
132
  return englishPlural(kindLower)
123
133
  }
124
134
 
135
+ export function kindToPluralWithGroup(kind: string, group: string): string {
136
+ if (!group) return kindToPlural(kind)
137
+ const kindLower = kind.toLowerCase()
138
+ const pluralToKindMap = getPluralToKind()
139
+ if (kindLower in pluralToKindMap) return kindLower
140
+ const groupKind = `${group}/${kindLower}`
141
+ return discoveredGroupKindToPlural?.[groupKind] ?? BUILTIN_GROUP_KIND_TO_PLURAL[groupKind] ?? kindToPlural(kind)
142
+ }
143
+
125
144
  /**
126
145
  * Convert a plural API resource name (e.g., "deployments") back to singular PascalCase kind (e.g., "Deployment").
127
146
  * Inverse of kindToPlural. Converts plural API resource names from URLs back to
@@ -151,7 +170,7 @@ export function refToSelectedResource(
151
170
  ref: Pick<ResourceRef, 'kind' | 'name' | 'group'> & { namespace?: string },
152
171
  ): SelectedResource {
153
172
  return {
154
- kind: kindToPlural(ref.kind),
173
+ kind: kindToPluralWithGroup(ref.kind, ref.group ?? ''),
155
174
  namespace: ref.namespace ?? '',
156
175
  name: ref.name,
157
176
  group: ref.group,
@@ -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
+ }