@skyhook-io/k8s-ui 1.10.4 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/package.json +5 -5
  2. package/src/components/applications/ApplicationDetail.test.tsx +46 -0
  3. package/src/components/applications/ApplicationDetail.tsx +14 -15
  4. package/src/components/issues/IssuesView.tsx +9 -0
  5. package/src/components/issues/diagnostic.ts +3 -0
  6. package/src/components/issues/issues.test.ts +11 -0
  7. package/src/components/issues/severity.ts +3 -0
  8. package/src/components/issues/types.ts +3 -0
  9. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  10. package/src/components/resources/ResourcesView.tsx +259 -30
  11. package/src/components/resources/index.ts +2 -0
  12. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  13. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  16. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  17. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  18. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  19. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  20. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  21. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  22. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  23. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  24. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  25. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  26. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  27. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  28. package/src/components/resources/renderers/index.ts +4 -0
  29. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  30. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  31. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  32. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  33. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  34. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  35. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  36. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  37. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  38. package/src/components/resources/resource-utils-velero.ts +214 -22
  39. package/src/components/resources/resource-utils.ts +70 -3
  40. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  41. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  42. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  43. package/src/components/topology/K8sResourceNode.tsx +11 -0
  44. package/src/components/topology/TopologyGraph.tsx +85 -5
  45. package/src/components/trace/ReachabilityGraph.tsx +517 -0
  46. package/src/components/trace/ReachabilityView.tsx +1173 -0
  47. package/src/components/trace/TracePanel.test.ts +172 -0
  48. package/src/components/trace/TracePanel.tsx +558 -0
  49. package/src/components/trace/TraceSummary.test.ts +213 -0
  50. package/src/components/trace/TraceSummary.tsx +168 -0
  51. package/src/components/trace/inClusterSummary.test.ts +98 -0
  52. package/src/components/trace/inClusterSummary.ts +81 -0
  53. package/src/components/trace/index.ts +19 -0
  54. package/src/components/trace/podReach.test.ts +71 -0
  55. package/src/components/trace/podReach.ts +37 -0
  56. package/src/components/trace/probe-display.test.ts +107 -0
  57. package/src/components/trace/probe-display.ts +37 -0
  58. package/src/components/trace/problemRows.test.ts +107 -0
  59. package/src/components/trace/reachFixtures.ts +261 -0
  60. package/src/components/trace/reachGraphModel.test.ts +1423 -0
  61. package/src/components/trace/reachGraphModel.ts +1521 -0
  62. package/src/components/trace/reachInspector.test.ts +892 -0
  63. package/src/components/trace/reachInspector.ts +797 -0
  64. package/src/components/trace/reachMarks.test.ts +698 -0
  65. package/src/components/trace/reachMarks.ts +623 -0
  66. package/src/components/trace/reachOrigins.test.ts +392 -0
  67. package/src/components/trace/reachOrigins.ts +449 -0
  68. package/src/components/trace/reachVerdict.test.ts +537 -0
  69. package/src/components/trace/reachVerdict.ts +601 -0
  70. package/src/components/trace/traceFingerprint.test.ts +214 -0
  71. package/src/components/trace/traceFingerprint.ts +122 -0
  72. package/src/components/trace/traceToSubgraph.test.ts +623 -0
  73. package/src/components/trace/traceToSubgraph.ts +463 -0
  74. package/src/components/trace/types.ts +414 -0
  75. package/src/components/ui/Badge.tsx +36 -0
  76. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  77. package/src/components/ui/InClusterConsentDialog.test.tsx +83 -0
  78. package/src/components/ui/InClusterConsentDialog.tsx +141 -0
  79. package/src/components/ui/index.ts +1 -0
  80. package/src/components/workload/WorkloadView.tsx +58 -5
  81. package/src/components/workload/index.ts +1 -1
  82. package/src/components/workload/reachabilityTab.test.ts +47 -0
  83. package/src/index.ts +5 -0
  84. package/src/theme/components.css +64 -0
  85. package/src/theme/variables.css +5 -0
  86. package/src/types/core.ts +8 -0
  87. package/src/utils/api-resources.ts +2 -0
  88. package/src/utils/application-history.test.ts +1 -1
  89. package/src/utils/applications.test.ts +63 -1
  90. package/src/utils/applications.ts +27 -0
  91. package/src/utils/inClusterConsent.test.ts +109 -0
  92. package/src/utils/inClusterConsent.ts +72 -0
  93. package/src/utils/index.ts +1 -0
  94. package/src/utils/navigation.test.ts +61 -0
  95. package/src/utils/navigation.ts +11 -9
  96. package/src/utils/pluralize.test.ts +80 -1
  97. package/src/utils/pluralize.ts +34 -0
  98. package/src/utils/resource-icons.test.ts +33 -0
  99. package/src/utils/resource-icons.ts +65 -2
