@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,291 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getGatewayPolicyStatus } from './gateway-policy-status'
3
+ import { getGenericResourceStatus } from './generic-status'
4
+
5
+ const cond = (type: string, status: string, reason?: string, message?: string) => ({ type, status, reason, message })
6
+ const policy = (...ancestors: any[]) => ({ status: { ancestors } })
7
+ const anc = (...conditions: any[]) => ({ ancestorRef: { kind: 'Gateway', name: 'gw' }, conditions })
8
+
9
+ describe('Gateway API PolicyStatus', () => {
10
+ // Taken from a live EnvoyPatchPolicy. The controller accepted the policy and
11
+ // then could not apply it. Read positives-first — the way the generic ladder
12
+ // resolves conditions — this object is healthy, which is the whole reason it
13
+ // gets its own reader.
14
+ it('reports the failure on a policy that was accepted and then not applied', () => {
15
+ const p = policy(anc(cond('Accepted', 'True', 'Accepted'), cond('Programmed', 'False', 'ResourceNotFound')))
16
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'ResourceNotFound', tone: 'unhealthy' })
17
+ })
18
+
19
+ it('reports a plain accepted policy as healthy', () => {
20
+ expect(getGatewayPolicyStatus(policy(anc(cond('Accepted', 'True', 'Accepted')))))
21
+ .toMatchObject({ text: 'Accepted', tone: 'healthy' })
22
+ })
23
+
24
+ it('reports a rejected policy with the controller reason', () => {
25
+ expect(getGatewayPolicyStatus(policy(anc(cond('Accepted', 'False', 'Conflicted')))))
26
+ .toMatchObject({ text: 'Conflicted', tone: 'unhealthy' })
27
+ })
28
+
29
+ it('falls back to the condition type when the controller gave no reason', () => {
30
+ expect(getGatewayPolicyStatus(policy(anc(cond('Accepted', 'False')))))
31
+ .toMatchObject({ text: 'Not Accepted', tone: 'unhealthy' })
32
+ })
33
+
34
+ // Envoy Gateway publishes this combination. A positives-first read returns
35
+ // healthy and the warning never surfaces.
36
+ it('does not let an accepted policy hide a warning', () => {
37
+ const p = policy(anc(cond('Accepted', 'True'), cond('Warning', 'True', 'ShadowedRules')))
38
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'ShadowedRules', tone: 'degraded' })
39
+ })
40
+
41
+ // Accepted=False reads as "Not Accepted"; Warning=True means there IS a
42
+ // warning, so the same construction would invert it.
43
+ it('names a reason-less warning after the condition, not its negation', () => {
44
+ const p = policy(anc(cond('Accepted', 'True'), cond('Warning', 'True')))
45
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'Warning', tone: 'degraded' })
46
+ })
47
+
48
+ it('carries the controller message as the tooltip reason', () => {
49
+ const p = policy(anc(cond('Accepted', 'False', 'Invalid', 'spec.targetRef.kind is not supported')))
50
+ expect(getGatewayPolicyStatus(p)?.reason).toBe('spec.targetRef.kind is not supported')
51
+ })
52
+ })
53
+
54
+ describe('aggregating across ancestors', () => {
55
+ // A policy may attach to several Gateways with different outcomes. Collapsing
56
+ // to a bare "Accepted" would hide the one that rejected it.
57
+ it('reports a failure anywhere, and how widespread it is', () => {
58
+ const p = policy(
59
+ anc(cond('Accepted', 'True')),
60
+ anc(cond('Accepted', 'False', 'NotAllowed')),
61
+ anc(cond('Accepted', 'True')),
62
+ )
63
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'NotAllowed (1/3)', tone: 'unhealthy' })
64
+ })
65
+
66
+ it('does not add a count when the policy attaches to one ancestor', () => {
67
+ expect(getGatewayPolicyStatus(policy(anc(cond('Accepted', 'False', 'NotAllowed'))))?.text)
68
+ .toBe('NotAllowed')
69
+ })
70
+
71
+ // Absence is not acceptance: an ancestor the controller has not answered for
72
+ // must not be counted as healthy just because its sibling was.
73
+ it('does not read an ancestor with no verdict as accepted', () => {
74
+ const p = policy(anc(cond('Accepted', 'True')), anc())
75
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'Pending (1/2)', tone: 'degraded' })
76
+ })
77
+
78
+ it('treats Unknown as undecided rather than as failure', () => {
79
+ const p = policy(anc(cond('Accepted', 'Unknown', 'Pending')))
80
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ tone: 'degraded' })
81
+ })
82
+
83
+ // One slot for both kinds of problem showed the warning's reason next to the
84
+ // failure's count and tone — the operator reads the warning as the failure.
85
+ it('reports the failure, not an earlier warning, when both are present', () => {
86
+ const p = policy(
87
+ anc(cond('Accepted', 'True'), cond('Warning', 'True', 'ShadowedRules')),
88
+ anc(cond('Accepted', 'False', 'NotAllowed')),
89
+ )
90
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'NotAllowed (1/2)', tone: 'unhealthy' })
91
+ })
92
+
93
+ it('still reports the warning when nothing failed', () => {
94
+ const p = policy(
95
+ anc(cond('Accepted', 'True'), cond('Warning', 'True', 'ShadowedRules')),
96
+ anc(cond('Accepted', 'True')),
97
+ )
98
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'ShadowedRules (1/2)', tone: 'degraded' })
99
+ })
100
+
101
+ it('is healthy only when every ancestor accepted', () => {
102
+ expect(getGatewayPolicyStatus(policy(anc(cond('Accepted', 'True')), anc(cond('Accepted', 'True')))))
103
+ .toMatchObject({ text: 'Accepted', tone: 'healthy' })
104
+ })
105
+ })
106
+
107
+ describe('shapes that are not a verdict', () => {
108
+ // Yields nothing rather than a verdict, so a policy that also publishes
109
+ // top-level conditions still gets read from those.
110
+ it('declines to judge a policy that attaches to nothing', () => {
111
+ expect(getGatewayPolicyStatus(policy())).toBeNull()
112
+ })
113
+
114
+ it('ignores malformed ancestors rather than throwing', () => {
115
+ expect(() => getGatewayPolicyStatus({ status: { ancestors: [null, 'nope', 42] } })).not.toThrow()
116
+ expect(() => getGatewayPolicyStatus({ status: { ancestors: [{ conditions: 'nope' }] } })).not.toThrow()
117
+ })
118
+
119
+ it('yields nothing when ancestors is absent or not an array', () => {
120
+ expect(getGatewayPolicyStatus({ status: { conditions: [] } })).toBeNull()
121
+ expect(getGatewayPolicyStatus({ status: { ancestors: {} } })).toBeNull()
122
+ expect(getGatewayPolicyStatus({ status: {} })).toBeNull()
123
+ })
124
+ })
125
+
126
+ describe('wiring into the generic ladder', () => {
127
+ it('resolves a policy through its own reader', () => {
128
+ const p = policy(anc(cond('Accepted', 'True', 'Accepted'), cond('Programmed', 'False', 'ResourceNotFound')))
129
+ expect(getGenericResourceStatus(p)).toMatchObject({ text: 'ResourceNotFound', tone: 'unhealthy' })
130
+ })
131
+
132
+ // The dispatch is on shape, so it must not intercept an ordinary resource.
133
+ it('leaves a resource with top-level conditions alone', () => {
134
+ const r = { status: { conditions: [{ type: 'Ready', status: 'True' }] } }
135
+ expect(getGenericResourceStatus(r)).toMatchObject({ text: 'Ready', tone: 'healthy' })
136
+ })
137
+
138
+ it('leaves a phase-based resource alone', () => {
139
+ expect(getGenericResourceStatus({ status: { phase: 'Running' } })).toMatchObject({ text: 'Running', tone: 'healthy' })
140
+ })
141
+ })
142
+
143
+ describe('condition vocabulary beyond Accepted', () => {
144
+ // GKE reports attachment with its own condition; Gateway API's
145
+ // BackendTLSPolicy requires ResolvedRefs as well as Accepted.
146
+ it.each([
147
+ ['Attached', 'InvalidTarget'],
148
+ ['ResolvedRefs', 'InvalidCACertificateRef'],
149
+ ['Programmed', 'ResourceNotFound'],
150
+ ])('treats %s=False as a failure', (type, reason) => {
151
+ const p = policy(anc(cond('Accepted', 'True'), cond(type, 'False', reason)))
152
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: reason, tone: 'unhealthy' })
153
+ })
154
+
155
+ // GEP-713 lists Reconciling as a legitimate state on the way to being
156
+ // applied. Painting ordinary convergence red teaches operators to ignore red.
157
+ it.each(['Reconciling', 'Pending'])('treats %s as in-flight, not failed', reason => {
158
+ const p = policy(anc(cond('Accepted', 'True'), cond('Programmed', 'False', reason)))
159
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: reason, tone: 'degraded' })
160
+ })
161
+
162
+ // GKE's healthy shape carries no Accepted condition at all. Checking only
163
+ // Accepted read it as pending, which is a policy that works reported amber.
164
+ it('accepts a verdict that is not spelled Accepted', () => {
165
+ expect(getGatewayPolicyStatus(policy(anc(cond('Attached', 'True')))))
166
+ .toMatchObject({ text: 'Attached', tone: 'healthy' })
167
+ expect(getGatewayPolicyStatus(policy(anc(cond('Programmed', 'True')))))
168
+ .toMatchObject({ text: 'Programmed', tone: 'healthy' })
169
+ })
170
+
171
+ // Refs resolving is a precondition, not a statement that the policy applies.
172
+ it('does not treat ResolvedRefs alone as a verdict', () => {
173
+ expect(getGatewayPolicyStatus(policy(anc(cond('ResolvedRefs', 'True')))))
174
+ .toMatchObject({ tone: 'degraded' })
175
+ })
176
+
177
+ it('does not read a partially applied policy as success', () => {
178
+ const p = policy(anc(cond('Accepted', 'True'), cond('Programmed', 'True', 'PartiallyProgrammed')))
179
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'PartiallyProgrammed', tone: 'degraded' })
180
+ })
181
+
182
+ // Reporting the first reason with a count claims every ancestor failed that
183
+ // way. When they differ, say how many failed instead of guessing why.
184
+ it('does not attribute one ancestor\'s reason to another', () => {
185
+ const p = policy(
186
+ anc(cond('Accepted', 'False', 'NotAllowed')),
187
+ anc(cond('Accepted', 'False', 'ResourceNotFound')),
188
+ anc(cond('Accepted', 'True')),
189
+ )
190
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: '2/3 failed', tone: 'unhealthy' })
191
+ })
192
+
193
+ // The badge says "2/3 failed"; the tooltip must say which Gateway failed
194
+ // how, or the operator digs through raw status for the names.
195
+ it('names each failed ancestor and its reason in the tooltip', () => {
196
+ const p = policy(
197
+ { ancestorRef: { kind: 'Gateway', namespace: 'team-a', name: 'gw-a' }, conditions: [cond('Accepted', 'False', 'NotAllowed')] },
198
+ { ancestorRef: { kind: 'Gateway', name: 'gw-b' }, conditions: [cond('Accepted', 'False', 'ResourceNotFound')] },
199
+ anc(cond('Accepted', 'True')),
200
+ )
201
+ expect(getGatewayPolicyStatus(p)).toMatchObject({
202
+ text: '2/3 failed',
203
+ tone: 'unhealthy',
204
+ reason: 'team-a/gw-a: NotAllowed; gw-b: ResourceNotFound',
205
+ })
206
+ })
207
+
208
+ // GEP-713 keys ancestor status on ancestorRef + controllerName: the same
209
+ // Gateway appears once per controller, and a bare ns/name label would
210
+ // attribute both verdicts to one indistinguishable thing.
211
+ it('tells colliding labels apart by controller', () => {
212
+ const p = policy(
213
+ { ancestorRef: { namespace: 'team-a', name: 'gw' }, controllerName: 'ctrl-a.example', conditions: [cond('Accepted', 'False', 'NotAllowed')] },
214
+ { ancestorRef: { namespace: 'team-a', name: 'gw' }, controllerName: 'ctrl-b.example', conditions: [cond('Accepted', 'False', 'ResourceNotFound')] },
215
+ )
216
+ expect(getGatewayPolicyStatus(p)?.reason)
217
+ .toBe('team-a/gw (ctrl-a.example): NotAllowed; team-a/gw (ctrl-b.example): ResourceNotFound')
218
+ })
219
+
220
+ // sectionName and a non-Gateway kind are part of the identity and short
221
+ // enough to always carry.
222
+ it('carries section and kind in the label', () => {
223
+ const p = policy(
224
+ { ancestorRef: { namespace: 'team-a', name: 'gw', sectionName: 'tls' }, conditions: [cond('Accepted', 'False', 'NotAllowed')] },
225
+ { ancestorRef: { kind: 'Service', namespace: 'team-a', name: 'svc' }, conditions: [cond('Accepted', 'False', 'ResourceNotFound')] },
226
+ )
227
+ expect(getGatewayPolicyStatus(p)?.reason)
228
+ .toBe('team-a/gw:tls: NotAllowed; Service team-a/svc: ResourceNotFound')
229
+ })
230
+
231
+ // The collision that qualifies a controller may be with an ancestor that
232
+ // SUCCEEDED: two controllers on one Gateway, one failing, must still say
233
+ // which one failed.
234
+ it('names the controller when the collision is with a successful ancestor', () => {
235
+ const p = policy(
236
+ { ancestorRef: { namespace: 'team-a', name: 'gw' }, controllerName: 'ctrl-a.example', conditions: [cond('Accepted', 'False', 'NotAllowed')] },
237
+ { ancestorRef: { namespace: 'team-a', name: 'gw' }, controllerName: 'ctrl-b.example', conditions: [cond('Accepted', 'True')] },
238
+ { ancestorRef: { name: 'gw-b' }, conditions: [cond('Accepted', 'False', 'ResourceNotFound')] },
239
+ )
240
+ expect(getGatewayPolicyStatus(p)?.reason)
241
+ .toBe('team-a/gw (ctrl-a.example): NotAllowed; gw-b: ResourceNotFound')
242
+ })
243
+
244
+ // group and port are part of the composite key too: entries differing only
245
+ // by them must not render identically.
246
+ it('carries group and port in the label', () => {
247
+ const p = policy(
248
+ { ancestorRef: { group: 'multicluster.x-k8s.io', kind: 'ServiceImport', namespace: 'team-a', name: 'svc' }, conditions: [cond('Accepted', 'False', 'NotAllowed')] },
249
+ { ancestorRef: { namespace: 'team-a', name: 'gw', port: 443 }, conditions: [cond('Accepted', 'False', 'ResourceNotFound')] },
250
+ )
251
+ expect(getGatewayPolicyStatus(p)?.reason)
252
+ .toBe('ServiceImport.multicluster.x-k8s.io team-a/svc: NotAllowed; team-a/gw:443: ResourceNotFound')
253
+ })
254
+
255
+ // Reasons may be 1024 chars and foreign CRDs need not honor the 16-ancestor
256
+ // cap, so the list is bounded rather than trusted.
257
+ it('caps the tooltip detail list', () => {
258
+ const p = policy(
259
+ ...[1, 2, 3, 4, 5, 6].map(i => ({
260
+ ancestorRef: { name: `gw-${i}` },
261
+ conditions: [cond('Accepted', 'False', `R${i}`)],
262
+ })),
263
+ )
264
+ expect(getGatewayPolicyStatus(p)?.reason).toBe('gw-1: R1; gw-2: R2; gw-3: R3; gw-4: R4; +2 more')
265
+ })
266
+
267
+ it('keeps the reason when every failure agrees', () => {
268
+ const p = policy(anc(cond('Accepted', 'False', 'NotAllowed')), anc(cond('Accepted', 'False', 'NotAllowed')))
269
+ expect(getGatewayPolicyStatus(p)).toMatchObject({ text: 'NotAllowed (2/2)', tone: 'unhealthy' })
270
+ })
271
+ })
272
+
273
+ describe('policies that also publish top-level conditions', () => {
274
+ // GCPBackendPolicy declares both status.ancestors and status.conditions.
275
+ // Dispatching on the shape alone suppressed the conditions entirely.
276
+ it('falls back to top-level conditions when the ancestors say nothing', () => {
277
+ const dual = { status: { ancestors: [], conditions: [{ type: 'Ready', status: 'False', reason: 'Invalid' }] } }
278
+ expect(getGenericResourceStatus(dual)).toMatchObject({ text: 'Invalid', tone: 'unhealthy' })
279
+ })
280
+
281
+ it('still prefers a verdict the ancestors did give', () => {
282
+ const dual = {
283
+ status: {
284
+ ancestors: [{ conditions: [cond('Accepted', 'False', 'NotAllowed')] }],
285
+ conditions: [{ type: 'Ready', status: 'True' }],
286
+ },
287
+ }
288
+ expect(getGenericResourceStatus(dual)).toMatchObject({ text: 'NotAllowed', tone: 'unhealthy' })
289
+ })
290
+ })
291
+
@@ -0,0 +1,237 @@
1
+ import type { GenericStatus } from './generic-status'
2
+
3
+ /**
4
+ * Gateway API policy attachment (GEP-713) reports status per *ancestor*, at
5
+ * `status.ancestors[].conditions`, not at `status.conditions` — so the generic
6
+ * ladder finds nothing and these kinds render "-".
7
+ *
8
+ * The shape is shared by every policy implementation: Gateway API's own
9
+ * BackendTLSPolicy and BackendLBPolicy, Envoy Gateway's SecurityPolicy /
10
+ * ClientTrafficPolicy / BackendTrafficPolicy / EnvoyPatchPolicy /
11
+ * EnvoyExtensionPolicy, and GKE's GCPBackendPolicy family. Dispatching on the
12
+ * shape rather than on a list of kinds means a policy from an implementation
13
+ * Radar has never heard of reads correctly too.
14
+ *
15
+ * Kept separate from the generic ladder rather than folded into it, because the
16
+ * ladder resolves positive conditions before negative ones and that order is
17
+ * wrong here: a policy is routinely `Accepted=True` *and* `Programmed=False`,
18
+ * which means the controller took ownership and then failed to apply it. Read
19
+ * positives-first, that object is healthy; it is not.
20
+ */
21
+
22
+ /**
23
+ * Conditions that describe whether the policy took effect. `Accepted` is
24
+ * GEP-713's own verdict; `Programmed` and `ResolvedRefs` are how Gateway API
25
+ * and its implementations report that a policy the controller took on could
26
+ * not actually be applied, and `Attached` is GKE's equivalent.
27
+ */
28
+ const EFFECT_CONDITIONS = ['Accepted', 'Attached', 'Programmed', 'ResolvedRefs'] as const
29
+
30
+ /**
31
+ * The subset that is a verdict in its own right, so True means the policy took
32
+ * effect. `ResolvedRefs` is deliberately absent: refs resolving is a
33
+ * precondition, not a statement that the policy applies. Kept separate from
34
+ * EFFECT_CONDITIONS because a condition can be conclusive when False and say
35
+ * nothing when True — GKE reports a healthy policy as `Attached=True` with no
36
+ * `Accepted` at all, so checking only `Accepted` read it as pending.
37
+ */
38
+ const VERDICT_CONDITIONS = ['Accepted', 'Attached', 'Programmed'] as const
39
+
40
+ /**
41
+ * Reasons that mean "not settled yet" rather than "failed". GEP-713 lists
42
+ * Reconciling as a legitimate state for a policy on its way to being applied,
43
+ * and rendering ordinary convergence as a red failure would train operators to
44
+ * ignore the colour.
45
+ */
46
+ const TRANSIENT_REASONS = new Set(['Reconciling', 'Pending'])
47
+
48
+ /** A True condition can still be qualified: partial application is not success. */
49
+ const QUALIFIED_SUCCESS_REASONS = new Set(['PartiallyProgrammed'])
50
+
51
+ /** Conditions that mean trouble when True, mirroring the generic ladder's set. */
52
+ const NEGATIVE_CONDITIONS = new Set(['Degraded', 'Warning'])
53
+
54
+ /**
55
+ * GEP-713 keys an ancestor's status on the full ancestorRef plus the
56
+ * controllerName — the same Gateway can appear once per controller, section,
57
+ * or kind. The label carries the short parts outright; the controllerName is
58
+ * long, so it is appended only when two labels would otherwise collide.
59
+ */
60
+ function ancestorLabel(ancestor: any): string {
61
+ const ref = ancestor?.ancestorRef
62
+ const name = typeof ref?.name === 'string' ? ref.name : ''
63
+ if (!name) return ''
64
+ const ns = typeof ref?.namespace === 'string' ? ref.namespace : ''
65
+ const kind = typeof ref?.kind === 'string' ? ref.kind : ''
66
+ const group = typeof ref?.group === 'string' ? ref.group : ''
67
+ const section = typeof ref?.sectionName === 'string' ? ref.sectionName : ''
68
+ const port = typeof ref?.port === 'number' ? ref.port : null
69
+ let label = ns ? `${ns}/${name}` : name
70
+ let prefix = kind && kind !== 'Gateway' ? kind : ''
71
+ if (group && group !== 'gateway.networking.k8s.io') prefix = prefix ? `${prefix}.${group}` : group
72
+ if (prefix) label = `${prefix} ${label}`
73
+ if (section) label += `:${section}`
74
+ if (port !== null) label += `:${port}`
75
+ return label
76
+ }
77
+
78
+ /**
79
+ * Reasons are usually short CamelCase tokens, but the API allows 1024 chars
80
+ * and this reader accepts any CRD with an ancestors array, so the list is
81
+ * capped rather than trusted to stay small.
82
+ */
83
+ const MAX_FAILURE_DETAILS = 4
84
+
85
+ function renderFailureDetails(
86
+ failures: { label: string; controller: string; problem: string }[],
87
+ // Collisions are counted across every ancestor, not just the failed ones: a
88
+ // failure and a success for the same Gateway under different controllers
89
+ // must still say which controller failed.
90
+ labelCounts: Map<string, number>,
91
+ ): string {
92
+ const entries = failures.map(f => {
93
+ let label = f.label
94
+ if (label && (labelCounts.get(label) ?? 0) > 1 && f.controller) label += ` (${f.controller})`
95
+ return label ? `${label}: ${f.problem}` : f.problem
96
+ })
97
+ const shown = entries.slice(0, MAX_FAILURE_DETAILS)
98
+ const hidden = entries.length - shown.length
99
+ return hidden > 0 ? `${shown.join('; ')}; +${hidden} more` : shown.join('; ')
100
+ }
101
+
102
+ function conditionsOf(ancestor: any): any[] {
103
+ return Array.isArray(ancestor?.conditions) ? ancestor.conditions : []
104
+ }
105
+
106
+ /**
107
+ * The controller's reason where it gave one — it names the actual failure
108
+ * (ResourceNotFound, Conflicted, NotAllowed) where the type does not.
109
+ *
110
+ * The fallback has to follow the condition's polarity. `Accepted=False` reads
111
+ * as "Not Accepted", but `Warning=True` means there *is* a warning, so the same
112
+ * construction would render "Not Warning" and inverts the meaning.
113
+ *
114
+ * Spelled with a space where the Go reader behind MCP writes "NotAccepted",
115
+ * matching each side's existing convention. Whenever a controller supplies a
116
+ * reason the two sides are identical; they part ways only here and on mixed
117
+ * failures, where the per-ancestor detail goes to the tooltip while MCP,
118
+ * having no tooltip, carries it in the text.
119
+ */
120
+ function problemText(cond: any, trueMeansTrouble = false): string {
121
+ const reason = typeof cond?.reason === 'string' ? cond.reason.trim() : ''
122
+ if (reason) return reason
123
+ const type = typeof cond?.type === 'string' ? cond.type.trim() : ''
124
+ if (!type) return 'Failed'
125
+ return trueMeansTrouble ? type : `Not ${type}`
126
+ }
127
+
128
+ /**
129
+ * Status for one policy, aggregated across the Gateways it attaches to.
130
+ *
131
+ * A policy may attach to several ancestors with different outcomes, so a
132
+ * failure anywhere wins and the count says how widespread it is — collapsing to
133
+ * a bare "Accepted" would hide the Gateway that rejected it.
134
+ */
135
+ export function getGatewayPolicyStatus(resource: any): GenericStatus | null {
136
+ const ancestors = resource?.status?.ancestors
137
+ if (!Array.isArray(ancestors)) return null
138
+
139
+ // Null rather than a verdict, so the caller can fall back to any top-level
140
+ // conditions the object also carries. A policy that attaches to nothing and
141
+ // says nothing else is left to render as absent, because "not attached" and
142
+ // "not reconciled yet" are indistinguishable from here.
143
+ if (ancestors.length === 0) return null
144
+
145
+ let failed = 0
146
+ let degraded = 0
147
+ let accepted = 0
148
+ // Tracked apart, not in one slot: a warning on the first ancestor and a
149
+ // failure on the second would otherwise show the warning's text alongside
150
+ // the failure's count and tone, reading as though the warning was the
151
+ // failure.
152
+ let failure: { text: string; reason?: string } | null = null
153
+ let warning: { text: string; reason?: string } | null = null
154
+ // Ancestors can fail for different reasons. Reporting the first one with a
155
+ // count claims they all failed that way.
156
+ const failureReasons = new Set<string>()
157
+ // Which Gateway failed with what, for the tooltip: "2/3 failed" without it
158
+ // sends the operator digging through raw status for the names.
159
+ const failureDetails: { label: string; controller: string; problem: string }[] = []
160
+ const ancestorLabelCounts = new Map<string, number>()
161
+ let acceptedAs: string | null = null
162
+
163
+ for (const ancestor of ancestors) {
164
+ const label = ancestorLabel(ancestor)
165
+ if (label) ancestorLabelCounts.set(label, (ancestorLabelCounts.get(label) ?? 0) + 1)
166
+ const conditions = conditionsOf(ancestor)
167
+
168
+ const broken = conditions.find(
169
+ (c: any) => EFFECT_CONDITIONS.includes(c?.type) && c?.status === 'False',
170
+ )
171
+ if (broken) {
172
+ const reason = typeof broken?.reason === 'string' ? broken.reason.trim() : ''
173
+ // A controller still working towards the desired state is not a failure.
174
+ if (TRANSIENT_REASONS.has(reason)) {
175
+ degraded++
176
+ warning ??= { text: reason, reason: broken?.message }
177
+ continue
178
+ }
179
+ failed++
180
+ failure ??= { text: problemText(broken), reason: broken?.message }
181
+ failureReasons.add(problemText(broken))
182
+ failureDetails.push({
183
+ label,
184
+ controller: typeof ancestor?.controllerName === 'string' ? ancestor.controllerName : '',
185
+ problem: problemText(broken),
186
+ })
187
+ continue
188
+ }
189
+
190
+ const warned = conditions.find((c: any) => NEGATIVE_CONDITIONS.has(c?.type) && c?.status === 'True')
191
+ if (warned) {
192
+ degraded++
193
+ warning ??= { text: problemText(warned, true), reason: warned?.message }
194
+ continue
195
+ }
196
+
197
+ // A qualified success is not success: PartiallyProgrammed means some of the
198
+ // policy landed and some did not.
199
+ const qualified = conditions.find(
200
+ (c: any) => EFFECT_CONDITIONS.includes(c?.type) && c?.status === 'True' &&
201
+ QUALIFIED_SUCCESS_REASONS.has(typeof c?.reason === 'string' ? c.reason.trim() : ''),
202
+ )
203
+ if (qualified) {
204
+ degraded++
205
+ warning ??= { text: qualified.reason.trim(), reason: qualified?.message }
206
+ continue
207
+ }
208
+
209
+ const verdict = conditions.find(
210
+ (c: any) => VERDICT_CONDITIONS.includes(c?.type) && c?.status === 'True',
211
+ )
212
+ if (verdict) {
213
+ accepted++
214
+ // Named after the condition that granted it, so a GKE policy reads
215
+ // "Attached" where an Envoy one reads "Accepted".
216
+ acceptedAs ??= verdict.type
217
+ }
218
+ }
219
+
220
+ const total = ancestors.length
221
+ const scope = (n: number) => (total > 1 ? ` (${n}/${total})` : '')
222
+
223
+ if (failed > 0 && failure) {
224
+ if (failureReasons.size > 1) {
225
+ return { text: `${failed}/${total} failed`, tone: 'unhealthy', reason: renderFailureDetails(failureDetails, ancestorLabelCounts) }
226
+ }
227
+ return { text: `${failure.text}${scope(failed)}`, tone: 'unhealthy', reason: failure.reason }
228
+ }
229
+ if (degraded > 0 && warning) {
230
+ return { text: `${warning.text}${scope(degraded)}`, tone: 'degraded', reason: warning.reason }
231
+ }
232
+ if (accepted === total) return { text: acceptedAs ?? 'Accepted', tone: 'healthy' }
233
+
234
+ // Some ancestor published neither an outcome nor a failure — the controller
235
+ // has seen the policy but not finished with it.
236
+ return { text: `Pending${scope(total - accepted)}`, tone: 'degraded' }
237
+ }