@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,199 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getGenericResourceStatus } from './generic-status'
3
+
4
+ const withStatus = (status: any) => ({ status })
5
+
6
+ describe('getGenericResourceStatus', () => {
7
+ describe('phase', () => {
8
+ it('reports a known-healthy phase as healthy', () => {
9
+ expect(getGenericResourceStatus(withStatus({ phase: 'Running' })))
10
+ .toEqual({ text: 'Running', tone: 'healthy' })
11
+ })
12
+
13
+ it('reports a known-degraded phase as degraded and carries the message', () => {
14
+ expect(getGenericResourceStatus(withStatus({ phase: 'Pending', message: 'waiting for quota' })))
15
+ .toEqual({ text: 'Pending', tone: 'degraded', reason: 'waiting for quota' })
16
+ })
17
+
18
+ it('reports a known-failure phase as unhealthy', () => {
19
+ expect(getGenericResourceStatus(withStatus({ phase: 'Failed' })))
20
+ .toMatchObject({ text: 'Failed', tone: 'unhealthy' })
21
+ })
22
+
23
+ // The cell and drawer ladders both painted any unrecognized phase red,
24
+ // so every operator-specific phase name read as an outage.
25
+ it('prefers a known phase over conditions', () => {
26
+ const s = getGenericResourceStatus(withStatus({
27
+ phase: 'Running',
28
+ conditions: [{ type: 'Ready', status: 'False' }],
29
+ }))
30
+ expect(s).toEqual({ text: 'Running', tone: 'healthy' })
31
+ })
32
+
33
+ // An unrecognized phase says nothing about health, so a condition or a
34
+ // replica count that does must win. Short-circuiting on it would hide a
35
+ // real failure behind an operator's private vocabulary.
36
+ it('lets a failing condition outrank an unrecognized phase', () => {
37
+ expect(getGenericResourceStatus(withStatus({
38
+ phase: 'Rebalancing',
39
+ conditions: [{ type: 'Ready', status: 'False', reason: 'QuorumLost' }],
40
+ }))).toMatchObject({ text: 'QuorumLost', tone: 'unhealthy' })
41
+ })
42
+
43
+ it('lets a failing replica count outrank an unrecognized phase', () => {
44
+ expect(getGenericResourceStatus(withStatus({ phase: 'Rebalancing', replicas: 3 })))
45
+ .toEqual({ text: '0/3 Ready', tone: 'unhealthy' })
46
+ })
47
+
48
+ it('keeps an unrecognized phase as the last resort', () => {
49
+ expect(getGenericResourceStatus(withStatus({ phase: 'Rebalancing' })))
50
+ .toMatchObject({ text: 'Rebalancing', tone: 'unknown' })
51
+ })
52
+
53
+ it('prefers a bare state string to an unrecognized phase', () => {
54
+ expect(getGenericResourceStatus(withStatus({ phase: 'Rebalancing', state: 'Draining' })))
55
+ .toEqual({ text: 'Draining', tone: 'unknown' })
56
+ })
57
+ })
58
+
59
+ describe('conditions with known polarity', () => {
60
+ it('reads Ready=True as healthy', () => {
61
+ expect(getGenericResourceStatus(withStatus({ conditions: [{ type: 'Ready', status: 'True' }] })))
62
+ .toEqual({ text: 'Ready', tone: 'healthy' })
63
+ })
64
+
65
+ it('prefers the condition reason over a synthesized negative label', () => {
66
+ expect(getGenericResourceStatus(withStatus({
67
+ conditions: [{ type: 'Ready', status: 'False', reason: 'BackoffLimitExceeded', message: 'too many retries' }],
68
+ }))).toEqual({ text: 'BackoffLimitExceeded', tone: 'unhealthy', reason: 'too many retries' })
69
+ })
70
+
71
+ it('synthesizes a negative label when there is no reason', () => {
72
+ expect(getGenericResourceStatus(withStatus({ conditions: [{ type: 'Available', status: 'False' }] })))
73
+ .toMatchObject({ text: 'Not Available', tone: 'unhealthy' })
74
+ })
75
+
76
+ // A controller that cannot evaluate a condition has not reported a failure.
77
+ it('does not treat Unknown as False', () => {
78
+ expect(getGenericResourceStatus(withStatus({ conditions: [{ type: 'Ready', status: 'Unknown' }] })))
79
+ .toMatchObject({ text: 'Ready', tone: 'unknown' })
80
+ })
81
+
82
+ it('resolves Ready ahead of Available regardless of array order', () => {
83
+ expect(getGenericResourceStatus(withStatus({
84
+ conditions: [{ type: 'Available', status: 'False' }, { type: 'Ready', status: 'True' }],
85
+ }))).toEqual({ text: 'Ready', tone: 'healthy' })
86
+ })
87
+
88
+ // Progressing=False is the resting state of a settled resource; the drawer
89
+ // ladder treated it like a failed Ready and mislabeled healthy objects.
90
+ it('does not infer health from Progressing', () => {
91
+ expect(getGenericResourceStatus(withStatus({ conditions: [{ type: 'Progressing', status: 'False' }] })))
92
+ .toMatchObject({ tone: 'unknown' })
93
+ })
94
+ })
95
+
96
+ describe('conditions with unknown polarity', () => {
97
+ // gmp PodMonitoring publishes only this; every TS ladder rendered a dash.
98
+ it('surfaces an unrecognized condition type as text without a health claim', () => {
99
+ expect(getGenericResourceStatus(withStatus({
100
+ conditions: [{ type: 'ConfigurationCreateSuccess', status: 'True' }],
101
+ }))).toEqual({ text: 'ConfigurationCreateSuccess', tone: 'unknown' })
102
+ })
103
+
104
+ it('reads GKE ComputeClass Health as a known positive condition', () => {
105
+ expect(getGenericResourceStatus(withStatus({ conditions: [{ type: 'Health', status: 'True' }] })))
106
+ .toEqual({ text: 'Health', tone: 'healthy' })
107
+ })
108
+
109
+ it('skips condition entries with no type', () => {
110
+ expect(getGenericResourceStatus(withStatus({
111
+ conditions: [{ status: 'True' }, { type: 'Settled', status: 'True' }],
112
+ }))).toEqual({ text: 'Settled', tone: 'unknown' })
113
+ })
114
+ })
115
+
116
+ describe('bare string status fields', () => {
117
+ // kubefledged ImageCache publishes status.status; the ladders checked
118
+ // status.state only, so it rendered a dash.
119
+ it('falls back to status.status', () => {
120
+ expect(getGenericResourceStatus(withStatus({ status: 'Succeeded', reason: 'ok' })))
121
+ .toEqual({ text: 'Succeeded', tone: 'unknown' })
122
+ })
123
+
124
+ it('prefers status.state over status.status', () => {
125
+ expect(getGenericResourceStatus(withStatus({ state: 'Bound', status: 'other' })))
126
+ .toEqual({ text: 'Bound', tone: 'unknown' })
127
+ })
128
+
129
+ it('ignores state/status when they are not strings', () => {
130
+ expect(getGenericResourceStatus(withStatus({ state: { phase: 'x' } }))).toBeNull()
131
+ })
132
+ })
133
+
134
+
135
+ describe('known-negative conditions and replicas', () => {
136
+ it('reads a Degraded=True condition as degraded', () => {
137
+ expect(getGenericResourceStatus(withStatus({
138
+ conditions: [{ type: 'Degraded', status: 'True', message: 'one shard offline' }],
139
+ }))).toEqual({ text: 'Degraded', tone: 'degraded', reason: 'one shard offline' })
140
+ })
141
+
142
+ it('ignores a known-negative condition that is False', () => {
143
+ expect(getGenericResourceStatus(withStatus({ conditions: [{ type: 'Degraded', status: 'False' }] })))
144
+ .toMatchObject({ tone: 'unknown' })
145
+ })
146
+
147
+ it('reports full replica coverage as healthy', () => {
148
+ expect(getGenericResourceStatus(withStatus({ replicas: 3, readyReplicas: 3 })))
149
+ .toEqual({ text: '3/3 Ready', tone: 'healthy' })
150
+ })
151
+
152
+ it('grades partial replica coverage degraded and none unhealthy', () => {
153
+ expect(getGenericResourceStatus(withStatus({ replicas: 3, readyReplicas: 1 })))
154
+ .toEqual({ text: '1/3 Ready', tone: 'degraded' })
155
+ expect(getGenericResourceStatus(withStatus({ replicas: 3 })))
156
+ .toEqual({ text: '0/3 Ready', tone: 'unhealthy' })
157
+ })
158
+
159
+ it('falls back to availableReplicas when readyReplicas is absent', () => {
160
+ expect(getGenericResourceStatus(withStatus({ replicas: 2, availableReplicas: 2 })))
161
+ .toEqual({ text: '2/2 Ready', tone: 'healthy' })
162
+ })
163
+
164
+ // Replicas say more than a vendor condition name, and both only rank below
165
+ // the condition types whose polarity is actually known.
166
+ it('ranks replicas above an unknown-polarity condition but below Ready', () => {
167
+ expect(getGenericResourceStatus(withStatus({
168
+ replicas: 2, readyReplicas: 2,
169
+ conditions: [{ type: 'ConfigurationCreateSuccess', status: 'True' }],
170
+ }))).toEqual({ text: '2/2 Ready', tone: 'healthy' })
171
+
172
+ expect(getGenericResourceStatus(withStatus({
173
+ replicas: 2, readyReplicas: 2,
174
+ conditions: [{ type: 'Ready', status: 'False', reason: 'Stalled' }],
175
+ }))).toMatchObject({ text: 'Stalled', tone: 'unhealthy' })
176
+ })
177
+
178
+ it('ignores a zero or non-numeric replica count', () => {
179
+ expect(getGenericResourceStatus(withStatus({ replicas: 0 }))).toBeNull()
180
+ expect(getGenericResourceStatus(withStatus({ replicas: '3' }))).toBeNull()
181
+ })
182
+ })
183
+
184
+ describe('nothing to report', () => {
185
+ it.each([
186
+ ['no resource', undefined],
187
+ ['no status', {}],
188
+ // A resource whose status IS a string, not a status object — guarded, or
189
+ // reading .phase off a string would silently yield undefined forever.
190
+ ['non-object status', { status: 'ok' }],
191
+ ['empty status', { status: {} }],
192
+ ['empty conditions', { status: { conditions: [] } }],
193
+ ['blank phase', { status: { phase: ' ' } }],
194
+ ['conditions that are not an array', { status: { conditions: 'Ready' } }],
195
+ ])('returns null for %s', (_label, resource) => {
196
+ expect(getGenericResourceStatus(resource as any)).toBeNull()
197
+ })
198
+ })
199
+ })
@@ -0,0 +1,171 @@
1
+ import type { HealthLevel } from './resource-utils'
2
+ import { getGatewayPolicyStatus } from './gateway-policy-status'
3
+
4
+ /**
5
+ * Derives a status for a resource with no dedicated renderer — a CRD Radar
6
+ * hasn't curated. Shared by the table cell, the column filter and sort, the
7
+ * drawer dispatch and the generic renderer, so a row cannot show one status
8
+ * and its filter dropdown offer another.
9
+ *
10
+ * Two rules it enforces:
11
+ * - Polarity is only claimed for a fixed set of condition types. Any other
12
+ * type is reported as text with an `unknown` tone —
13
+ * `ConfigurationCreateSuccess=True` is informative, but reading health out
14
+ * of an arbitrary condition name is a guess.
15
+ * - `Unknown` is not `False`. A condition the controller can't evaluate is
16
+ * not a failure.
17
+ *
18
+ * The Go summarizer behind MCP (pkg/ai/context/summary_crd.go) derives this
19
+ * separately and does not agree: it is conditions-first where this is
20
+ * phase-first, and reads `Unknown` as false. They differ on, for example,
21
+ * {phase: Running, Ready: False}.
22
+ */
23
+ export interface GenericStatus {
24
+ /** Display text. Never empty when a GenericStatus is returned. */
25
+ text: string
26
+ tone: HealthLevel
27
+ /** Condition message, when one explains a non-healthy tone. */
28
+ reason?: string
29
+ }
30
+
31
+ // Union of the phase vocabularies the five call sites had accumulated.
32
+ const HEALTHY_PHASES = new Set([
33
+ 'Running', 'Active', 'Succeeded', 'Ready', 'Healthy', 'Available', 'Bound', 'Complete', 'Completed',
34
+ ])
35
+ const DEGRADED_PHASES = new Set([
36
+ 'Pending', 'Progressing', 'Unknown', 'Terminating', 'Waiting', 'Provisioning', 'Reconciling',
37
+ ])
38
+ const UNHEALTHY_PHASES = new Set([
39
+ 'Failed', 'Error', 'CrashLoopBackOff', 'ImagePullBackOff', 'ErrImagePull', 'Lost', 'Rejected',
40
+ ])
41
+
42
+ /**
43
+ * Condition types whose polarity is a convention rather than a guess, most
44
+ * specific first. NOT the Go summarizer's order — it resolves Ready >
45
+ * Available > Synced and knows neither Healthy nor Health; see the note above
46
+ * on why the two are not yet aligned. `Progressing` is deliberately absent:
47
+ * `Progressing=False` on a settled resource is normal, so treating it like a
48
+ * failed `Ready` mislabels healthy objects.
49
+ */
50
+ export const KNOWN_POSITIVE_CONDITIONS = ['Ready', 'Available', 'Healthy', 'Health', 'Synced'] as const
51
+
52
+ /** Condition types that mean trouble when True — the mirror of the set above. */
53
+ export const KNOWN_NEGATIVE_CONDITIONS = ['Degraded', 'Warning', 'ScalingLimited'] as const
54
+
55
+ function conditionReason(cond: any): string | undefined {
56
+ const reason = typeof cond?.reason === 'string' ? cond.reason.trim() : ''
57
+ return reason || undefined
58
+ }
59
+
60
+ function conditionMessage(cond: any): string | undefined {
61
+ const message = typeof cond?.message === 'string' ? cond.message.trim() : ''
62
+ return message || undefined
63
+ }
64
+
65
+ function fromCondition(cond: any, polarityKnown: boolean): GenericStatus | null {
66
+ const type = typeof cond?.type === 'string' ? cond.type.trim() : ''
67
+ if (!type) return null
68
+ const state = cond?.status
69
+
70
+ if (state === 'True') {
71
+ return { text: type, tone: polarityKnown ? 'healthy' : 'unknown' }
72
+ }
73
+ if (state === 'False') {
74
+ return {
75
+ text: conditionReason(cond) || `Not ${type}`,
76
+ tone: polarityKnown ? 'unhealthy' : 'unknown',
77
+ reason: conditionMessage(cond),
78
+ }
79
+ }
80
+ // "Unknown" (or anything else): the controller could not determine this.
81
+ // Not a failure — report it without a health claim.
82
+ return { text: conditionReason(cond) || type, tone: 'unknown', reason: conditionMessage(cond) }
83
+ }
84
+
85
+ /**
86
+ * Derives a status for a resource with no kind-specific handling.
87
+ * Returns null when the object carries nothing that reads as a status —
88
+ * callers render their own placeholder rather than inventing one.
89
+ */
90
+ export function getGenericResourceStatus(resource: any): GenericStatus | null {
91
+ const status = resource?.status
92
+ if (!status || typeof status !== 'object') return null
93
+
94
+ // Gateway API policies keep their conditions per-ancestor, one level below
95
+ // where the rest of this function looks. Dispatched on the shape and resolved
96
+ // by its own reader, because the rungs below would answer it wrongly: they
97
+ // take a positive condition before a negative one, and a policy that is
98
+ // `Accepted=True, Programmed=False` has been taken up and then failed.
99
+ // Returns null when the ancestors say nothing, so a policy that also
100
+ // publishes top-level conditions still gets read — GCPBackendPolicy declares
101
+ // both, and suppressing its conditions would hide a Ready=False behind an
102
+ // empty ancestor list.
103
+ const policy = getGatewayPolicyStatus(resource)
104
+ if (policy) return policy
105
+
106
+ const phase = typeof status.phase === 'string' ? status.phase.trim() : ''
107
+ if (phase) {
108
+ const reason = typeof status.message === 'string' && status.message.trim()
109
+ ? status.message.trim()
110
+ : typeof status.reason === 'string' && status.reason.trim()
111
+ ? status.reason.trim()
112
+ : undefined
113
+ if (HEALTHY_PHASES.has(phase)) return { text: phase, tone: 'healthy' }
114
+ if (DEGRADED_PHASES.has(phase)) return { text: phase, tone: 'degraded', reason }
115
+ if (UNHEALTHY_PHASES.has(phase)) return { text: phase, tone: 'unhealthy', reason }
116
+ // An unrecognized phase does NOT short-circuit. Its health is unknown to
117
+ // us, so a Ready=False condition or a failed replica count below is the
118
+ // better answer; the phase text is kept as the last resort. Returning here
119
+ // would hide a real failure behind an operator's private vocabulary.
120
+ }
121
+
122
+ const conditions = Array.isArray(status.conditions) ? status.conditions : []
123
+ for (const type of KNOWN_POSITIVE_CONDITIONS) {
124
+ const match = conditions.find((c: any) => c?.type === type)
125
+ if (match) {
126
+ const derived = fromCondition(match, true)
127
+ if (derived) return derived
128
+ }
129
+ }
130
+ for (const type of KNOWN_NEGATIVE_CONDITIONS) {
131
+ const match = conditions.find((c: any) => c?.type === type && c?.status === 'True')
132
+ if (match) {
133
+ return { text: match.type, tone: 'degraded', reason: conditionMessage(match) ?? conditionReason(match) }
134
+ }
135
+ }
136
+
137
+ // Replica counts read as a status on their own, and say more than a vendor
138
+ // condition name would. Ranked above the unknown-polarity fallback for that
139
+ // reason.
140
+ if (typeof status.replicas === 'number' && status.replicas > 0) {
141
+ const desired = status.replicas
142
+ const ready = typeof status.readyReplicas === 'number' ? status.readyReplicas
143
+ : typeof status.availableReplicas === 'number' ? status.availableReplicas
144
+ : 0
145
+ const text = `${ready}/${desired} Ready`
146
+ if (ready >= desired) return { text, tone: 'healthy' }
147
+ return { text, tone: ready > 0 ? 'degraded' : 'unhealthy' }
148
+ }
149
+
150
+ // No condition whose polarity we know. Report the first one as text so a CRD
151
+ // that only publishes its own vocabulary still says something, but without a
152
+ // health claim.
153
+ for (const cond of conditions) {
154
+ const derived = fromCondition(cond, false)
155
+ if (derived) return derived
156
+ }
157
+
158
+ // `state` and `status` are both used as a bare string status by CRDs that
159
+ // publish neither a phase nor conditions. Guarded on string because both
160
+ // names are also used for nested objects.
161
+ for (const key of ['state', 'status'] as const) {
162
+ const value = status[key]
163
+ if (typeof value === 'string' && value.trim()) {
164
+ return { text: value.trim(), tone: 'unknown' }
165
+ }
166
+ }
167
+
168
+ if (phase) return { text: phase, tone: 'unknown' }
169
+
170
+ return null
171
+ }
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from 'fs'
2
+ import { join } from 'path'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { hasCuratedColumns } from './ResourcesView'
5
+
6
+ type RegistryRow = {
7
+ logicalId: string
8
+ group: string
9
+ plural: string
10
+ kind: string
11
+ scope: string
12
+ apiVersion: string
13
+ scenario: string
14
+ crdUrl: string
15
+ }
16
+
17
+ const REGISTRY_PATH = join(__dirname, '../../../../../scripts/gpu-ecosystem-demo/resources.tsv')
18
+ const [header, ...lines] = readFileSync(REGISTRY_PATH, 'utf8').trim().split('\n')
19
+
20
+ if (header.replace(/^# /, '') !== 'logical_id\tgroup\tplural\tkind\tscope\tapi_version\tscenario\tcrd_url') {
21
+ throw new Error(`Unexpected GPU ecosystem registry header: ${header}`)
22
+ }
23
+
24
+ const rows: RegistryRow[] = lines.map(line => {
25
+ const [logicalId, group, plural, kind, scope, apiVersion, scenario, crdUrl] = line.split('\t')
26
+ return { logicalId, group, plural, kind, scope, apiVersion, scenario, crdUrl }
27
+ })
28
+
29
+ const rowsFor = (scenario: 'current' | 'experimental') =>
30
+ rows.filter(row => row.scenario === 'all' || row.scenario === scenario)
31
+
32
+ describe.each(['current', 'experimental'] as const)('GPU ecosystem %s registry', scenario => {
33
+ const scenarioRows = rowsFor(scenario)
34
+
35
+ it('resolves to exactly 37 distinct logical resource identities', () => {
36
+ expect(scenarioRows).toHaveLength(37)
37
+ expect(new Set(scenarioRows.map(row => row.logicalId)).size).toBe(37)
38
+ })
39
+
40
+ it('maps every registered upstream resource to curated Radar columns', () => {
41
+ const uncurated = scenarioRows
42
+ .filter(row => !hasCuratedColumns(row.plural, row.group))
43
+ .map(row => `${row.plural}.${row.group}`)
44
+
45
+ expect(uncurated).toEqual([])
46
+ })
47
+
48
+ it('uses pinned tagged upstream CRDs', () => {
49
+ const unpinned = scenarioRows
50
+ .filter(row => !/^(v?\d|cluster-autoscaler-\d)/.test(new URL(row.crdUrl).pathname.split('/')[3] ?? ''))
51
+ .map(row => row.crdUrl)
52
+
53
+ expect(unpinned).toEqual([])
54
+ })
55
+ })
56
+
57
+ it('changes only the two inference-routing identities between scenarios', () => {
58
+ const current = new Map(rowsFor('current').map(row => [row.logicalId, `${row.plural}.${row.group}`]))
59
+ const experimental = new Map(rowsFor('experimental').map(row => [row.logicalId, `${row.plural}.${row.group}`]))
60
+ const changed = [...current].filter(([id, resource]) => experimental.get(id) !== resource).map(([id]) => id)
61
+
62
+ expect(changed).toEqual(['inferencepool', 'inferenceobjective'])
63
+ })
@@ -44,7 +44,15 @@ export * from './resource-utils-prometheus'
44
44
  export * from './resource-utils-trivy'
45
45
  export * from './resource-utils-traefik'
46
46
  export * from './resource-utils-velero'
47
- export { ResourcesView, ResourcesViewDataContext } from './ResourcesView'
47
+ export { ResourcesView, ResourcesViewDataContext, hasCuratedColumns } from './ResourcesView'
48
48
  export type { ResourceQueryResult, ExtraColumn, LargeListGuardState } from './ResourcesView'
49
49
  export { ResourcesSidebar } from './ResourcesSidebar'
50
50
  export type { ResourcesSidebarProps, SelectedKindInfo, PinnedItem } from './ResourcesSidebar'
51
+ export {
52
+ sanitizePrinterTable,
53
+ printerTableKey,
54
+ printerColumnKey,
55
+ } from './printer-columns'
56
+ export type { PrinterColumnDef, PrinterTable } from './printer-columns'
57
+ export { getGenericResourceStatus } from './generic-status'
58
+ export type { GenericStatus } from './generic-status'
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getCellFilterValue, getGatewayStatus } from './resource-utils'
3
+ import { getGenericResourceStatus } from './generic-status'
4
+
5
+ // The table renders Istio Gateway through IstioGatewayCell, which reads the
6
+ // spec. The filter dropdown and the sort key have to read the same thing, or
7
+ // the dropdown offers values that appear on no row and the column sorts on a
8
+ // string the user never saw — the failure this file exists to pin.
9
+ const gateway = (spec: any) => ({ apiVersion: 'networking.istio.io/v1', kind: 'Gateway', spec })
10
+
11
+ describe('Istio Gateway filter values', () => {
12
+ it('reads status from the spec, not the generic derivation', () => {
13
+ const tls = gateway({ servers: [{ port: { number: 443 }, tls: { mode: 'SIMPLE' } }] })
14
+ const plain = gateway({ servers: [{ port: { number: 80 } }] })
15
+ const none = gateway({ servers: [] })
16
+ expect(getCellFilterValue(tls, 'status', 'istiogateways')).toBe('TLS')
17
+ expect(getCellFilterValue(plain, 'status', 'istiogateways')).toBe('Active')
18
+ expect(getCellFilterValue(none, 'status', 'istiogateways')).toBe('No Servers')
19
+ })
20
+
21
+ // Read as a Gateway API Gateway instead, the same object filters as
22
+ // "Unknown" — a value that appears on no row, since the cell shows Active.
23
+ // That divergence is what routing the normalized key fixes.
24
+ it('diverges from the row when read under the Gateway API key', () => {
25
+ const g = gateway({ servers: [{ port: { number: 80 } }] })
26
+ expect(getCellFilterValue(g, 'status', 'gateways')).not.toBe(
27
+ getCellFilterValue(g, 'status', 'istiogateways'),
28
+ )
29
+ })
30
+
31
+ it('reads the server count and selector its columns display', () => {
32
+ const g = gateway({ servers: [{ port: { number: 80 } }, { port: { number: 443 } }], selector: { istio: 'ingressgateway' } })
33
+ expect(getCellFilterValue(g, 'servers', 'istiogateways')).toBe('2')
34
+ expect(getCellFilterValue(g, 'selector', 'istiogateways')).toBe('istio=ingressgateway')
35
+ })
36
+
37
+ it('renders an absent selector the way the cell does', () => {
38
+ expect(getCellFilterValue(gateway({ servers: [] }), 'selector', 'istiogateways')).toBe('-')
39
+ })
40
+
41
+ // Those column keys are Istio's. A kind that happens to share them gets
42
+ // nothing rather than a value read off the wrong shape.
43
+ it('claims servers and selector only for Istio Gateways', () => {
44
+ const other = { apiVersion: 'other.io/v1', kind: 'Thing', spec: { servers: [{ port: { number: 1 } }] } }
45
+ expect(getCellFilterValue(other, 'servers', 'things')).toBe('')
46
+ expect(getCellFilterValue(other, 'selector', 'things')).toBe('')
47
+ })
48
+ })
49
+
50
+ describe('Kyverno Policy filter values', () => {
51
+ // Reachable only once `policies` normalizes to `kyvernopolicies`; the filter
52
+ // call sites pass the normalized key, so this is the string they see.
53
+ it('reads the Kyverno status rather than the generic derivation', () => {
54
+ const policy = {
55
+ apiVersion: 'kyverno.io/v1',
56
+ kind: 'Policy',
57
+ spec: { rules: [{ name: 'r' }] },
58
+ status: { conditions: [{ type: 'Ready', status: 'True' }] },
59
+ }
60
+ expect(getCellFilterValue(policy, 'status', 'kyvernopolicies')).not.toBe('')
61
+ })
62
+ })
63
+
64
+ describe('curated kinds that publish no phase', () => {
65
+ // Sorting used to derive its own status here rather than reuse the reader the
66
+ // cell uses. A Gateway has no status.phase, so the sort key came from the
67
+ // generic ladder — which knows neither Programmed nor Accepted and called
68
+ // every Gateway "Accepted". A healthy Gateway and a pending one therefore
69
+ // sorted equal, and neither matched the text on screen.
70
+ const gateway = (accepted: string, programmed: string) => ({
71
+ apiVersion: 'gateway.networking.k8s.io/v1',
72
+ kind: 'Gateway',
73
+ status: { conditions: [{ type: 'Accepted', status: accepted }, { type: 'Programmed', status: programmed }] },
74
+ })
75
+
76
+ it.each([
77
+ ['True', 'True', 'Programmed'],
78
+ ['True', 'False', 'Accepted'],
79
+ ['False', 'False', 'Not Accepted'],
80
+ ])('Accepted=%s Programmed=%s sorts and filters on the text the cell shows', (a, p, expected) => {
81
+ const gw = gateway(a, p)
82
+ expect(getGatewayStatus(gw).text).toBe(expected)
83
+ expect(getCellFilterValue(gw, 'status', 'gateways')).toBe(expected)
84
+ })
85
+
86
+ // The reason the shared reader is load-bearing rather than incidental.
87
+ it('distinguishes a programmed Gateway from a pending one', () => {
88
+ const ok = getCellFilterValue(gateway('True', 'True'), 'status', 'gateways')
89
+ const pending = getCellFilterValue(gateway('True', 'False'), 'status', 'gateways')
90
+ expect(ok).not.toBe(pending)
91
+ // Both collapse to one value under the generic ladder, which is what made
92
+ // the column stop sorting.
93
+ expect(getGenericResourceStatus(gateway('True', 'True'))?.text)
94
+ .toBe(getGenericResourceStatus(gateway('True', 'False'))?.text)
95
+ })
96
+ })
97
+