@@ -0,0 +1,152 @@
1
+ // Cell components for the modern Kyverno CEL family, PolicyException and
2
+ // CleanupPolicy rows in ResourcesView.
3
+
4
+ import { clsx } from 'clsx'
5
+ import { formatAge } from '../resource-utils'
6
+ import {
7
+ getKyvernoAttestors,
8
+ getKyvernoDeletionConditions,
9
+ getKyvernoGenerations,
10
+ getKyvernoImageReferences,
11
+ getKyvernoMatchSummary,
12
+ getKyvernoMutations,
13
+ getKyvernoPolicyFamily,
14
+ getKyvernoLastExecutionTime,
15
+ getKyvernoSchedule,
16
+ getKyvernoValidations,
17
+ getModernKyvernoPolicyStatus,
18
+ } from '../resource-utils-kyverno-modern'
19
+ import {
20
+ getKyvernoCleanupMatchedKinds,
21
+ getKyvernoCleanupPolicyStatus,
22
+ getKyvernoCleanupSchedule,
23
+ getKyvernoExceptedPolicies,
24
+ getKyvernoPolicyExceptionStatus,
25
+ isBlanketKyvernoException,
26
+ } from '../resource-utils-kyverno-exceptions'
27
+
28
+ const dash = <span className="text-sm text-theme-text-tertiary">-</span>
29
+
30
+ /** Counts the per-family "rules" the row's Rules/Mutations/Generates column shows. */
31
+ function ruleCount(resource: any): number {
32
+ switch (getKyvernoPolicyFamily(resource?.kind)) {
33
+ case 'mutating':
34
+ return getKyvernoMutations(resource).length
35
+ case 'generating':
36
+ return getKyvernoGenerations(resource).length
37
+ case 'deleting':
38
+ return getKyvernoDeletionConditions(resource).length
39
+ default:
40
+ return getKyvernoValidations(resource).length
41
+ }
42
+ }
43
+
44
+ export function KyvernoModernPolicyCell({ resource, column }: { resource: any; column: string }) {
45
+ switch (column) {
46
+ case 'status': {
47
+ const status = getModernKyvernoPolicyStatus(resource)
48
+ return <span className={clsx('badge', status.color)}>{status.text}</span>
49
+ }
50
+ case 'appliesTo':
51
+ return <span className="text-sm text-theme-text-secondary">{getKyvernoMatchSummary(resource)}</span>
52
+ case 'rules': {
53
+ const count = ruleCount(resource)
54
+ // A DeletingPolicy with zero conditions deletes everything it matches,
55
+ // so a bare "0" would read as harmless when it is the opposite.
56
+ if (getKyvernoPolicyFamily(resource?.kind) === 'deleting' && count === 0) {
57
+ return <span className="text-sm text-theme-text-secondary">All matched</span>
58
+ }
59
+ return <span className="text-sm text-theme-text-secondary">{count}</span>
60
+ }
61
+ case 'lastRun': {
62
+ // Absence is stated, never blank. A blank cell reads the same whether the
63
+ // policy ran and we failed to show it or it has never run at all — and
64
+ // "never run" is the actual failure mode for a scheduled policy, so it
65
+ // has to be the loud state rather than the empty one.
66
+ const last = getKyvernoLastExecutionTime(resource)
67
+ if (!last) {
68
+ return (
69
+ <span className="badge status-degraded" title="This policy has not run since it was created">
70
+ Never run
71
+ </span>
72
+ )
73
+ }
74
+ return <span className="text-sm text-theme-text-secondary">{formatAge(last)} ago</span>
75
+ }
76
+ case 'schedule': {
77
+ const schedule = getKyvernoSchedule(resource)
78
+ return schedule ? (
79
+ <span className="text-sm font-mono text-theme-text-secondary">{schedule}</span>
80
+ ) : (
81
+ <span className="text-sm text-theme-text-tertiary">Not set</span>
82
+ )
83
+ }
84
+ case 'images': {
85
+ const refs = getKyvernoImageReferences(resource)
86
+ if (refs.length === 0) return <span className="text-sm text-theme-text-tertiary">All</span>
87
+ return (
88
+ <span className="text-sm font-mono text-theme-text-secondary truncate">
89
+ {refs.length === 1 ? refs[0] : `${refs[0]} +${refs.length - 1}`}
90
+ </span>
91
+ )
92
+ }
93
+ case 'attestors': {
94
+ const attestors = getKyvernoAttestors(resource)
95
+ // No attestor means nothing to verify signatures against.
96
+ if (attestors.length === 0) return <span className="badge status-degraded">None</span>
97
+ return <span className="text-sm text-theme-text-secondary">{attestors.length}</span>
98
+ }
99
+ default:
100
+ return dash
101
+ }
102
+ }
103
+
104
+ export function KyvernoPolicyExceptionCell({ resource, column }: { resource: any; column: string }) {
105
+ switch (column) {
106
+ case 'status': {
107
+ const status = getKyvernoPolicyExceptionStatus(resource)
108
+ return <span className={clsx('badge', status.color)}>{status.text}</span>
109
+ }
110
+ case 'policies': {
111
+ if (isBlanketKyvernoException(resource)) {
112
+ return <span className="text-sm text-theme-text-secondary">Every matching policy</span>
113
+ }
114
+ const names = getKyvernoExceptedPolicies(resource).map((p) => p.name)
115
+ return (
116
+ <span className="text-sm text-theme-text-secondary truncate">
117
+ {names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}`}
118
+ </span>
119
+ )
120
+ }
121
+ default:
122
+ return dash
123
+ }
124
+ }
125
+
126
+ export function KyvernoCleanupPolicyCell({ resource, column }: { resource: any; column: string }) {
127
+ switch (column) {
128
+ case 'status': {
129
+ const status = getKyvernoCleanupPolicyStatus(resource)
130
+ return <span className={clsx('badge', status.color)}>{status.text}</span>
131
+ }
132
+ case 'schedule': {
133
+ const schedule = getKyvernoCleanupSchedule(resource)
134
+ return schedule ? (
135
+ <span className="text-sm font-mono text-theme-text-secondary">{schedule}</span>
136
+ ) : (
137
+ <span className="text-sm text-theme-text-tertiary">Not set</span>
138
+ )
139
+ }
140
+ case 'appliesTo': {
141
+ const kinds = getKyvernoCleanupMatchedKinds(resource)
142
+ if (kinds.length === 0) return dash
143
+ return (
144
+ <span className="text-sm text-theme-text-secondary truncate">
145
+ {kinds.length <= 3 ? kinds.join(', ') : `${kinds.slice(0, 3).join(', ')} +${kinds.length - 3}`}
146
+ </span>
147
+ )
148
+ }
149
+ default:
150
+ return dash
151
+ }
152
+ }
@@ -1,6 +1,8 @@
1
1
  // Velero cell components for ResourcesView table
2
2
 
3
3
  import { clsx } from 'clsx'
4
+ import { Pause } from 'lucide-react'
5
+ import { Tooltip } from '../../ui/Tooltip'
4
6
  import {
5
7
  getBackupStatus,
6
8
  getBackupStorageLocation,
@@ -15,16 +17,48 @@ import {
15
17
  getRestoreDuration,
16
18
  getRestoreErrors,
17
19
  getScheduleStatus,
18
- getScheduleCron,
19
- getScheduleLastBackup,
20
20
  getSchedulePaused,
21
+ getScheduleCronInfo,
22
+ getScheduleLastBackup,
21
23
  getBSLStatus,
22
24
  getBSLProvider,
23
25
  getBSLBucket,
24
26
  getBSLDefault,
25
27
  getBSLLastValidation,
28
+ getVSLProvider,
29
+ getVSLConfig,
30
+ getBackupRepositoryStatus,
31
+ getBackupRepositoryType,
26
32
  } from '../resource-utils-velero'
27
33
 
34
+ // The detail-page Phase value. The badge carries the same label the table
35
+ // showed, so the row you clicked and the drawer you landed on agree — but the
36
+ // drawer has no width budget, so the raw `.status.phase` rides alongside it
37
+ // whenever the two differ. Without this the three collapsed partial-failure
38
+ // phases would be unreachable outside the YAML tab, and the label would stop
39
+ // being a display choice and start being a loss of information.
40
+ export function VeleroPhaseValue({
41
+ status,
42
+ phase,
43
+ }: {
44
+ status: { text: string; color: string }
45
+ phase: string
46
+ }) {
47
+ // Stacked rather than side-by-side: the property column is ~300px and the
48
+ // longest phase is 40 unbroken characters, so a row would clip the phase and
49
+ // wrap the badge at the same time. `break-all` because the token has no break
50
+ // opportunity — in a detail panel wrapping is the right answer, since the
51
+ // whole point of the value is that it is exact.
52
+ return (
53
+ <span className="flex flex-col items-start gap-1 min-w-0">
54
+ <span className={clsx('badge whitespace-nowrap', status.color)}>{status.text}</span>
55
+ {phase && phase !== status.text && (
56
+ <span className="text-xs font-mono text-theme-text-tertiary break-all">{phase}</span>
57
+ )}
58
+ </span>
59
+ )
60
+ }
61
+
28
62
  export function BackupCell({ resource, column }: { resource: any; column: string }) {
29
63
  switch (column) {
30
64
  case 'status': {
@@ -109,23 +143,108 @@ export function ScheduleCell({ resource, column }: { resource: any; column: stri
109
143
  switch (column) {
110
144
  case 'status': {
111
145
  const status = getScheduleStatus(resource)
146
+ // A paused schedule that is *also* rejected carries two independent facts
147
+ // and the badge can only show one. The failure takes the badge because it
148
+ // is the one to act on; paused rides alongside as a chip — the same
149
+ // primary-plus-secondary shape CellContent uses for Terminating in the
150
+ // name column. A chip rather than a second word is what keeps it inside
151
+ // the column budget: "Rejected" plus a spelled-out "Paused" does not fit.
152
+ const alsoPaused = getSchedulePaused(resource) && status.text !== 'Paused'
112
153
  return (
113
- <span className={clsx('badge', status.color)}>
114
- {status.text}
154
+ <span className="inline-flex items-center gap-1 min-w-0">
155
+ <span className={clsx('badge', status.color)}>
156
+ {status.text}
157
+ </span>
158
+ {alsoPaused && (
159
+ <Tooltip content="Also paused — this schedule will not run until it is resumed">
160
+ <Pause className="w-3 h-3 shrink-0 text-amber-600 dark:text-amber-400" />
161
+ </Tooltip>
162
+ )}
115
163
  </span>
116
164
  )
117
165
  }
118
166
  case 'schedule': {
119
- const cron = getScheduleCron(resource)
120
- return <span className="text-sm text-theme-text-secondary font-mono">{cron}</span>
167
+ const { cron, readable, malformed } = getScheduleCronInfo(resource)
168
+ return (
169
+ <div className="flex flex-col min-w-0">
170
+ <span
171
+ className={clsx(
172
+ 'text-sm font-mono truncate',
173
+ malformed ? 'text-red-600 dark:text-red-400' : 'text-theme-text-secondary'
174
+ )}
175
+ // Cron has no bounded width — `*/15 9-17 * * 1-5` measures 143px
176
+ // against 128px of cell — so the raw value carries a title on every
177
+ // row, not just the malformed ones.
178
+ title={malformed ? `${cron} is not a valid cron expression — this schedule cannot run` : cron}
179
+ >
180
+ {cron}
181
+ </span>
182
+ {/* The second line is always occupied, even when there is nothing to
183
+ say. Dropping it for the rows cronToHuman can't phrase left those
184
+ rows 10px shorter than their neighbours, which is the ragged
185
+ rhythm this table already had once. `invisible` reserves the line
186
+ without painting or announcing it. */}
187
+ {malformed ? (
188
+ <span className="text-xs text-red-600 dark:text-red-400">Invalid cron</span>
189
+ ) : readable ? (
190
+ <span className="text-xs text-theme-text-tertiary truncate">{readable}</span>
191
+ ) : (
192
+ <span aria-hidden className="text-xs invisible">&nbsp;</span>
193
+ )}
194
+ </div>
195
+ )
121
196
  }
122
197
  case 'lastBackup': {
123
198
  const last = getScheduleLastBackup(resource)
124
199
  return <span className="text-sm text-theme-text-secondary">{last}</span>
125
200
  }
126
- case 'paused': {
127
- const paused = getSchedulePaused(resource)
128
- return <span className={clsx('text-sm', paused ? 'text-yellow-400' : 'text-theme-text-tertiary')}>{paused ? 'Yes' : '-'}</span>
201
+ default:
202
+ return <span className="text-sm text-theme-text-tertiary">-</span>
203
+ }
204
+ }
205
+
206
+ export function BackupRepositoryCell({ resource, column }: { resource: any; column: string }) {
207
+ switch (column) {
208
+ case 'status': {
209
+ const status = getBackupRepositoryStatus(resource)
210
+ return (
211
+ <span className={clsx('badge', status.color)}>
212
+ {status.text}
213
+ </span>
214
+ )
215
+ }
216
+ case 'repositoryType': {
217
+ const type = getBackupRepositoryType(resource)
218
+ // restic is on its way out (no new backups since v1.17, restore dropped in
219
+ // v1.19), so flag it rather than rendering it as neutral configuration.
220
+ const isLegacy = type === 'restic'
221
+ return (
222
+ <span className={clsx('text-sm', isLegacy ? 'text-amber-600 dark:text-amber-400 font-medium' : 'text-theme-text-secondary')}>
223
+ {type}
224
+ </span>
225
+ )
226
+ }
227
+ default:
228
+ return <span className="text-sm text-theme-text-tertiary">-</span>
229
+ }
230
+ }
231
+
232
+ // VolumeSnapshotLocation has no status column on purpose: the VSL controller
233
+ // does not populate status.phase, so a badge would read "Unknown" forever.
234
+ export function VolumeSnapshotLocationCell({ resource, column }: { resource: any; column: string }) {
235
+ switch (column) {
236
+ case 'provider': {
237
+ const provider = getVSLProvider(resource)
238
+ return <span className="text-sm text-theme-text-secondary truncate block">{provider}</span>
239
+ }
240
+ case 'config': {
241
+ const config = getVSLConfig(resource)
242
+ const keys = Object.keys(config)
243
+ if (keys.length === 0) {
244
+ return <span className="text-sm text-theme-text-tertiary">-</span>
245
+ }
246
+ const summary = keys.map((k) => `${k}=${config[k]}`).join(', ')
247
+ return <span className="text-sm text-theme-text-secondary truncate block" title={summary}>{summary}</span>
129
248
  }
130
249
  default:
131
250
  return <span className="text-sm text-theme-text-tertiary">-</span>
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { VeleroPhaseValue } from './velero-cells'
4
+ import { VeleroBackupRenderer } from './VeleroBackupRenderer'
5
+ import { VeleroRestoreRenderer } from './VeleroRestoreRenderer'
6
+ import { VeleroScheduleRenderer } from './VeleroScheduleRenderer'
7
+ import { VeleroBSLRenderer } from './VeleroBSLRenderer'
8
+
9
+ // The table deliberately shows a shortened label, and three distinct phases
10
+ // collapse onto one of them. That is only defensible while the exact phase
11
+ // stays reachable without reading YAML, so these tests guard the drawer.
12
+
13
+ const backup = (phase: string) => ({
14
+ metadata: { name: 'b', namespace: 'velero' },
15
+ spec: {},
16
+ status: { phase },
17
+ })
18
+
19
+ describe('exact Velero phase stays reachable in the drawer', () => {
20
+ it.each([
21
+ 'WaitingForPluginOperationsPartiallyFailed',
22
+ 'FinalizingPartiallyFailed',
23
+ 'PartiallyFailed',
24
+ 'WaitingForPluginOperations',
25
+ 'FailedValidation',
26
+ 'InProgress',
27
+ 'ReadyToStart',
28
+ ])('Backup detail prints the raw %s', (phase) => {
29
+ expect(renderToString(<VeleroBackupRenderer data={backup(phase)} />)).toContain(phase)
30
+ })
31
+
32
+ it('Restore detail prints the raw phase', () => {
33
+ const html = renderToString(
34
+ <VeleroRestoreRenderer data={{ metadata: { name: 'r' }, spec: {}, status: { phase: 'WaitingForPluginOperationsPartiallyFailed' } }} />
35
+ )
36
+ expect(html).toContain('WaitingForPluginOperationsPartiallyFailed')
37
+ })
38
+
39
+ it('Schedule detail prints the raw phase even when the badge says Paused', () => {
40
+ // Velero leaves .status.phase at Enabled while a schedule is paused, so the
41
+ // badge and the phase disagree by design; the drawer must show both.
42
+ const html = renderToString(
43
+ <VeleroScheduleRenderer data={{ metadata: { name: 's' }, spec: { schedule: '0 1 * * *', paused: true }, status: { phase: 'Enabled' } }} />
44
+ )
45
+ expect(html).toContain('Paused')
46
+ expect(html).toContain('Enabled')
47
+ })
48
+
49
+ it('BSL detail prints the raw phase', () => {
50
+ const html = renderToString(
51
+ <VeleroBSLRenderer data={{ metadata: { name: 'bsl' }, spec: { provider: 'aws' }, status: { phase: 'Unavailable' } }} />
52
+ )
53
+ expect(html).toContain('Unavailable')
54
+ })
55
+ })
56
+
57
+ describe('VeleroPhaseValue', () => {
58
+ it('does not repeat the phase when the label already is the phase', () => {
59
+ const html = renderToString(
60
+ <VeleroPhaseValue status={{ text: 'Completed', color: 'x' }} phase="Completed" />
61
+ )
62
+ expect(html.match(/Completed/g)).toHaveLength(1)
63
+ })
64
+
65
+ it('shows the phase alongside the label when they differ', () => {
66
+ const html = renderToString(
67
+ <VeleroPhaseValue status={{ text: 'Partially failed', color: 'x' }} phase="FinalizingPartiallyFailed" />
68
+ )
69
+ expect(html).toContain('Partially failed')
70
+ expect(html).toContain('FinalizingPartiallyFailed')
71
+ })
72
+
73
+ it('renders nothing extra when the resource has no phase', () => {
74
+ const html = renderToString(
75
+ <VeleroPhaseValue status={{ text: 'Unknown', color: 'x' }} phase="" />
76
+ )
77
+ expect(html.match(/Unknown/g)).toHaveLength(1)
78
+ })
79
+ })
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { dirname, resolve } from 'node:path'
5
+ import {
6
+ getCNPGClusterStatus,
7
+ getCNPGClusterInstances,
8
+ classifyCNPGClusterPhase,
9
+ CNPG_CLUSTER_PHASES_HEALTHY,
10
+ CNPG_CLUSTER_PHASES_TRANSIENT,
11
+ CNPG_CLUSTER_PHASES_FAILING,
12
+ CNPG_CLUSTER_PHASES_TERMINAL,
13
+ CNPG_CLUSTER_PHASES_ATTENTION,
14
+ } from './resource-utils-cnpg'
15
+
16
+ // Shared specification, also asserted from Go in
17
+ // internal/issues/source_cnpg_golden_test.go. The sibling parity test compares
18
+ // only phase SPELLING between the two languages, which cannot catch a
19
+ // precedence, readiness-presence or condition difference — every badge/issue
20
+ // disagreement found on this branch slipped past it.
21
+ const GOLDEN = resolve(
22
+ dirname(fileURLToPath(import.meta.url)),
23
+ '../../../../../testdata/cnpg/badge-issue-matrix.json',
24
+ )
25
+
26
+ interface GoldenCase {
27
+ name: string
28
+ spec: Record<string, unknown>
29
+ status: Record<string, unknown>
30
+ badge: { text: string; level: string }
31
+ issues: string[]
32
+ worst: string
33
+ }
34
+
35
+ const cases: GoldenCase[] = JSON.parse(readFileSync(GOLDEN, 'utf8')).cases
36
+
37
+ describe('CNPG golden matrix — badge side', () => {
38
+ it('loads the shared specification', () => {
39
+ expect(cases.length).toBeGreaterThan(0)
40
+ })
41
+
42
+ it.each(cases.map((c) => [c.name, c] as const))('%s', (_name, tc) => {
43
+ const badge = getCNPGClusterStatus({ spec: tc.spec, status: tc.status })
44
+ expect({ text: badge.text, level: badge.level }).toEqual(tc.badge)
45
+ })
46
+
47
+ // The invariant itself, stated directly against the spec rather than inferred:
48
+ // a green badge must never accompany an issue, and a red badge must never
49
+ // accompany silence.
50
+ it.each(cases.map((c) => [c.name, c] as const))(
51
+ 'badge and issues do not contradict — %s',
52
+ (_name, tc) => {
53
+ const badge = getCNPGClusterStatus({ spec: tc.spec, status: tc.status })
54
+ if (badge.level === 'healthy') {
55
+ expect(tc.issues, 'a healthy badge must not sit next to an issue').toEqual([])
56
+ }
57
+ if (badge.level === 'unhealthy') {
58
+ expect(tc.worst, 'an unhealthy badge must not sit next to silence').not.toBe('none')
59
+ }
60
+ },
61
+ )
62
+
63
+ // The Instances CELL, derived from the same cases rather than specified in
64
+ // them. The matrix stays a Go/TS parity spec — Go has no instances cell, so a
65
+ // `cell` field would add a column only one language can assert to a file
66
+ // whose whole purpose is that both do. Deriving costs no schema change and
67
+ // covers every case added later for badge reasons.
68
+ //
69
+ // This is the gap that let the bug through: the matrix already pinned
70
+ // "readyInstances absent — unknown, not zero; must not fabricate an outage"
71
+ // for the badge, and the cell rendered 0/N on that very case.
72
+ it.each(cases.map((c) => [c.name, c] as const))(
73
+ 'instances cell does not claim an outage the badge denies — %s',
74
+ (_name, tc) => {
75
+ const cell = getCNPGClusterInstances({ spec: tc.spec, status: tc.status })
76
+ const ready = cell.split('/')[0]
77
+ const readyReported = typeof tc.status.readyInstances === 'number'
78
+ expect(
79
+ ready === '-',
80
+ `readyInstances ${readyReported ? 'is reported' : 'is absent'} but the cell reads ${cell}`,
81
+ ).toBe(!readyReported)
82
+ },
83
+ )
84
+ })
85
+
86
+ describe('CNPG phase buckets', () => {
87
+ // TS checks transient before attention; Go checks attention before transient.
88
+ // A phase in both would classify differently per language while the spelling
89
+ // parity test still passed.
90
+ it('are disjoint', () => {
91
+ const seen = new Map<string, string>()
92
+ const buckets: Record<string, readonly string[]> = {
93
+ healthy: CNPG_CLUSTER_PHASES_HEALTHY,
94
+ transient: CNPG_CLUSTER_PHASES_TRANSIENT,
95
+ failing: CNPG_CLUSTER_PHASES_FAILING,
96
+ terminal: CNPG_CLUSTER_PHASES_TERMINAL,
97
+ attention: CNPG_CLUSTER_PHASES_ATTENTION,
98
+ }
99
+ for (const [bucket, phases] of Object.entries(buckets)) {
100
+ for (const phase of phases) {
101
+ expect(seen.has(phase), `${phase} is in both ${seen.get(phase)} and ${bucket}`).toBe(false)
102
+ seen.set(phase, bucket)
103
+ }
104
+ }
105
+ })
106
+
107
+ it('classify every listed phase into its own bucket', () => {
108
+ for (const p of CNPG_CLUSTER_PHASES_TERMINAL) expect(classifyCNPGClusterPhase(p)).toBe('terminal')
109
+ for (const p of CNPG_CLUSTER_PHASES_TRANSIENT) expect(classifyCNPGClusterPhase(p)).toBe('transient')
110
+ for (const p of CNPG_CLUSTER_PHASES_ATTENTION) expect(classifyCNPGClusterPhase(p)).toBe('attention')
111
+ })
112
+ })