@skyhook-io/k8s-ui 1.17.0 → 1.17.1
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/README.md +41 -0
- package/package.json +3 -3
- package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
- package/src/components/charts/AreaChart.test.tsx +25 -0
- package/src/components/charts/AreaChart.tsx +141 -12
- package/src/components/charts/index.ts +1 -0
- package/src/components/checks/ChecksView.tsx +18 -1
- package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
- package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
- package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
- package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
- package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
- package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
- package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
- package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
- package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
- package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
- package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
- package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
- package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
- package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
- package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
- package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
- package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
- package/src/components/cnpg/backupRuns.test.ts +17 -0
- package/src/components/cnpg/backupRuns.ts +19 -0
- package/src/components/cnpg/connect.test.ts +124 -0
- package/src/components/cnpg/connect.ts +155 -0
- package/src/components/cnpg/databaseRole.test.ts +38 -0
- package/src/components/cnpg/databaseRole.ts +80 -0
- package/src/components/cnpg/ha.test.ts +446 -0
- package/src/components/cnpg/ha.ts +621 -0
- package/src/components/cnpg/index.ts +18 -0
- package/src/components/cnpg/logicalReplication.test.ts +121 -0
- package/src/components/cnpg/logicalReplication.ts +252 -0
- package/src/components/cnpg/pooler.test.ts +84 -0
- package/src/components/cnpg/pooler.ts +225 -0
- package/src/components/cnpg/primitives.tsx +21 -117
- package/src/components/cnpg/problems.ts +12 -0
- package/src/components/cnpg/relations.test.ts +63 -13
- package/src/components/cnpg/relations.ts +61 -52
- package/src/components/cnpg/schedule.ts +35 -0
- package/src/components/cnpg/workspace-disk.test.ts +97 -0
- package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
- package/src/components/cnpg/workspace-problems.test.ts +175 -0
- package/src/components/cnpg/workspace.test.ts +380 -9
- package/src/components/cnpg/workspace.ts +1137 -136
- package/src/components/dock/DockContext.test.tsx +32 -0
- package/src/components/dock/DockContext.tsx +18 -4
- package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
- package/src/components/dock/LocalTerminalTab.tsx +4 -3
- package/src/components/dock/TerminalTab.tsx +20 -8
- package/src/components/facts/certainty.tsx +36 -0
- package/src/components/facts/facts.test.tsx +24 -0
- package/src/components/facts/facts.tsx +62 -0
- package/src/components/facts/index.ts +6 -0
- package/src/components/facts/managed-by.test.tsx +17 -0
- package/src/components/facts/managed-by.tsx +36 -0
- package/src/components/facts/public-exports.test.tsx +35 -0
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
- package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
- package/src/components/gitops/insights/DriftPanel.tsx +62 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
- package/src/components/gitops/tree/layout.test.ts +111 -0
- package/src/components/gitops/tree/layout.ts +128 -0
- package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
- package/src/components/gitops/tree/tree-helpers.ts +7 -1
- package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
- package/src/components/issues/IssuesView.tsx +40 -5
- package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
- package/src/components/issues/ResourceIssuesSection.tsx +3 -0
- package/src/components/issues/index.ts +3 -1
- package/src/components/issues/issue-timing.ts +12 -1
- package/src/components/issues/issues.test.ts +49 -1
- package/src/components/issues/severity.ts +21 -0
- package/src/components/issues/types.ts +23 -0
- package/src/components/logs/LogCore.theme.test.tsx +81 -3
- package/src/components/logs/LogCore.tsx +25 -17
- package/src/components/logs/LogToolbarSelects.tsx +3 -1
- package/src/components/logs/LogsViewer.test.tsx +291 -0
- package/src/components/logs/LogsViewer.tsx +72 -11
- package/src/components/logs/StructuredLogLine.test.tsx +76 -0
- package/src/components/logs/StructuredLogLine.tsx +22 -30
- package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
- package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
- package/src/components/logs/useLogBuffer.test.ts +8 -0
- package/src/components/logs/useLogStream.ts +17 -1
- package/src/components/problems/index.ts +1 -0
- package/src/components/problems/problems.test.tsx +63 -0
- package/src/components/problems/problems.tsx +165 -0
- package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
- package/src/components/resources/ActiveFilterBar.tsx +86 -0
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
- package/src/components/resources/ResourcesSidebar.tsx +92 -41
- package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
- package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
- package/src/components/resources/ResourcesView.tsx +302 -109
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
- package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
- package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
- package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
- package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
- package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
- package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
- package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
- package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
- package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
- package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
- package/src/components/resources/renderers/HPARenderer.tsx +56 -16
- package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
- package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
- package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
- package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
- package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
- package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
- package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
- package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
- package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
- package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
- package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
- package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
- package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
- package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
- package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
- package/src/components/resources/renderers/PodRenderer.tsx +41 -29
- package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
- package/src/components/resources/renderers/VPARenderer.tsx +17 -11
- package/src/components/resources/resource-utils-capi.ts +1 -1
- package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
- package/src/components/resources/resource-utils-cnpg.ts +51 -20
- package/src/components/resources/resource-utils-contour.test.ts +44 -0
- package/src/components/resources/resource-utils-contour.ts +16 -0
- package/src/components/resources/resource-utils-istio.ts +12 -8
- package/src/components/resources/resource-utils-knative.ts +18 -0
- package/src/components/resources/resource-utils.ts +82 -6
- package/src/components/resources/rules-column-width.test.ts +1 -1
- package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
- package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
- package/src/components/shared/ActionConfirmDialog.tsx +246 -0
- package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
- package/src/components/shared/CreateResourceDialog.tsx +126 -80
- package/src/components/shared/DetailShell.test.tsx +80 -0
- package/src/components/shared/DetailShell.tsx +85 -10
- package/src/components/shared/EditableYamlView.test.tsx +7 -0
- package/src/components/shared/EditableYamlView.tsx +1 -1
- package/src/components/shared/ResourceActionsBar.tsx +11 -4
- package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
- package/src/components/shared/index.ts +2 -0
- package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
- package/src/components/timeline/timeline-filters.ts +1 -0
- package/src/components/topology/K8sResourceNode.test.ts +18 -1
- package/src/components/topology/K8sResourceNode.tsx +44 -2
- package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
- package/src/components/topology/TopologyGraph.tsx +3 -71
- package/src/components/topology/TopologySearch.tsx +1 -0
- package/src/components/topology/internet-groups.test.ts +35 -0
- package/src/components/topology/internet-groups.ts +55 -0
- package/src/components/topology/layout.ts +1 -1
- package/src/components/ui/Badge.tsx +1 -0
- package/src/components/ui/FoldSection.tsx +68 -0
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
- package/src/components/ui/RefLink.tsx +20 -0
- package/src/components/ui/RowActionMenu.tsx +8 -3
- package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
- package/src/components/ui/drawer-components.test.tsx +110 -1
- package/src/components/ui/drawer-components.tsx +62 -9
- package/src/components/ui/index.ts +3 -1
- package/src/components/ui/monacoEditorFeatures.ts +77 -0
- package/src/components/ui/monacoRuntime.ts +14 -19
- package/src/components/ui/status-tone.test.ts +10 -1
- package/src/components/ui/status-tone.tsx +29 -0
- package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
- package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
- package/src/components/workload/WorkloadView.tsx +180 -64
- package/src/index.ts +14 -1
- package/src/monaco-deep.d.ts +19 -12
- package/src/theme/components.css +13 -0
- package/src/types/capacity.ts +11 -2
- package/src/types/core.ts +29 -2
- package/src/types/gitops-flux-status.test.ts +32 -1
- package/src/types/gitops-insights.ts +19 -2
- package/src/types/gitops-tree.ts +10 -0
- package/src/types/gitops.ts +26 -6
- package/src/utils/api-resources.ts +7 -0
- package/src/utils/application-history.ts +1 -0
- package/src/utils/cnpg-backup.test.ts +26 -0
- package/src/utils/cnpg-backup.ts +70 -0
- package/src/utils/format.test.ts +9 -0
- package/src/utils/format.ts +4 -0
- package/src/utils/gitops-owner.ts +3 -2
- package/src/utils/grant.test.ts +19 -0
- package/src/utils/grant.ts +35 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/k8s-errors.ts +7 -7
- package/src/utils/log-format.test.ts +41 -0
- package/src/utils/log-format.ts +71 -15
- package/src/utils/log-level.ts +12 -8
- package/src/utils/navigation.test.ts +57 -0
- package/src/utils/navigation.ts +86 -8
- package/src/utils/object-reference-navigation.test.ts +43 -0
- package/src/utils/resource-hierarchy.test.ts +37 -0
- package/src/utils/resource-hierarchy.ts +15 -7
- package/src/utils/resource-icons.ts +2 -0
- package/src/utils/skeleton-yaml.test.ts +21 -0
- package/src/utils/skeleton-yaml.ts +16 -0
- package/src/utils/url-path.test.ts +35 -0
- package/src/utils/url-path.ts +21 -0
- package/src/utils/validators.test.ts +4 -13
- package/src/utils/validators.ts +5 -19
- package/src/utils/view-transition.test.ts +35 -1
- package/src/utils/view-transition.ts +16 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { normalizeURLForComparison } from '../../utils/url-path'
|
|
2
|
+
import { cnpgBackupDeclaration, cnpgBackupDestinationBlocker, cnpgBackupBlockerText } from '../../utils/cnpg-backup'
|
|
1
3
|
// Pure relationship lookups between CloudNativePG objects in the workspace
|
|
2
4
|
// payload. Each helper answers only from what the objects record; a relation
|
|
3
5
|
// that cannot be established returns null or an empty list, never a guess.
|
|
4
6
|
|
|
5
|
-
import type { BadgeSeverity } from '../ui/Badge'
|
|
6
|
-
import type { HealthLevel } from '../resources/resource-utils'
|
|
7
7
|
import {
|
|
8
8
|
CNPG_BARMAN_PLUGIN_NAME,
|
|
9
9
|
CNPG_GROUP,
|
|
@@ -12,15 +12,8 @@ import {
|
|
|
12
12
|
isApiGroup,
|
|
13
13
|
type CNPGObjectStoreRecoveryWindow,
|
|
14
14
|
} from '../resources/resource-utils-cnpg'
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
coverageReadable,
|
|
18
|
-
type CNPGFact,
|
|
19
|
-
type CNPGProblem,
|
|
20
|
-
type CNPGWorkspaceIssue,
|
|
21
|
-
type CNPGWorkspaceKey,
|
|
22
|
-
type CNPGWorkspaceResponse,
|
|
23
|
-
} from './workspace'
|
|
15
|
+
import { cnpgIssueCategory, cnpgIssueOrigin, cnpgIssueText, cnpgCoverageGap, coverageReadable, type CNPGProblem, type CNPGWorkspaceIssue, type CNPGWorkspaceKey, type CNPGWorkspaceResponse } from './workspace'
|
|
16
|
+
import { type Fact } from '../facts'
|
|
24
17
|
|
|
25
18
|
export interface CNPGObjectRef {
|
|
26
19
|
kind: string
|
|
@@ -58,21 +51,6 @@ export function refOf(obj: any, kind: string, group: string = CNPG_GROUP): CNPGO
|
|
|
58
51
|
return { kind, group, namespace: nsOf(obj), name: nameOf(obj) }
|
|
59
52
|
}
|
|
60
53
|
|
|
61
|
-
export function healthSeverity(level: HealthLevel): BadgeSeverity {
|
|
62
|
-
switch (level) {
|
|
63
|
-
case 'healthy':
|
|
64
|
-
return 'success'
|
|
65
|
-
case 'unhealthy':
|
|
66
|
-
return 'error'
|
|
67
|
-
case 'alert':
|
|
68
|
-
return 'alert'
|
|
69
|
-
case 'degraded':
|
|
70
|
-
return 'warning'
|
|
71
|
-
default:
|
|
72
|
-
return 'neutral'
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
54
|
// ---------------------------------------------------------------------------
|
|
77
55
|
// Workspace access
|
|
78
56
|
// ---------------------------------------------------------------------------
|
|
@@ -94,17 +72,7 @@ export function relationUnavailable(
|
|
|
94
72
|
if (!ws) return `${what} could not be read`
|
|
95
73
|
const cov = ws.coverage?.[key] ?? { state: 'notInstalled' as const }
|
|
96
74
|
if (coverageReadable(cov, namespace)) return null
|
|
97
|
-
|
|
98
|
-
case 'denied':
|
|
99
|
-
case 'partial':
|
|
100
|
-
return `No access to ${what}`
|
|
101
|
-
case 'syncing':
|
|
102
|
-
return 'Loading…'
|
|
103
|
-
case 'error':
|
|
104
|
-
return `Could not read ${what}`
|
|
105
|
-
default:
|
|
106
|
-
return `${what} are not installed`
|
|
107
|
-
}
|
|
75
|
+
return cnpgCoverageGap(cov, what, namespace, `${what} are not installed`)
|
|
108
76
|
}
|
|
109
77
|
|
|
110
78
|
export function clustersIn(ws: CNPGWorkspaceResponse | null | undefined): any[] {
|
|
@@ -115,7 +83,8 @@ export function clustersIn(ws: CNPGWorkspaceResponse | null | undefined): any[]
|
|
|
115
83
|
export function targetCluster(obj: any, clusters: any[]): any | null {
|
|
116
84
|
const name = specCluster(obj)
|
|
117
85
|
if (!name) return null
|
|
118
|
-
|
|
86
|
+
const cluster = clusters.find((c) => isCNPGKind(c, 'Cluster') && nsOf(c) === nsOf(obj) && nameOf(c) === name) ?? null
|
|
87
|
+
return isCNPGKind(obj, 'Backup') && !cnpgBackupMatchesCluster(obj, cluster) ? null : cluster
|
|
119
88
|
}
|
|
120
89
|
|
|
121
90
|
// ---------------------------------------------------------------------------
|
|
@@ -140,10 +109,10 @@ export function problemsForObject(issues: CNPGWorkspaceIssue[] | undefined, ref:
|
|
|
140
109
|
id: `${issue.id}:${issue.kind}/${issue.name}`,
|
|
141
110
|
severity: issue.severity,
|
|
142
111
|
category: cnpgIssueCategory(issue),
|
|
143
|
-
|
|
144
|
-
detail: issue.cause || undefined,
|
|
112
|
+
...cnpgIssueText(issue),
|
|
145
113
|
subject: { kind: issue.kind, group: issue.group ?? '', namespace: issue.namespace ?? '', name: issue.name },
|
|
146
114
|
source: 'issue',
|
|
115
|
+
origin: cnpgIssueOrigin(issue),
|
|
147
116
|
}))
|
|
148
117
|
}
|
|
149
118
|
|
|
@@ -195,20 +164,50 @@ export function backupTime(backup: any): number {
|
|
|
195
164
|
return parseTime(backup?.status?.startedAt) || parseTime(backup?.metadata?.creationTimestamp)
|
|
196
165
|
}
|
|
197
166
|
|
|
167
|
+
export function cnpgBackupMatchesCluster(backup: any, cluster: any): boolean {
|
|
168
|
+
if (!cluster || backup?.spec?.cluster?.name !== cluster.metadata?.name || backup.metadata?.namespace !== cluster.metadata?.namespace) return false
|
|
169
|
+
const owner = backup.metadata?.ownerReferences?.find((ref: any) => ref.kind === 'Cluster' && isApiGroup(ref.apiVersion, CNPG_GROUP))
|
|
170
|
+
const recordedUID = backup.status?.pluginMetadata?.clusterUID || owner?.uid
|
|
171
|
+
if (recordedUID && recordedUID !== cluster.metadata?.uid) return false
|
|
172
|
+
const began = Date.parse(backup.status?.startedAt ?? backup.metadata?.creationTimestamp ?? '')
|
|
173
|
+
const created = Date.parse(cluster.metadata?.creationTimestamp ?? '')
|
|
174
|
+
return !(Number.isFinite(began) && Number.isFinite(created) && began < created)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type CNPGArchiveSource =
|
|
178
|
+
| { kind: 'objectStore'; objectStore: string; serverName: string }
|
|
179
|
+
| { kind: 'inTree'; barmanObjectStore: Record<string, unknown>; serverName: string }
|
|
180
|
+
|
|
181
|
+
export function cnpgArchiveMatchesCluster(cluster: any, source: CNPGArchiveSource): boolean {
|
|
182
|
+
if (source.kind === 'objectStore') {
|
|
183
|
+
const plugin = getCNPGClusterBarmanPlugin(cluster)
|
|
184
|
+
return plugin?.barmanObjectName === source.objectStore &&
|
|
185
|
+
(plugin?.serverName || cluster.metadata?.name) === source.serverName
|
|
186
|
+
}
|
|
187
|
+
const archive = cluster.spec?.backup?.barmanObjectStore
|
|
188
|
+
const path = source.barmanObjectStore.destinationPath
|
|
189
|
+
const endpoint = source.barmanObjectStore.endpointURL
|
|
190
|
+
const destination = typeof path === 'string' ? normalizeURLForComparison(path) : null
|
|
191
|
+
const endpointKey = normalizeURLForComparison(typeof endpoint === 'string' ? endpoint : '')
|
|
192
|
+
return !!archive?.destinationPath && typeof path === 'string' && !!path &&
|
|
193
|
+
destination !== null && endpointKey !== null &&
|
|
194
|
+
(archive.serverName || cluster.metadata?.name) === source.serverName &&
|
|
195
|
+
normalizeURLForComparison(archive.destinationPath) === destination &&
|
|
196
|
+
normalizeURLForComparison(archive.endpointURL || '') === endpointKey
|
|
197
|
+
}
|
|
198
|
+
|
|
198
199
|
/**
|
|
199
|
-
*
|
|
200
|
-
* plugin
|
|
201
|
-
*
|
|
202
|
-
* there is marked inferred.
|
|
200
|
+
* Barman-cloud ignores Backup and ScheduledBackup parameters. The current
|
|
201
|
+
* Cluster plugin chooses the ObjectStore; it may have changed since a Backup
|
|
202
|
+
* ran, so the historical destination remains inferred.
|
|
203
203
|
*/
|
|
204
204
|
export function objectStoreForBackup(backup: any, clusters: any[]): { name: string; inferred: boolean } | null {
|
|
205
205
|
const method = backup?.status?.method || backup?.spec?.method
|
|
206
206
|
if (method !== 'plugin') return null
|
|
207
207
|
const cfg = backup?.spec?.pluginConfiguration
|
|
208
208
|
if (cfg?.name !== CNPG_BARMAN_PLUGIN_NAME) return null
|
|
209
|
-
const own = cfg?.parameters?.barmanObjectName
|
|
210
|
-
if (typeof own === 'string' && own) return { name: own, inferred: false }
|
|
211
209
|
const cluster = targetCluster(backup, clusters)
|
|
210
|
+
if (backup.kind === 'Backup' && !cnpgBackupMatchesCluster(backup, cluster)) return null
|
|
212
211
|
const current = cluster ? getCNPGClusterBarmanPlugin(cluster)?.barmanObjectName : undefined
|
|
213
212
|
return current ? { name: current, inferred: true } : null
|
|
214
213
|
}
|
|
@@ -229,6 +228,14 @@ export function backupDestination(backup: any, clusters: any[]): CNPGBackupDesti
|
|
|
229
228
|
return { type: 'unknown' }
|
|
230
229
|
}
|
|
231
230
|
|
|
231
|
+
/** Whether the Cluster declares a destination for this schedule's method; unread Clusters stay unknown. */
|
|
232
|
+
export function cnpgScheduleDestinationBlocker(schedule: any, clusters: any[]): string | null {
|
|
233
|
+
const cluster = targetCluster(schedule, clusters)
|
|
234
|
+
if (!cluster) return null
|
|
235
|
+
const blocker = cnpgBackupDestinationBlocker(cnpgBackupDeclaration(cluster), schedule.spec?.method || 'barmanObjectStore', schedule.spec?.pluginConfiguration?.name)
|
|
236
|
+
return blocker ? cnpgBackupBlockerText(blocker) : null
|
|
237
|
+
}
|
|
238
|
+
|
|
232
239
|
// ---------------------------------------------------------------------------
|
|
233
240
|
// ObjectStore
|
|
234
241
|
// ---------------------------------------------------------------------------
|
|
@@ -256,16 +263,16 @@ export function usersOfObjectStore(store: any, clusters: any[]): CNPGObjectStore
|
|
|
256
263
|
export interface CNPGObjectStoreEvidence {
|
|
257
264
|
cluster: CNPGObjectRef
|
|
258
265
|
serverName: string
|
|
259
|
-
archiving:
|
|
266
|
+
archiving: Fact
|
|
260
267
|
window: CNPGObjectStoreRecoveryWindow | null
|
|
261
268
|
}
|
|
262
269
|
|
|
263
270
|
export interface CNPGObjectStoreHealth {
|
|
264
|
-
summary:
|
|
271
|
+
summary: Fact
|
|
265
272
|
evidence: CNPGObjectStoreEvidence[]
|
|
266
273
|
}
|
|
267
274
|
|
|
268
|
-
function archivingFact(cluster: any):
|
|
275
|
+
function archivingFact(cluster: any): Fact {
|
|
269
276
|
const conds = cluster?.status?.conditions
|
|
270
277
|
const c = Array.isArray(conds) ? conds.find((x: any) => x?.type === 'ContinuousArchiving') : null
|
|
271
278
|
if (!c) return { text: 'WAL archiving not reported', tone: 'unknown' }
|
|
@@ -319,14 +326,17 @@ export function inferredObjectStoreHealth(store: any, users: CNPGObjectStoreUser
|
|
|
319
326
|
// Declarative objects
|
|
320
327
|
// ---------------------------------------------------------------------------
|
|
321
328
|
|
|
322
|
-
export function appliedFact(obj: any):
|
|
329
|
+
export function appliedFact(obj: any): Fact {
|
|
330
|
+
if (observedGenerationFact(obj).tone === 'degraded') {
|
|
331
|
+
return { text: 'Pending · awaiting the operator for the current spec', tone: 'unknown' }
|
|
332
|
+
}
|
|
323
333
|
const applied = obj?.status?.applied
|
|
324
334
|
if (applied === true) return { text: 'Applied', tone: 'healthy' }
|
|
325
335
|
if (applied === false) return { text: 'Not applied', tone: 'unhealthy' }
|
|
326
336
|
return { text: 'Pending · the operator has not reported a result yet', tone: 'unknown' }
|
|
327
337
|
}
|
|
328
338
|
|
|
329
|
-
export function observedGenerationFact(obj: any):
|
|
339
|
+
export function observedGenerationFact(obj: any): Fact {
|
|
330
340
|
const observed = obj?.status?.observedGeneration
|
|
331
341
|
const generation = obj?.metadata?.generation
|
|
332
342
|
if (typeof observed !== 'number') return { text: 'Not reported', tone: 'unknown' }
|
|
@@ -351,7 +361,6 @@ export function missingManagedRole(obj: any, cluster: any | null): string | null
|
|
|
351
361
|
return names.includes(m[1]) ? null : m[1]
|
|
352
362
|
}
|
|
353
363
|
|
|
354
|
-
export { cnpgGitOpsSource as gitopsSourceOf } from './workspace'
|
|
355
364
|
|
|
356
365
|
/** Publications and Subscriptions on the same Cluster and PostgreSQL database. */
|
|
357
366
|
export function replicationForDatabase(
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server's reading of a ScheduledBackup schedule (see
|
|
3
|
+
* /api/cnpg/scheduledbackups/{ns}/{name}/schedule-preview): parsed as
|
|
4
|
+
* CloudNativePG parses it, with the next runs counted as the operator counts
|
|
5
|
+
* them. Times are RFC 3339 UTC.
|
|
6
|
+
*/
|
|
7
|
+
export interface CNPGSchedulePreview {
|
|
8
|
+
schedule: string
|
|
9
|
+
valid: boolean
|
|
10
|
+
error?: string
|
|
11
|
+
description?: string
|
|
12
|
+
nextRuns?: string[]
|
|
13
|
+
/** The first run is due already: the operator creates a backup as soon as it reconciles. */
|
|
14
|
+
runsImmediately?: boolean
|
|
15
|
+
basis: 'lastCheckTime' | 'now'
|
|
16
|
+
lastCheckTime?: string
|
|
17
|
+
suspended?: boolean
|
|
18
|
+
clock?: { zone: string; declared: boolean; source: string }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function formatCNPGRunTime(iso: string): { utc: string; local: string } {
|
|
22
|
+
const d = new Date(iso)
|
|
23
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
24
|
+
const utc = `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`
|
|
25
|
+
return { utc, local: d.toLocaleString() }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One line for the runs list: why the first run is when it is. */
|
|
29
|
+
export function cnpgScheduleBasisNote(p: CNPGSchedulePreview): string {
|
|
30
|
+
const clock = p.clock?.source ?? 'Operator clock is not established; upcoming times assume UTC.'
|
|
31
|
+
if (p.suspended) return `Suspended: nothing runs until it is resumed. ${clock}`
|
|
32
|
+
const basis = p.basis === 'lastCheckTime' ? "Counted from the operator's last check (status.lastCheckTime)." : 'Counted from now; the operator starts counting at its first check.'
|
|
33
|
+
const due = p.runsImmediately ? ` ${p.clock?.declared ? 'A run is due on the declared clock.' : 'A run is due in this UTC estimate.'} The operator takes at most one catch-up backup.` : ''
|
|
34
|
+
return `${basis} ${clock}${due}`
|
|
35
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { applyCNPGDisk, buildCNPGFleet, cnpgDiskFact, CNPG_WORKSPACE_KEYS, type CNPGDiskReading, type CNPGWorkspaceResponse } from './workspace'
|
|
3
|
+
|
|
4
|
+
function cluster(name: string): any {
|
|
5
|
+
return {
|
|
6
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
7
|
+
kind: 'Cluster',
|
|
8
|
+
metadata: { name, namespace: 'db' },
|
|
9
|
+
spec: { instances: 1 },
|
|
10
|
+
status: { phase: 'Cluster in healthy state', readyInstances: 1, currentPrimary: `${name}-1` },
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fleetOf(...names: string[]) {
|
|
15
|
+
const coverage: CNPGWorkspaceResponse['coverage'] = {}
|
|
16
|
+
for (const k of CNPG_WORKSPACE_KEYS) coverage[k] = { state: 'full' }
|
|
17
|
+
return buildCNPGFleet({
|
|
18
|
+
installed: true,
|
|
19
|
+
context: 'test',
|
|
20
|
+
namespaces: null,
|
|
21
|
+
coverage,
|
|
22
|
+
objects: { clusters: names.map(cluster) },
|
|
23
|
+
issues: [],
|
|
24
|
+
audit: [],
|
|
25
|
+
backupsOmitted: 0,
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function reading(name: string, over: Partial<CNPGDiskReading> = {}): CNPGDiskReading {
|
|
30
|
+
return { namespace: 'db', name, state: 'ok', claims: 1, measured: 1, ...over }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const max = (ratio: number) => ({ claim: 'pg-1', instance: 'pg-1', role: 'PG_DATA', usedBytes: ratio * 10 * 1024 ** 3, capacityBytes: 10 * 1024 ** 3, ratio })
|
|
34
|
+
|
|
35
|
+
describe('cnpgDiskFact', () => {
|
|
36
|
+
it('never reads an unmeasured cluster as zero or healthy', () => {
|
|
37
|
+
for (const state of ['noSeries', 'noPrometheus', 'denied', 'error', 'notRead']) {
|
|
38
|
+
const f = cnpgDiskFact(reading('pg', { state, measured: 0 }))
|
|
39
|
+
expect(f.tone).toBe('unknown')
|
|
40
|
+
expect(f.text).not.toMatch(/\d/)
|
|
41
|
+
}
|
|
42
|
+
expect(cnpgDiskFact(undefined).tone).toBe('unknown')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('names the fullest volume and its source, and labels partial coverage', () => {
|
|
46
|
+
const f = cnpgDiskFact(reading('pg', { state: 'partial', claims: 2, measured: 1, max: max(0.5) }))
|
|
47
|
+
expect(f.text).toBe('50% used')
|
|
48
|
+
expect(f.tone).toBe('healthy')
|
|
49
|
+
expect(f.source).toContain('data volume of pg-1')
|
|
50
|
+
expect(f.source).toContain('kubelet volume stats')
|
|
51
|
+
expect(f.source).toContain('1 of 2 volumes measured')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('names the missing grant when denied', () => {
|
|
55
|
+
expect(cnpgDiskFact(reading('pg', { state: 'denied', grant: { verb: 'list', resource: 'persistentvolumeclaims', namespace: 'db' }, measured: 0 })).source).toBe('Needs list persistentvolumeclaims in namespace db')
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('applyCNPGDisk', () => {
|
|
60
|
+
it('says when the volume stats were matched to the cluster by claim name only', () => {
|
|
61
|
+
const note = "Matched by namespace and claim names. Radar couldn't confirm these volume stats belong to this exact cluster (no cluster label it could check)"
|
|
62
|
+
const fleet = applyCNPGDisk(fleetOf('pg-a'), [reading('pg-a', { max: max(0.95), isolation: { mode: 'unverified', note } })])
|
|
63
|
+
const p = fleet.rows[0].problems[0]
|
|
64
|
+
expect(p).toMatchObject({ measuredBy: 'kubelet, matched by claim name', unverifiedMatch: true })
|
|
65
|
+
expect(p.detail).toContain(note)
|
|
66
|
+
expect(fleet.rows[0].disk?.source).toContain(note)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('puts low-disk clusters into Needs attention by severity', () => {
|
|
70
|
+
const fleet = applyCNPGDisk(fleetOf('pg-a', 'pg-b', 'pg-c'), [
|
|
71
|
+
reading('pg-a', { max: max(0.85) }),
|
|
72
|
+
reading('pg-b', { max: max(0.95) }),
|
|
73
|
+
reading('pg-c', { max: max(0.4) }),
|
|
74
|
+
])
|
|
75
|
+
expect(fleet.attentionCount).toBe(2)
|
|
76
|
+
const a = fleet.rows.find((r) => r.name === 'pg-a')!
|
|
77
|
+
const b = fleet.rows.find((r) => r.name === 'pg-b')!
|
|
78
|
+
const c = fleet.rows.find((r) => r.name === 'pg-c')!
|
|
79
|
+
expect(a.problems[0].severity).toBe('warning')
|
|
80
|
+
expect(b.problems[0].severity).toBe('critical')
|
|
81
|
+
expect(b.problems[0].source).toBe('measurement')
|
|
82
|
+
expect(b.problems[0].title).toBe('The data volume of pg-1 is 95% full')
|
|
83
|
+
expect(c.attention).toBe(false)
|
|
84
|
+
expect(fleet.categoryCounts.availability).toBe(2)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('raises nothing without a measurement', () => {
|
|
88
|
+
const fleet = applyCNPGDisk(fleetOf('pg-a'), [reading('pg-a', { state: 'noPrometheus', measured: 0, reason: 'Radar is not connected to Prometheus: x' })])
|
|
89
|
+
expect(fleet.attentionCount).toBe(0)
|
|
90
|
+
expect(fleet.rows[0].disk).toMatchObject({ text: 'No usage metrics', source: 'Prometheus not connected', detail: 'Radar is not connected to Prometheus: x' })
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('leaves the fleet as built when no reading was requested', () => {
|
|
94
|
+
const base = fleetOf('pg-a')
|
|
95
|
+
expect(applyCNPGDisk(base, undefined)).toBe(base)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { applyCNPGFleetMetrics, buildCNPGFleet, CNPG_WORKSPACE_KEYS, type CNPGFleetMetricsReading, type CNPGWorkspaceResponse } from './workspace'
|
|
3
|
+
|
|
4
|
+
function pod(cluster: string, n: number, role: 'primary' | 'replica'): any {
|
|
5
|
+
return {
|
|
6
|
+
metadata: { name: `${cluster}-${n}`, namespace: 'db', labels: { 'cnpg.io/cluster': cluster, 'cnpg.io/instanceRole': role } },
|
|
7
|
+
status: { conditions: [{ type: 'Ready', status: 'True' }] },
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fleet() {
|
|
12
|
+
const coverage: CNPGWorkspaceResponse['coverage'] = {}
|
|
13
|
+
for (const k of CNPG_WORKSPACE_KEYS) coverage[k] = { state: 'full' }
|
|
14
|
+
const cluster = (name: string, instances: number) => ({
|
|
15
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
16
|
+
kind: 'Cluster',
|
|
17
|
+
metadata: { name, namespace: 'db' },
|
|
18
|
+
spec: { instances },
|
|
19
|
+
status: { phase: 'Cluster in healthy state', readyInstances: instances, currentPrimary: `${name}-1` },
|
|
20
|
+
})
|
|
21
|
+
return buildCNPGFleet({
|
|
22
|
+
installed: true,
|
|
23
|
+
context: 'test',
|
|
24
|
+
namespaces: null,
|
|
25
|
+
coverage,
|
|
26
|
+
objects: {
|
|
27
|
+
clusters: [cluster('ha', 2), cluster('solo', 1), cluster('dark', 2)],
|
|
28
|
+
pods: [pod('ha', 1, 'primary'), pod('ha', 2, 'replica'), pod('solo', 1, 'primary'), pod('dark', 1, 'primary'), pod('dark', 2, 'replica')],
|
|
29
|
+
},
|
|
30
|
+
issues: [],
|
|
31
|
+
audit: [],
|
|
32
|
+
backupsOmitted: 0,
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const reading = (name: string, lag: CNPGFleetMetricsReading['lag'], growth: CNPGFleetMetricsReading['growth'] = { state: 'noSeries' }): CNPGFleetMetricsReading => ({
|
|
37
|
+
namespace: 'db',
|
|
38
|
+
name,
|
|
39
|
+
lag,
|
|
40
|
+
growth,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const row = (f: ReturnType<typeof fleet>, name: string) => f.rows.find((r) => r.name === name)!
|
|
44
|
+
|
|
45
|
+
describe('applyCNPGFleetMetrics', () => {
|
|
46
|
+
it('replaces "lag unknown" with the measured standby lag and names its source', () => {
|
|
47
|
+
const f = applyCNPGFleetMetrics(fleet(), [reading('ha', { state: 'ok', seconds: 7.2, pod: 'ha-2' })], { source: 'prometheus', lagSource: 'Prometheus cnpg_pg_replication_lag' })
|
|
48
|
+
const r = row(f, 'ha')
|
|
49
|
+
expect(r.replication.text).toBe('1/1 Pods ready · lag 7.2 s')
|
|
50
|
+
expect(r.replication.tone).toBe('degraded')
|
|
51
|
+
expect(r.replication.source).toContain('ha-2')
|
|
52
|
+
expect(r.replication.source).toContain('cnpg_pg_replication_lag')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('says why lag is unknown instead of zero, and leaves non-replica facts alone', () => {
|
|
56
|
+
const f = applyCNPGFleetMetrics(fleet(), [reading('dark', { state: 'noSeries', reason: 'no exporter series' })], { source: 'prometheus' })
|
|
57
|
+
expect(row(f, 'dark').replication.text).toBe('1/1 Pods ready · lag unknown')
|
|
58
|
+
expect(row(f, 'dark').replication.source).toBeTruthy()
|
|
59
|
+
expect(row(f, 'dark').replication.tone).toBe('unknown')
|
|
60
|
+
expect(row(f, 'solo').replication.text).toBe('Single instance')
|
|
61
|
+
|
|
62
|
+
const denied = applyCNPGFleetMetrics(fleet(), [reading('ha', { state: 'denied', grant: { verb: 'get', resource: 'pods', namespace: 'db' } })], { source: 'prometheus' })
|
|
63
|
+
expect(row(denied, 'ha').replication.text).toBe('1/1 Pods ready · lag unknown')
|
|
64
|
+
expect(row(denied, 'ha').replication.source).toBe('Needs get pods in namespace db')
|
|
65
|
+
|
|
66
|
+
const none = applyCNPGFleetMetrics(fleet(), undefined, { source: 'none', reason: 'Radar is not connected to Prometheus' })
|
|
67
|
+
expect(row(none, 'ha').replication.text).toBe('1/1 Pods ready · lag unknown')
|
|
68
|
+
expect(row(none, 'ha').replication.source).toBe('Prometheus not connected')
|
|
69
|
+
expect(row(none, 'ha').replication.detail).toBe('Radar is not connected to Prometheus')
|
|
70
|
+
expect(row(none, 'ha').diskGrowth).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('keeps the fleet untouched when no reading was requested', () => {
|
|
74
|
+
const base = fleet()
|
|
75
|
+
expect(applyCNPGFleetMetrics(base, undefined, undefined)).toBe(base)
|
|
76
|
+
expect(row(base, 'ha').replication.text).toBe('1/1 Pods ready · lag unknown')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('reports disk growth only when measured', () => {
|
|
80
|
+
const f = applyCNPGFleetMetrics(
|
|
81
|
+
fleet(),
|
|
82
|
+
[reading('ha', { state: 'ok', seconds: 0.2, pod: 'ha-2' }, { state: 'ok', bytesPerHour: 1024 ** 3 / 24, claim: 'ha-1', instance: 'ha-1' })],
|
|
83
|
+
{ source: 'prometheus', growthSource: 'deriv' },
|
|
84
|
+
)
|
|
85
|
+
expect(row(f, 'ha').diskGrowth?.text).toBe('+1.0 GiB/day')
|
|
86
|
+
expect(row(f, 'ha').replication.tone).toBe('healthy')
|
|
87
|
+
expect(row(f, 'dark').diskGrowth).toBeUndefined()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('sustained replication lag', () => {
|
|
92
|
+
const src = { source: 'prometheus' as const, lagSource: 'Prometheus cnpg_pg_replication_lag' }
|
|
93
|
+
|
|
94
|
+
it('puts a cluster into Needs attention only when lag stayed high for the whole window', () => {
|
|
95
|
+
const f = applyCNPGFleetMetrics(
|
|
96
|
+
fleet(),
|
|
97
|
+
[
|
|
98
|
+
reading('ha', { state: 'ok', seconds: 95, pod: 'ha-2', sustainedSeconds: 40, sustainedPod: 'ha-2', sustainedWindow: '10m0s' }),
|
|
99
|
+
reading('dark', { state: 'ok', seconds: 120, pod: 'dark-2' }),
|
|
100
|
+
],
|
|
101
|
+
src,
|
|
102
|
+
)
|
|
103
|
+
const ha = row(f, 'ha')
|
|
104
|
+
expect(ha.attention).toBe(true)
|
|
105
|
+
expect(ha.problems[0]).toMatchObject({ severity: 'warning', category: 'availability', source: 'measurement' })
|
|
106
|
+
expect(ha.problems[0].title).toBe('ha-2 ≥ 40 s behind in every sample for 10 min')
|
|
107
|
+
expect(ha.problems[0]).toMatchObject({ measuredBy: 'Prometheus' })
|
|
108
|
+
expect(ha.problems[0].detail).not.toMatch(/cnpg_/)
|
|
109
|
+
expect(ha.problems[0].detail).toContain("If Prometheus missed some scrapes, those moments aren't included")
|
|
110
|
+
expect(ha.problems[0].shortTitle).toBe('ha-2: all samples ≥ 40 s behind (10 min)')
|
|
111
|
+
expect(ha.problems[0].detail).not.toContain('whole window')
|
|
112
|
+
expect(row(f, 'dark').attention).toBe(false)
|
|
113
|
+
expect(f.attentionCount).toBe(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('says when the series were matched to the cluster by Pod name only', () => {
|
|
117
|
+
const note = "Matched by namespace and Pod names. Radar couldn't confirm these series belong to this exact cluster (no cluster label it could check)"
|
|
118
|
+
const f = applyCNPGFleetMetrics(
|
|
119
|
+
fleet(),
|
|
120
|
+
[reading('ha', { state: 'ok', seconds: 95, pod: 'ha-2', sustainedSeconds: 40, sustainedPod: 'ha-2', sustainedWindow: '10m0s', isolation: { mode: 'unverified', note } })],
|
|
121
|
+
src,
|
|
122
|
+
)
|
|
123
|
+
const p = row(f, 'ha').problems[0]
|
|
124
|
+
expect(p.measuredBy).toBe('Prometheus, matched by Pod name')
|
|
125
|
+
expect(p.unverifiedMatch).toBe(true)
|
|
126
|
+
expect(p.detail).toContain(note)
|
|
127
|
+
const verified = applyCNPGFleetMetrics(
|
|
128
|
+
fleet(),
|
|
129
|
+
[reading('ha', { state: 'ok', seconds: 95, pod: 'ha-2', sustainedSeconds: 40, sustainedPod: 'ha-2', sustainedWindow: '10m0s', isolation: { mode: 'verified', note: 'x' } })],
|
|
130
|
+
src,
|
|
131
|
+
)
|
|
132
|
+
expect(row(verified, 'ha').problems[0].measuredBy).toBe('Prometheus')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('escalates to critical past five minutes and ignores a floor under 30 s', () => {
|
|
136
|
+
const f = applyCNPGFleetMetrics(
|
|
137
|
+
fleet(),
|
|
138
|
+
[
|
|
139
|
+
reading('ha', { state: 'ok', seconds: 400, pod: 'ha-2', sustainedSeconds: 360, sustainedPod: 'ha-2', sustainedWindow: '10m0s' }),
|
|
140
|
+
reading('dark', { state: 'ok', seconds: 20, pod: 'dark-2', sustainedSeconds: 12, sustainedPod: 'dark-2', sustainedWindow: '10m0s' }),
|
|
141
|
+
],
|
|
142
|
+
src,
|
|
143
|
+
)
|
|
144
|
+
expect(row(f, 'ha').problems[0]).toMatchObject({ severity: 'critical', title: 'ha-2 ≥ 6 min behind in every sample for 10 min' })
|
|
145
|
+
expect(row(f, 'dark').problems).toHaveLength(0)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('standbys that receive nothing, and the WAL slots hold', () => {
|
|
150
|
+
const src = { source: 'prometheus' as const, lagSource: 'Prometheus cnpg_pg_replication_lag' }
|
|
151
|
+
it('a standby whose WAL receiver is down is a problem, never "lag 0 s"', () => {
|
|
152
|
+
const f = applyCNPGFleetMetrics(
|
|
153
|
+
fleet(),
|
|
154
|
+
[reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', standbys: 1, receiving: 0, receiverDown: ['ha-2'], receiverDownSustained: ['ha-2'], receiverDownWindow: '5m0s' })],
|
|
155
|
+
src,
|
|
156
|
+
)
|
|
157
|
+
const r = row(f, 'ha')
|
|
158
|
+
expect(r.replication.text).toBe('1/1 Pods ready · ha-2 not receiving WAL')
|
|
159
|
+
expect(r.replication.tone).toBe('unhealthy')
|
|
160
|
+
expect(r.attention).toBe(true)
|
|
161
|
+
const p = r.problems.find((x) => x.id === 'standby:db/ha:ha-2')!
|
|
162
|
+
expect(p).toMatchObject({ severity: 'critical', title: 'ha-2 is not receiving WAL from the primary', subject: { kind: 'Pod', name: 'ha-2' }, measuredBy: 'Prometheus' })
|
|
163
|
+
expect(p.detail).toContain('Its WAL receiver was down in every sample Prometheus recorded over the last 5 minutes')
|
|
164
|
+
})
|
|
165
|
+
it('a receiver down for less than the window is shown, not raised: a restarting standby reconnects on its own', () => {
|
|
166
|
+
const f = applyCNPGFleetMetrics(fleet(), [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', standbys: 1, receiving: 0, receiverDown: ['ha-2'], receiverDownSustained: [], receiverDownWindow: '5m0s' })], src)
|
|
167
|
+
expect(row(f, 'ha').replication.text).toBe('1/1 Pods ready · ha-2 not receiving WAL')
|
|
168
|
+
expect(row(f, 'ha').problems.some((p) => p.id.startsWith('standby:'))).toBe(false)
|
|
169
|
+
})
|
|
170
|
+
it('lag alone does not establish streaming when the exporter reports no receiver state', () => {
|
|
171
|
+
const f = applyCNPGFleetMetrics(fleet(), [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', standbys: 1, receiverUnknown: true })], src)
|
|
172
|
+
expect(row(f, 'ha').replication).toMatchObject({ text: '1/1 Pods ready · lag 0 s · streaming unverified', tone: 'unknown' })
|
|
173
|
+
expect(row(f, 'ha').attention).toBe(false)
|
|
174
|
+
})
|
|
175
|
+
it('raises an inactive slot only from the primary, at 1 GiB or more, naming the standby it serves', () => {
|
|
176
|
+
const big = 4.9e9
|
|
177
|
+
const f = applyCNPGFleetMetrics(
|
|
178
|
+
fleet(),
|
|
179
|
+
[
|
|
180
|
+
{
|
|
181
|
+
...reading('ha', { state: 'ok', seconds: 0.1, pod: 'ha-2', standbys: 1, receiving: 1 }),
|
|
182
|
+
slots: {
|
|
183
|
+
state: 'ok',
|
|
184
|
+
inactive: [
|
|
185
|
+
{ slot: '_cnpg_ha_2', pod: 'ha-1', role: 'primary', bytes: big },
|
|
186
|
+
{ slot: '_cnpg_ha_2', pod: 'ha-2', role: 'standby', bytes: big },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{ ...reading('dark', { state: 'ok', seconds: 0.1, pod: 'dark-2', standbys: 1, receiving: 1 }), slots: { state: 'ok', inactive: [{ slot: '_cnpg_dark_9', pod: 'dark-2', role: 'standby', bytes: big }] } },
|
|
191
|
+
],
|
|
192
|
+
src,
|
|
193
|
+
)
|
|
194
|
+
const slots = row(f, 'ha').problems.filter((p) => p.id.startsWith('slot:'))
|
|
195
|
+
expect(slots).toHaveLength(1)
|
|
196
|
+
expect(slots[0].title).toBe('Inactive slot _cnpg_ha_2 holds 4.6 GiB of WAL on ha-1 for ha-2')
|
|
197
|
+
expect(row(f, 'dark').problems.some((p) => p.id.startsWith('slot:'))).toBe(false)
|
|
198
|
+
const small = applyCNPGFleetMetrics(fleet(), [{ ...reading('ha', { state: 'ok', seconds: 0.1, pod: 'ha-2' }), slots: { state: 'ok', inactive: [{ slot: '_cnpg_ha_2', pod: 'ha-1', role: 'primary', bytes: 5e8 }] } }], src)
|
|
199
|
+
expect(row(small, 'ha').problems.some((p) => p.id.startsWith('slot:'))).toBe(false)
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
describe('none receiving needs every expected standby accounted for', () => {
|
|
204
|
+
it('one standby down with another unreported is a warning, not "none receive"', () => {
|
|
205
|
+
const f = applyCNPGFleetMetrics(
|
|
206
|
+
fleet(),
|
|
207
|
+
[reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', standbys: 1, receiving: 0, receiverDown: ['ha-2'], receiverDownSustained: ['ha-2'], receiverDownWindow: '5m0s' })],
|
|
208
|
+
{ source: 'prometheus' },
|
|
209
|
+
)
|
|
210
|
+
expect(row(f, 'ha').problems.find((p) => p.id === 'standby:db/ha:ha-2')?.severity).toBe('critical')
|
|
211
|
+
const three = applyCNPGFleetMetrics(
|
|
212
|
+
fleet(),
|
|
213
|
+
[reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', standbys: 2, receiving: 0, receiverDown: ['ha-2'], receiverDownSustained: ['ha-2'], receiverDownWindow: '5m0s' })],
|
|
214
|
+
{ source: 'prometheus' },
|
|
215
|
+
)
|
|
216
|
+
// spec.instances 2 expects one standby; two reporting with one down is not "all down".
|
|
217
|
+
expect(row(three, 'ha').problems.find((p) => p.id === 'standby:db/ha:ha-2')?.severity).toBe('warning')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('fleet lag covers the standbys that report', () => {
|
|
222
|
+
it('says how many standbys the lag covers, and is not healthy while one is unreported', () => {
|
|
223
|
+
const three = fleet()
|
|
224
|
+
three.rows.find((r) => r.name === 'ha')!.instances.desired = 3
|
|
225
|
+
const f = applyCNPGFleetMetrics(three, [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', lagStandbys: 1, standbys: 1, receiving: 1 })], { source: 'prometheus' })
|
|
226
|
+
expect(row(f, 'ha').replication).toMatchObject({ text: expect.stringContaining('lag 0 s (1 of 2 standbys reporting)'), tone: 'unknown' })
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('counts the standbys whose lag was read, not those whose receiver was', () => {
|
|
230
|
+
const three = fleet()
|
|
231
|
+
three.rows.find((r) => r.name === 'ha')!.instances.desired = 3
|
|
232
|
+
const f = applyCNPGFleetMetrics(three, [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-2', lagStandbys: 1, standbys: 2, receiving: 2 })], { source: 'prometheus' })
|
|
233
|
+
expect(row(f, 'ha').replication).toMatchObject({ text: expect.stringContaining('(1 of 2 standbys reporting)'), tone: 'unknown' })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('expects every instance of a replica cluster to report, its designated primary included', () => {
|
|
237
|
+
const replica = fleet()
|
|
238
|
+
const r = replica.rows.find((x) => x.name === 'ha')!
|
|
239
|
+
r.instances.desired = 3
|
|
240
|
+
r.replicaCluster = { source: 'pg-origin' }
|
|
241
|
+
const f = applyCNPGFleetMetrics(replica, [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-1', lagStandbys: 2, standbys: 2, receiving: 2 })], { source: 'prometheus' })
|
|
242
|
+
expect(row(f, 'ha').replication).toMatchObject({ text: expect.stringContaining('(2 of 3 instances reporting)'), tone: 'unknown' })
|
|
243
|
+
const all = applyCNPGFleetMetrics(replica, [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-1', lagStandbys: 3, standbys: 3, receiving: 3 })], { source: 'prometheus' })
|
|
244
|
+
expect(row(all, 'ha').replication.text).not.toContain('reporting')
|
|
245
|
+
expect(row(all, 'ha').replication).toMatchObject({ text: expect.stringContaining('upstream feed not assessed'), tone: 'unknown' })
|
|
246
|
+
r.cluster = { ...r.cluster, status: { ...r.cluster?.status, currentPrimary: undefined } }
|
|
247
|
+
const unnamed = applyCNPGFleetMetrics(replica, [reading('ha', { state: 'ok', seconds: 0, pod: 'ha-1', lagStandbys: 3, standbys: 3, receiving: 3 })], { source: 'prometheus' })
|
|
248
|
+
expect(row(unnamed, 'ha').replication).toMatchObject({ text: expect.stringContaining('upstream feed not assessed'), tone: 'unknown' })
|
|
249
|
+
})
|
|
250
|
+
})
|