@skyhook-io/k8s-ui 1.18.2 → 1.20.0
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 +9 -2
- package/src/components/applications/ApplicationDetail.tsx +8 -2
- package/src/components/charts/AreaChart.test.tsx +39 -1
- package/src/components/charts/AreaChart.tsx +32 -13
- package/src/components/charts/axis.test.ts +17 -1
- package/src/components/charts/axis.ts +13 -0
- package/src/components/charts/format.test.ts +159 -2
- package/src/components/charts/format.ts +118 -0
- package/src/components/cluster-switcher/ClusterSwitcher.test.tsx +54 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +7 -3
- package/src/components/compare/ResourceCompareView.test.tsx +54 -0
- package/src/components/compare/ResourceCompareView.tsx +35 -12
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +16 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +54 -7
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +1 -1
- package/src/components/gitops/GitOpsTableView.tsx +26 -7
- package/src/components/gitops/RollbackDialog.test.tsx +80 -0
- package/src/components/gitops/RollbackDialog.tsx +25 -6
- package/src/components/gitops/SyncOptionsDialog.render.test.tsx +374 -0
- package/src/components/gitops/SyncOptionsDialog.test.ts +54 -1
- package/src/components/gitops/SyncOptionsDialog.tsx +179 -34
- package/src/components/gitops/applicationset-row.test.ts +12 -0
- package/src/components/gitops/argo-radar-upgrade.test.tsx +369 -0
- package/src/components/gitops/detail-helpers.test.ts +8 -2
- package/src/components/gitops/detail-helpers.ts +9 -11
- package/src/components/gitops/index.ts +1 -1
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +32 -0
- package/src/components/gitops/insights/GitOpsActivityInsightView.test.tsx +55 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +94 -46
- package/src/components/gitops/insights/GitOpsIssuesBand.test.tsx +108 -0
- package/src/components/gitops/insights/GitOpsRollbackInsightView.test.tsx +73 -0
- package/src/components/gitops/insights/GitOpsSyncWindowInsightView.test.tsx +29 -0
- package/src/components/gitops/insights/insights-helpers.test.ts +65 -0
- package/src/components/gitops/insights/insights-helpers.ts +17 -3
- package/src/components/issues/IssueGuidance.test.tsx +190 -0
- package/src/components/issues/IssueGuidance.tsx +48 -0
- package/src/components/issues/IssuesView.tsx +15 -3
- package/src/components/issues/ResourceIssuesSection.tsx +3 -1
- package/src/components/issues/diagnostic.ts +14 -1
- package/src/components/issues/index.ts +1 -0
- package/src/components/issues/severity.ts +15 -0
- package/src/components/issues/types.ts +10 -0
- package/src/components/kueue/AdmissionPath.tsx +223 -0
- package/src/components/kueue/QueueEvidence.tsx +148 -0
- package/src/components/kueue/constants.ts +1 -0
- package/src/components/kueue/eligibility.ts +43 -0
- package/src/components/kueue/flavors-tried.ts +79 -0
- package/src/components/kueue/helpers.test.ts +105 -0
- package/src/components/kueue/policy.ts +100 -0
- package/src/components/kueue/queue-detail.test.tsx +317 -0
- package/src/components/kueue/queue-status.ts +42 -0
- package/src/components/kueue/related-read.ts +48 -0
- package/src/components/kueue/testdata/kueue-v0.18-policy-enums.json +89 -0
- package/src/components/logs/StructuredLogLine.test.tsx +22 -0
- package/src/components/logs/StructuredLogLine.tsx +8 -3
- package/src/components/resources/KueueAdmissionSection.test.tsx +117 -0
- package/src/components/resources/KueueAdmissionSection.tsx +51 -12
- package/src/components/resources/KueuePendingPosition.test.tsx +97 -0
- package/src/components/resources/KueuePendingPosition.tsx +91 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +8 -0
- package/src/components/resources/ResourcesSidebar.tsx +3 -2
- package/src/components/resources/ResourcesView.crd.test.tsx +28 -0
- package/src/components/resources/ResourcesView.node-pods.test.tsx +25 -0
- package/src/components/resources/ResourcesView.strimzi.test.tsx +37 -0
- package/src/components/resources/ResourcesView.tsx +50 -3
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +7 -0
- package/src/components/resources/get-pod-phase-display.test.ts +70 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +54 -1
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +7 -8
- package/src/components/resources/renderers/CRDSchemaBrowser.test.tsx +105 -0
- package/src/components/resources/renderers/CRDSchemaBrowser.tsx +134 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.test.tsx +106 -0
- package/src/components/resources/renderers/CustomResourceDefinitionRenderer.tsx +152 -0
- package/src/components/resources/renderers/JobRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/JobRenderer.tsx +7 -2
- package/src/components/resources/renderers/KueueQueueRenderers.test.tsx +5 -5
- package/src/components/resources/renderers/KueueQueueRenderers.tsx +250 -170
- package/src/components/resources/renderers/KueueQueueWorkloads.test.tsx +189 -0
- package/src/components/resources/renderers/KueueQueueWorkloads.tsx +460 -0
- package/src/components/resources/renderers/KueueWorkloadRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +23 -52
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +11 -6
- package/src/components/resources/renderers/RayClusterRenderer.test.tsx +39 -0
- package/src/components/resources/renderers/RayClusterRenderer.tsx +23 -5
- package/src/components/resources/renderers/ServiceAccountRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/ServiceAccountRenderer.tsx +15 -10
- package/src/components/resources/renderers/StrimziConnectorRenderer.tsx +61 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/WebhookConfigRenderer.tsx +9 -3
- package/src/components/resources/renderers/crd-cells.test.tsx +30 -0
- package/src/components/resources/renderers/crd-cells.tsx +55 -0
- package/src/components/resources/renderers/crd-schema-parts.test.tsx +59 -0
- package/src/components/resources/renderers/crd-schema-parts.tsx +121 -0
- package/src/components/resources/renderers/index.ts +6 -0
- package/src/components/resources/renderers/kueue-cells.tsx +5 -0
- package/src/components/resources/resource-utils-argo.test.ts +94 -1
- package/src/components/resources/resource-utils-argo.ts +84 -5
- package/src/components/resources/resource-utils-crd.test.ts +32 -0
- package/src/components/resources/resource-utils-crd.ts +26 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +12 -0
- package/src/components/resources/resource-utils-kueue-admission.test.ts +26 -0
- package/src/components/resources/resource-utils-kueue.test.ts +15 -0
- package/src/components/resources/resource-utils-kueue.ts +137 -4
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +9 -1
- package/src/components/resources/resource-utils-opentelemetry.ts +1 -1
- package/src/components/resources/resource-utils-ray.test.ts +42 -1
- package/src/components/resources/resource-utils-ray.ts +38 -0
- package/src/components/resources/resource-utils-strimzi.test.tsx +118 -0
- package/src/components/resources/resource-utils-strimzi.ts +35 -0
- package/src/components/resources/resource-utils.ts +45 -1
- package/src/components/shared/ResourceActionsBar.tsx +83 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +37 -3
- package/src/components/shared/ResourceRendererDispatch.tsx +40 -7
- package/src/components/topology/K8sResourceNode.test.ts +14 -0
- package/src/components/topology/K8sResourceNode.tsx +18 -1
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +96 -94
- package/src/components/topology/layout.ts +3 -1
- package/src/components/topology/pod-group-expansion.test.ts +38 -0
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/RadarUpgradeNote.tsx +8 -0
- package/src/components/ui/drawer-components.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/workload/PodList.tsx +35 -9
- package/src/components/workload/WorkloadView.spec.test.tsx +41 -1
- package/src/components/workload/WorkloadView.tsx +61 -8
- package/src/components/workload/workload-logs-availability.test.ts +7 -0
- package/src/types/core.ts +41 -0
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +2 -2
- package/src/types/kueue-queues.ts +84 -0
- package/src/types/read-coverage.ts +2 -1
- package/src/types/scheduling.ts +72 -0
- package/src/utils/animation.test.ts +38 -0
- package/src/utils/animation.ts +34 -0
- package/src/utils/api-resources-crd.test.ts +11 -0
- package/src/utils/api-resources.ts +5 -1
- package/src/utils/applications.test.ts +15 -1
- package/src/utils/applications.ts +26 -1
- package/src/utils/crd-schema.test.ts +107 -0
- package/src/utils/crd-schema.ts +201 -0
- package/src/utils/format.test.ts +37 -1
- package/src/utils/format.ts +28 -80
- package/src/utils/gitops-owner.test.ts +28 -2
- package/src/utils/gitops-owner.ts +19 -0
- package/src/utils/kueue-queue-workloads.ts +19 -0
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/topology-neighborhood.test.ts +90 -1
- package/src/utils/topology-neighborhood.ts +55 -3
- package/src/utils/workload-history-notes.test.ts +76 -0
- package/src/utils/workload-history-notes.ts +93 -0
package/src/utils/format.ts
CHANGED
|
@@ -46,32 +46,14 @@ export function formatCPUNanocores(nanocores: number): string {
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Parse a Kubernetes CPU string (e.g., "500m", "2", "100n") to nanocores.
|
|
49
|
+
* metrics-server writes each value in its shortest exact form, so the same
|
|
50
|
+
* reading can arrive as "98753000n" or "98753u".
|
|
49
51
|
*
|
|
50
|
-
* @param cpuString - K8s CPU string like "500m", "2", "100n"
|
|
52
|
+
* @param cpuString - K8s CPU string like "500m", "2", "100n", "98753u"
|
|
51
53
|
* @returns CPU in nanocores
|
|
52
54
|
*/
|
|
53
55
|
export function parseCPUToNanocores(cpuString: string): number {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const str = cpuString.trim()
|
|
57
|
-
|
|
58
|
-
// Nanocores suffix
|
|
59
|
-
if (str.endsWith('n')) {
|
|
60
|
-
return parseInt(str.slice(0, -1), 10) || 0
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Millicores suffix
|
|
64
|
-
if (str.endsWith('m')) {
|
|
65
|
-
return (parseInt(str.slice(0, -1), 10) || 0) * 1_000_000
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Plain number = cores
|
|
69
|
-
const cores = parseFloat(str)
|
|
70
|
-
if (!isNaN(cores)) {
|
|
71
|
-
return cores * 1_000_000_000
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return 0
|
|
56
|
+
return Math.round(parseQuantityToNumber(cpuString) * 1e9)
|
|
75
57
|
}
|
|
76
58
|
|
|
77
59
|
/**
|
|
@@ -117,55 +99,7 @@ export function formatMemoryBytes(bytes: number): string {
|
|
|
117
99
|
* @returns Memory in bytes
|
|
118
100
|
*/
|
|
119
101
|
export function parseMemoryToBytes(memString: string): number {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const str = memString.trim()
|
|
123
|
-
// Decimal-exponent form ("1e9") is decided before the suffix table, as in
|
|
124
|
-
// parseQuantityToNumber, so "1E3" reads as 1000 and not as exa.
|
|
125
|
-
if (/^[+-]?\d+(?:\.\d+)?[eE][+-]?\d+$/.test(str)) return Number(str)
|
|
126
|
-
|
|
127
|
-
const match = str.match(/^(-?\d+(?:\.\d+)?)\s*([A-Za-z]*)$/)
|
|
128
|
-
if (!match) return 0
|
|
129
|
-
|
|
130
|
-
const num = parseFloat(match[1])
|
|
131
|
-
const suffix = match[2]
|
|
132
|
-
|
|
133
|
-
// Binary suffixes (powers of 1024). Pi/Ei are real inputs at cluster scale —
|
|
134
|
-
// an aggregate of a thousand 1TiB nodes serializes as "1Pi".
|
|
135
|
-
const binarySuffixes: Record<string, number> = {
|
|
136
|
-
'Ki': 1024,
|
|
137
|
-
'Mi': 1024 ** 2,
|
|
138
|
-
'Gi': 1024 ** 3,
|
|
139
|
-
'Ti': 1024 ** 4,
|
|
140
|
-
'Pi': 1024 ** 5,
|
|
141
|
-
'Ei': 1024 ** 6,
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
// Decimal suffixes (powers of 1000). The sub-unit ones are real inputs too:
|
|
145
|
-
// a request written as "1288490188800m" is valid, and a sum that includes one
|
|
146
|
-
// keeps the milli scale, so a whole cluster's total serializes with "m".
|
|
147
|
-
const decimalSuffixes: Record<string, number> = {
|
|
148
|
-
'n': 1000 ** -3,
|
|
149
|
-
'u': 1000 ** -2,
|
|
150
|
-
'm': 1000 ** -1,
|
|
151
|
-
'k': 1000,
|
|
152
|
-
'K': 1000,
|
|
153
|
-
'M': 1000 ** 2,
|
|
154
|
-
'G': 1000 ** 3,
|
|
155
|
-
'T': 1000 ** 4,
|
|
156
|
-
'P': 1000 ** 5,
|
|
157
|
-
'E': 1000 ** 6,
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (suffix in binarySuffixes) {
|
|
161
|
-
return num * binarySuffixes[suffix]
|
|
162
|
-
}
|
|
163
|
-
if (suffix in decimalSuffixes) {
|
|
164
|
-
return num * decimalSuffixes[suffix]
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// No suffix = bytes
|
|
168
|
-
return num
|
|
102
|
+
return parseQuantityToNumber(memString)
|
|
169
103
|
}
|
|
170
104
|
|
|
171
105
|
/**
|
|
@@ -224,32 +158,46 @@ const QUANTITY_SUFFIXES: Record<string, number> = {
|
|
|
224
158
|
* @returns The value as a number, or 0 when it cannot be parsed
|
|
225
159
|
*/
|
|
226
160
|
export function parseQuantityToNumber(quantity: string | number | null | undefined): number {
|
|
227
|
-
|
|
228
|
-
|
|
161
|
+
return parseQuantity(quantity) ?? 0
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A Quantity's number: optional sign, then digits with an optional fraction,
|
|
165
|
+
// where either side of the point may be empty ("1.", ".5") but not both.
|
|
166
|
+
const QUANTITY_NUMBER = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)`
|
|
167
|
+
const QUANTITY_EXPONENT_RE = new RegExp(`^(${QUANTITY_NUMBER})[eE]([+-]?\\d+)$`)
|
|
168
|
+
const QUANTITY_SUFFIX_RE = new RegExp(`^(${QUANTITY_NUMBER})\\s*([A-Za-z]*)$`)
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Like parseQuantityToNumber, but null when the input is missing or cannot be
|
|
172
|
+
* parsed, for callers that must tell garbage apart from a real 0.
|
|
173
|
+
*/
|
|
174
|
+
export function parseQuantity(quantity: string | number | null | undefined): number | null {
|
|
175
|
+
if (typeof quantity === 'number') return Number.isFinite(quantity) ? quantity : null
|
|
176
|
+
if (quantity == null) return null
|
|
229
177
|
|
|
230
178
|
const str = String(quantity).trim()
|
|
231
|
-
if (!str) return
|
|
179
|
+
if (!str) return null
|
|
232
180
|
|
|
233
181
|
// Decimal-exponent form. A Quantity's suffix is either an SI suffix or an
|
|
234
182
|
// exponent, never both, so this is decided before the suffix table — which
|
|
235
183
|
// also keeps "1E3" (1000) from being read as exa (1e18).
|
|
236
|
-
const exponent = str.match(
|
|
184
|
+
const exponent = str.match(QUANTITY_EXPONENT_RE)
|
|
237
185
|
if (exponent) {
|
|
238
186
|
const value = Number(`${exponent[1]}e${exponent[2]}`)
|
|
239
|
-
return Number.isFinite(value) ? value :
|
|
187
|
+
return Number.isFinite(value) ? value : null
|
|
240
188
|
}
|
|
241
189
|
|
|
242
|
-
const match = str.match(
|
|
243
|
-
if (!match) return
|
|
190
|
+
const match = str.match(QUANTITY_SUFFIX_RE)
|
|
191
|
+
if (!match) return null
|
|
244
192
|
|
|
245
193
|
const num = parseFloat(match[1])
|
|
246
|
-
if (!Number.isFinite(num)) return
|
|
194
|
+
if (!Number.isFinite(num)) return null
|
|
247
195
|
|
|
248
196
|
const suffix = match[2]
|
|
249
197
|
if (!suffix) return num
|
|
250
198
|
|
|
251
199
|
const multiplier = QUANTITY_SUFFIXES[suffix]
|
|
252
|
-
return multiplier === undefined ?
|
|
200
|
+
return multiplier === undefined ? null : num * multiplier
|
|
253
201
|
}
|
|
254
202
|
|
|
255
203
|
// =============================================================================
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import type { Relationships } from '../types/core'
|
|
3
|
+
import type { ManagerAnswer, Relationships } from '../types/core'
|
|
4
4
|
|
|
5
|
-
import { gitOpsOwnerFromRelationships, managerListing } from './gitops-owner'
|
|
5
|
+
import { argoAutoSyncController, gitOpsOwnerFromRelationships, managerListing } from './gitops-owner'
|
|
6
6
|
|
|
7
7
|
describe('gitOpsOwnerFromRelationships', () => {
|
|
8
8
|
it('returns null when relationships is undefined', () => {
|
|
@@ -134,3 +134,29 @@ describe('managerListing', () => {
|
|
|
134
134
|
expect(managerListing(owner, [app('hub')], true, false)).toBeUndefined()
|
|
135
135
|
})
|
|
136
136
|
})
|
|
137
|
+
|
|
138
|
+
describe('argoAutoSyncController', () => {
|
|
139
|
+
const self = { namespace: 'argocd', name: 'guestbook' }
|
|
140
|
+
const parent = { kind: 'Application', group: 'argoproj.io', namespace: 'argocd', name: 'apps' }
|
|
141
|
+
const managed = (state: ManagerAnswer['state'], lead = parent): Relationships => ({ ownership: { manager: { state, lead } } } as unknown as Relationships)
|
|
142
|
+
|
|
143
|
+
it('names the owning ApplicationSet first', () => {
|
|
144
|
+
const rel: Relationships = { ...managed('listed'), owner: { kind: 'ApplicationSet', group: 'argoproj.io', namespace: 'argocd', name: 'clusters' } }
|
|
145
|
+
expect(argoAutoSyncController(rel, self)).toEqual({ kind: 'ApplicationSet', name: 'clusters' })
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it.each(['agree', 'listed'] as const)('names a parent Application whose resources list it (%s)', (state) => {
|
|
149
|
+
expect(argoAutoSyncController(managed(state), self)).toEqual({ kind: 'Application', name: 'apps' })
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it.each(['claimed', 'shared', 'disagree', 'unknown', 'none'] as const)('names no parent on a %s answer', (state) => {
|
|
153
|
+
expect(argoAutoSyncController(managed(state), self)).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('names nothing without an answer, for a non-Argo owner, or for itself', () => {
|
|
157
|
+
expect(argoAutoSyncController(undefined, self)).toBeNull()
|
|
158
|
+
expect(argoAutoSyncController({ managedBy: [parent] }, self)).toBeNull()
|
|
159
|
+
expect(argoAutoSyncController({ owner: { kind: 'ApplicationSet', group: 'example.com', namespace: 'argocd', name: 'x' } }, self)).toBeNull()
|
|
160
|
+
expect(argoAutoSyncController(managed('listed', { ...parent, name: 'guestbook' }), self)).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -39,6 +39,25 @@ export function gitOpsOwnerFromRelationships(
|
|
|
39
39
|
return gitOpsOwnerFromRef(refs[0])
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The Argo CD object that rewrites an Application's spec, and so can turn its
|
|
44
|
+
* auto-sync back on after a suspend: the ApplicationSet that owns it, else the
|
|
45
|
+
* Application whose recorded resources list it. A tracking claim alone can
|
|
46
|
+
* outlive a detached parent, so it names nothing.
|
|
47
|
+
*/
|
|
48
|
+
export function argoAutoSyncController(
|
|
49
|
+
rel: Relationships | undefined | null,
|
|
50
|
+
self: { namespace: string; name: string },
|
|
51
|
+
): { kind: 'ApplicationSet' | 'Application'; name: string } | null {
|
|
52
|
+
const appSet = [rel?.owner, ...(rel?.owners ?? [])].find(ref => ref?.kind === 'ApplicationSet' && ref.group === ARGO_APPLICATION_GROUP)
|
|
53
|
+
if (appSet) return { kind: 'ApplicationSet', name: appSet.name }
|
|
54
|
+
const answer = rel?.ownership?.manager
|
|
55
|
+
const lead = answer?.state === 'agree' || answer?.state === 'listed' ? answer.lead : undefined
|
|
56
|
+
return lead?.kind === 'Application' && lead.group === ARGO_APPLICATION_GROUP && !(lead.namespace === self.namespace && lead.name === self.name)
|
|
57
|
+
? { kind: 'Application', name: lead.name }
|
|
58
|
+
: null
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
/** A manager ref (relationships.managedBy, a workspace's managedBy) as a GitOps owner, or null when the manager is not a GitOps controller. */
|
|
43
62
|
export function gitOpsOwnerFromRef(ref: ResourceRef): GitOpsOwnerRef | null {
|
|
44
63
|
switch (true) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { KueueQueueWorkloadsResponse } from '../types/kueue-queues'
|
|
2
|
+
import { readActionFor } from './read-actions'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A response part a quick retry can complete: a nested read error or gap
|
|
6
|
+
* whose remedy is to retry (still loading, a failed read). A permission check
|
|
7
|
+
* that didn't run also clears on its own, but each poll spends more checks,
|
|
8
|
+
* and a load that is overdue (`wait`) won't clear in seconds: both wait for
|
|
9
|
+
* the ordinary refresh.
|
|
10
|
+
*/
|
|
11
|
+
export function kueueQueueRetryable(page: KueueQueueWorkloadsResponse | undefined): boolean {
|
|
12
|
+
if (!page) return false
|
|
13
|
+
const codes = [
|
|
14
|
+
page.workloadsError?.error_code, page.localQueuesError?.error_code,
|
|
15
|
+
...(page.workloads?.coverage.gaps ?? []).map(gap => gap.code),
|
|
16
|
+
...(page.localQueues?.coverage.gaps ?? []).map(gap => gap.code),
|
|
17
|
+
]
|
|
18
|
+
return codes.some(code => code !== 'user_permission_check_failed' && readActionFor(code) === 'retry')
|
|
19
|
+
}
|
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
// GitOps
|
|
53
53
|
GitBranch,
|
|
54
54
|
Layers,
|
|
55
|
+
ListVideo,
|
|
55
56
|
Anchor,
|
|
56
57
|
FolderGit2,
|
|
57
58
|
|
|
@@ -91,6 +92,7 @@ const KIND_ICON_MAP: Record<string, LucideIcon> = {
|
|
|
91
92
|
replicaset: Copy,
|
|
92
93
|
job: Play,
|
|
93
94
|
cronjob: Timer,
|
|
95
|
+
jobset: ListVideo,
|
|
94
96
|
rollout: Rocket,
|
|
95
97
|
|
|
96
98
|
// Networking
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { batchRunParentNodes, neighborhoodFor, tagWorkloadOwnership, workloadKey } from './topology-neighborhood'
|
|
2
|
+
import { batchRunParentNodes, jobSetMemberRunKey, neighborhoodFor, tagWorkloadOwnership, workloadKey } from './topology-neighborhood'
|
|
3
3
|
import type { Topology, NodeKind, EdgeType } from '../types/core'
|
|
4
4
|
|
|
5
5
|
function node(id: string, kind: string, ns: string, name: string): Topology['nodes'][number] {
|
|
@@ -455,3 +455,92 @@ describe('tagWorkloadOwnership', () => {
|
|
|
455
455
|
expect(dataOf(topology, 'cert').ownerWorkloadId).toBe('Deployment/app/web')
|
|
456
456
|
})
|
|
457
457
|
})
|
|
458
|
+
|
|
459
|
+
describe('JobSet ownership in the neighborhood', () => {
|
|
460
|
+
const jobSetID = 'jobset/ml/train/jobset.x-k8s.io'
|
|
461
|
+
const topo: Topology = {
|
|
462
|
+
nodes: [
|
|
463
|
+
crdNode(jobSetID, 'JobSet', 'ml', 'train', 'jobset.x-k8s.io/v1alpha2'),
|
|
464
|
+
crdNode('job/ml/train-workers-0', 'Job', 'ml', 'train-workers-0', 'batch/v1'),
|
|
465
|
+
crdNode('job/ml/train-workers-1', 'Job', 'ml', 'train-workers-1', 'batch/v1'),
|
|
466
|
+
node('pod/ml/train-workers-0-a', 'Pod', 'ml', 'train-workers-0-a'),
|
|
467
|
+
node('pod/ml/train-workers-1-a', 'Pod', 'ml', 'train-workers-1-a'),
|
|
468
|
+
crdNode('job/ml/standalone', 'Job', 'ml', 'standalone', 'batch/v1'),
|
|
469
|
+
crdNode('job/ml/train-workers-0/batch.volcano.sh', 'Job', 'ml', 'train-workers-0', 'batch.volcano.sh/v1alpha1'),
|
|
470
|
+
],
|
|
471
|
+
edges: [
|
|
472
|
+
edge(jobSetID, 'job/ml/train-workers-0', 'manages'),
|
|
473
|
+
edge(jobSetID, 'job/ml/train-workers-1', 'manages'),
|
|
474
|
+
edge('job/ml/train-workers-0', 'pod/ml/train-workers-0-a', 'manages'),
|
|
475
|
+
edge('job/ml/train-workers-1', 'pod/ml/train-workers-1-a', 'manages'),
|
|
476
|
+
{ ...edge(jobSetID, 'pod/ml/train-workers-0-a', 'manages'), id: 'shortcut-0', skipIfKindVisible: 'Job' },
|
|
477
|
+
{ ...edge(jobSetID, 'job/ml/train-workers-0/batch.volcano.sh', 'manages'), id: 'foreign' },
|
|
478
|
+
],
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
it('walks a JobSet down to every member Job and Pod, not capped as retained runs', () => {
|
|
482
|
+
const out = neighborhoodFor(topo, [{ kind: 'JobSet', group: 'jobset.x-k8s.io', namespace: 'ml', name: 'train' }])
|
|
483
|
+
const ids = new Set(out.nodes.map((n) => n.id))
|
|
484
|
+
for (const id of ['job/ml/train-workers-0', 'job/ml/train-workers-1', 'pod/ml/train-workers-0-a', 'pod/ml/train-workers-1-a']) {
|
|
485
|
+
expect(ids.has(id)).toBe(true)
|
|
486
|
+
}
|
|
487
|
+
expect(ids.has('job/ml/standalone')).toBe(false)
|
|
488
|
+
// Jobs are present, so the collapsed shortcut is dropped.
|
|
489
|
+
expect(out.edges.some((e) => e.id === 'shortcut-0')).toBe(false)
|
|
490
|
+
expect(out.warnings ?? []).toEqual([])
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('shows the JobSet as context from a member Job without pulling in sibling members', () => {
|
|
494
|
+
const out = neighborhoodFor(topo, [{ kind: 'Job', group: 'batch', namespace: 'ml', name: 'train-workers-1' }])
|
|
495
|
+
const ids = new Set(out.nodes.map((n) => n.id))
|
|
496
|
+
expect(ids.has(jobSetID)).toBe(true)
|
|
497
|
+
expect(ids.has('job/ml/train-workers-0')).toBe(false)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('keys only directly controlled batch/v1 member Jobs for in-place selection', () => {
|
|
501
|
+
const byId = new Map(topo.nodes.map((n) => [n.id, n]))
|
|
502
|
+
expect(jobSetMemberRunKey(topo, jobSetID, byId.get('job/ml/train-workers-0')!)).toBe('jobs/ml/train-workers-0')
|
|
503
|
+
expect(batchRunParentNodes(topo, byId.get('job/ml/train-workers-0')!)).toEqual([byId.get(jobSetID)])
|
|
504
|
+
expect(batchRunParentNodes(topo, byId.get('job/ml/train-workers-0/batch.volcano.sh')!)).toEqual([])
|
|
505
|
+
expect(jobSetMemberRunKey(topo, jobSetID, byId.get('job/ml/standalone')!)).toBeUndefined()
|
|
506
|
+
expect(jobSetMemberRunKey(topo, jobSetID, byId.get('job/ml/train-workers-0/batch.volcano.sh')!)).toBeUndefined()
|
|
507
|
+
expect(jobSetMemberRunKey(topo, jobSetID, byId.get('pod/ml/train-workers-0-a')!)).toBeUndefined()
|
|
508
|
+
// Only a JobSet of the exact group selects members.
|
|
509
|
+
expect(jobSetMemberRunKey(topo, 'job/ml/train-workers-0', byId.get('pod/ml/train-workers-0-a')!)).toBeUndefined()
|
|
510
|
+
})
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
describe('JobSet member cap', () => {
|
|
514
|
+
it('keeps failed and active members first and says how many it left out', () => {
|
|
515
|
+
const jobSetID = 'jobset/ml/big/jobset.x-k8s.io'
|
|
516
|
+
const nodes: Topology['nodes'] = [crdNode(jobSetID, 'JobSet', 'ml', 'big', 'jobset.x-k8s.io/v1alpha2')]
|
|
517
|
+
const edges: Topology['edges'] = []
|
|
518
|
+
for (let i = 0; i < 40; i++) {
|
|
519
|
+
const id = `job/ml/big-w-${String(i).padStart(2, '0')}`
|
|
520
|
+
const job = { ...crdNode(id, 'Job', 'ml', `big-w-${String(i).padStart(2, '0')}`, 'batch/v1'), status: 'neutral' as const }
|
|
521
|
+
if (i === 39) job.status = 'unhealthy' as never
|
|
522
|
+
if (i === 38) job.data = { ...job.data, active: 1 }
|
|
523
|
+
if (i !== 38) job.data = { ...job.data, completionTime: '2026-11-01T00:00:00Z', succeeded: 1 }
|
|
524
|
+
nodes.push(job)
|
|
525
|
+
edges.push(edge(jobSetID, id, 'manages'))
|
|
526
|
+
const pod = `pod/ml/big-w-${i}-a`
|
|
527
|
+
nodes.push(node(pod, 'Pod', 'ml', `big-w-${i}-a`))
|
|
528
|
+
edges.push(edge(id, pod, 'manages'))
|
|
529
|
+
edges.push({ ...edge(jobSetID, pod, 'manages'), id: `shortcut-${i}`, skipIfKindVisible: 'Job' })
|
|
530
|
+
}
|
|
531
|
+
nodes.push(crdNode('workload/ml/big', 'Workload', 'ml', 'big', 'kueue.x-k8s.io/v1beta2'))
|
|
532
|
+
edges.push(edge(jobSetID, 'workload/ml/big', 'manages'))
|
|
533
|
+
|
|
534
|
+
const out = neighborhoodFor({ nodes, edges }, [{ kind: 'JobSet', group: 'jobset.x-k8s.io', namespace: 'ml', name: 'big' }])
|
|
535
|
+
const ids = new Set(out.nodes.map((n) => n.id))
|
|
536
|
+
const jobs = out.nodes.filter((n) => n.kind === 'Job')
|
|
537
|
+
expect(jobs).toHaveLength(24)
|
|
538
|
+
expect(ids.has('job/ml/big-w-39')).toBe(true) // failed first
|
|
539
|
+
expect(ids.has('job/ml/big-w-38')).toBe(true) // active next
|
|
540
|
+
// Pods only of the shown members: shortcuts don't bring back the rest.
|
|
541
|
+
expect(out.nodes.filter((n) => n.kind === 'Pod')).toHaveLength(24)
|
|
542
|
+
// Other things the JobSet owns aren't capped as members.
|
|
543
|
+
expect(ids.has('workload/ml/big')).toBe(true)
|
|
544
|
+
expect(out.warnings).toEqual(['Topology view: showing 24 of 40 member Jobs of JobSet/big, failed and active first. Find any member in the Overview\'s member list.'])
|
|
545
|
+
})
|
|
546
|
+
})
|
|
@@ -40,6 +40,9 @@ export interface NeighborhoodSeed {
|
|
|
40
40
|
const IDENTITY_EDGES = new Set<EdgeType>(['manages'])
|
|
41
41
|
const ROUTING_EDGES = new Set<EdgeType>(['exposes', 'routes-to'])
|
|
42
42
|
const BATCH_RUN_FANOUT_LIMIT = 8
|
|
43
|
+
// A JobSet's member Jobs run side by side, so they aren't thinned like
|
|
44
|
+
// retained runs; past this many, the failed and active ones are kept first.
|
|
45
|
+
const JOBSET_MEMBER_LIMIT = 24
|
|
43
46
|
// context: 'configures' | 'uses' | 'protects' — everything else is leaf-attached.
|
|
44
47
|
|
|
45
48
|
// GitOps managers: included as context ("managed by"), never expanded through.
|
|
@@ -92,9 +95,36 @@ function isBatchRunFanoutEdge(edge: TopologyEdge, nodeById: Map<string, Topology
|
|
|
92
95
|
return isTemplateToRunEdge(edge, nodeById)
|
|
93
96
|
}
|
|
94
97
|
|
|
98
|
+
/** The member key (`jobs/<ns>/<name>`, the JobSet member list's selection key)
|
|
99
|
+
* when `node` is a batch/v1 Job the JobSet node `jobSetNodeId` controls, by
|
|
100
|
+
* its direct manages edge — the server draws that edge only for exact
|
|
101
|
+
* controller-owned members. Undefined for anything else. */
|
|
102
|
+
export function jobSetMemberRunKey(topology: Topology, jobSetNodeId: string, node: TopologyNode): string | undefined {
|
|
103
|
+
const root = topology.nodes.find((candidate) => candidate.id === jobSetNodeId)
|
|
104
|
+
if (!root || root.kind !== 'JobSet' || nodeGroup(root) !== 'jobset.x-k8s.io') return undefined
|
|
105
|
+
if (node.kind !== 'Job' || canonicalResourceGroup('Job', nodeGroup(node)) !== 'batch') return undefined
|
|
106
|
+
const member = topology.edges.some((edge) =>
|
|
107
|
+
edge.type === 'manages' && edge.source === root.id && edge.target === node.id && !edge.skipIfKindVisible,
|
|
108
|
+
)
|
|
109
|
+
return member ? `jobs/${nodeNamespace(node)}/${node.name}` : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function isJobSetMemberEdge(edge: TopologyEdge, nodeById: Map<string, TopologyNode>): boolean {
|
|
113
|
+
const source = nodeById.get(edge.source)
|
|
114
|
+
const target = nodeById.get(edge.target)
|
|
115
|
+
return edge.type === 'manages' && !edge.skipIfKindVisible
|
|
116
|
+
&& source?.kind === 'JobSet' && !!source && nodeGroup(source) === 'jobset.x-k8s.io'
|
|
117
|
+
&& target?.kind === 'Job' && canonicalResourceGroup('Job', nodeGroup(target)) === 'batch'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function memberRank(node: TopologyNode): number {
|
|
121
|
+
if (node.status === 'unhealthy') return 0
|
|
122
|
+
return runDisposition(node) === 'active' ? 1 : 2
|
|
123
|
+
}
|
|
124
|
+
|
|
95
125
|
export function batchRunParentNodes(topology: Topology, run: TopologyNode): TopologyNode[] {
|
|
96
126
|
const nodeById = new Map(topology.nodes.map((node) => [node.id, node]))
|
|
97
|
-
const parentEdges = topology.edges.filter((edge) => edge.target === run.id && isBatchRunFanoutEdge(edge, nodeById))
|
|
127
|
+
const parentEdges = topology.edges.filter((edge) => edge.target === run.id && (isBatchRunFanoutEdge(edge, nodeById) || isJobSetMemberEdge(edge, nodeById)))
|
|
98
128
|
parentEdges.sort((left, right) => Number(right.type === 'manages') - Number(left.type === 'manages'))
|
|
99
129
|
return parentEdges.flatMap((edge) => {
|
|
100
130
|
const parent = nodeById.get(edge.source)
|
|
@@ -207,8 +237,24 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
|
|
|
207
237
|
}
|
|
208
238
|
}
|
|
209
239
|
|
|
210
|
-
const limitedFanouts = new Map<string, { allowed: Set<string>; total: number; kind: string; activeTotal: number; activeShown: number }>()
|
|
240
|
+
const limitedFanouts = new Map<string, { allowed: Set<string>; total: number; kind: string; activeTotal: number; activeShown: number; members?: boolean }>()
|
|
211
241
|
for (const [sourceId, edges] of adjacency) {
|
|
242
|
+
const memberEdges = edges.filter((edge) => edge.source === sourceId && isJobSetMemberEdge(edge, nodeById))
|
|
243
|
+
if (memberEdges.length > JOBSET_MEMBER_LIMIT) {
|
|
244
|
+
const members = memberEdges
|
|
245
|
+
.map((edge) => nodeById.get(edge.target))
|
|
246
|
+
.filter((node): node is TopologyNode => !!node)
|
|
247
|
+
.sort((a, b) => memberRank(a) - memberRank(b) || a.name.localeCompare(b.name))
|
|
248
|
+
limitedFanouts.set(sourceId, {
|
|
249
|
+
allowed: new Set(members.slice(0, JOBSET_MEMBER_LIMIT).map((node) => node.id)),
|
|
250
|
+
total: members.length,
|
|
251
|
+
kind: 'Job',
|
|
252
|
+
activeTotal: 0,
|
|
253
|
+
activeShown: 0,
|
|
254
|
+
members: true,
|
|
255
|
+
})
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
212
258
|
const runEdges = edges.filter((edge) => edge.source === sourceId && batchFanoutLimit(edge, nodeById) !== null)
|
|
213
259
|
if (runEdges.length <= BATCH_RUN_FANOUT_LIMIT) continue
|
|
214
260
|
const targets = runEdges
|
|
@@ -240,7 +286,10 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
|
|
|
240
286
|
const nextNode = nodeById.get(nextId)
|
|
241
287
|
if (!nextNode) continue
|
|
242
288
|
const fanout = e.source === id ? limitedFanouts.get(id) : undefined
|
|
243
|
-
|
|
289
|
+
// A capped JobSet's shortcuts to Pods would bring back the Pods of the
|
|
290
|
+
// members left out; the shown members still reach theirs.
|
|
291
|
+
if (fanout?.members && e.skipIfKindVisible) continue
|
|
292
|
+
if (fanout && (!fanout.members || isJobSetMemberEdge(e, nodeById)) && !fanout.allowed.has(nextId)) {
|
|
244
293
|
cappedSources.add(id)
|
|
245
294
|
continue
|
|
246
295
|
}
|
|
@@ -308,6 +357,9 @@ export function neighborhoodFor(topology: Topology, seeds: NeighborhoodSeed[]):
|
|
|
308
357
|
...Array.from(cappedSources).map((sourceId) => {
|
|
309
358
|
const source = nodeById.get(sourceId)
|
|
310
359
|
const fanout = limitedFanouts.get(sourceId)
|
|
360
|
+
if (fanout?.members) {
|
|
361
|
+
return `Topology view: showing ${JOBSET_MEMBER_LIMIT} of ${fanout.total} member Jobs of ${source?.kind ?? 'JobSet'}/${source?.name ?? sourceId}, failed and active first. Find any member in the Overview's member list.`
|
|
362
|
+
}
|
|
311
363
|
const activeNote = fanout && fanout.activeTotal > fanout.activeShown ? ` showing the ${fanout.activeShown - 1} newest and oldest of ${fanout.activeTotal} active runs plus representative completed runs` : ' showing active and representative completed runs'
|
|
312
364
|
return `Topology view:${activeNote} from ${fanout?.total ?? BATCH_RUN_FANOUT_LIMIT} retained ${fanout?.kind ?? 'batch'} runs for ${source?.kind ?? 'workload'}/${source?.name ?? sourceId}. Use Run history for the full retained set.`
|
|
313
365
|
}),
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { workloadHistoryNotes } from './workload-history-notes'
|
|
3
|
+
|
|
4
|
+
describe('workloadHistoryNotes', () => {
|
|
5
|
+
it('says nothing when the history is complete and nothing is withheld', () => {
|
|
6
|
+
expect(workloadHistoryNotes(undefined, undefined)).toEqual([])
|
|
7
|
+
expect(workloadHistoryNotes({ incomplete: false, reasons: [] }, [])).toEqual([])
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('dates the start of complete history when the viewer may see the time', () => {
|
|
11
|
+
const since = '2026-11-03T05:00:00Z'
|
|
12
|
+
const [note] = workloadHistoryNotes({ incomplete: true, complete_since: since, reasons: ['observation_started_late'] })
|
|
13
|
+
expect(note).toBe(`Changes before ${new Date(since).toLocaleString()} may be missing: Radar began recording part of this history later.`)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('names both reasons, and no time when the server withheld it', () => {
|
|
17
|
+
expect(workloadHistoryNotes({ incomplete: true, reasons: ['observation_started_late', 'events_evicted'] })).toEqual([
|
|
18
|
+
'Earlier changes may be missing: Radar began recording part of this history later and older history was deleted to stay within its limits.',
|
|
19
|
+
])
|
|
20
|
+
expect(workloadHistoryNotes({ incomplete: true, reasons: ['something_new'] })).toEqual(['Earlier changes may be missing.'])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('names the Kueue kinds a viewer may not list', () => {
|
|
24
|
+
expect(
|
|
25
|
+
workloadHistoryNotes(null, [
|
|
26
|
+
{
|
|
27
|
+
code: 'user_forbidden',
|
|
28
|
+
kinds: { count: 2, names: [{ kind: 'Workload', group: 'kueue.x-k8s.io' }, { kind: 'ProvisioningRequest', group: 'autoscaling.x-k8s.io' }] },
|
|
29
|
+
namespaces: { count: 1, names: ['ml'] },
|
|
30
|
+
},
|
|
31
|
+
]),
|
|
32
|
+
).toEqual(["Changes to Kueue Workloads and ProvisioningRequests aren't shown: you don't have access to list them in this namespace."])
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('separates what Radar does not collect from what is still loading', () => {
|
|
36
|
+
expect(
|
|
37
|
+
workloadHistoryNotes(null, [
|
|
38
|
+
{ code: 'radar_forbidden', kinds: { count: 1, names: [{ kind: 'Workload', group: 'kueue.x-k8s.io' }] } },
|
|
39
|
+
{ code: 'kind_sync_pending', kinds: { count: 1, names: [{ kind: 'ProvisioningRequest', group: 'autoscaling.x-k8s.io' }] } },
|
|
40
|
+
{ code: 'some_future_code' },
|
|
41
|
+
]),
|
|
42
|
+
).toEqual([
|
|
43
|
+
"Changes to Kueue Workloads aren't shown: Radar's Kubernetes identity may not list them.",
|
|
44
|
+
"Changes to ProvisioningRequests aren't shown: still loading.",
|
|
45
|
+
"Some related changes aren't shown: the read failed.",
|
|
46
|
+
])
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
describe('history gap remedies', () => {
|
|
52
|
+
it('preserves known Radar access and collection reasons separately', () => {
|
|
53
|
+
expect(workloadHistoryNotes(null, [
|
|
54
|
+
{ code: 'radar_credentials_rejected', action: 'radar_config' },
|
|
55
|
+
{ code: 'radar_namespace_not_collected', action: 'radar_config' },
|
|
56
|
+
{ code: 'cluster_disconnected', action: 'radar_config' },
|
|
57
|
+
])).toEqual([
|
|
58
|
+
"Some related changes aren't shown: Kubernetes rejected Radar's credentials.",
|
|
59
|
+
"Some related changes aren't shown: Radar doesn't collect them here.",
|
|
60
|
+
"Some related changes aren't shown: Radar lost its connection.",
|
|
61
|
+
])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it.each([
|
|
65
|
+
['user_access', "you don't have access to list them in this namespace"],
|
|
66
|
+
['radar_config', "Radar's connection or collection settings need attention"],
|
|
67
|
+
['retry', 'retry the read'],
|
|
68
|
+
['wait', 'wait and retry the read'],
|
|
69
|
+
['check_target', 'check the requested target and retry'],
|
|
70
|
+
['change_request', 'change the read request'],
|
|
71
|
+
])('uses the supplied %s action for an unknown code', (action, clause) => {
|
|
72
|
+
expect(workloadHistoryNotes(null, [{ code: 'new_server_code', action }])).toEqual([
|
|
73
|
+
`Some related changes aren't shown: ${clause}.`,
|
|
74
|
+
])
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { ReadGap, ReadKindRef } from '../types/read-coverage'
|
|
2
|
+
import { canonicalReadCode, gapReasonText, READ_ACTIONS, readActionFor } from './read-actions'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether Radar holds every change since a workload existed, as Radar's
|
|
6
|
+
* issuesapi.HistoryCoverage serializes it. Present only when incomplete;
|
|
7
|
+
* complete_since is sent only to callers who can read every namespace.
|
|
8
|
+
* An absent coverage value means complete history or an older Radar; it
|
|
9
|
+
* does not establish completeness on its own.
|
|
10
|
+
*/
|
|
11
|
+
export interface WorkloadHistoryCoverage {
|
|
12
|
+
incomplete: boolean
|
|
13
|
+
complete_since?: string
|
|
14
|
+
reasons: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Kinds a history gap names, as a reader knows them.
|
|
18
|
+
const KIND_LABELS: Record<string, string> = {
|
|
19
|
+
'kueue.x-k8s.io/Workload': 'Kueue Workloads',
|
|
20
|
+
'autoscaling.x-k8s.io/ProvisioningRequest': 'ProvisioningRequests',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function kindLabel(ref: ReadKindRef): string {
|
|
24
|
+
return KIND_LABELS[`${ref.group ?? ''}/${ref.kind}`] ?? `${ref.kind} resources`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function joinWords(words: string[]): string {
|
|
28
|
+
if (words.length <= 1) return words[0] ?? ''
|
|
29
|
+
return `${words.slice(0, -1).join(', ')} and ${words[words.length - 1]}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function gapClause(gap: ReadGap): string {
|
|
33
|
+
const code = canonicalReadCode(gap.code)
|
|
34
|
+
switch (code) {
|
|
35
|
+
case 'radar_forbidden':
|
|
36
|
+
return "Radar's Kubernetes identity may not list them"
|
|
37
|
+
case 'radar_credentials_rejected':
|
|
38
|
+
return "Kubernetes rejected Radar's credentials"
|
|
39
|
+
case 'radar_kind_not_collected':
|
|
40
|
+
case 'radar_namespace_not_collected':
|
|
41
|
+
return "Radar doesn't collect them here"
|
|
42
|
+
}
|
|
43
|
+
if (typeof code === 'string' && Object.hasOwn(READ_ACTIONS, code)) {
|
|
44
|
+
return readActionFor(code) === 'user_access'
|
|
45
|
+
? "you don't have access to list them in this namespace"
|
|
46
|
+
: gapReasonText(code)
|
|
47
|
+
}
|
|
48
|
+
switch (gap.action) {
|
|
49
|
+
case 'user_access':
|
|
50
|
+
return "you don't have access to list them in this namespace"
|
|
51
|
+
case 'radar_config':
|
|
52
|
+
return "Radar's connection or collection settings need attention"
|
|
53
|
+
case 'retry':
|
|
54
|
+
return 'retry the read'
|
|
55
|
+
case 'wait':
|
|
56
|
+
return 'wait and retry the read'
|
|
57
|
+
case 'check_target':
|
|
58
|
+
return 'check the requested target and retry'
|
|
59
|
+
case 'change_request':
|
|
60
|
+
return 'change the read request'
|
|
61
|
+
default:
|
|
62
|
+
return gapReasonText(gap.code)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatSince(iso: string): string {
|
|
67
|
+
const date = new Date(iso)
|
|
68
|
+
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sentences for a workload Timeline's note line: since when Radar's history
|
|
73
|
+
* is complete, and which related kinds it can't include for this viewer.
|
|
74
|
+
* Unknown reasons and codes from a newer Radar still produce a sentence.
|
|
75
|
+
*/
|
|
76
|
+
export function workloadHistoryNotes(coverage?: WorkloadHistoryCoverage | null, gaps?: ReadGap[] | null): string[] {
|
|
77
|
+
const notes: string[] = []
|
|
78
|
+
if (coverage?.incomplete) {
|
|
79
|
+
const why: string[] = []
|
|
80
|
+
if (coverage.reasons.includes('observation_started_late')) why.push('Radar began recording part of this history later')
|
|
81
|
+
if (coverage.reasons.includes('events_evicted')) why.push('older history was deleted to stay within its limits')
|
|
82
|
+
const head = coverage.complete_since
|
|
83
|
+
? `Changes before ${formatSince(coverage.complete_since)} may be missing`
|
|
84
|
+
: 'Earlier changes may be missing'
|
|
85
|
+
notes.push(why.length > 0 ? `${head}: ${joinWords(why)}.` : `${head}.`)
|
|
86
|
+
}
|
|
87
|
+
for (const gap of gaps ?? []) {
|
|
88
|
+
const kinds = gap.kinds?.names?.map(kindLabel) ?? []
|
|
89
|
+
const what = kinds.length > 0 ? `Changes to ${joinWords(kinds)}` : 'Some related changes'
|
|
90
|
+
notes.push(`${what} aren't shown: ${gapClause(gap)}.`)
|
|
91
|
+
}
|
|
92
|
+
return notes
|
|
93
|
+
}
|