@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.
Files changed (97) hide show
  1. package/package.json +1 -1
  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 +39 -7
  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 +4 -1
  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 +964 -96
  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/PVCRenderer.test.tsx +44 -0
  36. package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
  37. package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
  38. package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
  39. package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
  40. package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
  41. package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
  42. package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
  43. package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
  44. package/src/components/resources/renderers/kai-cells.tsx +67 -0
  45. package/src/components/resources/renderers/kaito-cells.tsx +62 -0
  46. package/src/components/resources/renderers/kserve-cells.tsx +145 -0
  47. package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
  48. package/src/components/resources/renderers/kueue-cells.tsx +176 -0
  49. package/src/components/resources/renderers/nim-cells.tsx +81 -0
  50. package/src/components/resources/renderers/ray-cells.tsx +124 -0
  51. package/src/components/resources/renderers/volcano-cells.tsx +140 -0
  52. package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
  53. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +222 -0
  54. package/src/components/resources/resource-utils-hpa.test.ts +4 -0
  55. package/src/components/resources/resource-utils-hpa.ts +6 -2
  56. package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
  57. package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
  58. package/src/components/resources/resource-utils-kai.ts +105 -0
  59. package/src/components/resources/resource-utils-kaito.ts +114 -0
  60. package/src/components/resources/resource-utils-kserve.ts +186 -0
  61. package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
  62. package/src/components/resources/resource-utils-kueue.ts +198 -0
  63. package/src/components/resources/resource-utils-nim.ts +109 -0
  64. package/src/components/resources/resource-utils-ray.ts +214 -0
  65. package/src/components/resources/resource-utils-rollout.test.ts +183 -0
  66. package/src/components/resources/resource-utils-velero.test.ts +30 -0
  67. package/src/components/resources/resource-utils-volcano.ts +176 -0
  68. package/src/components/resources/resource-utils.ts +156 -47
  69. package/src/components/resources/resources-column-filter.test.ts +27 -1
  70. package/src/components/resources/workload-rollout-golden.test.ts +55 -0
  71. package/src/components/shared/CreateResourceDialog.tsx +1 -0
  72. package/src/components/shared/EditableYamlView.tsx +4 -2
  73. package/src/components/shared/ResourceActionsBar.tsx +170 -25
  74. package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
  75. package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
  76. package/src/components/shared/SetImageDialog.test.ts +129 -0
  77. package/src/components/shared/SetImageDialog.tsx +577 -0
  78. package/src/components/shared/index.ts +1 -0
  79. package/src/components/shared/revision-role-badges.test.ts +56 -4
  80. package/src/components/timeline/TimelineList.tsx +19 -3
  81. package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
  82. package/src/components/trace/ReachabilityView.tsx +1 -1
  83. package/src/components/ui/drawer-components.tsx +12 -0
  84. package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
  85. package/src/components/workload/WorkloadView.tsx +144 -81
  86. package/src/hooks/useProgressiveRefresh.ts +37 -0
  87. package/src/source-hygiene.test.ts +28 -0
  88. package/src/theme/tailwind-theme.css +4 -0
  89. package/src/types/core.ts +55 -0
  90. package/src/utils/api-resources.test.ts +40 -1
  91. package/src/utils/api-resources.ts +11 -1
  92. package/src/utils/applications.ts +50 -0
  93. package/src/utils/bulk-workload-actions.test.ts +20 -0
  94. package/src/utils/bulk-workload-actions.ts +15 -0
  95. package/src/utils/navigation.test.ts +31 -1
  96. package/src/utils/navigation.ts +21 -2
  97. 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 { describe, expect, it } from 'vitest'
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 { kindToPlural } from '../../../utils/navigation'
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
- // Parse API group from apiVersion (format: "group/version" or "v1" for core)
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: kindToPlural(involvedObject.kind),
80
+ kind: kindToPluralWithGroup(involvedObject.kind, group ?? ''),
83
81
  namespace: involvedObject.namespace || '',
84
82
  name: involvedObject.name,
85
83
  group,