@skyhook-io/k8s-ui 1.13.3 → 1.13.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/applications/AppTooltips.tsx +13 -1
- package/src/components/applications/ApplicationDetail.test.tsx +25 -0
- package/src/components/applications/ApplicationDetail.tsx +39 -7
- package/src/components/applications/ApplicationsView.tsx +18 -3
- package/src/components/applications/ReadyBar.tsx +3 -2
- package/src/components/applications/applications-view.test.tsx +91 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +40 -31
- package/src/components/issues/ResourceIssuesSection.tsx +3 -2
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +123 -4
- package/src/components/issues/issues.test.ts +221 -10
- package/src/components/issues/types.ts +31 -5
- package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
- package/src/components/resources/ResourcesView.tsx +964 -96
- package/src/components/resources/column-settings-key.test.ts +98 -0
- package/src/components/resources/column-visibility-merge.test.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +236 -0
- package/src/components/resources/gateway-policy-status.test.ts +291 -0
- package/src/components/resources/gateway-policy-status.ts +237 -0
- package/src/components/resources/generic-status.test.ts +199 -0
- package/src/components/resources/generic-status.ts +171 -0
- package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
- package/src/components/resources/index.ts +9 -1
- package/src/components/resources/istio-gateway-filter.test.ts +97 -0
- package/src/components/resources/printer-columns.test.ts +298 -0
- package/src/components/resources/printer-columns.ts +249 -0
- package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -5
- package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
- package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
- package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
- package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
- package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
- package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
- package/src/components/resources/renderers/kai-cells.tsx +67 -0
- package/src/components/resources/renderers/kaito-cells.tsx +62 -0
- package/src/components/resources/renderers/kserve-cells.tsx +145 -0
- package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
- package/src/components/resources/renderers/kueue-cells.tsx +176 -0
- package/src/components/resources/renderers/nim-cells.tsx +81 -0
- package/src/components/resources/renderers/ray-cells.tsx +124 -0
- package/src/components/resources/renderers/volcano-cells.tsx +140 -0
- package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
- package/src/components/resources/resource-utils-hpa.test.ts +4 -0
- package/src/components/resources/resource-utils-hpa.ts +6 -2
- package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
- package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
- package/src/components/resources/resource-utils-kai.ts +105 -0
- package/src/components/resources/resource-utils-kaito.ts +114 -0
- package/src/components/resources/resource-utils-kserve.ts +186 -0
- package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
- package/src/components/resources/resource-utils-kueue.ts +198 -0
- package/src/components/resources/resource-utils-nim.ts +109 -0
- package/src/components/resources/resource-utils-ray.ts +214 -0
- package/src/components/resources/resource-utils-rollout.test.ts +183 -0
- package/src/components/resources/resource-utils-velero.test.ts +30 -0
- package/src/components/resources/resource-utils-volcano.ts +176 -0
- package/src/components/resources/resource-utils.ts +156 -47
- package/src/components/resources/resources-column-filter.test.ts +27 -1
- package/src/components/resources/workload-rollout-golden.test.ts +55 -0
- package/src/components/shared/CreateResourceDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.tsx +4 -2
- package/src/components/shared/ResourceActionsBar.tsx +170 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
- package/src/components/shared/SetImageDialog.test.ts +129 -0
- package/src/components/shared/SetImageDialog.tsx +577 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/shared/revision-role-badges.test.ts +56 -4
- package/src/components/timeline/TimelineList.tsx +19 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
- package/src/components/trace/ReachabilityView.tsx +1 -1
- package/src/components/ui/drawer-components.tsx +12 -0
- package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
- package/src/components/workload/WorkloadView.tsx +144 -81
- package/src/hooks/useProgressiveRefresh.ts +37 -0
- package/src/source-hygiene.test.ts +28 -0
- package/src/theme/tailwind-theme.css +4 -0
- package/src/types/core.ts +55 -0
- package/src/utils/api-resources.test.ts +40 -1
- package/src/utils/api-resources.ts +11 -1
- package/src/utils/applications.ts +50 -0
- package/src/utils/bulk-workload-actions.test.ts +20 -0
- package/src/utils/bulk-workload-actions.ts +15 -0
- package/src/utils/navigation.test.ts +31 -1
- package/src/utils/navigation.ts +21 -2
- package/src/utils/workload-rollout.ts +292 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
formatPrinterCell,
|
|
4
|
+
printerTableMatchesKind,
|
|
5
|
+
parseHumanDuration,
|
|
6
|
+
printerCellSortValue,
|
|
7
|
+
printerCellTone,
|
|
8
|
+
printerColumnKey,
|
|
9
|
+
printerTableKey,
|
|
10
|
+
readPrinterCell,
|
|
11
|
+
sanitizePrinterTable,
|
|
12
|
+
} from './printer-columns'
|
|
13
|
+
|
|
14
|
+
describe('sanitizePrinterTable', () => {
|
|
15
|
+
it('keeps a well-formed table and defaults priority to 0', () => {
|
|
16
|
+
const t = sanitizePrinterTable({
|
|
17
|
+
columns: [{ name: 'Phase', type: 'string' }, { name: 'Replicas', type: 'integer', priority: 1 }],
|
|
18
|
+
cells: { a: ['Running', 3] },
|
|
19
|
+
})
|
|
20
|
+
expect(t?.columns).toEqual([
|
|
21
|
+
{ name: 'Phase', type: 'string', format: undefined, description: undefined, priority: 0 },
|
|
22
|
+
{ name: 'Replicas', type: 'integer', format: undefined, description: undefined, priority: 1 },
|
|
23
|
+
])
|
|
24
|
+
expect(t?.cells.a).toEqual(['Running', 3])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it.each([
|
|
28
|
+
['null', null],
|
|
29
|
+
['no columns key', { cells: {} }],
|
|
30
|
+
['empty columns', { columns: [], cells: {} }],
|
|
31
|
+
['columns that are not an array', { columns: 'Phase' }],
|
|
32
|
+
['every column malformed', { columns: [{ type: 'string' }, null, { name: ' ' }] }],
|
|
33
|
+
])('returns null for %s', (_label, raw) => {
|
|
34
|
+
expect(sanitizePrinterTable(raw)).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// A dropped duplicate must take its cell with it, or every later value
|
|
38
|
+
// renders one column to the left.
|
|
39
|
+
it('realigns cells when a column is dropped', () => {
|
|
40
|
+
const t = sanitizePrinterTable({
|
|
41
|
+
columns: [{ name: 'Phase' }, { name: 'Phase' }, { name: 'Host' }],
|
|
42
|
+
cells: { a: ['Running', 'dup', 'example.com'] },
|
|
43
|
+
})
|
|
44
|
+
expect(t?.columns.map(c => c.name)).toEqual(['Phase', 'Host'])
|
|
45
|
+
expect(t?.cells.a).toEqual(['Running', 'example.com'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('ignores rows that are not arrays', () => {
|
|
49
|
+
const t = sanitizePrinterTable({ columns: [{ name: 'A' }, { name: 'B' }], cells: { a: 'nope', b: ['1', '2'] } })
|
|
50
|
+
expect(t?.cells).toEqual({ b: ['1', '2'] })
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('tolerates a missing cells map', () => {
|
|
54
|
+
expect(sanitizePrinterTable({ columns: [{ name: 'A' }] })?.cells).toEqual({})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('printerTableKey', () => {
|
|
59
|
+
it('is stable across refetches and changes with the column set', () => {
|
|
60
|
+
const a = { columns: [{ name: 'Phase' }, { name: 'Host' }], cells: {} }
|
|
61
|
+
const b = { columns: [{ name: 'Phase' }, { name: 'Host' }], cells: { x: ['1'] } }
|
|
62
|
+
expect(printerTableKey(a)).toBe(printerTableKey(b))
|
|
63
|
+
expect(printerTableKey(a)).not.toBe(printerTableKey({ columns: [{ name: 'Phase' }], cells: {} }))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('is empty when there is no table', () => {
|
|
67
|
+
expect(printerTableKey(null)).toBe('')
|
|
68
|
+
expect(printerTableKey({ columns: [], cells: {} })).toBe('')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('printerColumnKey', () => {
|
|
73
|
+
// Built-in keys never carry a prefix and custom columns use label:/annotation:,
|
|
74
|
+
// so this namespace cannot collide with either.
|
|
75
|
+
it('namespaces the key', () => {
|
|
76
|
+
expect(printerColumnKey({ name: 'Status' })).toBe('printer:Status')
|
|
77
|
+
expect(printerColumnKey({ name: 'Status' })).not.toBe('status')
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('readPrinterCell', () => {
|
|
82
|
+
const table = { columns: [{ name: 'A' }, { name: 'B' }], cells: { u1: ['x', 2] } }
|
|
83
|
+
it('reads by uid and index', () => {
|
|
84
|
+
expect(readPrinterCell(table, 'u1', 0)).toBe('x')
|
|
85
|
+
expect(readPrinterCell(table, 'u1', 1)).toBe(2)
|
|
86
|
+
})
|
|
87
|
+
it('is undefined for an unknown uid, a missing uid, or an out-of-range index', () => {
|
|
88
|
+
expect(readPrinterCell(table, 'nope', 0)).toBeUndefined()
|
|
89
|
+
expect(readPrinterCell(table, undefined, 0)).toBeUndefined()
|
|
90
|
+
expect(readPrinterCell(table, 'u1', 9)).toBeUndefined()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('formatPrinterCell', () => {
|
|
95
|
+
it.each([
|
|
96
|
+
['string', 'Running', 'Running'],
|
|
97
|
+
['zero', 0, '0'],
|
|
98
|
+
['number', 3, '3'],
|
|
99
|
+
['true', true, 'True'],
|
|
100
|
+
['false', false, 'False'],
|
|
101
|
+
['null', null, ''],
|
|
102
|
+
['undefined', undefined, ''],
|
|
103
|
+
// The apiserver leaves an unresolvable path null, but a `type` mismatch can
|
|
104
|
+
// still hand back a structure. Rendering "[object Object]" would be worse
|
|
105
|
+
// than rendering nothing.
|
|
106
|
+
['object', { a: 1 }, ''],
|
|
107
|
+
['array', [1, 2], ''],
|
|
108
|
+
])('renders %s', (_label, value, want) => {
|
|
109
|
+
expect(formatPrinterCell(value)).toBe(want)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('printerCellSortValue', () => {
|
|
114
|
+
// Sorting must use the value, not its rendered text, or 10 sorts before 9.
|
|
115
|
+
it('sorts numbers numerically', () => {
|
|
116
|
+
expect([10, 9, 100].map(printerCellSortValue).sort((a, b) => (a as number) - (b as number)))
|
|
117
|
+
.toEqual([9, 10, 100])
|
|
118
|
+
})
|
|
119
|
+
it('sorts booleans by truth and falls back to empty for anything else', () => {
|
|
120
|
+
expect(printerCellSortValue(true)).toBe(1)
|
|
121
|
+
expect(printerCellSortValue(false)).toBe(0)
|
|
122
|
+
expect(printerCellSortValue({ a: 1 })).toBe('')
|
|
123
|
+
expect(printerCellSortValue(null)).toBe('')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('printerTableMatchesKind', () => {
|
|
128
|
+
const table = { kind: 'widgets', group: 'example.com', columns: [{ name: 'A' }], cells: {} }
|
|
129
|
+
|
|
130
|
+
it('matches its own kind and group', () => {
|
|
131
|
+
expect(printerTableMatchesKind(table, 'widgets', 'example.com')).toBe(true)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// The selected kind changes before the new list resolves, so an unchecked
|
|
135
|
+
// table can briefly be the PREVIOUS kind's — which the visibility effect
|
|
136
|
+
// would then persist under the new kind's storage key.
|
|
137
|
+
it('rejects a table belonging to another kind or another group', () => {
|
|
138
|
+
expect(printerTableMatchesKind(table, 'gadgets', 'example.com')).toBe(false)
|
|
139
|
+
expect(printerTableMatchesKind(table, 'widgets', 'other.io')).toBe(false)
|
|
140
|
+
expect(printerTableMatchesKind(table, 'widgets', undefined)).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('rejects a table with no identity rather than pairing it blindly', () => {
|
|
144
|
+
expect(printerTableMatchesKind({ columns: [{ name: 'A' }], cells: {} }, 'widgets', 'example.com')).toBe(false)
|
|
145
|
+
expect(printerTableMatchesKind(null, 'widgets', 'example.com')).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('treats an absent group and an empty group as the same', () => {
|
|
149
|
+
const noGroup = { kind: 'widgets', columns: [{ name: 'A' }], cells: {} }
|
|
150
|
+
expect(printerTableMatchesKind(noGroup, 'widgets', '')).toBe(true)
|
|
151
|
+
expect(printerTableMatchesKind(noGroup, 'widgets', undefined)).toBe(true)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('sanitizePrinterTable identity', () => {
|
|
156
|
+
it('carries kind and group through', () => {
|
|
157
|
+
const t = sanitizePrinterTable({ kind: 'widgets', group: 'example.com', columns: [{ name: 'A' }], cells: {} })
|
|
158
|
+
expect(t).toMatchObject({ kind: 'widgets', group: 'example.com' })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('leaves them undefined when the server sent none', () => {
|
|
162
|
+
const t = sanitizePrinterTable({ columns: [{ name: 'A' }], cells: {} })
|
|
163
|
+
expect(t?.kind).toBeUndefined()
|
|
164
|
+
expect(t?.group).toBeUndefined()
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('date sorting', () => {
|
|
169
|
+
// The table converter hands back humanized ages, so sorting them as text puts
|
|
170
|
+
// "10m" before "2m".
|
|
171
|
+
it.each([['5s', 5], ['2m', 120], ['3h', 10800], ['4d', 345600], ['1y', 31536000], ['2d3h', 183600], ['1y64d', 37065600]])(
|
|
172
|
+
'parses %s', (text, want) => expect(parseHumanDuration(text)).toBe(want))
|
|
173
|
+
|
|
174
|
+
it.each([['', null], [' ', null], ['abc', null], ['5', null], ['5x', null], ['-3m', null], ['<invalid>', null]])(
|
|
175
|
+
'rejects %s', (text, want) => expect(parseHumanDuration(text)).toBe(want))
|
|
176
|
+
|
|
177
|
+
it('orders date cells oldest-first ascending, like the Age column', () => {
|
|
178
|
+
const def = { name: 'Last Refresh', type: 'date' }
|
|
179
|
+
const sorted = ['10m', '2m', '1h', '30s']
|
|
180
|
+
.map(v => ({ v, k: printerCellSortValue(v, def) as number }))
|
|
181
|
+
.sort((a, b) => a.k - b.k)
|
|
182
|
+
.map(x => x.v)
|
|
183
|
+
expect(sorted).toEqual(['1h', '10m', '2m', '30s'])
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('leaves a date column that is not a duration sorting as text', () => {
|
|
187
|
+
expect(printerCellSortValue('<invalid>', { name: 'D', type: 'date' })).toBe('<invalid>')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('does not reinterpret duration-looking values in non-date columns', () => {
|
|
191
|
+
expect(printerCellSortValue('10m', { name: 'Size', type: 'string' })).toBe('10m')
|
|
192
|
+
expect(printerCellSortValue('10m')).toBe('10m')
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('printerTableKey priority', () => {
|
|
197
|
+
// Default visibility is derived from priority, so promoting a column out of
|
|
198
|
+
// the -o wide tier has to re-run the visibility defaults.
|
|
199
|
+
it('changes when a column priority changes', () => {
|
|
200
|
+
const wide = { columns: [{ name: 'A' }, { name: 'B', priority: 1 }], cells: {} }
|
|
201
|
+
const promoted = { columns: [{ name: 'A' }, { name: 'B', priority: 0 }], cells: {} }
|
|
202
|
+
expect(printerTableKey(wide)).not.toBe(printerTableKey(promoted))
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe('printerCellTone', () => {
|
|
207
|
+
// The one place Radar interprets vendor data rather than passing it through.
|
|
208
|
+
// Deliberately narrow: the vendor NAMED the column, which is a far stronger
|
|
209
|
+
// signal than inferring health from a value.
|
|
210
|
+
it.each([
|
|
211
|
+
['Ready', true, 'healthy'],
|
|
212
|
+
['Ready', false, 'unhealthy'],
|
|
213
|
+
['Healthy', 'True', 'healthy'],
|
|
214
|
+
['Healthy', 'False', 'unhealthy'],
|
|
215
|
+
['Established', 'true', 'healthy'],
|
|
216
|
+
['Installed', 'True', 'healthy'],
|
|
217
|
+
['Provided', 'True', 'healthy'],
|
|
218
|
+
['ReadyToUse', 'False', 'unhealthy'],
|
|
219
|
+
['Synced', 'True', 'healthy'],
|
|
220
|
+
])('%s=%s tones as %s', (name, value, want) => {
|
|
221
|
+
expect(printerCellTone({ name }, value)).toBe(want)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// Names whose polarity is inverted must not be read as positive.
|
|
225
|
+
it.each([
|
|
226
|
+
['Degraded', 'False', 'healthy'],
|
|
227
|
+
['Denied', 'False', 'healthy'],
|
|
228
|
+
])('%s=%s tones as %s', (name, value, want) => {
|
|
229
|
+
expect(printerCellTone({ name }, value)).toBe(want)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// A controller that could not evaluate a field it owns has not reported a
|
|
233
|
+
// failure — but only for a column we have a claim to speak about.
|
|
234
|
+
it('reports Unknown without calling it a failure, on named columns', () => {
|
|
235
|
+
expect(printerCellTone({ name: 'Ready' }, 'Unknown')).toBe('unknown')
|
|
236
|
+
expect(printerCellTone({ name: 'Degraded' }, 'Unknown')).toBe('unknown')
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// The name decides whether we speak at all. Checking the value first badged
|
|
240
|
+
// `Mode: Unknown` on a column whose polarity we have no claim to.
|
|
241
|
+
it('stays silent on an unnamed column even when the value is Unknown', () => {
|
|
242
|
+
expect(printerCellTone({ name: 'Mode' }, 'Unknown')).toBeNull()
|
|
243
|
+
expect(printerCellTone({ name: 'Strategy' }, 'Unknown')).toBeNull()
|
|
244
|
+
expect(printerCellTone({ name: 'Whatever' }, 'Unknown')).toBeNull()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// The same condition must not change severity depending on whether it
|
|
248
|
+
// arrived through the generic Status column or a printer column.
|
|
249
|
+
it('tones a degraded name amber, matching the generic ladder', () => {
|
|
250
|
+
expect(printerCellTone({ name: 'Degraded' }, 'True')).toBe('degraded')
|
|
251
|
+
expect(printerCellTone({ name: 'Warning' }, 'True')).toBe('degraded')
|
|
252
|
+
expect(printerCellTone({ name: 'ScalingLimited' }, 'True')).toBe('degraded')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('reserves red for genuinely terminal names', () => {
|
|
256
|
+
expect(printerCellTone({ name: 'Denied' }, 'True')).toBe('unhealthy')
|
|
257
|
+
expect(printerCellTone({ name: 'Failed' }, 'True')).toBe('unhealthy')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('renders plain text for a column whose polarity we do not know', () => {
|
|
261
|
+
expect(printerCellTone({ name: 'Mode' }, 'True')).toBeNull()
|
|
262
|
+
expect(printerCellTone({ name: 'Strategy' }, false)).toBeNull()
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// A named column holding something other than a truth value is not a badge —
|
|
266
|
+
// "Ready: 3/5" is a count, not a state.
|
|
267
|
+
it.each([
|
|
268
|
+
['Ready', '3/5'],
|
|
269
|
+
['Ready', 'Active'],
|
|
270
|
+
['Healthy', 5],
|
|
271
|
+
['Ready', null],
|
|
272
|
+
['Ready', { a: 1 }],
|
|
273
|
+
])('renders %s=%s as plain text', (name, value) => {
|
|
274
|
+
expect(printerCellTone({ name }, value)).toBeNull()
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('matches the column name case-insensitively and ignores padding', () => {
|
|
278
|
+
expect(printerCellTone({ name: ' READY ' }, 'true')).toBe('healthy')
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
describe('printerTableKey collision safety', () => {
|
|
283
|
+
// Column names are vendor text and really do contain spaces ("VERIFY IMAGES",
|
|
284
|
+
// "FAILURE POLICY"). A separator-joined key let two different column sets
|
|
285
|
+
// produce one string, so the visibility effect would miss a real change.
|
|
286
|
+
it('distinguishes sets that a separator-joined key would merge', () => {
|
|
287
|
+
const a = printerTableKey({ columns: [{ name: 'A' }, { name: 'B' }, { name: 'C' }], cells: {} })
|
|
288
|
+
const b = printerTableKey({ columns: [{ name: 'A:0 B' }, { name: 'C' }], cells: {} })
|
|
289
|
+
expect(a).not.toBe(b)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('still changes when only a priority changes', () => {
|
|
293
|
+
const before = printerTableKey({ columns: [{ name: 'Phase', priority: 1 }], cells: {} })
|
|
294
|
+
const after = printerTableKey({ columns: [{ name: 'Phase', priority: 0 }], cells: {} })
|
|
295
|
+
expect(before).not.toBe(after)
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { HealthLevel } from './resource-utils'
|
|
2
|
+
import { KNOWN_NEGATIVE_CONDITIONS, KNOWN_POSITIVE_CONDITIONS } from './generic-status'
|
|
3
|
+
|
|
4
|
+
// Vendor-declared table columns from a CRD's additionalPrinterColumns,
|
|
5
|
+
// evaluated server-side (see internal/server/printer_columns.go) and shipped
|
|
6
|
+
// with the list response. The pure parts live here so they're unit-tested;
|
|
7
|
+
// the JSX render is materialized in ResourcesView.
|
|
8
|
+
|
|
9
|
+
/** One column definition, as the CRD declared it. */
|
|
10
|
+
export interface PrinterColumnDef {
|
|
11
|
+
name: string
|
|
12
|
+
type?: string
|
|
13
|
+
format?: string
|
|
14
|
+
/** The vendor's own text. Absent for most CRDs, which declare none. */
|
|
15
|
+
description?: string
|
|
16
|
+
/** kubectl's `-o wide` tier. Declared, but not shown by default. */
|
|
17
|
+
priority?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PrinterTable {
|
|
21
|
+
/** The kind these columns describe, echoed by the server. */
|
|
22
|
+
kind?: string
|
|
23
|
+
group?: string
|
|
24
|
+
columns: PrinterColumnDef[]
|
|
25
|
+
/** metadata.uid -> one value per entry in `columns`, same order. */
|
|
26
|
+
cells: Record<string, unknown[]>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stable identity for a column set. Printer columns are fixed per CRD version,
|
|
31
|
+
* so this string only changes when the kind changes or an operator is upgraded
|
|
32
|
+
* mid-session — which is exactly when the visibility defaults should be
|
|
33
|
+
* recomputed, and never on an ordinary refetch.
|
|
34
|
+
*/
|
|
35
|
+
export function printerTableKey(table: PrinterTable | null | undefined): string {
|
|
36
|
+
if (!table?.columns?.length) return ''
|
|
37
|
+
// Priority is part of the identity, not just the name: default visibility is
|
|
38
|
+
// derived from it, so an operator upgrade that promotes a column out of the
|
|
39
|
+
// `-o wide` tier has to re-run the visibility defaults. Serialized rather
|
|
40
|
+
// than concatenated because column names are vendor text that can contain
|
|
41
|
+
// the separators — real ones already carry spaces ("VERIFY IMAGES").
|
|
42
|
+
return JSON.stringify(table.columns.map(c => [c.name, c.priority ?? 0]))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Column key. Prefixed so it can never collide with a built-in column key or
|
|
47
|
+
* with a user-defined `label:`/`annotation:` custom column. Build-only — never
|
|
48
|
+
* parsed back into a name.
|
|
49
|
+
*/
|
|
50
|
+
export const PRINTER_COLUMN_PREFIX = 'printer:'
|
|
51
|
+
|
|
52
|
+
export function printerColumnKey(def: PrinterColumnDef): string {
|
|
53
|
+
return `${PRINTER_COLUMN_PREFIX}${def.name}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Drops anything malformed. The server owns this shape, but it crosses the
|
|
58
|
+
* wire, and a bad entry would otherwise produce a dead column or crash a map.
|
|
59
|
+
*/
|
|
60
|
+
export function sanitizePrinterTable(raw: any): PrinterTable | null {
|
|
61
|
+
if (!raw || !Array.isArray(raw.columns) || raw.columns.length === 0) return null
|
|
62
|
+
const seen = new Set<string>()
|
|
63
|
+
const columns: PrinterColumnDef[] = []
|
|
64
|
+
const keptIdx: number[] = []
|
|
65
|
+
raw.columns.forEach((c: any, i: number) => {
|
|
66
|
+
if (!c || typeof c.name !== 'string') return
|
|
67
|
+
const name = c.name.trim()
|
|
68
|
+
if (!name || seen.has(name)) return
|
|
69
|
+
seen.add(name)
|
|
70
|
+
columns.push({
|
|
71
|
+
name,
|
|
72
|
+
type: typeof c.type === 'string' ? c.type : undefined,
|
|
73
|
+
format: typeof c.format === 'string' ? c.format : undefined,
|
|
74
|
+
description: typeof c.description === 'string' ? c.description : undefined,
|
|
75
|
+
priority: typeof c.priority === 'number' ? c.priority : 0,
|
|
76
|
+
})
|
|
77
|
+
keptIdx.push(i)
|
|
78
|
+
})
|
|
79
|
+
if (!columns.length) return null
|
|
80
|
+
|
|
81
|
+
const cells: Record<string, unknown[]> = {}
|
|
82
|
+
const rawCells = raw.cells && typeof raw.cells === 'object' ? raw.cells : {}
|
|
83
|
+
for (const uid of Object.keys(rawCells)) {
|
|
84
|
+
const row = rawCells[uid]
|
|
85
|
+
if (!Array.isArray(row)) continue
|
|
86
|
+
// Realign to the surviving columns, or a dropped duplicate would shift
|
|
87
|
+
// every later value into the wrong column.
|
|
88
|
+
cells[uid] = keptIdx.map(i => row[i])
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
kind: typeof raw.kind === 'string' ? raw.kind : undefined,
|
|
92
|
+
group: typeof raw.group === 'string' ? raw.group : undefined,
|
|
93
|
+
columns,
|
|
94
|
+
cells,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Whether a table describes the kind currently selected. The selected kind
|
|
100
|
+
* changes before the new list resolves, so an unchecked table can briefly be
|
|
101
|
+
* the PREVIOUS kind's — which the column-visibility effect would then persist
|
|
102
|
+
* under the new kind's storage key.
|
|
103
|
+
*/
|
|
104
|
+
export function printerTableMatchesKind(
|
|
105
|
+
table: PrinterTable | null | undefined,
|
|
106
|
+
kind: string,
|
|
107
|
+
group: string | undefined,
|
|
108
|
+
): boolean {
|
|
109
|
+
if (!table) return false
|
|
110
|
+
// A server that sent no identity cannot be paired safely.
|
|
111
|
+
if (!table.kind) return false
|
|
112
|
+
// The server echoes the lowercased request path segment, so compare
|
|
113
|
+
// case-insensitively rather than relying on the caller's plural already
|
|
114
|
+
// being lowercase — a deep link that discovery could not resolve isn't.
|
|
115
|
+
return table.kind.toLowerCase() === kind.toLowerCase() && (table.group ?? '') === (group ?? '')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function readPrinterCell(table: PrinterTable, uid: string | undefined, index: number): unknown {
|
|
119
|
+
if (!uid) return undefined
|
|
120
|
+
const row = table.cells[uid]
|
|
121
|
+
return Array.isArray(row) ? row[index] : undefined
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Display text for one cell. Every value is a scalar by the time it gets here:
|
|
126
|
+
* the table converter humanizes `date` cells to "5m", coerces the numeric
|
|
127
|
+
* types, and JSON-encodes a `string` column whose path lands on an object or
|
|
128
|
+
* array — so a badly-aimed JSONPath arrives as a blob like `{"foo":"bar"}`
|
|
129
|
+
* rather than as structure. Non-scalars can therefore only come from a
|
|
130
|
+
* malformed response, and those read as absent.
|
|
131
|
+
*/
|
|
132
|
+
export function formatPrinterCell(value: unknown): string {
|
|
133
|
+
if (value === null || value === undefined) return ''
|
|
134
|
+
if (typeof value === 'boolean') return value ? 'True' : 'False'
|
|
135
|
+
if (typeof value === 'number') return String(value)
|
|
136
|
+
if (typeof value === 'string') return value
|
|
137
|
+
return ''
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Seconds represented by a Kubernetes human-readable duration ("5m", "2d3h",
|
|
142
|
+
* "1y64d"), or null if it isn't one. The table converter emits these for `date`
|
|
143
|
+
* columns, and sorting them as text puts "10m" before "2m".
|
|
144
|
+
*/
|
|
145
|
+
const DURATION_UNIT_SECONDS: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400, y: 31536000 }
|
|
146
|
+
|
|
147
|
+
export function parseHumanDuration(text: string): number | null {
|
|
148
|
+
const trimmed = text.trim()
|
|
149
|
+
if (!trimmed || !/^(\d+[smhdy])+$/.test(trimmed)) return null
|
|
150
|
+
let total = 0
|
|
151
|
+
for (const [, amount, unit] of trimmed.matchAll(/(\d+)([smhdy])/g)) {
|
|
152
|
+
total += Number(amount) * DURATION_UNIT_SECONDS[unit]
|
|
153
|
+
}
|
|
154
|
+
return total
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Sort key. Numbers and booleans sort by value, not by their rendered text. */
|
|
158
|
+
export function printerCellSortValue(value: unknown, def?: PrinterColumnDef): string | number {
|
|
159
|
+
if (typeof value === 'number') return value
|
|
160
|
+
if (typeof value === 'boolean') return value ? 1 : 0
|
|
161
|
+
if (typeof value === 'string') {
|
|
162
|
+
if (def?.type === 'date') {
|
|
163
|
+
const seconds = parseHumanDuration(value)
|
|
164
|
+
// A date cell holds an age, so a bigger number is an OLDER object —
|
|
165
|
+
// the reverse of the Age column's timestamp. Negating restores the
|
|
166
|
+
// shared meaning: ascending puts the oldest first in both.
|
|
167
|
+
if (seconds !== null) return -seconds
|
|
168
|
+
}
|
|
169
|
+
return value
|
|
170
|
+
}
|
|
171
|
+
return ''
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Width class for a column. Printer columns carry no width hint, so this is
|
|
176
|
+
* derived from the declared type: scalars are narrow, strings need room.
|
|
177
|
+
*/
|
|
178
|
+
export function printerColumnWidth(def: PrinterColumnDef): string {
|
|
179
|
+
switch (def.type) {
|
|
180
|
+
case 'integer':
|
|
181
|
+
case 'number':
|
|
182
|
+
case 'boolean':
|
|
183
|
+
return 'w-24'
|
|
184
|
+
case 'date':
|
|
185
|
+
return 'w-28'
|
|
186
|
+
default:
|
|
187
|
+
return 'w-40'
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Column names whose polarity is a convention rather than a guess. Shared with
|
|
193
|
+
* the condition sets in generic-status.ts so the two can't drift, plus the
|
|
194
|
+
* names CRDs actually use for the same idea in a printer column.
|
|
195
|
+
*
|
|
196
|
+
* This is the one place Radar interprets vendor data rather than passing it
|
|
197
|
+
* through. It is deliberately narrow: the vendor NAMED the column, which is a
|
|
198
|
+
* much stronger signal than inferring health from a value. Anything not on
|
|
199
|
+
* these lists renders as plain text with no health claim.
|
|
200
|
+
*/
|
|
201
|
+
const POSITIVE_COLUMN_NAMES = new Set(
|
|
202
|
+
[...KNOWN_POSITIVE_CONDITIONS, 'ReadyToUse', 'Established', 'Installed', 'Provided']
|
|
203
|
+
.map(n => n.toLowerCase()),
|
|
204
|
+
)
|
|
205
|
+
// Split by severity, not just polarity. generic-status.ts tones a true
|
|
206
|
+
// KNOWN_NEGATIVE_CONDITION as `degraded` (amber); collapsing these to red here
|
|
207
|
+
// would make the same `Degraded=True` amber in the generic Status column and
|
|
208
|
+
// red in a printer column.
|
|
209
|
+
const DEGRADED_COLUMN_NAMES = new Set(KNOWN_NEGATIVE_CONDITIONS.map(n => n.toLowerCase()))
|
|
210
|
+
const TERMINAL_COLUMN_NAMES = new Set(['denied', 'failed'])
|
|
211
|
+
|
|
212
|
+
/** The three states a boolean-ish printer cell can hold, however it is typed. */
|
|
213
|
+
function truthiness(value: unknown): 'true' | 'false' | 'unknown' | null {
|
|
214
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
|
215
|
+
if (typeof value !== 'string') return null
|
|
216
|
+
switch (value.trim().toLowerCase()) {
|
|
217
|
+
case 'true': return 'true'
|
|
218
|
+
case 'false': return 'false'
|
|
219
|
+
case 'unknown': return 'unknown'
|
|
220
|
+
default: return null
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Health tone for a printer cell, or null to render it as plain text.
|
|
226
|
+
*
|
|
227
|
+
* Only fires when the column's NAME carries a known polarity and its VALUE is
|
|
228
|
+
* True/False/Unknown — the shape a condition-derived printer column has. A
|
|
229
|
+
* column named `Healthy` holding `True` is the vendor telling us this row is
|
|
230
|
+
* fine; rendering that as grey text throws away the only scannable signal on
|
|
231
|
+
* the kinds that have no curated Status column.
|
|
232
|
+
*/
|
|
233
|
+
export function printerCellTone(def: PrinterColumnDef, value: unknown): HealthLevel | null {
|
|
234
|
+
const state = truthiness(value)
|
|
235
|
+
if (!state) return null
|
|
236
|
+
// The NAME decides whether we say anything at all — including for Unknown.
|
|
237
|
+
// Checking the value first would badge `Mode: Unknown` on a column whose
|
|
238
|
+
// polarity we have no claim to.
|
|
239
|
+
const name = def.name.trim().toLowerCase()
|
|
240
|
+
const positive = POSITIVE_COLUMN_NAMES.has(name)
|
|
241
|
+
const degraded = DEGRADED_COLUMN_NAMES.has(name)
|
|
242
|
+
const terminal = TERMINAL_COLUMN_NAMES.has(name)
|
|
243
|
+
if (!positive && !degraded && !terminal) return null
|
|
244
|
+
// Unknown is not a failure — the controller could not evaluate it.
|
|
245
|
+
if (state === 'unknown') return 'unknown'
|
|
246
|
+
if (positive) return state === 'true' ? 'healthy' : 'unhealthy'
|
|
247
|
+
if (degraded) return state === 'true' ? 'degraded' : 'healthy'
|
|
248
|
+
return state === 'true' ? 'unhealthy' : 'healthy'
|
|
249
|
+
}
|
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
import { renderToString } from 'react-dom/server'
|
|
2
|
-
import {
|
|
2
|
+
import type { ReactElement, ReactNode } from 'react'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
4
|
|
|
4
5
|
import { EventRenderer } from './EventRenderer'
|
|
6
|
+
import { initNavigationMap, resetNavigationMap } from '../../../utils/navigation'
|
|
7
|
+
|
|
8
|
+
afterEach(resetNavigationMap)
|
|
9
|
+
|
|
10
|
+
function findButton(node: ReactNode): ReactElement<{ onClick: () => void }> | undefined {
|
|
11
|
+
if (!node || typeof node !== 'object' || !('props' in node)) return undefined
|
|
12
|
+
const element = node as ReactElement<{ children?: ReactNode; onClick?: () => void }>
|
|
13
|
+
if (element.type === 'button') return element as ReactElement<{ onClick: () => void }>
|
|
14
|
+
const children = element.props.children
|
|
15
|
+
for (const child of Array.isArray(children) ? children : [children]) {
|
|
16
|
+
const button = findButton(child)
|
|
17
|
+
if (button) return button
|
|
18
|
+
}
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
5
21
|
|
|
6
22
|
describe('EventRenderer', () => {
|
|
7
23
|
it.each([
|
|
@@ -20,4 +36,37 @@ describe('EventRenderer', () => {
|
|
|
20
36
|
expect(html).not.toMatch(/text-(?:amber|blue)-(?:200|300|400)/)
|
|
21
37
|
expect(html).not.toContain('opacity-')
|
|
22
38
|
})
|
|
39
|
+
|
|
40
|
+
it('navigates to the group-qualified native PodGroup resource', () => {
|
|
41
|
+
initNavigationMap([{
|
|
42
|
+
group: 'scheduling.k8s.io',
|
|
43
|
+
version: 'v1beta1',
|
|
44
|
+
kind: 'PodGroup',
|
|
45
|
+
name: 'podgroups',
|
|
46
|
+
namespaced: true,
|
|
47
|
+
isCrd: false,
|
|
48
|
+
verbs: ['get', 'list'],
|
|
49
|
+
}])
|
|
50
|
+
const onNavigate = vi.fn()
|
|
51
|
+
const tree = EventRenderer({
|
|
52
|
+
data: {
|
|
53
|
+
involvedObject: {
|
|
54
|
+
apiVersion: 'scheduling.k8s.io/v1beta1',
|
|
55
|
+
kind: 'PodGroup',
|
|
56
|
+
namespace: 'default',
|
|
57
|
+
name: 'batch',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
onNavigate,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
findButton(tree)?.props.onClick()
|
|
64
|
+
|
|
65
|
+
expect(onNavigate).toHaveBeenCalledWith({
|
|
66
|
+
kind: 'podgroups',
|
|
67
|
+
namespace: 'default',
|
|
68
|
+
name: 'batch',
|
|
69
|
+
group: 'scheduling.k8s.io',
|
|
70
|
+
})
|
|
71
|
+
})
|
|
23
72
|
})
|
|
@@ -4,7 +4,7 @@ import { Badge } from '../../ui/Badge'
|
|
|
4
4
|
import { Section, PropertyList, Property } from '../../ui/drawer-components'
|
|
5
5
|
import { formatAge } from '../resource-utils'
|
|
6
6
|
import type { NavigateToResource } from '../../../utils/navigation'
|
|
7
|
-
import {
|
|
7
|
+
import { apiVersionToGroup, kindToPluralWithGroup } from '../../../utils/navigation'
|
|
8
8
|
|
|
9
9
|
interface EventRendererProps {
|
|
10
10
|
data: any
|
|
@@ -75,11 +75,9 @@ export function EventRenderer({ data, onNavigate }: EventRendererProps) {
|
|
|
75
75
|
{onNavigate ? (
|
|
76
76
|
<button
|
|
77
77
|
onClick={() => {
|
|
78
|
-
|
|
79
|
-
const apiVersion = involvedObject.apiVersion || ''
|
|
80
|
-
const group = apiVersion.includes('/') ? apiVersion.split('/')[0] : undefined
|
|
78
|
+
const group = apiVersionToGroup(involvedObject.apiVersion) || undefined
|
|
81
79
|
onNavigate({
|
|
82
|
-
kind:
|
|
80
|
+
kind: kindToPluralWithGroup(involvedObject.kind, group ?? ''),
|
|
83
81
|
namespace: involvedObject.namespace || '',
|
|
84
82
|
name: involvedObject.name,
|
|
85
83
|
group,
|