@skyhook-io/radar-app 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 +1 -1
- package/package.json +3 -3
- package/src/App.tsx +125 -87
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +266 -39
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/radarFeatures.render.test.tsx +47 -3
- package/src/api/radarFeatures.ts +4 -1
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
- package/src/components/capacity/CapacityView.test.tsx +323 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +11 -129
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +228 -122
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +28 -15
- package/src/components/timeline/TimelineList.tsx +22 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineView.tsx +18 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +10 -1
- package/src/components/workload/WorkloadView.tsx +27 -239
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -1,34 +1,38 @@
|
|
|
1
|
-
import { useMemo } from 'react'
|
|
1
|
+
import { useId, useMemo, useState } from 'react'
|
|
2
|
+
import { X } from 'lucide-react'
|
|
2
3
|
import { formatRelativeAgeTime } from '@skyhook-io/k8s-ui/utils/format'
|
|
3
4
|
import {
|
|
4
5
|
Badge,
|
|
6
|
+
DialogPortal,
|
|
7
|
+
SectionHeading,
|
|
8
|
+
CNPGClusterBackupFacts,
|
|
9
|
+
backupsForScheduledBackup,
|
|
10
|
+
cnpgScheduleDestinationBlocker,
|
|
11
|
+
refToSelectedResource,
|
|
5
12
|
FactValue,
|
|
6
13
|
formatDuration,
|
|
7
14
|
getCNPGBackupStatus,
|
|
8
15
|
getCNPGClusterBarmanPlugin,
|
|
9
16
|
getCNPGObjectStoreDestination,
|
|
10
|
-
getCNPGScheduledBackupStatus,
|
|
17
|
+
getCNPGScheduledBackupStatus, getCNPGScheduledBackupOverdueMs,
|
|
11
18
|
inferredObjectStoreHealth,
|
|
12
|
-
|
|
19
|
+
cnpgBackupRunsInWindow,
|
|
20
|
+
cnpgBackupRunInFlight,
|
|
21
|
+
CNPG_BACKUP_RUN_WINDOW_MS,
|
|
22
|
+
coverageReadable,
|
|
23
|
+
cnpgCoverageGap,
|
|
24
|
+
getCNPGScheduledBackupNextSchedule,
|
|
13
25
|
toneTextClass,
|
|
14
26
|
usersOfObjectStore,
|
|
15
27
|
type CNPGFleetRow,
|
|
16
28
|
type HealthLevel,
|
|
29
|
+
Tooltip,
|
|
17
30
|
} from '@skyhook-io/k8s-ui'
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
ScreenBody,
|
|
24
|
-
SectionTable,
|
|
25
|
-
Sub,
|
|
26
|
-
clusterResource,
|
|
27
|
-
coverageEmpty,
|
|
28
|
-
cnpgResource,
|
|
29
|
-
namespaceChip,
|
|
30
|
-
type CNPGScreenProps,
|
|
31
|
-
} from './shared'
|
|
31
|
+
import { CNPGWorkspaceHeader, CoverageNotice, clusterResource, coverageEmpty, cnpgResource, type CNPGScreenProps } from './shared'
|
|
32
|
+
import { BreakText, FilterChips, Mono, namespaceChip, PathText, ScreenBody, SectionTable, Segments, Sub } from '../workspace/layout'
|
|
33
|
+
import { useCNPGNavigate } from './useCNPGNavigate'
|
|
34
|
+
import { cnpgClusterFullPath } from './paths'
|
|
35
|
+
import { currentPageLabel } from '../../utils/page-links'
|
|
32
36
|
|
|
33
37
|
const SEVERITY: Record<HealthLevel, 'success' | 'warning' | 'alert' | 'error' | 'neutral'> = {
|
|
34
38
|
healthy: 'success',
|
|
@@ -39,12 +43,6 @@ const SEVERITY: Record<HealthLevel, 'success' | 'warning' | 'alert' | 'error' |
|
|
|
39
43
|
neutral: 'neutral',
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
|
43
|
-
|
|
44
|
-
function backupTime(b: any): string | undefined {
|
|
45
|
-
return b?.status?.stoppedAt || b?.status?.startedAt || b?.metadata?.creationTimestamp
|
|
46
|
-
}
|
|
47
|
-
|
|
48
46
|
function backupStart(b: any): string | undefined {
|
|
49
47
|
return b?.status?.startedAt || b?.metadata?.creationTimestamp
|
|
50
48
|
}
|
|
@@ -90,6 +88,60 @@ function storeHealth(store: any, users: CNPGFleetRow[]): StoreRow['health'] {
|
|
|
90
88
|
}
|
|
91
89
|
}
|
|
92
90
|
|
|
91
|
+
// Enough to see the pattern of recent runs without pushing schedules and
|
|
92
|
+
// destinations off the page.
|
|
93
|
+
const RUNS_SHOWN = 10
|
|
94
|
+
|
|
95
|
+
function isFailedRun(b: any): boolean {
|
|
96
|
+
const level = getCNPGBackupStatus(b).level
|
|
97
|
+
return level === 'unhealthy' || level === 'alert'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function runDetail(b: any): string {
|
|
101
|
+
const started = Date.parse(backupStart(b) ?? '')
|
|
102
|
+
const stopped = Date.parse(b?.status?.stoppedAt ?? '')
|
|
103
|
+
const method = b?.status?.method || b?.spec?.method
|
|
104
|
+
const took = Number.isFinite(started) && Number.isFinite(stopped) && stopped >= started ? `took ${formatDuration(stopped - started)}` : null
|
|
105
|
+
return [method, took].filter(Boolean).join(' · ') || '—'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The newest Backup the schedule created and how it ended; the schedule's own
|
|
109
|
+
// lastScheduleTime says only that it fired.
|
|
110
|
+
function LastRun({ schedule, backups, readable }: { schedule: any; backups: any[]; readable: boolean }) {
|
|
111
|
+
const fired = schedule?.status?.lastScheduleTime
|
|
112
|
+
const last = backupsForScheduledBackup(schedule, backups)[0]
|
|
113
|
+
if (!fired && !last) return <span className="text-theme-text-tertiary">Not yet</span>
|
|
114
|
+
if (!last) {
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
{ageText(fired)}
|
|
118
|
+
<Sub>{readable ? 'no Backup found for it' : 'outcome needs list backups'}</Sub>
|
|
119
|
+
</>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
// Settled Backups older than 7 days are not loaded, so the newest one here
|
|
123
|
+
// can be from an earlier run than the schedule's last firing.
|
|
124
|
+
const firedAt = Date.parse(fired ?? '')
|
|
125
|
+
const startedAt = Date.parse(backupStart(last) ?? '')
|
|
126
|
+
if (Number.isFinite(firedAt) && Number.isFinite(startedAt) && startedAt < firedAt - 2 * 60_000) {
|
|
127
|
+
return (
|
|
128
|
+
<>
|
|
129
|
+
{ageText(fired)}
|
|
130
|
+
<Sub>its Backup is not loaded</Sub>
|
|
131
|
+
</>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
const st = getCNPGBackupStatus(last)
|
|
135
|
+
return (
|
|
136
|
+
<>
|
|
137
|
+
{ageText(backupStart(last) ?? fired)}
|
|
138
|
+
<Sub>
|
|
139
|
+
{st.level === 'healthy' ? <Badge severity="success" size="sm">{st.text.toLowerCase()}</Badge> : <span className={toneTextClass(st.level)}>{st.text.toLowerCase()}</span>}
|
|
140
|
+
</Sub>
|
|
141
|
+
</>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
93
145
|
export function CNPGProtection({
|
|
94
146
|
data,
|
|
95
147
|
fleet,
|
|
@@ -101,25 +153,26 @@ export function CNPGProtection({
|
|
|
101
153
|
onClearNamespaces,
|
|
102
154
|
scopeCluster,
|
|
103
155
|
}: CNPGScreenProps & { scopeCluster?: { namespace: string; name: string } }) {
|
|
156
|
+
const evidenceTitleId = useId()
|
|
157
|
+
const navigate = useCNPGNavigate()
|
|
158
|
+
const [evidenceKey, setEvidenceKey] = useState<string | null>(null)
|
|
159
|
+
const evidence = fleet.rows.find((row) => row.key === evidenceKey)
|
|
104
160
|
const clusterFilter = scopeCluster ? `${scopeCluster.namespace}/${scopeCluster.name}` : searchParams.get('cluster')
|
|
105
161
|
const rows = useMemo(
|
|
106
162
|
() => fleet.rows.filter((r) => !clusterFilter || `${r.namespace}/${r.name}` === clusterFilter),
|
|
107
163
|
[fleet.rows, clusterFilter],
|
|
108
164
|
)
|
|
109
165
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
.filter((b) => !clusterFilter || `${b.metadata?.namespace}/${b.spec?.cluster?.name}` === clusterFilter)
|
|
121
|
-
.sort((a, b) => Date.parse(backupTime(b) ?? '') - Date.parse(backupTime(a) ?? ''))
|
|
122
|
-
}, [data.objects.backups, clusterFilter])
|
|
166
|
+
const runs = useMemo(
|
|
167
|
+
() => cnpgBackupRunsInWindow(data.objects.backups ?? [])
|
|
168
|
+
.filter((b) => !clusterFilter || `${b.metadata?.namespace}/${b.spec?.cluster?.name}` === clusterFilter),
|
|
169
|
+
[data.objects.backups, clusterFilter],
|
|
170
|
+
)
|
|
171
|
+
const failedRuns = useMemo(() => runs.filter((b) => isFailedRun(b)), [runs])
|
|
172
|
+
const [runFilter, setRunFilter] = useState<'failed' | 'all' | null>(null)
|
|
173
|
+
const showRuns = runFilter ?? (scopeCluster || failedRuns.length === 0 ? 'all' : 'failed')
|
|
174
|
+
const [allRuns, setAllRuns] = useState(false)
|
|
175
|
+
const shownRuns = showRuns === 'failed' ? failedRuns : runs
|
|
123
176
|
|
|
124
177
|
const stores = useMemo<StoreRow[]>(() => {
|
|
125
178
|
return (data.objects.objectStores ?? []).map((s) => {
|
|
@@ -144,73 +197,59 @@ export function CNPGProtection({
|
|
|
144
197
|
...(clusterFilter ? [{ label: `Cluster: ${clusterFilter}`, onClear: () => onSetParams({ cluster: null }) }] : []),
|
|
145
198
|
...namespaceChip(namespaces, onClearNamespaces),
|
|
146
199
|
]
|
|
147
|
-
const
|
|
200
|
+
const backupsCoverage = data.coverage.backups!
|
|
201
|
+
const scopedNamespace = scopeCluster?.namespace ?? clusterFilter?.split('/')[0]
|
|
202
|
+
const backupsReadable = coverageReadable(backupsCoverage, scopedNamespace)
|
|
203
|
+
const countText = (count: number) => backupsReadable ? String(count) : count > 0 ? `≥${count}` : '?'
|
|
204
|
+
const backupsGap = backupsReadable ? undefined : cnpgCoverageGap(backupsCoverage, 'Backups', scopedNamespace)
|
|
148
205
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
<ScreenBody>
|
|
158
|
-
<CoverageNotice fleet={fleet} data={data} />
|
|
206
|
+
const body = (
|
|
207
|
+
<>
|
|
208
|
+
<DialogPortal open={!!evidence} onClose={() => setEvidenceKey(null)} ariaLabelledBy={evidenceTitleId} className="w-full max-w-3xl">
|
|
209
|
+
<div className="flex items-start justify-between gap-3 border-b border-theme-border p-4"><div><h3 id={evidenceTitleId} className="text-lg font-semibold text-theme-text-primary">Recovery evidence</h3><p className="text-xs text-theme-text-tertiary">{evidence?.namespace}/{evidence?.name}</p></div><button type="button" onClick={() => setEvidenceKey(null)} aria-label="Close recovery evidence" className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover"><X className="h-5 w-5" /></button></div>
|
|
210
|
+
<div className="max-h-[70vh] overflow-y-auto p-4">{evidence && <CNPGClusterBackupFacts row={evidence} onNavigate={(ref) => { setEvidenceKey(null); onInspect(refToSelectedResource(ref)) }} />}</div>
|
|
211
|
+
<div className="flex justify-end border-t border-theme-border p-4"><button type="button" onClick={() => { if (evidence) navigate(cnpgClusterFullPath(evidence.namespace, evidence.name, data.context, 'backups'), { state: { returnLabel: currentPageLabel(), returnCtx: data.context } }); setEvidenceKey(null) }} className="btn-secondary px-3 py-1.5 text-sm">Open Cluster Backups →</button></div>
|
|
212
|
+
</DialogPortal>
|
|
213
|
+
<CoverageNotice fleet={fleet} data={data} kinds={['clusters', 'backups', 'scheduledBackups', 'objectStores']} namespace={scopeCluster?.namespace} />
|
|
159
214
|
<FilterChips chips={chips} />
|
|
160
215
|
|
|
161
|
-
|
|
162
|
-
|
|
216
|
+
{scopeCluster && rows[0] && (
|
|
217
|
+
<section className="rounded-xl border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
|
|
218
|
+
<SectionHeading>Recovery evidence</SectionHeading>
|
|
219
|
+
<CNPGClusterBackupFacts row={rows[0]} onNavigate={(ref) => onInspect(refToSelectedResource(ref))} />
|
|
220
|
+
{stores.length > 0 && <p className="mt-2 text-xs text-theme-text-tertiary">Recovery windows come from ObjectStore status.</p>}
|
|
221
|
+
</section>
|
|
222
|
+
)}
|
|
223
|
+
{!scopeCluster && <SectionTable
|
|
224
|
+
title="Recovery evidence by cluster"
|
|
163
225
|
columns={[
|
|
164
226
|
{
|
|
165
227
|
header: 'Cluster',
|
|
166
|
-
width: '
|
|
228
|
+
width: '20%',
|
|
167
229
|
cell: (r: CNPGFleetRow) => (
|
|
168
230
|
<>
|
|
169
|
-
<
|
|
231
|
+
<BreakText value={r.name} after="-" className="font-medium" />
|
|
170
232
|
<Sub>{r.namespace}</Sub>
|
|
171
233
|
</>
|
|
172
234
|
),
|
|
173
235
|
},
|
|
174
|
-
{ header: 'Schedule', width: '13%', cell: (r) => <FactValue fact={r.protection.schedule} /> },
|
|
175
236
|
{
|
|
176
|
-
header: '
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<FactValue fact={r.protection.lastSuccessfulBackup} />
|
|
181
|
-
{r.protection.lastSuccessfulBackup.source && <Sub>{r.protection.lastSuccessfulBackup.source}</Sub>}
|
|
182
|
-
</>
|
|
183
|
-
),
|
|
237
|
+
header: 'Archive & schedule', width: '27%', cell: (r) => <>
|
|
238
|
+
<Tooltip content={[r.protection.walArchiving.source, r.protection.walArchiving.detail].filter(Boolean).join(' · ')}><FactValue fact={r.protection.walArchiving} /></Tooltip>
|
|
239
|
+
<Sub><FactValue fact={r.protection.schedule} /></Sub>
|
|
240
|
+
</>,
|
|
184
241
|
},
|
|
185
|
-
{ header: 'WAL archiving', width: '16%', cell: (r) => <FactValue fact={r.protection.walArchiving} className="line-clamp-2 break-words" /> },
|
|
186
242
|
{
|
|
187
|
-
header: '
|
|
188
|
-
width: '13%',
|
|
189
|
-
cell: (r) =>
|
|
190
|
-
r.protection.recoveryWindow.from ? (
|
|
191
|
-
<>
|
|
192
|
-
<span className={toneTextClass(r.protection.recoveryWindow.tone)}>
|
|
193
|
-
from {ageText(r.protection.recoveryWindow.from)}
|
|
194
|
-
</span>
|
|
195
|
-
<Sub>
|
|
196
|
-
{r.protection.recoveryWindow.tone === 'degraded' ? 'not advancing: archiving failing' : 'to the newest archived WAL'}
|
|
197
|
-
</Sub>
|
|
198
|
-
</>
|
|
199
|
-
) : (
|
|
200
|
-
<FactValue fact={r.protection.recoveryWindow} />
|
|
201
|
-
),
|
|
243
|
+
header: 'Successful backup', width: '19%', cell: (r) => <Tooltip content={r.protection.lastSuccessfulBackup.source}><FactValue fact={r.protection.lastSuccessfulBackup} /></Tooltip>,
|
|
202
244
|
},
|
|
203
245
|
{
|
|
204
|
-
header: '
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
),
|
|
246
|
+
header: 'Recovery boundary', width: '21%', cell: (r) => r.protection.recoveryWindow.from ? <>
|
|
247
|
+
<span className={toneTextClass(r.protection.recoveryWindow.tone)}>from {ageText(r.protection.recoveryWindow.from)}</span>
|
|
248
|
+
<Sub>{r.protection.recoveryWindow.tone === 'degraded' ? 'not advancing' : 'to newest archived WAL'}</Sub>
|
|
249
|
+
</> : <FactValue fact={r.protection.recoveryWindow} />,
|
|
209
250
|
},
|
|
210
251
|
{
|
|
211
|
-
header: '
|
|
212
|
-
width: '15%',
|
|
213
|
-
cell: (r) => <FactValue fact={r.protection.destination} className={r.protection.destination.method === 'barmanObjectStore' ? 'break-all font-mono text-[12.5px]' : 'break-words'} />,
|
|
252
|
+
header: 'Evidence', width: '13%', cell: (r) => <button type="button" aria-label={`Recovery evidence for ${r.namespace}/${r.name}`} onClick={(event) => { event.stopPropagation(); setEvidenceKey(r.key) }} className="rounded text-accent-text hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent">Details…</button>,
|
|
214
253
|
},
|
|
215
254
|
]}
|
|
216
255
|
rows={rows}
|
|
@@ -218,38 +257,89 @@ export function CNPGProtection({
|
|
|
218
257
|
rowResource={(r) => clusterResource(r.namespace, r.name)}
|
|
219
258
|
onInspect={onInspect}
|
|
220
259
|
inspected={inspected}
|
|
221
|
-
minWidth={
|
|
260
|
+
minWidth={800}
|
|
222
261
|
empty={coverageEmpty(data.coverage.clusters, 'PostgreSQL clusters')}
|
|
223
|
-
footer="Kubernetes records no restore tests, so restore validation is never shown as passed. Recovery windows come from ObjectStore status."
|
|
224
|
-
/>
|
|
262
|
+
footer={`${rows.some((r) => r.protection.walArchiving.state === 'no_destination' && r.protection.walArchiving.operatorCondition?.status === 'True') ? "With no backup destination, CloudNativePG reports archiving as working because it accepts each WAL file without keeping it. " : ""}Kubernetes records no restore tests, so restore validation is never shown as passed.${stores.length > 0 ? " Recovery windows come from ObjectStore status." : ""}`}
|
|
263
|
+
/>}
|
|
225
264
|
|
|
226
265
|
<SectionTable
|
|
227
|
-
title="
|
|
228
|
-
subtitle=
|
|
266
|
+
title="Backup runs"
|
|
267
|
+
subtitle={
|
|
268
|
+
<span className="inline-flex flex-wrap items-center gap-2">
|
|
269
|
+
last 7 days · all in-flight runs
|
|
270
|
+
<Segments
|
|
271
|
+
label="Backup runs shown"
|
|
272
|
+
value={showRuns}
|
|
273
|
+
onChange={(v) => setRunFilter(v)}
|
|
274
|
+
options={[
|
|
275
|
+
{ id: 'failed', label: `Failed ${countText(failedRuns.length)}` },
|
|
276
|
+
{ id: 'all', label: `All ${countText(runs.length)}` },
|
|
277
|
+
]}
|
|
278
|
+
/>
|
|
279
|
+
{backupsGap && <span className="text-theme-text-tertiary">{backupsGap}</span>}
|
|
280
|
+
</span>
|
|
281
|
+
}
|
|
229
282
|
columns={[
|
|
230
|
-
{ header: 'Backup', width: '
|
|
231
|
-
{ header: 'Cluster', width: '
|
|
232
|
-
{ header: 'Started', width: '12%', cell: (b) => ageText(backupStart(b)) },
|
|
283
|
+
{ header: 'Backup', width: scopeCluster ? '32%' : '26%', cell: (b: any) => <Mono>{b.metadata?.name}</Mono> },
|
|
284
|
+
...(scopeCluster ? [] : [{ header: 'Cluster', width: '14%', cell: (b: any) => <>{b.spec?.cluster?.name ?? '—'}<Sub>{b.metadata?.namespace}</Sub></> }]),
|
|
285
|
+
{ header: 'Started', width: '12%', cell: (b: any) => <>{ageText(backupStart(b))}{cnpgBackupRunInFlight(b) && Date.now() - Date.parse(backupStart(b) ?? '') > CNPG_BACKUP_RUN_WINDOW_MS && <Sub>started more than a week ago</Sub>}</> },
|
|
286
|
+
{
|
|
287
|
+
header: 'Outcome',
|
|
288
|
+
width: '12%',
|
|
289
|
+
cell: (b: any) => {
|
|
290
|
+
const st = getCNPGBackupStatus(b)
|
|
291
|
+
return <Badge severity={SEVERITY[st.level]} size="sm">{st.text}</Badge>
|
|
292
|
+
},
|
|
293
|
+
},
|
|
233
294
|
{
|
|
234
|
-
header: '
|
|
235
|
-
width: '44%',
|
|
236
|
-
cell: (b) =>
|
|
295
|
+
header: 'Detail',
|
|
296
|
+
width: scopeCluster ? '44%' : '36%',
|
|
297
|
+
cell: (b: any) =>
|
|
298
|
+
isFailedRun(b) ? (
|
|
299
|
+
<span className={toneTextClass('unhealthy')}>{b.status?.error || getCNPGBackupStatus(b).text}</span>
|
|
300
|
+
) : (
|
|
301
|
+
<span className="text-theme-text-secondary">{runDetail(b)}</span>
|
|
302
|
+
),
|
|
237
303
|
},
|
|
238
304
|
]}
|
|
239
|
-
rows={
|
|
305
|
+
rows={allRuns ? shownRuns : shownRuns.slice(0, RUNS_SHOWN)}
|
|
240
306
|
rowKey={(b) => `${b.metadata?.namespace}/${b.metadata?.name}`}
|
|
241
307
|
rowResource={(b) => cnpgResource('backups', b.metadata?.namespace, b.metadata?.name)}
|
|
242
308
|
onInspect={onInspect}
|
|
243
309
|
inspected={inspected}
|
|
244
|
-
empty={
|
|
310
|
+
empty={
|
|
311
|
+
backupsReadable
|
|
312
|
+
? showRuns === 'failed'
|
|
313
|
+
? 'No failed backups in the last 7 days.'
|
|
314
|
+
: 'No backups in the last 7 days.'
|
|
315
|
+
: coverageEmpty(data.coverage.backups, 'backups')
|
|
316
|
+
}
|
|
317
|
+
footer={
|
|
318
|
+
shownRuns.length > RUNS_SHOWN ? (
|
|
319
|
+
<button type="button" onClick={() => setAllRuns((v) => !v)} className="text-accent-text hover:underline">
|
|
320
|
+
{allRuns ? `Show the newest ${RUNS_SHOWN}` : `Show all ${shownRuns.length} runs`}
|
|
321
|
+
</button>
|
|
322
|
+
) : undefined
|
|
323
|
+
}
|
|
245
324
|
/>
|
|
246
325
|
|
|
247
326
|
<SectionTable
|
|
248
327
|
title="Destinations"
|
|
249
328
|
subtitle="ObjectStores (barman-cloud plugin)"
|
|
250
329
|
columns={[
|
|
251
|
-
{
|
|
252
|
-
|
|
330
|
+
{
|
|
331
|
+
header: 'ObjectStore',
|
|
332
|
+
width: '18%',
|
|
333
|
+
cell: (s: StoreRow) => (
|
|
334
|
+
<>
|
|
335
|
+
<Tooltip content={s.name} wrapperClassName="max-w-full">
|
|
336
|
+
<span className="block truncate">{s.name}</span>
|
|
337
|
+
</Tooltip>
|
|
338
|
+
<Sub>{s.namespace}</Sub>
|
|
339
|
+
</>
|
|
340
|
+
),
|
|
341
|
+
},
|
|
342
|
+
{ header: 'Destination', width: '30%', cell: (s) => (s.destination ? <PathText value={s.destination} /> : '—') },
|
|
253
343
|
{ header: 'Used by', width: '20%', cell: (s) => (s.users.length ? s.users.map((u) => u.name).join(', ') : <span className="text-theme-text-tertiary">None visible</span>) },
|
|
254
344
|
{
|
|
255
345
|
header: 'Upload health (inferred)',
|
|
@@ -268,42 +358,53 @@ export function CNPGProtection({
|
|
|
268
358
|
onInspect={onInspect}
|
|
269
359
|
inspected={inspected}
|
|
270
360
|
empty={data.coverage.objectStores?.state === 'notInstalled' ? 'The barman-cloud plugin’s ObjectStore kind is not installed.' : coverageEmpty(data.coverage.objectStores, 'ObjectStores')}
|
|
271
|
-
footer="ObjectStore has no health status of its own; upload health is inferred from its clusters’ WAL archiving and backup results."
|
|
361
|
+
footer={stores.length > 0 ? "ObjectStore has no health status of its own; upload health is inferred from its clusters’ WAL archiving and backup results." : undefined}
|
|
272
362
|
/>
|
|
273
363
|
|
|
274
364
|
<SectionTable
|
|
275
365
|
title="Schedules"
|
|
366
|
+
minWidth={820}
|
|
276
367
|
columns={[
|
|
277
|
-
{ header: 'ScheduledBackup', width: '
|
|
278
|
-
{ header: 'Cluster', width: '
|
|
368
|
+
{ header: 'ScheduledBackup', width: scopeCluster ? '22%' : '20%', cell: (s: any) => <>{s.metadata?.name}<Sub>{s.metadata?.namespace}</Sub></> },
|
|
369
|
+
...(scopeCluster ? [] : [{ header: 'Cluster', width: '12%', cell: (s: any) => s.spec?.cluster?.name ?? '—' }]),
|
|
279
370
|
{
|
|
280
371
|
header: 'Schedule',
|
|
281
|
-
width: '24%',
|
|
282
|
-
cell: (s) =>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
372
|
+
width: scopeCluster ? '24%' : '20%',
|
|
373
|
+
cell: (s) => {
|
|
374
|
+
const reading = data.scheduleReadings?.[`${s.metadata?.namespace}/${s.metadata?.name}`]
|
|
375
|
+
return reading ? (
|
|
376
|
+
<>
|
|
377
|
+
{reading}
|
|
378
|
+
<Sub>
|
|
379
|
+
<Mono>{s.spec?.schedule}</Mono>
|
|
380
|
+
</Sub>
|
|
381
|
+
</>
|
|
382
|
+
) : (
|
|
383
|
+
<>
|
|
384
|
+
<Mono>{s.spec?.schedule ?? '—'}</Mono>
|
|
385
|
+
<Sub>CNPG cron, seconds first</Sub>
|
|
386
|
+
</>
|
|
387
|
+
)
|
|
388
|
+
},
|
|
288
389
|
},
|
|
289
390
|
{
|
|
290
|
-
header: '
|
|
291
|
-
width: '
|
|
391
|
+
header: 'State',
|
|
392
|
+
width: scopeCluster ? '25%' : '27%',
|
|
292
393
|
cell: (s) => {
|
|
293
394
|
const st = getCNPGScheduledBackupStatus(s)
|
|
294
|
-
|
|
395
|
+
const last = backupsForScheduledBackup(s, data.objects.backups ?? [])[0]
|
|
396
|
+
const blocker = cnpgScheduleDestinationBlocker(s, data.objects.clusters ?? [])
|
|
397
|
+
const guarded = s.spec?.suspend || getCNPGScheduledBackupOverdueMs(s) !== null
|
|
398
|
+
const text = guarded ? st.text : (blocker || last || s.status?.lastScheduleTime ? 'Enabled' : 'Enabled · not run yet')
|
|
399
|
+
const severity = guarded ? SEVERITY[st.level] : 'neutral'
|
|
400
|
+
return <div className="flex flex-wrap gap-1"><Badge severity={severity} size="sm" className="whitespace-nowrap">{text}</Badge>{blocker && <Badge severity="warning" size="sm" className="whitespace-nowrap">{blocker}</Badge>}</div>
|
|
295
401
|
},
|
|
296
402
|
},
|
|
297
|
-
{ header: 'Last run', width: '
|
|
403
|
+
{ header: 'Last run', width: scopeCluster ? '13%' : '10%', cell: (s) => <LastRun schedule={s} backups={data.objects.backups ?? []} readable={backupsReadable} /> },
|
|
298
404
|
{
|
|
299
|
-
header:
|
|
300
|
-
width: '
|
|
301
|
-
cell: (s) => {
|
|
302
|
-
const next = s.status?.nextScheduleTime
|
|
303
|
-
if (!next) return '—'
|
|
304
|
-
const ms = Date.parse(next) - Date.now()
|
|
305
|
-
return ms >= 0 ? `in ${formatDuration(ms)}` : `${formatDuration(-ms)} overdue`
|
|
306
|
-
},
|
|
405
|
+
header: <span className="block whitespace-normal">Next run reported by the operator</span>,
|
|
406
|
+
width: scopeCluster ? '16%' : '11%',
|
|
407
|
+
cell: (s) => { const next = getCNPGScheduledBackupNextSchedule(s); return next === '-' ? 'Not reported' : next },
|
|
307
408
|
},
|
|
308
409
|
]}
|
|
309
410
|
rows={schedules}
|
|
@@ -314,7 +415,12 @@ export function CNPGProtection({
|
|
|
314
415
|
empty={coverageEmpty(data.coverage.scheduledBackups, 'ScheduledBackups')}
|
|
315
416
|
footer={data.backupsOmitted > 0 ? `${data.backupsOmitted} settled backups older than 7 days are not listed.` : undefined}
|
|
316
417
|
/>
|
|
317
|
-
|
|
418
|
+
</>
|
|
419
|
+
)
|
|
420
|
+
return (
|
|
421
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
422
|
+
{!scopeCluster && <CNPGWorkspaceHeader title="Backups" subtitle="Recovery evidence, backup runs, schedules and destinations for every cluster. Configured, backed up and restore-tested are separate facts." />}
|
|
423
|
+
{scopeCluster ? <div className="space-y-4 p-4">{body}</div> : <ScreenBody>{body}</ScreenBody>}
|
|
318
424
|
</div>
|
|
319
425
|
)
|
|
320
426
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { expect, it, vi } from 'vitest'
|
|
3
|
+
import { CNPGReplicationTab, OtherSlots } from './CNPGReplicationTab'
|
|
4
|
+
const state = vi.hoisted(() => ({ haProps: {} as any, navigate: vi.fn(), runtime: undefined as any, ha: undefined as any }))
|
|
5
|
+
vi.mock('../../api/cnpg', () => ({ useCNPGRuntime: () => ({ data: state.runtime }), useCNPGClusterCapabilities: () => ({}) }))
|
|
6
|
+
vi.mock('../../api/cnpg-ha', () => ({ useCNPGClusterHA: () => ({ data: state.ha }), cnpgInstanceLive: () => undefined, cnpgInstanceLiveUnavailable: () => 'not read' }))
|
|
7
|
+
vi.mock('./useCNPGSidebarWorkspace', () => ({ useCNPGFleet: () => ({ fleet: { rows: [{ name: 'pg', namespace: 'db', cluster: { status: { currentPrimary: 'pg-1' } } }] } }) }))
|
|
8
|
+
vi.mock('./actions/CNPGInstanceActions', () => ({ CNPGInstanceActions: () => null }))
|
|
9
|
+
vi.mock('./useCNPGNavigate', () => ({ useCNPGNavigate: () => state.navigate }))
|
|
10
|
+
vi.mock('./CNPGClusterTabs', () => ({ CNPGTabVerdict: ({ id, alwaysShow }: { id: string; alwaysShow?: boolean }) => <span>verdict: {id}{alwaysShow ? " always" : ""}</span> }))
|
|
11
|
+
vi.mock('@skyhook-io/k8s-ui', async (original) => ({ ...(await original<typeof import('@skyhook-io/k8s-ui')>()), CNPGClusterHASection: (props: any) => { state.haProps = props; return null } }))
|
|
12
|
+
it('explains Serving on Replication and connects endpoint evidence to Reachability', () => {
|
|
13
|
+
const html = renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)
|
|
14
|
+
expect(html).toContain('verdict: serving always')
|
|
15
|
+
expect(state.haProps.showInstances).toBe(false)
|
|
16
|
+
expect(html).toContain('verdict: replication')
|
|
17
|
+
expect(state.haProps.currentPrimary).toBe('pg-1')
|
|
18
|
+
state.haProps.onOpenReachability({ namespace: 'db', name: 'pg-rw' })
|
|
19
|
+
expect(state.navigate).toHaveBeenCalledWith('/workload/services/db/pg-rw?tab=reachability')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('associates a slot with a recorded expected standby while keeping unknown associations unverified', () => {
|
|
23
|
+
const primary = { pod: 'orders-1', status: { state: 'ok', slots: [{ name: '_cnpg_orders_2', type: 'physical', active: false }] } } as any
|
|
24
|
+
const html = renderToStaticMarkup(<OtherSlots primary={primary} instances={['orders-1']} expectedInstances={['orders-1', 'orders-2']} joiningInstances={['orders-2']} clusterObject={{}} />)
|
|
25
|
+
expect(html).toContain('orders-2</span> — waiting to join')
|
|
26
|
+
expect(html).not.toContain('unmanaged')
|
|
27
|
+
expect(html).toContain('retained WAL not reported')
|
|
28
|
+
const unverified = renderToStaticMarkup(<OtherSlots primary={primary} instances={['orders-1']} clusterObject={{}} />)
|
|
29
|
+
expect(unverified).toContain('association unverified')
|
|
30
|
+
expect(unverified).not.toContain('waiting to join')
|
|
31
|
+
const expected = renderToStaticMarkup(<OtherSlots primary={primary} instances={['orders-1']} expectedInstances={['orders-2']} />)
|
|
32
|
+
expect(expected).toContain('orders-2</span> — no instance Pod observed')
|
|
33
|
+
expect(expected).not.toContain('waiting to join')
|
|
34
|
+
})
|
|
35
|
+
it('distinguishes an empty slot inventory from slots associated with standbys', () => {
|
|
36
|
+
const primary = { pod: 'payments-1', status: { state: 'ok', slots: [] } } as any
|
|
37
|
+
const html = renderToStaticMarkup(<OtherSlots primary={primary} instances={['payments-1']} />)
|
|
38
|
+
expect(html).toContain('No other slots reported')
|
|
39
|
+
expect(html).not.toContain('every slot')
|
|
40
|
+
primary.status.slots = [{ name: '_cnpg_payments_2', type: 'physical' }]
|
|
41
|
+
expect(renderToStaticMarkup(<OtherSlots primary={primary} instances={['payments-1', 'payments-2']} />)).toContain('No other slots reported')
|
|
42
|
+
primary.status.slotsTruncated = true
|
|
43
|
+
expect(renderToStaticMarkup(<OtherSlots primary={primary} instances={['payments-1', 'payments-2']} />)).toContain('No other slots in the reported inventory; inventory incomplete')
|
|
44
|
+
})
|
|
45
|
+
it('says checked instead of sampled when no instance answered', () => {
|
|
46
|
+
state.runtime = { permission: { proxy: 'allowed' }, sampledAt: '2026-10-05T12:00:00Z', instances: [] }
|
|
47
|
+
// The composed view has its own capabilities hook; isolate its source label here.
|
|
48
|
+
const html = renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)
|
|
49
|
+
expect(html).toContain('No instance data yet; checked')
|
|
50
|
+
expect(html).not.toContain('sampled')
|
|
51
|
+
expect(html).not.toContain('catch-up measure')
|
|
52
|
+
expect(html).toContain('Source: instance manager status')
|
|
53
|
+
state.runtime = undefined
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('keeps the replication measurement explanation absent for a lone primary, and present for measured standby rows', () => {
|
|
57
|
+
state.runtime = { permission: { proxy: 'allowed' }, sampledAt: '2026-10-05T12:00:00Z', instances: [{ pod: 'pg-1', role: 'primary', status: { state: 'ok', currentLsn: '0/4000' }, metrics: { state: 'ok' } }] }
|
|
58
|
+
expect(renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)).not.toContain('catch-up measure')
|
|
59
|
+
state.runtime.instances.push({ pod: 'pg-2', role: 'replica', status: { state: 'ok', replayLsn: '0/3000' }, metrics: { state: 'ok' } })
|
|
60
|
+
expect(renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)).toContain('catch-up measure')
|
|
61
|
+
state.runtime = undefined
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('keeps expected and other slots in one section', () => {
|
|
65
|
+
const primary = { pod: 'orders-1', status: { state: 'ok', slots: [{ name: '_cnpg_orders_2', type: 'physical', active: false }] } } as any
|
|
66
|
+
const html = renderToStaticMarkup(<OtherSlots primary={primary} instances={['orders-1']} expectedInstances={['orders-2']} />)
|
|
67
|
+
expect(html.match(/>Replication slots</g)).toHaveLength(1)
|
|
68
|
+
expect(html).toContain('No other slots reported')
|
|
69
|
+
expect(html.match(/_cnpg_orders_2/g)).toHaveLength(1)
|
|
70
|
+
})
|
|
71
|
+
it('uses streaming language only for standbys when PostgreSQL reads are denied', () => {
|
|
72
|
+
state.runtime = { permission: { proxy: 'denied' }, instances: [] }
|
|
73
|
+
state.ha = { pods: { state: 'ok' }, instances: [{ pod: 'pg-1', role: 'primary', ready: true, restartCount: 0 }, { pod: 'pg-2', role: 'replica', ready: true, restartCount: 0 }] }
|
|
74
|
+
const html = renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)
|
|
75
|
+
expect(html.match(/streaming not read/g)).toHaveLength(1)
|
|
76
|
+
expect(html.match(/live PostgreSQL status not read/g)).toHaveLength(1)
|
|
77
|
+
state.runtime = undefined; state.ha = undefined
|
|
78
|
+
})
|
|
79
|
+
it('names instance position, timeline and manager version without abbreviations', () => {
|
|
80
|
+
state.runtime = { permission: { proxy: 'allowed' }, instances: [{ pod: 'pg-1', role: 'primary', status: { state: 'ok', currentLsn: '0/4000', timeline: 1, instanceManagerVersion: '1.30.1' }, metrics: { state: 'ok' } }] }
|
|
81
|
+
const html = renderToStaticMarkup(<CNPGReplicationTab namespace="db" name="pg" />)
|
|
82
|
+
expect(html).toContain('WAL position')
|
|
83
|
+
expect(html).toContain('0/4000')
|
|
84
|
+
expect(html).toContain('timeline 1')
|
|
85
|
+
expect(html).toContain('CNPG instance manager 1.30.1')
|
|
86
|
+
state.runtime = undefined
|
|
87
|
+
})
|