@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,18 +1,23 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import type { UseQueryResult } from '@tanstack/react-query'
|
|
3
|
-
import {
|
|
4
|
-
import { Database, X } from 'lucide-react'
|
|
3
|
+
import { Database } from 'lucide-react'
|
|
5
4
|
import {
|
|
6
5
|
CNPG_KIND_BY_KEY,
|
|
6
|
+
FoldSection,
|
|
7
|
+
coverageReadable,
|
|
7
8
|
PaneLoader,
|
|
9
|
+
RadarUpgradeAction,
|
|
10
|
+
getRadarUpgradeRequirement,
|
|
11
|
+
radarUpgradeDetail,
|
|
12
|
+
radarUpgradeHeadline,
|
|
8
13
|
type CNPGFleet,
|
|
9
14
|
type CNPGKindCoverage,
|
|
10
15
|
type CNPGWorkspaceResponse,
|
|
16
|
+
type CNPGWorkspaceKey,
|
|
11
17
|
} from '@skyhook-io/k8s-ui'
|
|
12
18
|
import type { SelectedResource } from '../../types'
|
|
13
19
|
import { useConnection } from '../../context/ConnectionContext'
|
|
14
|
-
import {
|
|
15
|
-
import { sameResource } from './routes'
|
|
20
|
+
import { Notice, RefreshFailedNotice, ScreenEmptyState } from '../workspace/layout'
|
|
16
21
|
|
|
17
22
|
export interface CNPGScreenProps {
|
|
18
23
|
data: CNPGWorkspaceResponse
|
|
@@ -23,25 +28,33 @@ export interface CNPGScreenProps {
|
|
|
23
28
|
onInspect: (resource: SelectedResource) => void
|
|
24
29
|
inspected: SelectedResource | null
|
|
25
30
|
onClearNamespaces: () => void
|
|
31
|
+
/** Opens the create dialog for this screen's kind (the Clusters view's Create). */
|
|
32
|
+
onCreate?: () => void
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
const COVERAGE_LABEL: Record<string, string> = {
|
|
29
36
|
denied: 'no access',
|
|
30
|
-
partial: '
|
|
37
|
+
partial: 'not read in some namespaces',
|
|
38
|
+
uncached: 'not cached by Radar',
|
|
31
39
|
syncing: 'still loading',
|
|
32
40
|
error: 'could not be read',
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
// The workspace name sits on the title line, as on the CloudNativePG detail
|
|
44
|
+
// pages; it is not a link, since on Clusters it would lead to itself.
|
|
35
45
|
export function CNPGWorkspaceHeader({ title, subtitle, actions }: { title: string; subtitle?: ReactNode; actions?: ReactNode }) {
|
|
36
46
|
return (
|
|
37
47
|
<div className="px-5 pt-4 xl:px-7">
|
|
38
|
-
<div className="flex items-
|
|
39
|
-
<Database className="h-3.5 w-3.5" />
|
|
40
|
-
<span>CloudNativePG</span>
|
|
41
|
-
</div>
|
|
42
|
-
<div className="mt-1 flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
|
|
48
|
+
<div className="flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
|
|
43
49
|
<div className="min-w-0">
|
|
44
|
-
<
|
|
50
|
+
<div className="flex flex-wrap items-center gap-x-2">
|
|
51
|
+
<span className="flex items-center gap-1.5 text-sm text-theme-text-tertiary">
|
|
52
|
+
<Database className="h-3.5 w-3.5" />
|
|
53
|
+
CloudNativePG
|
|
54
|
+
<span>/</span>
|
|
55
|
+
</span>
|
|
56
|
+
<h1 className="text-lg font-semibold text-theme-text-primary">{title}</h1>
|
|
57
|
+
</div>
|
|
45
58
|
{subtitle && <div className="mt-0.5 text-sm text-theme-text-secondary">{subtitle}</div>}
|
|
46
59
|
</div>
|
|
47
60
|
{actions}
|
|
@@ -50,16 +63,52 @@ export function CNPGWorkspaceHeader({ title, subtitle, actions }: { title: strin
|
|
|
50
63
|
)
|
|
51
64
|
}
|
|
52
65
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
66
|
+
// The causes the server named for the namespaces it left unread. A partial or
|
|
67
|
+
// uncached read can name both kinds of namespace at once.
|
|
68
|
+
function namedCauses(cov: CNPGKindCoverage | undefined, uncachedNoun: string): { denied?: string; uncached?: string } {
|
|
69
|
+
return {
|
|
70
|
+
denied: cov?.deniedNamespaces?.length ? cov.deniedNamespaces.join(', ') : undefined,
|
|
71
|
+
uncached: cov?.uncachedNamespaces?.length ? `${uncachedNoun} in ${cov.uncachedNamespaces.join(', ')}` : undefined,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** How much of a kind was read, in a few words ("not cached by Radar in pg"). */
|
|
76
|
+
export function coverageLabel(cov: CNPGKindCoverage | undefined): string {
|
|
77
|
+
if (!cov) return 'not read by this Radar'
|
|
78
|
+
const { denied, uncached } = namedCauses(cov, 'not cached by Radar')
|
|
79
|
+
const named = [denied && `no access in ${denied}`, uncached].filter(Boolean)
|
|
80
|
+
if (named.length > 0) return named.join('; ')
|
|
81
|
+
return COVERAGE_LABEL[cov?.state ?? ''] ?? cov?.state ?? 'unknown'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const INCOMPLETE_STATES = new Set(['partial', 'denied', 'syncing', 'error', 'uncached'])
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The incomplete kinds grouped by what was left unread, each reason said once.
|
|
88
|
+
* Jobs count when their coverage is incomplete: a Cluster's initdb, join and
|
|
89
|
+
* restore Job Pods are read only where they are.
|
|
90
|
+
*/
|
|
91
|
+
export function incompleteKindsText(kinds: CNPGFleet['incompleteKinds'], coverage: CNPGWorkspaceResponse['coverage'], jobCoverage?: CNPGKindCoverage): string {
|
|
92
|
+
const byLabel = new Map<string, string[]>()
|
|
93
|
+
const add = (kind: string, cov: CNPGKindCoverage | undefined) => {
|
|
94
|
+
const label = coverageLabel(cov)
|
|
95
|
+
byLabel.set(label, [...(byLabel.get(label) ?? []), kind])
|
|
96
|
+
}
|
|
97
|
+
for (const k of kinds) add(CNPG_KIND_BY_KEY[k].kind, coverage[k])
|
|
98
|
+
if (jobCoverage && INCOMPLETE_STATES.has(jobCoverage.state)) add('Job', jobCoverage)
|
|
99
|
+
return [...byLabel].map(([label, names]) => `${names.join(', ')} (${label})`).join('; ')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function CoverageNotice({ fleet, data, kinds, namespace, includeJobs = false }: { fleet: CNPGFleet; data: CNPGWorkspaceResponse; kinds: readonly CNPGWorkspaceKey[]; namespace?: string; includeJobs?: boolean }) {
|
|
103
|
+
const incomplete = fleet.incompleteKinds.filter((k) => { const coverage = data.coverage[k]; return kinds.includes(k) && (!coverage || !coverageReadable(coverage, namespace)) })
|
|
104
|
+
const jobsIncomplete = includeJobs && !!data.jobCoverage && INCOMPLETE_STATES.has(data.jobCoverage.state) && !coverageReadable(data.jobCoverage, namespace)
|
|
105
|
+
if (incomplete.length === 0 && !jobsIncomplete) return null
|
|
60
106
|
return (
|
|
61
107
|
<Notice>
|
|
62
|
-
Some
|
|
108
|
+
Some data used on this screen was not read.
|
|
109
|
+
<FoldSection title="Coverage details" summary="Kinds and reasons" attention={false}>
|
|
110
|
+
{incompleteKindsText(incomplete, data.coverage, jobsIncomplete ? data.jobCoverage : undefined)}. Counts over incomplete data are lower bounds.
|
|
111
|
+
</FoldSection>
|
|
63
112
|
</Notice>
|
|
64
113
|
)
|
|
65
114
|
}
|
|
@@ -81,172 +130,44 @@ export function CNPGScreenGate({
|
|
|
81
130
|
const data = query.data
|
|
82
131
|
if (!data && query.isLoading) return <PaneLoader label="Loading CloudNativePG…" className="flex-1" />
|
|
83
132
|
if (!data) {
|
|
133
|
+
const upgrade = getRadarUpgradeRequirement(query.error)
|
|
134
|
+
if (upgrade) {
|
|
135
|
+
return (
|
|
136
|
+
<ScreenEmptyState
|
|
137
|
+
icon={Database}
|
|
138
|
+
title={radarUpgradeHeadline('CloudNativePG')}
|
|
139
|
+
detail={radarUpgradeDetail(upgrade)}
|
|
140
|
+
action={
|
|
141
|
+
<div className="mt-3 text-sm">
|
|
142
|
+
<RadarUpgradeAction requirement={upgrade} />
|
|
143
|
+
</div>
|
|
144
|
+
}
|
|
145
|
+
/>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
84
148
|
return (
|
|
85
|
-
<
|
|
149
|
+
<ScreenEmptyState
|
|
86
150
|
icon={Database}
|
|
87
|
-
title="CloudNativePG
|
|
88
|
-
detail={query.error instanceof Error ? query.error.message : '
|
|
151
|
+
title="CloudNativePG data unavailable"
|
|
152
|
+
detail={query.error instanceof Error ? query.error.message : 'Radar could not load the CloudNativePG data.'}
|
|
153
|
+
action={
|
|
154
|
+
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching} className="btn-secondary mt-3 px-3 py-1.5 text-sm">
|
|
155
|
+
{query.isFetching ? 'Retrying…' : 'Retry'}
|
|
156
|
+
</button>
|
|
157
|
+
}
|
|
89
158
|
/>
|
|
90
159
|
)
|
|
91
160
|
}
|
|
92
161
|
if (!data.installed || !fleet) {
|
|
93
162
|
return (
|
|
94
|
-
<
|
|
163
|
+
<ScreenEmptyState
|
|
95
164
|
icon={Database}
|
|
96
165
|
title="CloudNativePG is not installed"
|
|
97
166
|
detail={`No postgresql.cnpg.io resources are served in ${connection.context || 'this cluster'}.`}
|
|
98
167
|
/>
|
|
99
168
|
)
|
|
100
169
|
}
|
|
101
|
-
return
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function ScreenBody({ children }: { children: ReactNode }) {
|
|
105
|
-
return (
|
|
106
|
-
<div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
|
107
|
-
<div className="space-y-5 px-5 pb-6 pt-3 xl:px-7">{children}</div>
|
|
108
|
-
</div>
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function FilterChips({ chips }: { chips: { label: string; onClear: () => void }[] }) {
|
|
113
|
-
if (chips.length === 0) return null
|
|
114
|
-
return (
|
|
115
|
-
<div className="flex flex-wrap gap-1.5">
|
|
116
|
-
{chips.map((c) => (
|
|
117
|
-
<span key={c.label} className="inline-flex items-center gap-1 rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs text-theme-text-secondary">
|
|
118
|
-
{c.label}
|
|
119
|
-
<button type="button" onClick={c.onClear} aria-label={`Remove ${c.label}`} className="rounded-full p-0.5 hover:bg-theme-hover">
|
|
120
|
-
<X className="h-3 w-3" />
|
|
121
|
-
</button>
|
|
122
|
-
</span>
|
|
123
|
-
))}
|
|
124
|
-
</div>
|
|
125
|
-
)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
export function namespaceChip(namespaces: string[], onClear: () => void) {
|
|
129
|
-
return namespaces.length > 0 ? [{ label: `Namespace: ${namespaces.join(', ')}`, onClear }] : []
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
export function Segments<T extends string>({
|
|
133
|
-
value,
|
|
134
|
-
options,
|
|
135
|
-
onChange,
|
|
136
|
-
label,
|
|
137
|
-
}: {
|
|
138
|
-
value: T
|
|
139
|
-
options: { id: T; label: string; count?: number }[]
|
|
140
|
-
onChange: (id: T) => void
|
|
141
|
-
label: string
|
|
142
|
-
}) {
|
|
143
|
-
return (
|
|
144
|
-
<div role="tablist" aria-label={label} className="inline-flex rounded-lg bg-theme-elevated p-0.5">
|
|
145
|
-
{options.map((o) => {
|
|
146
|
-
const on = o.id === value
|
|
147
|
-
return (
|
|
148
|
-
<button
|
|
149
|
-
key={o.id}
|
|
150
|
-
type="button"
|
|
151
|
-
role="tab"
|
|
152
|
-
aria-selected={on}
|
|
153
|
-
onClick={() => onChange(o.id)}
|
|
154
|
-
className={clsx(
|
|
155
|
-
'inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium transition-colors',
|
|
156
|
-
on ? 'bg-theme-surface text-theme-text-primary shadow-theme-sm' : 'text-theme-text-secondary hover:text-theme-text-primary',
|
|
157
|
-
)}
|
|
158
|
-
>
|
|
159
|
-
{o.label}
|
|
160
|
-
{o.count !== undefined && <span className="font-mono text-xs text-theme-text-tertiary">{o.count}</span>}
|
|
161
|
-
</button>
|
|
162
|
-
)
|
|
163
|
-
})}
|
|
164
|
-
</div>
|
|
165
|
-
)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export interface TableColumn<T> {
|
|
169
|
-
header: ReactNode
|
|
170
|
-
width?: string
|
|
171
|
-
cell: (row: T) => ReactNode
|
|
172
|
-
className?: string
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** A workspace table. Rows inspect in the drawer; the inspected row is highlighted. */
|
|
176
|
-
export function SectionTable<T>({
|
|
177
|
-
title,
|
|
178
|
-
subtitle,
|
|
179
|
-
columns,
|
|
180
|
-
rows,
|
|
181
|
-
rowKey,
|
|
182
|
-
rowResource,
|
|
183
|
-
onInspect,
|
|
184
|
-
inspected,
|
|
185
|
-
empty,
|
|
186
|
-
minWidth = 760,
|
|
187
|
-
footer,
|
|
188
|
-
}: {
|
|
189
|
-
title: ReactNode
|
|
190
|
-
subtitle?: ReactNode
|
|
191
|
-
columns: TableColumn<T>[]
|
|
192
|
-
rows: T[]
|
|
193
|
-
rowKey: (row: T) => string
|
|
194
|
-
rowResource?: (row: T) => SelectedResource | null
|
|
195
|
-
onInspect?: (resource: SelectedResource) => void
|
|
196
|
-
inspected?: SelectedResource | null
|
|
197
|
-
empty: ReactNode
|
|
198
|
-
minWidth?: number
|
|
199
|
-
footer?: ReactNode
|
|
200
|
-
}) {
|
|
201
|
-
return (
|
|
202
|
-
<section>
|
|
203
|
-
<div className="mb-2 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
|
|
204
|
-
<h2 className="text-sm font-semibold text-theme-text-primary">{title}</h2>
|
|
205
|
-
{subtitle && <span className="text-xs text-theme-text-tertiary">{subtitle}</span>}
|
|
206
|
-
</div>
|
|
207
|
-
<div className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
208
|
-
{rows.length === 0 ? (
|
|
209
|
-
<div className="px-4 py-5 text-sm text-theme-text-tertiary">{empty}</div>
|
|
210
|
-
) : (
|
|
211
|
-
<div className={TABLE_WRAP}>
|
|
212
|
-
<table className="w-full table-fixed" style={{ minWidth }}>
|
|
213
|
-
<colgroup>
|
|
214
|
-
{columns.map((c, i) => (
|
|
215
|
-
<col key={i} style={c.width ? { width: c.width } : undefined} />
|
|
216
|
-
))}
|
|
217
|
-
</colgroup>
|
|
218
|
-
<thead className={TABLE_HEAD}>
|
|
219
|
-
<tr>
|
|
220
|
-
{columns.map((c, i) => (
|
|
221
|
-
<th key={i} className={TH}>{c.header}</th>
|
|
222
|
-
))}
|
|
223
|
-
</tr>
|
|
224
|
-
</thead>
|
|
225
|
-
<tbody className={TBODY}>
|
|
226
|
-
{rows.map((row) => {
|
|
227
|
-
const res = rowResource?.(row) ?? null
|
|
228
|
-
const active = !!res && sameResource(inspected, res)
|
|
229
|
-
return (
|
|
230
|
-
<tr
|
|
231
|
-
key={rowKey(row)}
|
|
232
|
-
onClick={res && onInspect ? () => onInspect(res) : undefined}
|
|
233
|
-
className={clsx(res && onInspect && 'cursor-pointer', ROW_HOVER, active && 'selection')}
|
|
234
|
-
aria-selected={res ? active : undefined}
|
|
235
|
-
>
|
|
236
|
-
{columns.map((c, i) => (
|
|
237
|
-
<td key={i} className={clsx(TD, c.className)}>{c.cell(row)}</td>
|
|
238
|
-
))}
|
|
239
|
-
</tr>
|
|
240
|
-
)
|
|
241
|
-
})}
|
|
242
|
-
</tbody>
|
|
243
|
-
</table>
|
|
244
|
-
</div>
|
|
245
|
-
)}
|
|
246
|
-
</div>
|
|
247
|
-
{footer && <div className="mt-1.5 text-xs text-theme-text-tertiary">{footer}</div>}
|
|
248
|
-
</section>
|
|
249
|
-
)
|
|
170
|
+
return <><RefreshFailedNotice queries={[query]} className="px-5 pt-3 xl:px-7" />{children(data, fleet)}</>
|
|
250
171
|
}
|
|
251
172
|
|
|
252
173
|
/** Empty-state text for a collection, derived from how much of it was readable. */
|
|
@@ -255,7 +176,12 @@ export function coverageEmpty(cov: CNPGKindCoverage | undefined, noun: string):
|
|
|
255
176
|
case 'full':
|
|
256
177
|
return `No ${noun} in this scope.`
|
|
257
178
|
case 'partial':
|
|
258
|
-
|
|
179
|
+
case 'uncached': {
|
|
180
|
+
const { denied, uncached } = namedCauses(cov, `Radar does not cache ${noun}`)
|
|
181
|
+
const causes = [denied && `no access in ${denied}`, uncached].filter(Boolean)
|
|
182
|
+
if (causes.length > 0) return `No ${noun} visible: ${causes.join('; ')}.`
|
|
183
|
+
return cov.state === 'uncached' ? `Radar does not cache ${noun} in this scope.` : `No ${noun} visible. Some namespaces were not read.`
|
|
184
|
+
}
|
|
259
185
|
case 'denied':
|
|
260
186
|
return `No access to ${noun}.`
|
|
261
187
|
case 'syncing':
|
|
@@ -269,18 +195,10 @@ export function coverageEmpty(cov: CNPGKindCoverage | undefined, noun: string):
|
|
|
269
195
|
|
|
270
196
|
/** The less complete of two coverages, for collections built from several kinds. */
|
|
271
197
|
export function worstCoverage(...covs: (CNPGKindCoverage | undefined)[]): CNPGKindCoverage | undefined {
|
|
272
|
-
const rank: Record<string, number> = { error: 0, denied: 1,
|
|
198
|
+
const rank: Record<string, number> = { error: 0, denied: 1, uncached: 2, syncing: 3, partial: 4, full: 5, notInstalled: 6 }
|
|
273
199
|
return covs.filter(Boolean).sort((a, b) => (rank[a!.state] ?? 9) - (rank[b!.state] ?? 9))[0]
|
|
274
200
|
}
|
|
275
201
|
|
|
276
|
-
export function Mono({ children }: { children: ReactNode }) {
|
|
277
|
-
return <span className="font-mono text-[12.5px] break-all">{children}</span>
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export function Sub({ children }: { children: ReactNode }) {
|
|
281
|
-
return <div className="mt-0.5 text-xs text-theme-text-tertiary break-words">{children}</div>
|
|
282
|
-
}
|
|
283
|
-
|
|
284
202
|
export function clusterResource(namespace: string, name: string): SelectedResource {
|
|
285
203
|
return { kind: 'clusters', group: 'postgresql.cnpg.io', namespace, name }
|
|
286
204
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildResizeManifest, cnpgFloorTone, cnpgInstanceDiskTone, cnpgSharedExpansionGap, cnpgSlotRetentionText, cnpgWALUsageFloor } from './storageModel'
|
|
3
|
+
import type { CNPGStorageVolume, CNPGStorageWAL } from '../../api/cnpg-storage'
|
|
4
|
+
|
|
5
|
+
const cluster = {
|
|
6
|
+
metadata: { name: 'pg', namespace: 'db' },
|
|
7
|
+
spec: {
|
|
8
|
+
storage: { size: '10Gi', storageClass: 'fast' },
|
|
9
|
+
tablespaces: [
|
|
10
|
+
{ name: 'hot', storage: { size: '5Gi' }, owner: { name: 'app' } },
|
|
11
|
+
{ name: 'cold', storage: { pvcTemplate: { resources: { requests: { storage: '50Gi' } }, storageClassName: 'slow' } } },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('buildResizeManifest', () => {
|
|
17
|
+
it('applies only the data size', () => {
|
|
18
|
+
const m = buildResizeManifest(cluster, { role: 'PG_DATA', field: 'spec.storage.size', declared: '10Gi' }, '20Gi')
|
|
19
|
+
expect(m).toEqual({ apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'pg', namespace: 'db' }, spec: { storage: { size: '20Gi' } } })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('writes WAL sizes under walStorage, following a pvcTemplate', () => {
|
|
23
|
+
const m = buildResizeManifest(cluster, { role: 'PG_WAL', field: 'spec.walStorage.pvcTemplate.resources.requests.storage' }, '4Gi')
|
|
24
|
+
expect(m.spec).toEqual({ walStorage: { pvcTemplate: { resources: { requests: { storage: '4Gi' } } } } })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('sends every tablespace, changing only the one named', () => {
|
|
28
|
+
const m = buildResizeManifest(cluster, { role: 'PG_TABLESPACE', tablespace: 'cold', field: 'spec.tablespaces[name=cold].storage.pvcTemplate.resources.requests.storage' }, '80Gi')
|
|
29
|
+
expect(m.spec.tablespaces[0]).toEqual(cluster.spec.tablespaces[0])
|
|
30
|
+
expect(m.spec.tablespaces[1].storage).toEqual({ pvcTemplate: { resources: { requests: { storage: '80Gi' } }, storageClassName: 'slow' } })
|
|
31
|
+
expect(cluster.spec.tablespaces[1]!.storage.pvcTemplate?.resources.requests.storage).toBe('50Gi')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('cnpgSlotRetentionText', () => {
|
|
36
|
+
it('shows the largest slot, never a sum, and 0 B when nothing is held', () => {
|
|
37
|
+
expect(cnpgSlotRetentionText([])).toBe('No slots')
|
|
38
|
+
expect(cnpgSlotRetentionText([{ name: 'a', retainedBytes: 0 }])).toBe('0 B')
|
|
39
|
+
expect(cnpgSlotRetentionText([{ name: 'a', retainedBytes: 1024 }])).toBe('1.0 KiB')
|
|
40
|
+
const two = cnpgSlotRetentionText([{ name: 'a', retainedBytes: 10 * 1024 * 1024 }, { name: 'b', retainedBytes: 2 * 1024 * 1024 }])
|
|
41
|
+
expect(two).toBe('10 MiB (largest slot)')
|
|
42
|
+
expect(two).not.toContain('up to')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('cnpgSharedExpansionGap', () => {
|
|
47
|
+
const vol = (reason?: string, allow?: boolean) => ({ storageClass: { name: 'standard', allowVolumeExpansion: allow, reason } })
|
|
48
|
+
it('names one shared reason for every unknown volume, so the page says it once', () => {
|
|
49
|
+
expect(cnpgSharedExpansionGap([vol('cannot list StorageClasses'), vol('cannot list StorageClasses')])).toBe('cannot list StorageClasses')
|
|
50
|
+
expect(cnpgSharedExpansionGap([vol('a'), vol('b')])).toBeUndefined()
|
|
51
|
+
expect(cnpgSharedExpansionGap([vol('a')])).toBeUndefined()
|
|
52
|
+
expect(cnpgSharedExpansionGap([vol(undefined, true), vol(undefined, true)])).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('cnpgWALUsageFloor', () => {
|
|
57
|
+
const vol = (state: string, claim = 'pg-1') => ({ claim, role: 'PG_DATA', capacity: '1Gi', capacityBytes: 1024 ** 3, storageClass: {}, resize: {}, usage: { state } }) as CNPGStorageVolume
|
|
58
|
+
const wal = (sizeBytes: number, volume = 'pg-1') => ({ status: { state: 'ok' }, metrics: { state: 'ok' }, volume, sizeBytes }) as CNPGStorageWAL
|
|
59
|
+
it('is the WAL size on the claim that holds it, only when kubelet did not measure', () => {
|
|
60
|
+
expect(cnpgWALUsageFloor(vol('noSeries'), wal(512 * 1024 ** 2))).toEqual({ bytes: 512 * 1024 ** 2, ratio: 0.5 })
|
|
61
|
+
expect(cnpgWALUsageFloor(vol('ok'), wal(512 * 1024 ** 2))).toBeUndefined()
|
|
62
|
+
expect(cnpgWALUsageFloor(vol('noSeries', 'pg-1-wal'), wal(512 * 1024 ** 2))).toBeUndefined()
|
|
63
|
+
expect(cnpgWALUsageFloor(vol('noSeries'), { ...wal(1), metrics: { state: 'denied' } } as CNPGStorageWAL)).toBeUndefined()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('cnpgFloorTone', () => {
|
|
68
|
+
it('never reads a lower bound below the warning line as healthy', () => {
|
|
69
|
+
expect(cnpgFloorTone(0.5)).toBe('unknown')
|
|
70
|
+
expect(cnpgFloorTone(0.85)).toBe('degraded')
|
|
71
|
+
expect(cnpgFloorTone(3.3)).toBe('unhealthy')
|
|
72
|
+
expect(cnpgFloorTone(undefined)).toBe('unknown')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('cnpgInstanceDiskTone', () => {
|
|
77
|
+
const measured = (ratio: number, claim = 'pg-1') => ({ claim, role: 'PG_DATA', capacity: '1Gi', capacityBytes: 1024 ** 3, storageClass: {}, resize: {}, usage: { state: 'ok', ratio } }) as CNPGStorageVolume
|
|
78
|
+
const unmeasured = (claim: string) => ({ claim, role: 'PG_WAL', capacity: '1Gi', capacityBytes: 1024 ** 3, storageClass: {}, resize: {}, usage: { state: 'noSeries' } }) as CNPGStorageVolume
|
|
79
|
+
const wal = (sizeBytes: number, volume: string) => ({ status: { state: 'ok' }, metrics: { state: 'ok' }, volume, sizeBytes }) as CNPGStorageWAL
|
|
80
|
+
it('lets a dangerous WAL bound on one volume outrank a calm measurement on another', () => {
|
|
81
|
+
expect(cnpgInstanceDiskTone([measured(0.2), unmeasured('pg-1-wal')], wal(0.95 * 1024 ** 3, 'pg-1-wal'))).toBe('unhealthy')
|
|
82
|
+
})
|
|
83
|
+
it('stays unknown, not healthy, while any volume is unmeasured', () => {
|
|
84
|
+
expect(cnpgInstanceDiskTone([measured(0.2), unmeasured('pg-1-wal')], wal(0.1 * 1024 ** 3, 'pg-1-wal'))).toBe('unknown')
|
|
85
|
+
expect(cnpgInstanceDiskTone([measured(0.2)], undefined)).toBe('healthy')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('does not infer slot absence from absent byte readings', () => {
|
|
90
|
+
expect(cnpgSlotRetentionText(undefined)).toBe('Slot inventory not read')
|
|
91
|
+
expect(cnpgSlotRetentionText(null)).toBe('Slot inventory not read')
|
|
92
|
+
expect(cnpgSlotRetentionText([], false)).toBe('Slot inventory partly read')
|
|
93
|
+
expect(cnpgSlotRetentionText([{ name: 'a', active: false }], false)).toBe('≥1 inactive slot; retained WAL not reported')
|
|
94
|
+
expect(cnpgSlotRetentionText([{ name: '_cnpg_orders_2', active: false }])).toBe('1 inactive slot; retained WAL not reported')
|
|
95
|
+
expect(cnpgSlotRetentionText([{ name: 'a', retainedBytes: 10 }, { name: 'b' }])).toContain('≥10 B reported; some slots unmeasured')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('keeps measured retained WAL when slot inventory is unavailable', () => {
|
|
99
|
+
expect(cnpgSlotRetentionText(null, true, [{ slot: 'a', bytes: 2048 }])).toBe('≥2.0 KiB retained WAL reported; slot inventory not read')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('can separate an unmeasured retention note from slot counts without changing the default', () => {
|
|
103
|
+
const slots = [{ name: '_cnpg_orders_2', active: false }]
|
|
104
|
+
expect(cnpgSlotRetentionText(slots, true, [], { separateMissingBytes: true })).toBe('1 inactive slot')
|
|
105
|
+
expect(cnpgSlotRetentionText(slots, false, [], { separateMissingBytes: true })).toBe('≥1 inactive slot')
|
|
106
|
+
expect(cnpgSlotRetentionText(slots)).toBe('1 inactive slot; retained WAL not reported')
|
|
107
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { formatBytes } from './lsn'
|
|
2
|
+
import { cnpgDiskTone, type HealthLevel } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import type { CNPGStorageTarget, CNPGStorageVolume, CNPGStorageWAL } from '../../api/cnpg-storage'
|
|
4
|
+
|
|
5
|
+
function setPath(obj: Record<string, any>, path: string[], value: unknown) {
|
|
6
|
+
let cur = obj
|
|
7
|
+
for (const p of path.slice(0, -1)) {
|
|
8
|
+
cur[p] = cur[p] && typeof cur[p] === 'object' ? { ...cur[p] } : {}
|
|
9
|
+
cur = cur[p]
|
|
10
|
+
}
|
|
11
|
+
cur[path[path.length - 1]] = value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Where the size lives under the storage block the target names.
|
|
15
|
+
function sizePath(t: CNPGStorageTarget): string[] {
|
|
16
|
+
return t.field.endsWith('.pvcTemplate.resources.requests.storage') ? ['pvcTemplate', 'resources', 'requests', 'storage'] : ['size']
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The manifest server-side applies only the size. Tablespaces are a list the
|
|
21
|
+
* CRD does not merge by key, so the whole list is sent with one size changed.
|
|
22
|
+
*/
|
|
23
|
+
export function buildResizeManifest(cluster: any, target: CNPGStorageTarget, size: string): Record<string, any> {
|
|
24
|
+
const spec: Record<string, any> = {}
|
|
25
|
+
if (target.role === 'PG_TABLESPACE') {
|
|
26
|
+
spec.tablespaces = (cluster?.spec?.tablespaces ?? []).map((ts: any) => {
|
|
27
|
+
if (ts?.name !== target.tablespace) return ts
|
|
28
|
+
const storage = { ...(ts.storage ?? {}) }
|
|
29
|
+
setPath(storage, sizePath(target), size)
|
|
30
|
+
return { ...ts, storage }
|
|
31
|
+
})
|
|
32
|
+
} else {
|
|
33
|
+
const block: Record<string, any> = {}
|
|
34
|
+
setPath(block, sizePath(target), size)
|
|
35
|
+
spec[target.role === 'PG_WAL' ? 'walStorage' : 'storage'] = block
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
39
|
+
kind: 'Cluster',
|
|
40
|
+
metadata: { name: cluster?.metadata?.name, namespace: cluster?.metadata?.namespace },
|
|
41
|
+
spec,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* WAL held by replication slots on one instance. Slots overlap (they can
|
|
47
|
+
* hold the same segments), so the figure is the largest slot, never a sum.
|
|
48
|
+
*/
|
|
49
|
+
export function cnpgSlotRetentionText(slots: { name: string; active?: boolean; retainedBytes?: number }[] | null | undefined, complete = true, retained: { slot: string; bytes: number }[] = [], options: { separateMissingBytes?: boolean } = {}): string {
|
|
50
|
+
if (!slots) return retained.length ? `≥${formatBytes(Math.max(...retained.map((s) => s.bytes)))} retained WAL reported; slot inventory not read` : 'Slot inventory not read'
|
|
51
|
+
if (slots.length === 0) return complete ? 'No slots' : 'Slot inventory partly read'
|
|
52
|
+
const measured = slots.filter((s) => s.retainedBytes !== undefined)
|
|
53
|
+
if (measured.length < slots.length) {
|
|
54
|
+
const inactive = slots.filter((s) => s.active === false).length
|
|
55
|
+
const count = inactive === slots.length ? `${inactive} inactive slot${inactive === 1 ? '' : 's'}` : `${slots.length} slot${slots.length === 1 ? '' : 's'}`
|
|
56
|
+
if (options.separateMissingBytes && measured.length === 0) return `${complete ? '' : '≥'}${count}`
|
|
57
|
+
return `${complete ? '' : '≥'}${count}; retained WAL ${measured.length ? `≥${formatBytes(Math.max(...measured.map((s) => s.retainedBytes!)))} reported; some slots unmeasured` : 'not reported'}`
|
|
58
|
+
}
|
|
59
|
+
const max = Math.max(...measured.map((s) => s.retainedBytes!))
|
|
60
|
+
return !complete ? `${formatBytes(max)} (largest reported slot)` : max === 0 || slots.length === 1 ? formatBytes(max) : `${formatBytes(max)} (largest slot)`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Why StorageClass expansion is unknown, when it is the same reason for
|
|
65
|
+
* every volume whose class could not be read (and more than one), so it can be
|
|
66
|
+
* said once for the page instead of under each volume.
|
|
67
|
+
*/
|
|
68
|
+
export function cnpgSharedExpansionGap(volumes: { storageClass: { name?: string; allowVolumeExpansion?: boolean; reason?: string } }[]): string | undefined {
|
|
69
|
+
const unknown = volumes.filter((v) => v.storageClass.name && v.storageClass.allowVolumeExpansion === undefined)
|
|
70
|
+
if (unknown.length < 2) return undefined
|
|
71
|
+
const reasons = new Set(unknown.map((v) => v.storageClass.reason ?? ''))
|
|
72
|
+
return reasons.size === 1 ? [...reasons][0] || undefined : undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A lower bound on a volume's used space when kubelet didn't measure it: the
|
|
77
|
+
* WAL the instance reports lives on this claim, so at least that much is used.
|
|
78
|
+
*/
|
|
79
|
+
export function cnpgWALUsageFloor(v: CNPGStorageVolume, wal: CNPGStorageWAL | undefined): { bytes: number; ratio?: number } | undefined {
|
|
80
|
+
if (v.usage.state === 'ok' || !wal || wal.metrics.state !== 'ok' || wal.sizeBytes === undefined || wal.volume !== v.claim) return undefined
|
|
81
|
+
return { bytes: wal.sizeBytes, ratio: v.capacityBytes ? wal.sizeBytes / v.capacityBytes : undefined }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A lower bound can prove a volume is filling, never that it's fine. */
|
|
85
|
+
export function cnpgFloorTone(ratio: number | undefined): HealthLevel {
|
|
86
|
+
if (ratio === undefined) return 'unknown'
|
|
87
|
+
const tone = cnpgDiskTone(ratio)
|
|
88
|
+
return tone === 'healthy' ? 'unknown' : tone
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* An instance's disk tone across its volumes: the worst measured or bounded
|
|
93
|
+
* reading that crosses a line, otherwise healthy only when every volume was
|
|
94
|
+
* measured below it. One unmeasured volume leaves a calm verdict unknown.
|
|
95
|
+
*/
|
|
96
|
+
export function cnpgInstanceDiskTone(volumes: CNPGStorageVolume[], wal: CNPGStorageWAL | undefined): HealthLevel {
|
|
97
|
+
const rank: Record<string, number> = { unhealthy: 3, degraded: 2, unknown: 1, healthy: 0 }
|
|
98
|
+
const tones = volumes.map((v) => (v.usage.ratio !== undefined ? cnpgDiskTone(v.usage.ratio) : cnpgFloorTone(cnpgWALUsageFloor(v, wal)?.ratio)))
|
|
99
|
+
if (tones.length === 0) return 'unknown'
|
|
100
|
+
return tones.reduce((a, b) => ((rank[b] ?? 1) > (rank[a] ?? 1) ? b : a))
|
|
101
|
+
}
|