@skyhook-io/k8s-ui 1.13.3 → 1.14.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.
- package/package.json +4 -4
- package/src/components/applications/AppTooltips.tsx +13 -1
- package/src/components/applications/ApplicationDetail.test.tsx +25 -0
- package/src/components/applications/ApplicationDetail.tsx +39 -7
- package/src/components/applications/ApplicationsView.tsx +18 -3
- package/src/components/applications/ReadyBar.tsx +3 -2
- package/src/components/applications/applications-view.test.tsx +91 -0
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +40 -31
- package/src/components/issues/ResourceIssuesSection.tsx +3 -2
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +123 -4
- package/src/components/issues/issues.test.ts +221 -10
- package/src/components/issues/types.ts +31 -5
- package/src/components/namespace-switcher/NamespacePicker.tsx +21 -13
- package/src/components/resources/ResourcesView.tsx +966 -98
- package/src/components/resources/column-settings-key.test.ts +98 -0
- package/src/components/resources/column-visibility-merge.test.ts +64 -0
- package/src/components/resources/curated-column-ownership.test.ts +236 -0
- package/src/components/resources/gateway-policy-status.test.ts +291 -0
- package/src/components/resources/gateway-policy-status.ts +237 -0
- package/src/components/resources/generic-status.test.ts +199 -0
- package/src/components/resources/generic-status.ts +171 -0
- package/src/components/resources/gpu-ecosystem-registry.test.ts +63 -0
- package/src/components/resources/index.ts +9 -1
- package/src/components/resources/istio-gateway-filter.test.ts +97 -0
- package/src/components/resources/kyverno-legacy-action.test.ts +100 -1
- package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
- package/src/components/resources/printer-columns.test.ts +298 -0
- package/src/components/resources/printer-columns.ts +249 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
- package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
- package/src/components/resources/renderers/EventRenderer.test.tsx +50 -1
- package/src/components/resources/renderers/EventRenderer.tsx +3 -5
- package/src/components/resources/renderers/GenericRenderer.tsx +18 -83
- package/src/components/resources/renderers/HPARenderer.test.tsx +28 -0
- package/src/components/resources/renderers/HPARenderer.tsx +6 -1
- package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +2 -1
- package/src/components/resources/renderers/PVCRenderer.test.tsx +44 -0
- package/src/components/resources/renderers/PVCRenderer.tsx +19 -2
- package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
- package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +35 -0
- package/src/components/resources/renderers/RolloutRenderer.tsx +22 -3
- package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
- package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
- package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
- package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
- package/src/components/resources/renderers/WorkloadRenderer.test.tsx +83 -0
- package/src/components/resources/renderers/WorkloadRenderer.tsx +25 -65
- package/src/components/resources/renderers/amd-gpu-cells.tsx +36 -0
- package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
- package/src/components/resources/renderers/inference-gateway-cells.tsx +62 -0
- package/src/components/resources/renderers/jobset-lws-cells.tsx +82 -0
- package/src/components/resources/renderers/kai-cells.tsx +67 -0
- package/src/components/resources/renderers/kaito-cells.tsx +62 -0
- package/src/components/resources/renderers/kserve-cells.tsx +145 -0
- package/src/components/resources/renderers/kubeflow-training-cells.tsx +72 -0
- package/src/components/resources/renderers/kueue-cells.tsx +176 -0
- package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
- package/src/components/resources/renderers/nim-cells.tsx +81 -0
- package/src/components/resources/renderers/ray-cells.tsx +124 -0
- package/src/components/resources/renderers/volcano-cells.tsx +140 -0
- package/src/components/resources/resource-utils-amd-gpu.ts +61 -0
- package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
- package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
- package/src/components/resources/resource-utils-cnpg.ts +132 -7
- package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +236 -0
- package/src/components/resources/resource-utils-hpa.test.ts +4 -0
- package/src/components/resources/resource-utils-hpa.ts +6 -2
- package/src/components/resources/resource-utils-inference-gateway.ts +119 -0
- package/src/components/resources/resource-utils-jobset-lws.ts +117 -0
- package/src/components/resources/resource-utils-kai.ts +105 -0
- package/src/components/resources/resource-utils-kaito.ts +114 -0
- package/src/components/resources/resource-utils-kserve.ts +186 -0
- package/src/components/resources/resource-utils-kubeflow-training.ts +121 -0
- package/src/components/resources/resource-utils-kueue.ts +200 -0
- package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
- package/src/components/resources/resource-utils-kyverno.ts +51 -2
- package/src/components/resources/resource-utils-nim.ts +109 -0
- package/src/components/resources/resource-utils-ray.ts +214 -0
- package/src/components/resources/resource-utils-rollout.test.ts +183 -0
- package/src/components/resources/resource-utils-velero.test.ts +30 -0
- package/src/components/resources/resource-utils-velero.ts +32 -0
- package/src/components/resources/resource-utils-volcano.ts +176 -0
- package/src/components/resources/resource-utils.ts +156 -47
- package/src/components/resources/resources-column-filter.test.ts +27 -1
- package/src/components/resources/workload-rollout-golden.test.ts +55 -0
- package/src/components/shared/CreateResourceDialog.tsx +1 -0
- package/src/components/shared/EditableYamlView.tsx +4 -2
- package/src/components/shared/ResourceActionsBar.tsx +170 -25
- package/src/components/shared/ResourceRendererDispatch.test.tsx +206 -1
- package/src/components/shared/ResourceRendererDispatch.tsx +84 -40
- package/src/components/shared/SetImageDialog.test.ts +129 -0
- package/src/components/shared/SetImageDialog.tsx +577 -0
- package/src/components/shared/index.ts +1 -0
- package/src/components/shared/revision-role-badges.test.ts +56 -4
- package/src/components/timeline/TimelineList.tsx +19 -3
- package/src/components/timeline/TimelineSwimlanes.tsx +11 -3
- package/src/components/trace/ReachabilityView.tsx +1 -1
- package/src/components/ui/SelectMenu.tsx +10 -1
- package/src/components/ui/drawer-components.tsx +12 -0
- package/src/components/workload/WorkloadRolloutNotice.tsx +60 -0
- package/src/components/workload/WorkloadView.tsx +144 -81
- package/src/hooks/useProgressiveRefresh.ts +37 -0
- package/src/source-hygiene.test.ts +28 -0
- package/src/theme/tailwind-theme.css +4 -0
- package/src/types/core.ts +55 -0
- package/src/utils/api-resources.test.ts +40 -1
- package/src/utils/api-resources.ts +11 -1
- package/src/utils/applications.ts +50 -0
- package/src/utils/bulk-workload-actions.test.ts +20 -0
- package/src/utils/bulk-workload-actions.ts +15 -0
- package/src/utils/navigation.test.ts +31 -1
- package/src/utils/navigation.ts +21 -2
- package/src/utils/workload-rollout.ts +292 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Volcano cell components for ResourcesView table
|
|
2
|
+
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import {
|
|
5
|
+
getVolcanoJobStatus,
|
|
6
|
+
getVolcanoJobQueue,
|
|
7
|
+
getVolcanoJobMinAvailable,
|
|
8
|
+
getVolcanoJobPodCounts,
|
|
9
|
+
getVolcanoQueueStatus,
|
|
10
|
+
getVolcanoQueueWeight,
|
|
11
|
+
getVolcanoQueueCapability,
|
|
12
|
+
getVolcanoQueueAllocated,
|
|
13
|
+
getVolcanoQueuePodGroupCounts,
|
|
14
|
+
getVolcanoPodGroupStatus,
|
|
15
|
+
getVolcanoPodGroupQueue,
|
|
16
|
+
getVolcanoPodGroupMinMember,
|
|
17
|
+
getJobFlowStatus,
|
|
18
|
+
getJobFlowFlowCount,
|
|
19
|
+
getJobTemplateStatus,
|
|
20
|
+
getJobTemplateTaskCount,
|
|
21
|
+
} from '../resource-utils-volcano'
|
|
22
|
+
|
|
23
|
+
export function VolcanoJobCell({ resource, column }: { resource: any; column: string }) {
|
|
24
|
+
switch (column) {
|
|
25
|
+
case 'status': {
|
|
26
|
+
const status = getVolcanoJobStatus(resource)
|
|
27
|
+
return (
|
|
28
|
+
<span className={clsx('badge', status.color)}>
|
|
29
|
+
{status.text}
|
|
30
|
+
</span>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
case 'queue': {
|
|
34
|
+
const queue = getVolcanoJobQueue(resource)
|
|
35
|
+
return <span className="text-sm text-theme-text-secondary">{queue}</span>
|
|
36
|
+
}
|
|
37
|
+
case 'minAvailable': {
|
|
38
|
+
const minAvailable = getVolcanoJobMinAvailable(resource)
|
|
39
|
+
return <span className="text-sm text-theme-text-secondary">{minAvailable}</span>
|
|
40
|
+
}
|
|
41
|
+
case 'pods': {
|
|
42
|
+
const counts = getVolcanoJobPodCounts(resource)
|
|
43
|
+
return <span className="text-sm text-theme-text-secondary truncate block">{counts}</span>
|
|
44
|
+
}
|
|
45
|
+
default:
|
|
46
|
+
return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function VolcanoQueueCell({ resource, column }: { resource: any; column: string }) {
|
|
51
|
+
switch (column) {
|
|
52
|
+
case 'status': {
|
|
53
|
+
const status = getVolcanoQueueStatus(resource)
|
|
54
|
+
return (
|
|
55
|
+
<span className={clsx('badge', status.color)}>
|
|
56
|
+
{status.text}
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
case 'weight': {
|
|
61
|
+
const weight = getVolcanoQueueWeight(resource)
|
|
62
|
+
return <span className="text-sm text-theme-text-secondary">{weight}</span>
|
|
63
|
+
}
|
|
64
|
+
case 'capability': {
|
|
65
|
+
const capability = getVolcanoQueueCapability(resource)
|
|
66
|
+
return <span className="text-sm text-theme-text-secondary truncate block">{capability}</span>
|
|
67
|
+
}
|
|
68
|
+
case 'allocated': {
|
|
69
|
+
const allocated = getVolcanoQueueAllocated(resource)
|
|
70
|
+
return <span className="text-sm text-theme-text-secondary truncate block">{allocated}</span>
|
|
71
|
+
}
|
|
72
|
+
case 'podGroups': {
|
|
73
|
+
const counts = getVolcanoQueuePodGroupCounts(resource)
|
|
74
|
+
return <span className="text-sm text-theme-text-secondary truncate block">{counts}</span>
|
|
75
|
+
}
|
|
76
|
+
default:
|
|
77
|
+
return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function VolcanoPodGroupCell({ resource, column }: { resource: any; column: string }) {
|
|
82
|
+
switch (column) {
|
|
83
|
+
case 'status': {
|
|
84
|
+
const status = getVolcanoPodGroupStatus(resource)
|
|
85
|
+
return (
|
|
86
|
+
<span className={clsx('badge', status.color)}>
|
|
87
|
+
{status.text}
|
|
88
|
+
</span>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
case 'queue': {
|
|
92
|
+
const queue = getVolcanoPodGroupQueue(resource)
|
|
93
|
+
return <span className="text-sm text-theme-text-secondary">{queue}</span>
|
|
94
|
+
}
|
|
95
|
+
case 'minMember': {
|
|
96
|
+
const minMember = getVolcanoPodGroupMinMember(resource)
|
|
97
|
+
return <span className="text-sm text-theme-text-secondary">{minMember}</span>
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function JobFlowCell({ resource, column }: { resource: any; column: string }) {
|
|
105
|
+
switch (column) {
|
|
106
|
+
case 'status': {
|
|
107
|
+
const status = getJobFlowStatus(resource)
|
|
108
|
+
return (
|
|
109
|
+
<span className={clsx('badge', status.color)}>
|
|
110
|
+
{status.text}
|
|
111
|
+
</span>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
case 'flows': {
|
|
115
|
+
const count = getJobFlowFlowCount(resource)
|
|
116
|
+
return <span className="text-sm text-theme-text-secondary">{count > 0 ? count : '-'}</span>
|
|
117
|
+
}
|
|
118
|
+
default:
|
|
119
|
+
return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function JobTemplateCell({ resource, column }: { resource: any; column: string }) {
|
|
124
|
+
switch (column) {
|
|
125
|
+
case 'status': {
|
|
126
|
+
const status = getJobTemplateStatus(resource)
|
|
127
|
+
return (
|
|
128
|
+
<span className={clsx('badge', status.color)}>
|
|
129
|
+
{status.text}
|
|
130
|
+
</span>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
case 'tasks': {
|
|
134
|
+
const count = getJobTemplateTaskCount(resource)
|
|
135
|
+
return <span className="text-sm text-theme-text-secondary">{count > 0 ? count : '-'}</span>
|
|
136
|
+
}
|
|
137
|
+
default:
|
|
138
|
+
return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// AMD GPU Operator CRD utility functions
|
|
2
|
+
|
|
3
|
+
import type { StatusBadge } from './resource-utils'
|
|
4
|
+
import { healthColors } from './resource-utils'
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// AMD DEVICECONFIG UTILITIES
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export function getAMDDeviceConfigStatus(resource: any): StatusBadge {
|
|
11
|
+
const status = resource.status
|
|
12
|
+
if (!status) {
|
|
13
|
+
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ready = Array.isArray(status.conditions)
|
|
17
|
+
? status.conditions.find((condition: any) => condition?.type === 'Ready')
|
|
18
|
+
: undefined
|
|
19
|
+
if (ready?.status === 'False') {
|
|
20
|
+
return { text: ready.reason || 'NotReady', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
21
|
+
}
|
|
22
|
+
if (ready?.status === 'True') {
|
|
23
|
+
return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const components = [status.driver, status.devicePlugin, status.metricsExporter, status.configManager]
|
|
27
|
+
.filter((c: any) => c && (c.desiredNumber ?? 0) > 0)
|
|
28
|
+
|
|
29
|
+
if (components.length === 0) {
|
|
30
|
+
return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const allReady = components.every((c: any) => (c.availableNumber ?? 0) >= (c.desiredNumber ?? 0))
|
|
34
|
+
if (allReady) {
|
|
35
|
+
return { text: 'Ready', color: healthColors.healthy, level: 'healthy' }
|
|
36
|
+
}
|
|
37
|
+
return { text: 'Degraded', color: healthColors.degraded, level: 'degraded' }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getAMDDeviceConfigDriver(resource: any): string {
|
|
41
|
+
const driver = resource.spec?.driver
|
|
42
|
+
if (!driver) return '-'
|
|
43
|
+
if (driver.enable === true) {
|
|
44
|
+
return driver.version ? `Enabled (${driver.version})` : 'Enabled'
|
|
45
|
+
}
|
|
46
|
+
if (driver.enable === false) return 'Disabled'
|
|
47
|
+
return '-'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getAMDDeviceConfigDevicePluginImage(resource: any): string {
|
|
51
|
+
return resource.spec?.devicePlugin?.devicePluginImage || '-'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getAMDDeviceConfigNodeCount(resource: any): string {
|
|
55
|
+
const dp = resource.status?.devicePlugin || resource.status?.driver
|
|
56
|
+
if (!dp) return '-'
|
|
57
|
+
const available = dp.availableNumber ?? 0
|
|
58
|
+
const desired = dp.desiredNumber ?? 0
|
|
59
|
+
if (desired === 0) return '-'
|
|
60
|
+
return `${available}/${desired}`
|
|
61
|
+
}
|
|
@@ -27,6 +27,7 @@ interface GoldenCase {
|
|
|
27
27
|
name: string
|
|
28
28
|
spec: Record<string, unknown>
|
|
29
29
|
status: Record<string, unknown>
|
|
30
|
+
metadata?: Record<string, unknown>
|
|
30
31
|
badge: { text: string; level: string }
|
|
31
32
|
issues: string[]
|
|
32
33
|
worst: string
|
|
@@ -40,7 +41,7 @@ describe('CNPG golden matrix — badge side', () => {
|
|
|
40
41
|
})
|
|
41
42
|
|
|
42
43
|
it.each(cases.map((c) => [c.name, c] as const))('%s', (_name, tc) => {
|
|
43
|
-
const badge = getCNPGClusterStatus({ spec: tc.spec, status: tc.status })
|
|
44
|
+
const badge = getCNPGClusterStatus({ metadata: tc.metadata, spec: tc.spec, status: tc.status })
|
|
44
45
|
expect({ text: badge.text, level: badge.level }).toEqual(tc.badge)
|
|
45
46
|
})
|
|
46
47
|
|
|
@@ -50,7 +51,7 @@ describe('CNPG golden matrix — badge side', () => {
|
|
|
50
51
|
it.each(cases.map((c) => [c.name, c] as const))(
|
|
51
52
|
'badge and issues do not contradict — %s',
|
|
52
53
|
(_name, tc) => {
|
|
53
|
-
const badge = getCNPGClusterStatus({ spec: tc.spec, status: tc.status })
|
|
54
|
+
const badge = getCNPGClusterStatus({ metadata: tc.metadata, spec: tc.spec, status: tc.status })
|
|
54
55
|
if (badge.level === 'healthy') {
|
|
55
56
|
expect(tc.issues, 'a healthy badge must not sit next to an issue').toEqual([])
|
|
56
57
|
}
|
|
@@ -69,16 +70,23 @@ describe('CNPG golden matrix — badge side', () => {
|
|
|
69
70
|
// This is the gap that let the bug through: the matrix already pinned
|
|
70
71
|
// "readyInstances absent — unknown, not zero; must not fabricate an outage"
|
|
71
72
|
// for the badge, and the cell rendered 0/N on that very case.
|
|
73
|
+
//
|
|
74
|
+
// The cell reads '-' exactly when the ready count is genuinely unknown: no
|
|
75
|
+
// explicit readyInstances AND no currentPrimary. Once a primary has been
|
|
76
|
+
// elected, an omitted readyInstances is a real 0 and the cell says so — which
|
|
77
|
+
// is what keeps "0/3" agreeing with the "Not Ready" badge on a was-up outage,
|
|
78
|
+
// while a still-bootstrapping cluster (no primary) keeps its dash.
|
|
72
79
|
it.each(cases.map((c) => [c.name, c] as const))(
|
|
73
80
|
'instances cell does not claim an outage the badge denies — %s',
|
|
74
81
|
(_name, tc) => {
|
|
75
82
|
const cell = getCNPGClusterInstances({ spec: tc.spec, status: tc.status })
|
|
76
83
|
const ready = cell.split('/')[0]
|
|
77
|
-
const
|
|
84
|
+
const readyResolvable =
|
|
85
|
+
typeof tc.status.readyInstances === 'number' || !!tc.status.currentPrimary
|
|
78
86
|
expect(
|
|
79
87
|
ready === '-',
|
|
80
|
-
`
|
|
81
|
-
).toBe(!
|
|
88
|
+
`ready is ${readyResolvable ? 'resolvable' : 'unknown'} but the cell reads ${cell}`,
|
|
89
|
+
).toBe(!readyResolvable)
|
|
82
90
|
},
|
|
83
91
|
)
|
|
84
92
|
})
|
|
@@ -27,6 +27,12 @@ import {
|
|
|
27
27
|
getCNPGClusterDisplayState,
|
|
28
28
|
getCNPGClusterInstances,
|
|
29
29
|
getCNPGPoolerInstances,
|
|
30
|
+
getCNPGClusterIsReplica,
|
|
31
|
+
getCNPGClusterReplicaSource,
|
|
32
|
+
getCNPGClusterAvailability,
|
|
33
|
+
isCNPGClusterHibernated,
|
|
34
|
+
isCNPGClusterFencedAll,
|
|
35
|
+
CNPG_DOWN_GRACE_MS,
|
|
30
36
|
} from './resource-utils-cnpg'
|
|
31
37
|
import { getCellFilterValue } from './resource-utils'
|
|
32
38
|
|
|
@@ -176,8 +182,10 @@ describe('getCNPGClusterStatus', () => {
|
|
|
176
182
|
expect(getCNPGClusterStatus(cluster({ phase: 'Failing over', readyInstances: 2 }, { instances: 3 })).level).toBe('alert')
|
|
177
183
|
})
|
|
178
184
|
|
|
179
|
-
it('treats zero ready instances as down regardless of phase', () => {
|
|
180
|
-
|
|
185
|
+
it('treats a was-up cluster with zero ready instances as down regardless of phase', () => {
|
|
186
|
+
// currentPrimary proves it was serving; the omitted-then-zero count is a
|
|
187
|
+
// regression, not a first bootstrap. Even a "healthy" phase does not rescue it.
|
|
188
|
+
const s = getCNPGClusterStatus(cluster({ phase: 'Cluster in healthy state', currentPrimary: 'pg-1', readyInstances: 0 }, { instances: 3 }))
|
|
181
189
|
expect(s.level).toBe('unhealthy')
|
|
182
190
|
expect(s.text).toBe('Not Ready')
|
|
183
191
|
})
|
|
@@ -193,6 +201,68 @@ describe('getCNPGClusterStatus', () => {
|
|
|
193
201
|
})
|
|
194
202
|
})
|
|
195
203
|
|
|
204
|
+
describe('getCNPGClusterAvailability — three states a bare zero-ready count collapses', () => {
|
|
205
|
+
// CNPG omits readyInstances when it is 0, so these fixtures OMIT it — the shape
|
|
206
|
+
// the operator actually emits for a down cluster.
|
|
207
|
+
const withPrimary = (extra: any = {}, meta: any = {}) => ({
|
|
208
|
+
metadata: meta,
|
|
209
|
+
spec: { instances: 3 },
|
|
210
|
+
status: { currentPrimary: 'pg-1', ...extra },
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('is null while still bootstrapping — reported, but no primary ever elected', () => {
|
|
214
|
+
// The truly-statusless case AND the reported-but-no-primary case both stay
|
|
215
|
+
// null: nothing was serving to lose.
|
|
216
|
+
expect(getCNPGClusterAvailability({ spec: { instances: 3 }, status: {} })).toBeNull()
|
|
217
|
+
expect(getCNPGClusterAvailability({ spec: { instances: 3 }, status: { phase: 'Setting up primary' } })).toBeNull()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('is null when a real ready count exists', () => {
|
|
221
|
+
expect(getCNPGClusterAvailability({ spec: { instances: 3 }, status: { currentPrimary: 'pg-1', readyInstances: 2 } })).toBeNull()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('is down for a was-up cluster with no Ready condition — escalates immediately', () => {
|
|
225
|
+
expect(getCNPGClusterAvailability(withPrimary({ phase: 'Waiting for the instances to become active' }))).toBe('down')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('debounces a fresh Ready=False, then escalates once it ages past the grace', () => {
|
|
229
|
+
const recent = new Date(Date.now() - 60 * 1000).toISOString()
|
|
230
|
+
const old = new Date(Date.now() - CNPG_DOWN_GRACE_MS - 60 * 1000).toISOString()
|
|
231
|
+
const cond = (ltt: string) => withPrimary({
|
|
232
|
+
conditions: [{ type: 'Ready', status: 'False', reason: 'ClusterIsNotReady', lastTransitionTime: ltt }],
|
|
233
|
+
})
|
|
234
|
+
expect(getCNPGClusterAvailability(cond(recent))).toBeNull()
|
|
235
|
+
expect(getCNPGClusterAvailability(cond(old))).toBe('down')
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('reads hibernation and all-fencing as neutral, ahead of the down verdict', () => {
|
|
239
|
+
expect(getCNPGClusterAvailability(withPrimary({}, { annotations: { 'cnpg.io/hibernation': 'on' } }))).toBe('hibernated')
|
|
240
|
+
expect(getCNPGClusterAvailability(withPrimary({}, { annotations: { 'cnpg.io/fencedInstances': '["*"]' } }))).toBe('fenced')
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('a partial fence is not all-fenced — "*" is the only all-down token', () => {
|
|
244
|
+
expect(isCNPGClusterFencedAll({ metadata: { annotations: { 'cnpg.io/fencedInstances': '["pg-1-2"]' } } })).toBe(false)
|
|
245
|
+
expect(isCNPGClusterFencedAll({ metadata: { annotations: { 'cnpg.io/fencedInstances': '["*"]' } } })).toBe(true)
|
|
246
|
+
// A malformed annotation is treated as not-fenced rather than throwing.
|
|
247
|
+
expect(isCNPGClusterFencedAll({ metadata: { annotations: { 'cnpg.io/fencedInstances': 'not-json' } } })).toBe(false)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('hibernation is an explicit "on", nothing else', () => {
|
|
251
|
+
expect(isCNPGClusterHibernated({ metadata: { annotations: { 'cnpg.io/hibernation': 'on' } } })).toBe(true)
|
|
252
|
+
expect(isCNPGClusterHibernated({ metadata: { annotations: { 'cnpg.io/hibernation': 'off' } } })).toBe(false)
|
|
253
|
+
expect(isCNPGClusterHibernated({ metadata: {} })).toBe(false)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('renders the badge neutral for hibernation and fencing, red only for a real outage', () => {
|
|
257
|
+
expect(getCNPGClusterStatus(withPrimary({}, { annotations: { 'cnpg.io/hibernation': 'on' } })))
|
|
258
|
+
.toMatchObject({ text: 'Hibernated', level: 'neutral' })
|
|
259
|
+
expect(getCNPGClusterStatus(withPrimary({}, { annotations: { 'cnpg.io/fencedInstances': '["*"]' } })))
|
|
260
|
+
.toMatchObject({ text: 'Fenced', level: 'neutral' })
|
|
261
|
+
expect(getCNPGClusterStatus(withPrimary({ phase: 'Switchover in progress' })))
|
|
262
|
+
.toMatchObject({ text: 'Not Ready', level: 'unhealthy' })
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
196
266
|
describe('getCNPGBackupStatus', () => {
|
|
197
267
|
it('escalates walArchivingFailing instead of rendering it neutral', () => {
|
|
198
268
|
// The shipped bug: this phase hit the default branch and rendered grey,
|
|
@@ -716,3 +786,73 @@ describe('CNPG scheduled backup lateness', () => {
|
|
|
716
786
|
expect(getCNPGScheduledBackupStatus(sb(false, undefined, at(-24 * 60 * MIN))).level).toBe('healthy')
|
|
717
787
|
})
|
|
718
788
|
})
|
|
789
|
+
|
|
790
|
+
describe('getCNPGClusterIsReplica — the live replica-cluster role, per CNPG semantics', () => {
|
|
791
|
+
// CNPG carries replica-cluster state in `spec.replica` (enabled/primary/self/source),
|
|
792
|
+
// and its own Cluster.IsReplica() reads: enabled wins when set; otherwise the
|
|
793
|
+
// cluster is a replica only while (self || name) != primary. Promotion mutates
|
|
794
|
+
// those fields but leaves the stanza in place, so presence alone cannot be the
|
|
795
|
+
// signal.
|
|
796
|
+
|
|
797
|
+
it('standalone replica cluster (replica mode enabled) reads as a replica', () => {
|
|
798
|
+
const resource = {
|
|
799
|
+
metadata: { name: 'pg-eu' },
|
|
800
|
+
spec: { replica: { source: 'pg-us', enabled: true } },
|
|
801
|
+
}
|
|
802
|
+
expect(getCNPGClusterIsReplica(resource)).toBe(true)
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
it('a promoted standalone cluster (replica.enabled turned off) is no longer a replica', () => {
|
|
806
|
+
const resource = {
|
|
807
|
+
metadata: { name: 'pg-eu' },
|
|
808
|
+
spec: { replica: { source: 'pg-us', enabled: false } },
|
|
809
|
+
}
|
|
810
|
+
expect(getCNPGClusterIsReplica(resource)).toBe(false)
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('distributed topology: a designated replica (primary names another cluster) reads as a replica', () => {
|
|
814
|
+
const resource = {
|
|
815
|
+
metadata: { name: 'pg-eu' },
|
|
816
|
+
spec: { replica: { source: 'pg-us', primary: 'pg-us', self: 'pg-eu' } },
|
|
817
|
+
}
|
|
818
|
+
expect(getCNPGClusterIsReplica(resource)).toBe(true)
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('distributed topology: after promotion (primary now names this cluster) it is the primary, stanza still present', () => {
|
|
822
|
+
const resource = {
|
|
823
|
+
metadata: { name: 'pg-eu' },
|
|
824
|
+
spec: { replica: { source: 'pg-us', primary: 'pg-eu', self: 'pg-eu' } },
|
|
825
|
+
}
|
|
826
|
+
expect(getCNPGClusterIsReplica(resource)).toBe(false)
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
it('distributed topology without self falls back to the resource name', () => {
|
|
830
|
+
const promoted = {
|
|
831
|
+
metadata: { name: 'pg-eu' },
|
|
832
|
+
spec: { replica: { source: 'pg-us', primary: 'pg-eu' } },
|
|
833
|
+
}
|
|
834
|
+
expect(getCNPGClusterIsReplica(promoted)).toBe(false)
|
|
835
|
+
|
|
836
|
+
const stillReplica = {
|
|
837
|
+
metadata: { name: 'pg-eu' },
|
|
838
|
+
spec: { replica: { source: 'pg-us', primary: 'pg-us' } },
|
|
839
|
+
}
|
|
840
|
+
expect(getCNPGClusterIsReplica(stillReplica)).toBe(true)
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
it('a bootstrapping standalone replica with only a source (no enabled, no primary) is a replica', () => {
|
|
844
|
+
// The stanza's source is required; before the operator writes enabled, the
|
|
845
|
+
// cluster is still a replica — (self || name) never equals an unset primary.
|
|
846
|
+
const resource = { metadata: { name: 'pg-eu' }, spec: { replica: { source: 'pg-us' } } }
|
|
847
|
+
expect(getCNPGClusterIsReplica(resource)).toBe(true)
|
|
848
|
+
})
|
|
849
|
+
|
|
850
|
+
it('an ordinary standalone cluster with no replica stanza is not a replica', () => {
|
|
851
|
+
expect(getCNPGClusterIsReplica({ metadata: { name: 'pg' }, spec: { instances: 3 } })).toBe(false)
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
it('getCNPGClusterReplicaSource reads the source from the replica stanza', () => {
|
|
855
|
+
const resource = { metadata: { name: 'pg-eu' }, spec: { replica: { source: 'pg-us', enabled: true } } }
|
|
856
|
+
expect(getCNPGClusterReplicaSource(resource)).toBe('pg-us')
|
|
857
|
+
})
|
|
858
|
+
})
|
|
@@ -145,6 +145,100 @@ export function classifyCNPGClusterPhase(phase: string): CNPGPhaseBucket {
|
|
|
145
145
|
return 'unknown'
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// Debounce window for calling a was-up cluster "down". A single instance
|
|
149
|
+
// restarting flips Ready=False for a minute or two while the pod comes back;
|
|
150
|
+
// escalating instantly would fire on routine rollouts. Kept in step with
|
|
151
|
+
// cnpgDownGrace in internal/issues/source_cnpg.go — the two must not drift.
|
|
152
|
+
export const CNPG_DOWN_GRACE_MS = 5 * 60 * 1000
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Hibernation is a deliberate scale-to-zero: the operator removes the pods and
|
|
156
|
+
* the cluster reports nothing ready by design. Signalled by an annotation, not
|
|
157
|
+
* by any status field, so it has to be read before a zero-ready count is called
|
|
158
|
+
* an outage.
|
|
159
|
+
*/
|
|
160
|
+
export function isCNPGClusterHibernated(resource: any): boolean {
|
|
161
|
+
return resource?.metadata?.annotations?.['cnpg.io/hibernation'] === 'on'
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Fencing stops PostgreSQL on the named instances while leaving the pods up. The
|
|
166
|
+
* annotation is a JSON array of instance names; `"*"` means every instance is
|
|
167
|
+
* fenced, so nothing serves — intentionally, not as a fault. A malformed value
|
|
168
|
+
* is treated as "not fenced" rather than throwing.
|
|
169
|
+
*/
|
|
170
|
+
export function isCNPGClusterFencedAll(resource: any): boolean {
|
|
171
|
+
const raw = resource?.metadata?.annotations?.['cnpg.io/fencedInstances']
|
|
172
|
+
if (typeof raw !== 'string' || raw === '') return false
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(raw)
|
|
175
|
+
return Array.isArray(parsed) && parsed.includes('*')
|
|
176
|
+
} catch {
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Whether a zero-ready cluster is actually unavailable, and why.
|
|
183
|
+
*
|
|
184
|
+
* The load-bearing distinction the badge and the Go detector both turn on:
|
|
185
|
+
* CNPG omits `status.readyInstances` when it is 0 (omitempty int), so "no
|
|
186
|
+
* status yet" and "0 ready" are byte-identical on the wire. The field's
|
|
187
|
+
* presence therefore cannot distinguish an outage from an unreported cluster;
|
|
188
|
+
* any other status the operator has written (phase, primary, a condition) is
|
|
189
|
+
* what marks the count as a real 0 rather than "not reported".
|
|
190
|
+
*
|
|
191
|
+
* `status.currentPrimary` is the version-robust "was up" signal: CNPG sets it
|
|
192
|
+
* the first time a primary is elected and never clears it (present on 1.27 and
|
|
193
|
+
* 1.28). So a zero-ready cluster that HAS a currentPrimary was serving before —
|
|
194
|
+
* a regression — while one that never had a primary is still bootstrapping.
|
|
195
|
+
*
|
|
196
|
+
* - null — not down: still bootstrapping, or a real ready count exists
|
|
197
|
+
* - 'hibernated' / 'fenced' — deliberately not serving, no alarm
|
|
198
|
+
* - 'down' — was up, now zero ready past the grace window
|
|
199
|
+
*
|
|
200
|
+
* Mirrored by getCNPGClusterAvailability's Go counterpart in
|
|
201
|
+
* internal/issues/source_cnpg.go (the allDown verdict) — same signals, same
|
|
202
|
+
* grace, or a badge and its issue disagree.
|
|
203
|
+
*/
|
|
204
|
+
export function getCNPGClusterAvailability(
|
|
205
|
+
resource: any,
|
|
206
|
+
): 'hibernated' | 'fenced' | 'down' | null {
|
|
207
|
+
const status = resource.status || {}
|
|
208
|
+
const desired = resource.spec?.instances ?? 0
|
|
209
|
+
const ready = typeof status.readyInstances === 'number' ? status.readyInstances : 0
|
|
210
|
+
// Any status the operator has written proves it has reconciled at least once,
|
|
211
|
+
// which is what lets an absent readyInstances be read as a real 0 rather than
|
|
212
|
+
// "not reported". Absent EVERYTHING is the truly-statusless case, still unknown.
|
|
213
|
+
const reported = !!(status.phase || status.currentPrimary || status.conditions?.length)
|
|
214
|
+
if (desired <= 0 || ready > 0 || !reported) return null
|
|
215
|
+
// Deliberate not-serving states outrank the down verdict — checked before the
|
|
216
|
+
// was-up gate so a hibernated cluster reads neutral even if it kept a primary.
|
|
217
|
+
if (isCNPGClusterHibernated(resource)) return 'hibernated'
|
|
218
|
+
if (isCNPGClusterFencedAll(resource)) return 'fenced'
|
|
219
|
+
// Never elected a primary → first bootstrap, not a regression.
|
|
220
|
+
if (!status.currentPrimary) return null
|
|
221
|
+
const conditions = status.conditions || []
|
|
222
|
+
const readyCond = conditions.find((c: any) => c.type === 'Ready')
|
|
223
|
+
// A was-up cluster whose Ready only just went False is likely a single
|
|
224
|
+
// instance bouncing; wait out the grace. No Ready=False at all (or no
|
|
225
|
+
// timestamp) means nothing is debouncing, so escalate immediately rather than
|
|
226
|
+
// inventing a start time.
|
|
227
|
+
if (
|
|
228
|
+
readyCond?.status === 'False' &&
|
|
229
|
+
readyCond.lastTransitionTime &&
|
|
230
|
+
Date.now() - Date.parse(readyCond.lastTransitionTime) < CNPG_DOWN_GRACE_MS
|
|
231
|
+
) {
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
// A cluster woken from hibernation or lifted from a full fence transiently
|
|
235
|
+
// shows this same shape — was-up, zero ready, Ready=False from long before —
|
|
236
|
+
// until its first instance is Ready. CNPG drops the hibernation marker at that
|
|
237
|
+
// point, so from the Cluster status alone this is indistinguishable from a real
|
|
238
|
+
// outage, and it reads down for that short window.
|
|
239
|
+
return 'down'
|
|
240
|
+
}
|
|
241
|
+
|
|
148
242
|
export function getCNPGClusterStatus(resource: any): StatusBadge {
|
|
149
243
|
const status = resource.status || {}
|
|
150
244
|
const phase = status.phase || ''
|
|
@@ -165,10 +259,19 @@ export function getCNPGClusterStatus(resource: any): StatusBadge {
|
|
|
165
259
|
if (bucket === 'terminal') {
|
|
166
260
|
return { text: getCNPGClusterDisplayState(phase), color: healthColors.unhealthy, level: 'unhealthy' }
|
|
167
261
|
}
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
262
|
+
// Availability outranks every phase branch except terminal. A zero-ready
|
|
263
|
+
// cluster CNPG reports by omitting readyInstances would otherwise be invisible
|
|
264
|
+
// here (countsKnown is false without the field) and fall through to the
|
|
265
|
+
// transient phase, painting amber on a total outage. Hibernation and all-fenced
|
|
266
|
+
// are deliberate not-serving states, so they read neutral rather than red.
|
|
267
|
+
const avail = getCNPGClusterAvailability(resource)
|
|
268
|
+
if (avail === 'hibernated') {
|
|
269
|
+
return { text: 'Hibernated', color: healthColors.neutral, level: 'neutral' }
|
|
270
|
+
}
|
|
271
|
+
if (avail === 'fenced') {
|
|
272
|
+
return { text: 'Fenced', color: healthColors.neutral, level: 'neutral' }
|
|
273
|
+
}
|
|
274
|
+
if (avail === 'down') {
|
|
172
275
|
return { text: 'Not Ready', color: healthColors.unhealthy, level: 'unhealthy' }
|
|
173
276
|
}
|
|
174
277
|
if (bucket === 'failing') {
|
|
@@ -241,7 +344,20 @@ export function getCNPGClusterStatus(resource: any): StatusBadge {
|
|
|
241
344
|
const countOrDash = (n: unknown): string => (typeof n === 'number' ? String(n) : '-')
|
|
242
345
|
|
|
243
346
|
export function getCNPGClusterInstances(resource: any): string {
|
|
244
|
-
|
|
347
|
+
const status = resource.status || {}
|
|
348
|
+
// Absent readyInstances resolves to 0 only for a was-up cluster (one that
|
|
349
|
+
// elected a primary): there the omitted field genuinely means zero ready, and
|
|
350
|
+
// "0/3" agrees with the "Not Ready" badge. Before a primary exists the count
|
|
351
|
+
// is truly unknown, so it stays a dash — the guard against fabricating an
|
|
352
|
+
// outage the badge denies. currentPrimary is the same was-up signal the badge
|
|
353
|
+
// gates its down verdict on, so the cell can never read 0 beside a green badge.
|
|
354
|
+
const ready =
|
|
355
|
+
typeof status.readyInstances === 'number'
|
|
356
|
+
? status.readyInstances
|
|
357
|
+
: status.currentPrimary
|
|
358
|
+
? 0
|
|
359
|
+
: undefined
|
|
360
|
+
return `${countOrDash(ready)}/${countOrDash(resource.spec?.instances)}`
|
|
245
361
|
}
|
|
246
362
|
|
|
247
363
|
export function getCNPGClusterPrimary(resource: any): string {
|
|
@@ -554,12 +670,21 @@ export function getCNPGClusterMonitoring(resource: any): {
|
|
|
554
670
|
}
|
|
555
671
|
}
|
|
556
672
|
|
|
673
|
+
// CNPG carries replica-cluster state in `spec.replica`. Presence is not the
|
|
674
|
+
// signal: promoting a replica to primary mutates the stanza's fields but leaves
|
|
675
|
+
// it in place. This mirrors the operator's own Cluster.IsReplica() — `enabled`
|
|
676
|
+
// decides when set; otherwise the cluster is a replica only while it is not the
|
|
677
|
+
// designated primary of the distributed topology ((self || name) != primary).
|
|
557
678
|
export function getCNPGClusterIsReplica(resource: any): boolean {
|
|
558
|
-
|
|
679
|
+
const replica = resource.spec?.replica
|
|
680
|
+
if (!replica) return false
|
|
681
|
+
if (replica.enabled != null) return replica.enabled === true
|
|
682
|
+
const self = replica.self || resource.metadata?.name
|
|
683
|
+
return self !== replica.primary
|
|
559
684
|
}
|
|
560
685
|
|
|
561
686
|
export function getCNPGClusterReplicaSource(resource: any): string {
|
|
562
|
-
const replica = resource.spec?.
|
|
687
|
+
const replica = resource.spec?.replica
|
|
563
688
|
if (!replica) return '-'
|
|
564
689
|
return replica.source || replica.primary || '-'
|
|
565
690
|
}
|