@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
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
// High-availability facts for one CloudNativePG Cluster, from
|
|
2
|
+
// GET /api/cnpg/clusters/{ns}/{name}/ha, plus pure derivations the Overview and
|
|
3
|
+
// the switchover dialog share. Each sub-read carries its own state: a denied or
|
|
4
|
+
// unavailable source is "unknown", never none or healthy.
|
|
5
|
+
|
|
6
|
+
import { formatAge, type HealthLevel } from '../resources/resource-utils'
|
|
7
|
+
import { formatGrant, type Grant } from '../../utils/grant'
|
|
8
|
+
import { CNPG_PROMETHEUS_NOT_CONNECTED, cnpgFormatLag, cnpgLagTone, cnpgReplicationTone, type CNPGFleetRow } from './workspace'
|
|
9
|
+
import { type Fact } from '../facts'
|
|
10
|
+
import { type FoldSummary } from '../ui/FoldSection'
|
|
11
|
+
import { worseTone } from '../ui/status-tone'
|
|
12
|
+
|
|
13
|
+
export type CNPGHASourceState = 'ok' | 'denied' | 'notFound' | 'notInstalled' | 'unavailable' | 'error'
|
|
14
|
+
|
|
15
|
+
export interface CNPGHASource {
|
|
16
|
+
state: CNPGHASourceState
|
|
17
|
+
reason?: string
|
|
18
|
+
grant?: Grant
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CNPGHAInstance {
|
|
22
|
+
pod: string
|
|
23
|
+
podUID: string
|
|
24
|
+
role: 'primary' | 'replica' | 'unknown'
|
|
25
|
+
ready: boolean
|
|
26
|
+
node?: string
|
|
27
|
+
zone?: string
|
|
28
|
+
qosClass?: string
|
|
29
|
+
image?: string
|
|
30
|
+
imageMatches?: boolean
|
|
31
|
+
podCreatedAt?: string
|
|
32
|
+
postgresStartedAt?: string
|
|
33
|
+
restartCount: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CNPGHAQuorum {
|
|
37
|
+
enabled: boolean
|
|
38
|
+
enabledBy?: 'spec' | 'annotation'
|
|
39
|
+
method?: string
|
|
40
|
+
number?: number
|
|
41
|
+
dataDurability?: string
|
|
42
|
+
object: CNPGHASource
|
|
43
|
+
status?: { method?: string; standbyNames: string[]; standbyNumber: number; primary?: string }
|
|
44
|
+
n?: number
|
|
45
|
+
w?: number
|
|
46
|
+
r?: number
|
|
47
|
+
promotable?: string[]
|
|
48
|
+
holds?: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CNPGHAPDB {
|
|
52
|
+
name: string
|
|
53
|
+
role: 'primary' | 'replicas' | 'other'
|
|
54
|
+
minAvailable?: string
|
|
55
|
+
maxUnavailable?: string
|
|
56
|
+
expectedPods: number
|
|
57
|
+
currentHealthy: number
|
|
58
|
+
desiredHealthy: number
|
|
59
|
+
disruptionsAllowed: number
|
|
60
|
+
observed: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CNPGHALease extends CNPGHASource {
|
|
64
|
+
namespace?: string
|
|
65
|
+
name?: string
|
|
66
|
+
holder?: string
|
|
67
|
+
renewTime?: string
|
|
68
|
+
durationSeconds?: number
|
|
69
|
+
expired?: boolean
|
|
70
|
+
controlledByCluster?: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CNPGHAJob {
|
|
74
|
+
name: string
|
|
75
|
+
role?: string
|
|
76
|
+
instance?: string
|
|
77
|
+
phase: 'running' | 'active' | 'succeeded' | 'failed' | 'pending'
|
|
78
|
+
reason?: string
|
|
79
|
+
startTime?: string
|
|
80
|
+
completionTime?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CNPGHACertificate {
|
|
84
|
+
secret: string
|
|
85
|
+
purposes?: string[]
|
|
86
|
+
raw: string
|
|
87
|
+
expiresAt?: string
|
|
88
|
+
renewal: 'operator' | 'user'
|
|
89
|
+
metadata?: CNPGHASource
|
|
90
|
+
certManager?: { certificate: string; issuer?: string; issuerKind?: string }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CNPGMaintenanceFacts {
|
|
94
|
+
declared: boolean
|
|
95
|
+
inProgress: boolean
|
|
96
|
+
reusePVC: boolean
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface CNPGClusterHA {
|
|
100
|
+
cluster: { namespace: string; name: string; uid: string }
|
|
101
|
+
sampledAt: string
|
|
102
|
+
desiredImage?: string
|
|
103
|
+
declaredInstances?: number
|
|
104
|
+
expectedInstances?: string[]
|
|
105
|
+
instances: CNPGHAInstance[]
|
|
106
|
+
pods: CNPGHASource
|
|
107
|
+
nodes: CNPGHASource
|
|
108
|
+
quorum: CNPGHAQuorum
|
|
109
|
+
pdbs: CNPGHASource & { enabled: boolean; items: CNPGHAPDB[] }
|
|
110
|
+
primaryLease: CNPGHALease
|
|
111
|
+
operatorLease: CNPGHALease
|
|
112
|
+
jobs: CNPGHASource & { items: CNPGHAJob[] }
|
|
113
|
+
rwEndpoints: CNPGHASource & { service: string; pods: string[] }
|
|
114
|
+
certificates: CNPGHACertificate[]
|
|
115
|
+
maintenance: CNPGMaintenanceFacts
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Per-instance facts read live from each instance manager (/pg/status). Only
|
|
120
|
+
* callers who can read them pass them in: they never come from the cached
|
|
121
|
+
* Cluster object.
|
|
122
|
+
*/
|
|
123
|
+
export interface CNPGInstanceLive {
|
|
124
|
+
pod: string
|
|
125
|
+
/** ok, or why the instance's own report is missing. */
|
|
126
|
+
state: string
|
|
127
|
+
pendingRestart?: boolean
|
|
128
|
+
pendingRestartForDecrease?: boolean
|
|
129
|
+
/** The report did not finish its reads, so pendingRestart is not established. */
|
|
130
|
+
incomplete?: boolean
|
|
131
|
+
/** Why the report is missing or incomplete, in words. */
|
|
132
|
+
reason?: string
|
|
133
|
+
roleDetail?: 'primary' | 'pgRewind' | 'replayPaused' | 'streaming' | 'fileBased'
|
|
134
|
+
instanceManagerVersion?: string
|
|
135
|
+
timeline?: number
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export const CNPG_ROLE_DETAIL_TEXT: Record<NonNullable<CNPGInstanceLive['roleDetail']>, string> = {
|
|
139
|
+
primary: 'primary',
|
|
140
|
+
pgRewind: 'pg_rewind running',
|
|
141
|
+
replayPaused: 'replay paused',
|
|
142
|
+
streaming: 'streaming standby',
|
|
143
|
+
fileBased: 'file-based standby (no WAL receiver)',
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function cnpgHASourceText(src: CNPGHASource | undefined, what: string): string {
|
|
147
|
+
if (!src) return `${what}: unknown`
|
|
148
|
+
switch (src.state) {
|
|
149
|
+
case 'ok':
|
|
150
|
+
return ''
|
|
151
|
+
case 'denied':
|
|
152
|
+
return `No access to ${what}${src.grant ? ` (needs ${formatGrant(src.grant)})` : ''}`
|
|
153
|
+
case 'notInstalled':
|
|
154
|
+
return src.reason ?? `${what}: not available in this CloudNativePG version`
|
|
155
|
+
case 'notFound':
|
|
156
|
+
return src.reason ?? `No ${what}`
|
|
157
|
+
case 'unavailable':
|
|
158
|
+
return src.reason ?? `${what}: unavailable`
|
|
159
|
+
default:
|
|
160
|
+
return src.reason ? `${what} could not be read: ${src.reason}` : `${what} could not be read`
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Failure domains
|
|
166
|
+
|
|
167
|
+
export interface CNPGZoneSpread {
|
|
168
|
+
/** false when Nodes (or Pods) could not be read: zones are unknown. */
|
|
169
|
+
known: boolean
|
|
170
|
+
zones: { zone: string; pods: string[] }[]
|
|
171
|
+
/** Instances whose Node carries no zone label. */
|
|
172
|
+
unlabelled: string[]
|
|
173
|
+
primaryZone?: string
|
|
174
|
+
/** true when every labelled instance shares one zone (and there is more than one instance). */
|
|
175
|
+
singleZone: boolean
|
|
176
|
+
nodes: { node: string; pods: string[] }[]
|
|
177
|
+
/** true when two or more instances share one Node. */
|
|
178
|
+
sharedNode: boolean
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function cnpgZoneSpread(ha: CNPGClusterHA | undefined): CNPGZoneSpread {
|
|
182
|
+
const empty: CNPGZoneSpread = { known: false, zones: [], unlabelled: [], singleZone: false, nodes: [], sharedNode: false }
|
|
183
|
+
if (!ha || ha.pods.state !== 'ok') return empty
|
|
184
|
+
const byNode = new Map<string, string[]>()
|
|
185
|
+
for (const i of ha.instances) {
|
|
186
|
+
if (!i.node) continue
|
|
187
|
+
byNode.set(i.node, [...(byNode.get(i.node) ?? []), i.pod])
|
|
188
|
+
}
|
|
189
|
+
const nodes = [...byNode.entries()].map(([node, pods]) => ({ node, pods })).sort((a, b) => a.node.localeCompare(b.node))
|
|
190
|
+
const sharedNode = nodes.some((n) => n.pods.length > 1)
|
|
191
|
+
if (ha.nodes.state !== 'ok') return { ...empty, nodes, sharedNode }
|
|
192
|
+
const byZone = new Map<string, string[]>()
|
|
193
|
+
const unlabelled: string[] = []
|
|
194
|
+
let primaryZone: string | undefined
|
|
195
|
+
for (const i of ha.instances) {
|
|
196
|
+
if (!i.zone) {
|
|
197
|
+
unlabelled.push(i.pod)
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
byZone.set(i.zone, [...(byZone.get(i.zone) ?? []), i.pod])
|
|
201
|
+
if (i.role === 'primary') primaryZone = i.zone
|
|
202
|
+
}
|
|
203
|
+
const zones = [...byZone.entries()].map(([zone, pods]) => ({ zone, pods })).sort((a, b) => a.zone.localeCompare(b.zone))
|
|
204
|
+
return {
|
|
205
|
+
known: true,
|
|
206
|
+
zones,
|
|
207
|
+
unlabelled,
|
|
208
|
+
primaryZone,
|
|
209
|
+
singleZone: zones.length === 1 && ha.instances.length > 1 && unlabelled.length === 0,
|
|
210
|
+
nodes,
|
|
211
|
+
sharedNode,
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Quorum, PDB, images, certificates
|
|
217
|
+
|
|
218
|
+
export function cnpgQuorumFact(q: CNPGHAQuorum | undefined): Fact {
|
|
219
|
+
if (!q) return { text: 'Unknown', tone: 'unknown' }
|
|
220
|
+
if (!q.enabled) {
|
|
221
|
+
if (q.number !== undefined || q.method) {
|
|
222
|
+
return { text: `Synchronous ${q.method ?? ''} ${q.number ?? ''}`.replace(/\s+/g, ' ').trim() + ' · quorum failover off', tone: 'neutral', source: 'Cluster spec.postgresql.synchronous' }
|
|
223
|
+
}
|
|
224
|
+
return { text: 'Off (asynchronous replication)', tone: 'neutral', source: 'Cluster spec' }
|
|
225
|
+
}
|
|
226
|
+
const unread = cnpgHASourceText(q.object, 'FailoverQuorum')
|
|
227
|
+
if (q.object.state !== 'ok') return { text: `Quorum failover on · ${unread}`, tone: 'unknown' }
|
|
228
|
+
if (q.n === undefined || q.w === undefined) {
|
|
229
|
+
return {
|
|
230
|
+
text: 'Quorum failover on · no synchronous configuration recorded: a failover would wait',
|
|
231
|
+
tone: 'degraded',
|
|
232
|
+
source: 'FailoverQuorum status (reset while PostgreSQL configuration changes)',
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const base = `W ${q.w} of N ${q.n} potentially synchronous`
|
|
236
|
+
if (q.r === undefined || q.holds === undefined) {
|
|
237
|
+
return { text: `${base} · promotable replicas unknown`, tone: 'unknown', source: 'FailoverQuorum status; Pods not readable' }
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
text: `${base} · R ${q.r} promotable · R + W ${q.holds ? '>' : '≤'} N`,
|
|
241
|
+
tone: q.holds ? 'healthy' : 'degraded',
|
|
242
|
+
source: 'FailoverQuorum status (the recorded configuration, not the operator’s decision); R from ready standby Pods',
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function cnpgPDBFact(pdbs: CNPGClusterHA['pdbs'] | undefined): Fact {
|
|
247
|
+
if (!pdbs) return { text: 'Unknown', tone: 'unknown' }
|
|
248
|
+
if (pdbs.state !== 'ok') return { text: cnpgHASourceText(pdbs, 'PodDisruptionBudgets'), tone: 'unknown' }
|
|
249
|
+
if (pdbs.items.length === 0) {
|
|
250
|
+
return pdbs.enabled
|
|
251
|
+
? { text: 'None found although spec.enablePDB is on', tone: 'degraded', source: 'PodDisruptionBudgets owned by the Cluster' }
|
|
252
|
+
: { text: 'Disabled (spec.enablePDB: false): node drains are not held back', tone: 'neutral', source: 'Cluster spec' }
|
|
253
|
+
}
|
|
254
|
+
const parts = pdbs.items.map((p) => `${p.role === 'primary' ? 'primary' : p.role === 'replicas' ? 'standbys' : p.name}: ${p.disruptionsAllowed} disruption${p.disruptionsAllowed === 1 ? '' : 's'} allowed (${p.currentHealthy}/${p.expectedPods} healthy)`)
|
|
255
|
+
const stale = pdbs.items.some((p) => !p.observed)
|
|
256
|
+
return {
|
|
257
|
+
text: parts.join(' · '),
|
|
258
|
+
tone: stale ? 'unknown' : 'neutral',
|
|
259
|
+
source: stale ? 'PodDisruptionBudget status (not yet updated for the latest spec)' : 'PodDisruptionBudget status',
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function cnpgImageDrift(ha: CNPGClusterHA | undefined): { known: boolean; drifted: CNPGHAInstance[] } {
|
|
264
|
+
if (!ha || ha.pods.state !== 'ok' || !ha.desiredImage || ha.instances.length === 0) return { known: false, drifted: [] }
|
|
265
|
+
return { known: ha.instances.every((i) => i.imageMatches !== undefined), drifted: ha.instances.filter((i) => i.imageMatches === false) }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface CNPGCertificateView extends CNPGHACertificate {
|
|
269
|
+
/** Whole days until expiry; negative when expired; undefined when the expiry did not parse. */
|
|
270
|
+
daysLeft?: number
|
|
271
|
+
tone: HealthLevel
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The same thresholds as the Issues engine: a certificate its owner renews is
|
|
276
|
+
* flagged from 30 days; the operator renews its own, so those only matter once
|
|
277
|
+
* renewal is overdue.
|
|
278
|
+
*/
|
|
279
|
+
export function cnpgCertificateViews(certs: CNPGHACertificate[] | undefined, now = Date.now()): CNPGCertificateView[] {
|
|
280
|
+
return (certs ?? []).map((c) => {
|
|
281
|
+
if (!c.expiresAt) return { ...c, tone: 'unknown' as HealthLevel }
|
|
282
|
+
const ms = Date.parse(c.expiresAt) - now
|
|
283
|
+
const daysLeft = Math.floor(ms / 86_400_000)
|
|
284
|
+
let tone: HealthLevel = 'healthy'
|
|
285
|
+
if (ms <= 0) tone = 'unhealthy'
|
|
286
|
+
else if (c.renewal === 'user' && ms < 7 * 86_400_000) tone = 'unhealthy'
|
|
287
|
+
else if (c.renewal === 'user' && ms < 30 * 86_400_000) tone = 'degraded'
|
|
288
|
+
else if (c.renewal === 'operator' && ms < 86_400_000) tone = 'unhealthy'
|
|
289
|
+
return { ...c, daysLeft, tone }
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* "HA and instances" in one line: what is wrong when something is, otherwise
|
|
295
|
+
* the readiness and placement facts that are known. Unknown facts never read
|
|
296
|
+
* as fine; they are left out of a calm summary rather than claimed.
|
|
297
|
+
*/
|
|
298
|
+
export function cnpgHASummary(
|
|
299
|
+
ha: CNPGClusterHA | undefined,
|
|
300
|
+
live: CNPGInstanceLive[] | undefined,
|
|
301
|
+
primaryConflict?: { status: string; labelled: string },
|
|
302
|
+
): FoldSummary {
|
|
303
|
+
if (!ha) return { text: 'Not read', attention: false }
|
|
304
|
+
const issues: string[] = []
|
|
305
|
+
const calm: string[] = []
|
|
306
|
+
if (ha.pods.state === 'ok') {
|
|
307
|
+
const ready = ha.instances.filter((i) => i.ready).length
|
|
308
|
+
const missing = [...new Set(ha.expectedInstances ?? [])].filter((name) => !ha.instances.some((i) => i.pod === name))
|
|
309
|
+
if (ha.declaredInstances !== undefined && ready < ha.declaredInstances) {
|
|
310
|
+
issues.push(`${ready} of ${ha.declaredInstances} declared instances ready${missing.length ? `; no instance Pod observed for ${missing.join(', ')}` : ''}`)
|
|
311
|
+
} else if (ha.declaredInstances !== undefined && ready > ha.declaredInstances) {
|
|
312
|
+
issues.push(`${ready} observed instances ready; ${ha.declaredInstances} instances declared`)
|
|
313
|
+
} else if (ha.instances.length === 0) issues.push('no instance Pods')
|
|
314
|
+
else if (ready < ha.instances.length) issues.push(`${ha.instances.length - ready} of ${ha.instances.length} instances not ready`)
|
|
315
|
+
else calm.push(ha.declaredInstances !== undefined ? `${ready} of ${ha.declaredInstances} declared instances ready` : `${ready}/${ha.instances.length} observed instances ready`)
|
|
316
|
+
}
|
|
317
|
+
if (primaryConflict) issues.push('primary labels disagree')
|
|
318
|
+
const spread = cnpgZoneSpread(ha)
|
|
319
|
+
if (spread.singleZone) issues.push('every instance in one zone')
|
|
320
|
+
if (spread.sharedNode) issues.push('instances share a Node')
|
|
321
|
+
if (spread.known && !spread.singleZone && spread.zones.length > 1) calm.push(`${spread.zones.length} zones`)
|
|
322
|
+
const pending = cnpgPendingRestart(live)
|
|
323
|
+
if (pending.pods.length > 0) issues.push(`restart pending on ${pending.pods.join(', ')}`)
|
|
324
|
+
const drift = cnpgImageDrift(ha)
|
|
325
|
+
if (drift.drifted.length > 0) issues.push(`${drift.drifted.length === 1 ? 'an instance Pod has' : `${drift.drifted.length} instance Pods have`} a different image`)
|
|
326
|
+
else if (drift.known && ha.instances.length > 0 && ha.instances.every((i) => i.imageMatches === true)) calm.push('images match')
|
|
327
|
+
const quorum = cnpgQuorumFact(ha.quorum)
|
|
328
|
+
if (quorum.tone === 'degraded' || quorum.tone === 'unhealthy') issues.push('failover quorum does not hold')
|
|
329
|
+
const pdb = cnpgPDBFact(ha.pdbs)
|
|
330
|
+
if (pdb.tone === 'degraded' || pdb.tone === 'unhealthy') issues.push('disruption budgets missing')
|
|
331
|
+
for (const [lease, what] of [[ha.primaryLease, 'primary lease'], [ha.operatorLease, 'operator lease']] as const) {
|
|
332
|
+
if (lease.state === 'ok' && lease.expired) issues.push(`${what} expired`)
|
|
333
|
+
}
|
|
334
|
+
if (ha.primaryLease.state === 'ok' && ha.primaryLease.controlledByCluster === false) issues.push('primary lease not owned by this Cluster')
|
|
335
|
+
const failedJobs = ha.jobs.state === 'ok' ? ha.jobs.items.filter((j) => j.phase === 'failed').length : 0
|
|
336
|
+
if (failedJobs > 0) issues.push(`${failedJobs} failed ${failedJobs === 1 ? 'Job' : 'Jobs'}`)
|
|
337
|
+
// A fact that could not be read is named, never folded into a calm line.
|
|
338
|
+
const unread: string[] = []
|
|
339
|
+
const check = (src: CNPGHASource | undefined, label: string) => {
|
|
340
|
+
if (src && (src.state === 'denied' || src.state === 'unavailable' || src.state === 'error')) unread.push(label)
|
|
341
|
+
}
|
|
342
|
+
check(ha.pods, 'Pods')
|
|
343
|
+
check(ha.nodes, 'zones')
|
|
344
|
+
if (ha.quorum.enabled) check(ha.quorum.object, 'quorum')
|
|
345
|
+
check(ha.pdbs, 'disruption budgets')
|
|
346
|
+
check(ha.primaryLease, 'primary lease')
|
|
347
|
+
check(ha.operatorLease, 'operator lease')
|
|
348
|
+
check(ha.jobs, 'Jobs')
|
|
349
|
+
if (!pending.known && !(ha.pods.state === 'ok' && ha.instances.length === 0)) unread.push('pending restarts')
|
|
350
|
+
const notRead = unread.length > 0 ? `not read: ${unread.join(', ')}` : ''
|
|
351
|
+
if (issues.length > 0) return { text: [...issues, notRead].filter(Boolean).join(' · '), attention: true }
|
|
352
|
+
if (calm.length === 0) return { text: notRead ? notRead[0].toUpperCase() + notRead.slice(1) : 'Nothing reported out of line', attention: false }
|
|
353
|
+
return { text: [...calm, notRead].filter(Boolean).join(' · '), attention: false }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Certificates in one line: the nearest expiry and who renews them. */
|
|
357
|
+
export function cnpgCertificatesSummary(certs: CNPGHACertificate[] | undefined, now = Date.now()): FoldSummary {
|
|
358
|
+
const views = cnpgCertificateViews(certs, now)
|
|
359
|
+
if (views.length === 0) return { text: 'No expiry reported by the operator', attention: false }
|
|
360
|
+
const dated = views.filter((c) => Number.isFinite(c.daysLeft)).sort((a, b) => (a.daysLeft ?? 0) - (b.daysLeft ?? 0))
|
|
361
|
+
const unreadable = views.length - dated.length
|
|
362
|
+
// An unreadable expiry could be past already, so it opens the section too.
|
|
363
|
+
const attention = unreadable > 0 || views.some((c) => c.tone === 'degraded' || c.tone === 'unhealthy')
|
|
364
|
+
const nearest = dated[0]
|
|
365
|
+
const when = [
|
|
366
|
+
!nearest ? '' : nearest.daysLeft! < 0 ? `${nearest.secret} expired` : `nearest reported expiry in ${nearest.daysLeft} d (${nearest.secret})`,
|
|
367
|
+
unreadable > 0 ? `${unreadable} expiry unreadable` : '',
|
|
368
|
+
]
|
|
369
|
+
.filter(Boolean)
|
|
370
|
+
.join(' · ')
|
|
371
|
+
const renewers = new Set(views.map((c) => c.renewal))
|
|
372
|
+
const who = renewers.size > 1 ? 'some renewed by you' : renewers.has('user') ? 'you renew them' : 'CloudNativePG renews them'
|
|
373
|
+
return { text: `${views.length} ${views.length === 1 ? 'certificate' : 'certificates'} · ${when} · ${who}`, attention }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function cnpgLiveRead(l: CNPGInstanceLive): boolean {
|
|
377
|
+
return (l.state === 'ok' || l.state === 'partial') && !l.incomplete
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
export function cnpgPendingRestart(live: CNPGInstanceLive[] | undefined): { known: boolean; pods: string[]; forDecrease: boolean } {
|
|
381
|
+
const read = (live ?? []).filter(cnpgLiveRead)
|
|
382
|
+
if (read.length === 0) return { known: false, pods: [], forDecrease: false }
|
|
383
|
+
const pending = read.filter((l) => l.pendingRestart)
|
|
384
|
+
return { known: read.length === (live ?? []).length, pods: pending.map((l) => l.pod), forDecrease: pending.some((l) => l.pendingRestartForDecrease) }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Why instance-manager facts are not established: the host's reason when
|
|
389
|
+
* there is no runtime read at all (`unavailable`, e.g. the missing grant),
|
|
390
|
+
* otherwise each instance that did not report and why.
|
|
391
|
+
*/
|
|
392
|
+
export function cnpgLiveGap(live: CNPGInstanceLive[] | undefined, unavailable?: string): string {
|
|
393
|
+
if (!live) return unavailable ?? 'needs each instance manager’s status (get pods/proxy)'
|
|
394
|
+
if (live.length === 0) return 'no instance was read'
|
|
395
|
+
const unread = live.filter((l) => !cnpgLiveRead(l))
|
|
396
|
+
if (unread.length === 0) return ''
|
|
397
|
+
return unread
|
|
398
|
+
.map((l) => {
|
|
399
|
+
const why = l.reason ?? (l.state === 'denied' ? 'no access' : l.state)
|
|
400
|
+
return l.incomplete ? `${l.pod} reported incompletely (${why})` : `${l.pod} did not report (${why})`
|
|
401
|
+
})
|
|
402
|
+
.join('; ')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
// Header dimensions
|
|
407
|
+
|
|
408
|
+
export type CNPGDimensionId = 'serving' | 'replication' | 'protection' | 'storage'
|
|
409
|
+
|
|
410
|
+
export interface CNPGDimension {
|
|
411
|
+
id: CNPGDimensionId
|
|
412
|
+
label: string
|
|
413
|
+
/** unknown = unassessed: its source is not available. */
|
|
414
|
+
tone: HealthLevel
|
|
415
|
+
text: string
|
|
416
|
+
source: string
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export interface CNPGReplicationLive {
|
|
420
|
+
/** Standbys the primary reports as streaming. */
|
|
421
|
+
streaming: number
|
|
422
|
+
/** Standby Pods the runtime read saw; the verdict compares against spec.instances − 1, not this. */
|
|
423
|
+
standbys: number
|
|
424
|
+
maxReplayLagSeconds?: number
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function cnpgDimensions({
|
|
428
|
+
row,
|
|
429
|
+
ha,
|
|
430
|
+
replication,
|
|
431
|
+
replicationGap,
|
|
432
|
+
storage,
|
|
433
|
+
}: {
|
|
434
|
+
row: CNPGFleetRow
|
|
435
|
+
ha?: CNPGClusterHA
|
|
436
|
+
/** From the primary's pg_stat_replication; absent when runtime data is not readable. */
|
|
437
|
+
replication?: CNPGReplicationLive
|
|
438
|
+
/** Why `replication` is absent (e.g. "needs get pods/proxy in db"). */
|
|
439
|
+
replicationGap?: string
|
|
440
|
+
/** Supplied by the host once storage is assessed; unassessed otherwise. */
|
|
441
|
+
storage?: CNPGDimension
|
|
442
|
+
}): CNPGDimension[] {
|
|
443
|
+
return [
|
|
444
|
+
servingDimension(row, ha),
|
|
445
|
+
replicationDimension(row, replication, replicationGap, ha),
|
|
446
|
+
storage ?? storageDimension(row),
|
|
447
|
+
protectionDimension(row),
|
|
448
|
+
]
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function storageDimension(row: CNPGFleetRow): CNPGDimension {
|
|
452
|
+
return withSlotRetention(row, volumeDimension(row))
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function volumeDimension(row: CNPGFleetRow): CNPGDimension {
|
|
456
|
+
const base = { id: 'storage' as const, label: 'Storage' }
|
|
457
|
+
const disk = row.disk
|
|
458
|
+
if (!disk) return { ...base, tone: 'unknown', text: 'Reading…', source: 'Reading volume usage' }
|
|
459
|
+
if (disk.tone === 'unknown') return { ...base, tone: 'unknown', text: disk.source === CNPG_PROMETHEUS_NOT_CONNECTED ? disk.text : [disk.text, disk.source].filter(Boolean).join(': '), source: disk.source === CNPG_PROMETHEUS_NOT_CONNECTED ? '' : disk.detail ?? '' }
|
|
460
|
+
return { ...base, tone: disk.tone, text: disk.text, source: disk.source ?? 'Fullest volume' }
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// WAL an inactive slot pins is a storage concern even while volume usage is
|
|
464
|
+
// unmeasured; the chip says so instead of reading "unassessed".
|
|
465
|
+
function withSlotRetention(row: CNPGFleetRow, dim: CNPGDimension): CNPGDimension {
|
|
466
|
+
const slot = row.problems.find((p) => p.reason === 'CNPGInactiveSlot')
|
|
467
|
+
if (!slot) return dim
|
|
468
|
+
const held = 'WAL held by an inactive slot'
|
|
469
|
+
return {
|
|
470
|
+
...dim,
|
|
471
|
+
tone: worseTone(dim.tone, 'degraded'),
|
|
472
|
+
text: dim.tone === 'unknown' ? held : `${dim.text} · ${held}`,
|
|
473
|
+
source: dim.tone === 'unknown' ? `${slot.title}. Volume usage: ${[dim.text, dim.source].filter(Boolean).join(' · ')}` : `${slot.title}. ${dim.source ?? ''}`.trim(),
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function servingDimension(row: CNPGFleetRow, ha?: CNPGClusterHA): CNPGDimension {
|
|
478
|
+
const base = { id: 'serving' as const, label: 'Serving' }
|
|
479
|
+
if (row.hibernated) return { ...base, tone: 'neutral', text: 'hibernated', source: 'cnpg.io/hibernation annotation' }
|
|
480
|
+
if (ha?.rwEndpoints.state === 'ok' && ha.rwEndpoints.pods.length === 0) return { ...base, tone: 'unhealthy', text: 'not serving: no ready read-write endpoint', source: `EndpointSlices of Service ${ha.rwEndpoints.service}` }
|
|
481
|
+
const primaryName = row.cluster?.status?.currentPrimary as string | undefined
|
|
482
|
+
const primary = row.pods.find((p) => p.name === primaryName)
|
|
483
|
+
if (!primaryName) return { ...base, tone: 'unknown', text: 'unassessed', source: 'No current primary reported' }
|
|
484
|
+
if (!primary || primary.ready === null) return { ...base, tone: 'unknown', text: 'unassessed', source: 'Instance Pods are not readable' }
|
|
485
|
+
if (!primary.ready) return { ...base, tone: 'unhealthy', text: 'primary not ready', source: `Pod ${primaryName} readiness` }
|
|
486
|
+
if (ha?.rwEndpoints.state === 'ok') {
|
|
487
|
+
if (!ha.rwEndpoints.pods.includes(primaryName)) {
|
|
488
|
+
return {
|
|
489
|
+
...base,
|
|
490
|
+
tone: 'unhealthy',
|
|
491
|
+
text: ha.rwEndpoints.pods.length === 0 ? 'no read-write endpoint' : 'read-write endpoint not on the primary',
|
|
492
|
+
source: `EndpointSlices of Service ${ha.rwEndpoints.service}`,
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return { ...base, tone: 'healthy', text: 'primary ready', source: `Pod ${primaryName} ready and behind Service ${ha.rwEndpoints.service}` }
|
|
496
|
+
}
|
|
497
|
+
return { ...base, tone: 'healthy', text: 'primary ready', source: `Pod ${primaryName} readiness (Service endpoints not readable)` }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// A standby measured far behind for the whole window outranks what the live
|
|
501
|
+
// read shows: the chip must not read calmer than the finding below it.
|
|
502
|
+
function replicationDimension(row: CNPGFleetRow, live?: CNPGReplicationLive, gap?: string, ha?: CNPGClusterHA): CNPGDimension {
|
|
503
|
+
let dim = withStandbyGaps(row, liveReplicationDimension(row, live, gap))
|
|
504
|
+
if (!row.hibernated && !row.replicaCluster && (row.instances.desired ?? 0) > 1) {
|
|
505
|
+
const primary = row.cluster?.status?.currentPrimary
|
|
506
|
+
const pods = ha?.pods.state === 'ok' ? ha.instances.map((i) => ({ name: i.pod, ready: i.ready })) : row.podReadiness ? row.pods : undefined
|
|
507
|
+
if (pods && primary) {
|
|
508
|
+
const expected = ha?.pods.state === 'ok' ? ha.expectedInstances ?? row.cluster?.status?.instanceNames ?? [] : row.cluster?.status?.instanceNames ?? []
|
|
509
|
+
const missing = expected.filter((name: string) => name !== primary && !pods.some((p) => p.name === name))
|
|
510
|
+
if (!live && missing.length > 0) {
|
|
511
|
+
const otherGaps = row.problems.filter((p) => p.reason === 'CNPGStandbyNotReceiving' && !missing.includes(p.subject.name))
|
|
512
|
+
const measuredGaps = withStandbyGaps({ ...row, problems: otherGaps }, liveReplicationDimension(row, live, gap))
|
|
513
|
+
dim = {
|
|
514
|
+
...dim, tone: worseTone(dim.tone, 'degraded'),
|
|
515
|
+
text: `Expected standby${missing.length === 1 ? '' : 's'} ${missing.join(', ')} ${missing.length === 1 ? 'is' : 'are'} not running${otherGaps.length > 0 ? ` · ${measuredGaps.text}` : ''}`,
|
|
516
|
+
source: `${otherGaps.length > 0 ? `${measuredGaps.source}; ` : ''}Instance Pods; streaming not measured: ${gap ?? 'not read'}`,
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
if (pods.some((p) => p.name === primary && p.ready === true) && !pods.some((p) => p.name !== primary && p.ready === true)) dim = {
|
|
520
|
+
...dim, tone: worseTone(dim.tone, 'degraded'), source: `${dim.source}. No ready standby to fail over to`,
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const sustained = row.problems.find((p) => p.reason === 'CNPGSustainedLag')
|
|
525
|
+
if (!sustained) return dim
|
|
526
|
+
const tone: HealthLevel = sustained.severity === 'critical' ? 'unhealthy' : 'degraded'
|
|
527
|
+
return {
|
|
528
|
+
...dim,
|
|
529
|
+
tone: worseTone(dim.tone, tone),
|
|
530
|
+
text: dim.tone === 'unknown' ? 'sustained lag' : `${dim.text} · sustained lag`,
|
|
531
|
+
source: sustained.title,
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// A standby another source saw receiving nothing (Prometheus in the fleet, the
|
|
536
|
+
// instance manager here) keeps the chip from reading calm or unassessed.
|
|
537
|
+
function withStandbyGaps(row: CNPGFleetRow, dim: CNPGDimension): CNPGDimension {
|
|
538
|
+
const gaps = row.problems.filter((p) => p.reason === 'CNPGStandbyNotReceiving')
|
|
539
|
+
if (gaps.length === 0) return dim
|
|
540
|
+
const pods = gaps.map((p) => p.subject.name).join(', ')
|
|
541
|
+
const tone: HealthLevel = gaps.some((p) => p.severity === 'critical') ? 'unhealthy' : 'degraded'
|
|
542
|
+
return {
|
|
543
|
+
...dim,
|
|
544
|
+
tone: worseTone(dim.tone, tone),
|
|
545
|
+
text: dim.tone === 'unknown' ? `${pods} not receiving WAL` : `${dim.text} · ${pods} not receiving WAL`,
|
|
546
|
+
source: gaps.map((p) => p.title).join('; '),
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function liveReplicationDimension(row: CNPGFleetRow, live?: CNPGReplicationLive, gap?: string): CNPGDimension {
|
|
551
|
+
const dim = localReplicationDimension(row, live, gap)
|
|
552
|
+
return row.replicaCluster && !row.hibernated ? withUpstreamUnassessed(row.replicaCluster, dim) : dim
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// A replica cluster's designated primary replays WAL from outside the cluster,
|
|
556
|
+
// by streaming or from the archive. Its pg_stat_replication lists only the
|
|
557
|
+
// standbys downstream of it, so a calm local reading says nothing about that
|
|
558
|
+
// feed: a dead one still reads every local standby streaming.
|
|
559
|
+
function withUpstreamUnassessed(replica: { source?: string }, dim: CNPGDimension): CNPGDimension {
|
|
560
|
+
const calm = dim.tone === 'healthy' || dim.tone === 'neutral'
|
|
561
|
+
return {
|
|
562
|
+
...dim,
|
|
563
|
+
tone: calm ? 'unknown' : dim.tone,
|
|
564
|
+
text: dim.tone === 'unknown' && dim.text === 'unassessed' ? dim.text : `${dim.text} · upstream feed not assessed`,
|
|
565
|
+
source: `${dim.source}. The designated primary replays WAL from ${replica.source ? `external cluster ${replica.source}` : 'an external cluster'}; Radar does not assess that feed`,
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function localReplicationDimension(row: CNPGFleetRow, live?: CNPGReplicationLive, gap?: string): CNPGDimension {
|
|
570
|
+
const base = { id: 'replication' as const, label: 'Replication' }
|
|
571
|
+
if (row.hibernated) return { ...base, tone: 'neutral', text: 'hibernated', source: 'cnpg.io/hibernation annotation' }
|
|
572
|
+
if (row.instances.desired === 1) return { ...base, tone: 'neutral', text: 'no standby', source: 'spec.instances is 1: there is no failover target' }
|
|
573
|
+
if (!live) return { ...base, tone: 'unknown', text: 'unassessed', source: `Needs the primary’s pg_stat_replication: ${gap ?? 'read through get pods/proxy'}` }
|
|
574
|
+
// Standbys whose Pods are gone are missing from the runtime read too, so
|
|
575
|
+
// the denominator is what the Cluster asks for, never what is running.
|
|
576
|
+
const desired = row.instances.desired
|
|
577
|
+
if (desired === null) {
|
|
578
|
+
return { ...base, tone: 'unknown', text: `${live.streaming} streaming`, source: 'spec.instances is not reported, so the expected standbys are unknown' }
|
|
579
|
+
}
|
|
580
|
+
const expected = Math.max(0, desired - 1)
|
|
581
|
+
const source = `Primary’s pg_stat_replication against spec.instances ${desired}`
|
|
582
|
+
const lag = live.maxReplayLagSeconds
|
|
583
|
+
const tone = cnpgReplicationTone(live.streaming, expected, lag)
|
|
584
|
+
// pg_stat_replication's replay_lag is the recent replay delay, not WAL still
|
|
585
|
+
// to replay; it can stay high after a standby caught up, so it is not "behind".
|
|
586
|
+
const lagText = lag !== undefined && cnpgLagTone(lag) !== 'healthy' ? ` · replay delay ${cnpgFormatLag(lag)}` : ''
|
|
587
|
+
if (live.streaming < expected) {
|
|
588
|
+
return { ...base, tone, text: `${live.streaming} of ${expected} expected standbys streaming${lagText}`, source }
|
|
589
|
+
}
|
|
590
|
+
if (lagText) return { ...base, tone, text: `${live.streaming} of ${expected} streaming${lagText}`, source }
|
|
591
|
+
return { ...base, tone: 'healthy', text: `${live.streaming} of ${expected} standbys streaming`, source }
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function protectionDimension(row: CNPGFleetRow): CNPGDimension {
|
|
595
|
+
const base = { id: 'protection' as const, label: 'Backups' }
|
|
596
|
+
const p = row.protection
|
|
597
|
+
if (p.walArchiving.tone === 'unhealthy') return { ...base, tone: 'unhealthy', text: 'WAL archiving failing', source: 'ContinuousArchiving condition' }
|
|
598
|
+
const blockedSchedule = row.problems.find((problem) => problem.reason === 'CNPGScheduleDestinationMissing')
|
|
599
|
+
if (blockedSchedule) return { ...base, tone: 'degraded', text: blockedSchedule.title, source: blockedSchedule.origin?.detail ?? 'ScheduledBackup method against Cluster spec' }
|
|
600
|
+
if (p.destination.method === 'none') return { ...base, tone: 'neutral', text: 'no backup destination', source: 'Cluster spec' }
|
|
601
|
+
if (p.lastSuccessfulBackup.tone === 'unhealthy' || p.lastSuccessfulBackup.tone === 'degraded') {
|
|
602
|
+
const text = !p.lastSuccessfulBackup.at && p.walArchiving.tone === 'healthy'
|
|
603
|
+
? `CNPG reports archiving · ${p.lastSuccessfulBackup.text.toLowerCase()}`
|
|
604
|
+
: p.lastSuccessfulBackup.text
|
|
605
|
+
return { ...base, tone: p.lastSuccessfulBackup.tone, text, source: p.lastSuccessfulBackup.source ?? 'Backups' }
|
|
606
|
+
}
|
|
607
|
+
if (p.lastSuccessfulBackup.tone === 'unknown') return { ...base, tone: 'unknown', text: 'unassessed', source: p.lastSuccessfulBackup.source ?? p.lastSuccessfulBackup.text }
|
|
608
|
+
if (p.walArchiving.tone === 'unknown') return { ...base, tone: 'unknown', text: 'unassessed', source: 'WAL archiving not reported' }
|
|
609
|
+
const last = p.lastSuccessfulBackup.at ? ` · last backup ${formatAge(p.lastSuccessfulBackup.at)} ago` : ''
|
|
610
|
+
if (p.walArchiving.tone === 'neutral') return { ...base, tone: 'healthy', text: `Backup completed${last}`, source: p.lastSuccessfulBackup.source ?? 'Backups' }
|
|
611
|
+
return { ...base, tone: 'healthy', text: `CNPG reports archiving${last}`, source: `ContinuousArchiving condition${p.lastSuccessfulBackup.source ? ` · last backup: ${p.lastSuccessfulBackup.source}` : ''}` }
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The Pod a Lease holder names. controller-runtime's leader election records
|
|
616
|
+
* "<pod>_<uuid>"; a CNPG primary Lease records the Pod name alone.
|
|
617
|
+
*/
|
|
618
|
+
export function cnpgLeaseHolderPod(holder: string): string {
|
|
619
|
+
const i = holder.indexOf('_')
|
|
620
|
+
return i > 0 ? holder.slice(0, i) : holder
|
|
621
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
export * from './workspace'
|
|
2
|
+
export * from './databaseRole'
|
|
3
|
+
export * from './ha'
|
|
4
|
+
export * from './CNPGClusterHASection'
|
|
2
5
|
export * from './primitives'
|
|
3
6
|
export * from './CNPGClusterSummary'
|
|
4
7
|
export * from './CNPGBackupSummary'
|
|
@@ -6,9 +9,24 @@ export * from './CNPGObjectStoreSummary'
|
|
|
6
9
|
export * from './CNPGDeclarativeSummary'
|
|
7
10
|
export * from './CNPGPoolerSummary'
|
|
8
11
|
export * from './CNPGImageCatalogSummary'
|
|
12
|
+
export * from './pooler'
|
|
13
|
+
export * from './connect'
|
|
14
|
+
export * from './CNPGConnectSection'
|
|
15
|
+
export * from './schedule'
|
|
16
|
+
export * from './logicalReplication'
|
|
17
|
+
export * from './CNPGLogicalPath'
|
|
9
18
|
export {
|
|
19
|
+
backupsForScheduledBackup,
|
|
20
|
+
cnpgScheduleDestinationBlocker,
|
|
21
|
+
objectStoreForBackup,
|
|
22
|
+
cnpgBackupMatchesCluster,
|
|
23
|
+
cnpgArchiveMatchesCluster,
|
|
24
|
+
type CNPGArchiveSource,
|
|
10
25
|
inferredObjectStoreHealth,
|
|
26
|
+
relationUnavailable,
|
|
11
27
|
usersOfObjectStore,
|
|
12
28
|
type CNPGObjectStoreHealth,
|
|
13
29
|
type CNPGObjectStoreUser,
|
|
14
30
|
} from './relations'
|
|
31
|
+
|
|
32
|
+
export * from './backupRuns'
|