@skyhook-io/radar-app 1.16.3 → 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 +138 -93
- package/src/RadarApp.tsx +27 -17
- 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.goneResource.test.ts +49 -0
- 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 +381 -47
- 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/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +65 -3
- package/src/api/radarFeatures.ts +6 -0
- 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/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -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 +24 -62
- package/src/components/capacity/CapacityView.test.tsx +354 -1
- 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 +23 -150
- 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 +230 -124
- 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/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- 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/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/ChartBrowser.tsx +3 -3
- 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/RevisionHistory.tsx +3 -2
- 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/ClusterHealthCard.tsx +7 -0
- 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/home/mcpToolCatalog.ts +1 -1
- 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/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +150 -34
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +25 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +25 -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/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -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 +11 -1
- package/src/components/workload/WorkloadView.tsx +162 -529
- 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/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- 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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { CNPGBackend } from '../../api/cnpg-sessions'
|
|
2
|
+
|
|
3
|
+
export interface BlockingNode {
|
|
4
|
+
session: CNPGBackend
|
|
5
|
+
children: BlockingNode[]
|
|
6
|
+
/** Blockers of this session other than the parent it is shown under. */
|
|
7
|
+
alsoWaitsOn: number[]
|
|
8
|
+
/** This session waits on one of its own ancestors: a lock cycle (deadlock). */
|
|
9
|
+
cycle?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Blocker → victim trees from pg_blocking_pids. A root blocks others and
|
|
14
|
+
* waits on nobody listed; a victim appears under each blocker it waits on.
|
|
15
|
+
* Sessions left over after walking from the roots are in a cycle (a deadlock
|
|
16
|
+
* PostgreSQL has not resolved yet) and become roots of their own, marked.
|
|
17
|
+
*/
|
|
18
|
+
export function buildBlockingTree(sessions: CNPGBackend[]): BlockingNode[] {
|
|
19
|
+
const byPid = new Map(sessions.map((s) => [s.pid, s]))
|
|
20
|
+
const victimsOf = new Map<number, CNPGBackend[]>()
|
|
21
|
+
for (const s of sessions) {
|
|
22
|
+
for (const b of s.blockedBy) {
|
|
23
|
+
if (!byPid.has(b)) continue
|
|
24
|
+
victimsOf.set(b, [...(victimsOf.get(b) ?? []), s])
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const seen = new Set<number>()
|
|
28
|
+
const build = (s: CNPGBackend, parent: number | undefined, path: Set<number>): BlockingNode => {
|
|
29
|
+
seen.add(s.pid)
|
|
30
|
+
const node: BlockingNode = { session: s, children: [], alsoWaitsOn: s.blockedBy.filter((b) => b !== parent) }
|
|
31
|
+
const nextPath = new Set(path).add(s.pid)
|
|
32
|
+
for (const v of victimsOf.get(s.pid) ?? []) {
|
|
33
|
+
if (nextPath.has(v.pid)) {
|
|
34
|
+
node.cycle = true
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
node.children.push(build(v, s.pid, nextPath))
|
|
38
|
+
}
|
|
39
|
+
return node
|
|
40
|
+
}
|
|
41
|
+
const roots = sessions
|
|
42
|
+
.filter((s) => s.blockedBy.every((b) => !byPid.has(b)) && (victimsOf.get(s.pid)?.length ?? 0) > 0)
|
|
43
|
+
.map((s) => build(s, undefined, new Set()))
|
|
44
|
+
// Waiting on a backend that is not listed (capped, or ended between reads).
|
|
45
|
+
for (const s of sessions) {
|
|
46
|
+
if (!seen.has(s.pid) && s.blockedBy.length > 0 && s.blockedBy.every((b) => !byPid.has(b))) roots.push(build(s, undefined, new Set()))
|
|
47
|
+
}
|
|
48
|
+
for (const s of sessions) {
|
|
49
|
+
if (!seen.has(s.pid)) {
|
|
50
|
+
const node = build(s, undefined, new Set())
|
|
51
|
+
node.cycle = true
|
|
52
|
+
roots.push(node)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return roots
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** How many sessions wait behind this one, directly or transitively. */
|
|
59
|
+
export function countVictims(node: BlockingNode): number {
|
|
60
|
+
const pids = new Set<number>()
|
|
61
|
+
const walk = (n: BlockingNode) => {
|
|
62
|
+
for (const c of n.children) {
|
|
63
|
+
pids.add(c.session.pid)
|
|
64
|
+
walk(c)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
walk(node)
|
|
68
|
+
return pids.size
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CNPGConnectionFigure {
|
|
72
|
+
/** e.g. "6 in use" */
|
|
73
|
+
value: string
|
|
74
|
+
/** What it is out of, e.g. "of 97 usable (max_connections 100, 3 reserved)", shown beneath. */
|
|
75
|
+
limit: string
|
|
76
|
+
/** What the figure is measured against, and from where. */
|
|
77
|
+
detail: string
|
|
78
|
+
/** used / usable, for a headroom bar; undefined when the limit is unknown. */
|
|
79
|
+
ratio?: number
|
|
80
|
+
tone?: 'degraded' | 'unhealthy'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function connectionTone(ratio: number | undefined): CNPGConnectionFigure['tone'] {
|
|
84
|
+
if (ratio === undefined) return undefined
|
|
85
|
+
return ratio >= 0.95 ? 'unhealthy' : ratio >= 0.8 ? 'degraded' : undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The one connections figure the Sessions section shows. pg_stat_activity
|
|
90
|
+
* (read over exec) knows the superuser reserve, so it gives usable headroom;
|
|
91
|
+
* the exporter's count stands in when that read is unavailable and says what
|
|
92
|
+
* it cannot account for.
|
|
93
|
+
*/
|
|
94
|
+
export function cnpgConnectionFigure(
|
|
95
|
+
exec: { maxConnections?: number; superuserReservedConnections?: number; clientBackends?: number } | undefined,
|
|
96
|
+
exporter?: { sessionsTotal?: number; maxConnections?: number },
|
|
97
|
+
): CNPGConnectionFigure | undefined {
|
|
98
|
+
if (exec?.maxConnections !== undefined && exec.clientBackends !== undefined) {
|
|
99
|
+
const reserved = exec.superuserReservedConnections ?? 0
|
|
100
|
+
const usable = exec.maxConnections - reserved
|
|
101
|
+
const ratio = usable > 0 ? exec.clientBackends / usable : 1
|
|
102
|
+
return {
|
|
103
|
+
value: `${exec.clientBackends} in use`,
|
|
104
|
+
limit: `of ${usable} usable (max_connections ${exec.maxConnections}, ${reserved} reserved)`,
|
|
105
|
+
detail: `Client backends from pg_stat_activity; ${reserved} of max_connections ${exec.maxConnections} are reserved for superusers`,
|
|
106
|
+
ratio,
|
|
107
|
+
tone: connectionTone(ratio),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (exporter?.sessionsTotal === undefined) return undefined
|
|
111
|
+
if (exporter.maxConnections === undefined) {
|
|
112
|
+
return { value: `${exporter.sessionsTotal} in use`, limit: 'max_connections not reported', detail: 'Sessions from the metrics exporter' }
|
|
113
|
+
}
|
|
114
|
+
const ratio = exporter.maxConnections > 0 ? exporter.sessionsTotal / exporter.maxConnections : 1
|
|
115
|
+
return {
|
|
116
|
+
value: `${exporter.sessionsTotal} in use`,
|
|
117
|
+
limit: `of max_connections ${exporter.maxConnections} (superuser reserve not read)`,
|
|
118
|
+
detail: 'Sessions from the metrics exporter; the superuser reserve is read only with exec into the instance',
|
|
119
|
+
ratio,
|
|
120
|
+
tone: connectionTone(ratio),
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether no instance has metrics readings (null = none for that Pod,
|
|
126
|
+
* undefined = not answered yet), so the panel says so once. That can be a
|
|
127
|
+
* missing metrics API or Pods not sampled yet; it never claims which.
|
|
128
|
+
*/
|
|
129
|
+
export function cnpgNoMetricsReadings(results: (unknown | null | undefined)[]): boolean {
|
|
130
|
+
return results.length > 0 && results.every((r) => r === null)
|
|
131
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { coverageEmpty, coverageLabel, incompleteKindsText } from './shared'
|
|
3
|
+
|
|
4
|
+
describe('coverage wording', () => {
|
|
5
|
+
it('names every cause the server named, whatever the overall state', () => {
|
|
6
|
+
const mixed = { state: 'uncached' as const, uncachedNamespaces: ['b'], deniedNamespaces: ['c'] }
|
|
7
|
+
expect(coverageEmpty(mixed, 'Pods')).toBe('No Pods visible: no access in c; Radar does not cache Pods in b.')
|
|
8
|
+
expect(coverageLabel(mixed)).toBe('no access in c; not cached by Radar in b')
|
|
9
|
+
})
|
|
10
|
+
it('falls back to the state when no namespace is named', () => {
|
|
11
|
+
expect(coverageEmpty({ state: 'uncached' }, 'Pods')).toBe('Radar does not cache Pods in this scope.')
|
|
12
|
+
expect(coverageEmpty({ state: 'partial' }, 'Pods')).toBe('No Pods visible. Some namespaces were not read.')
|
|
13
|
+
expect(coverageLabel({ state: 'denied' })).toBe('no access')
|
|
14
|
+
})
|
|
15
|
+
it('says each reason once for the kinds it applies to', () => {
|
|
16
|
+
const text = incompleteKindsText(['clusters', 'backups', 'clusterImageCatalogs'], {
|
|
17
|
+
clusters: { state: 'partial' },
|
|
18
|
+
backups: { state: 'partial' },
|
|
19
|
+
clusterImageCatalogs: { state: 'uncached' },
|
|
20
|
+
})
|
|
21
|
+
expect(text).toBe('Cluster, Backup (not read in some namespaces); ClusterImageCatalog (not cached by Radar)')
|
|
22
|
+
})
|
|
23
|
+
it("lists Jobs it could not read, since a Cluster's Job Pods are read only where they are", () => {
|
|
24
|
+
expect(incompleteKindsText(['clusters'], { clusters: { state: 'partial' } }, { state: 'denied' })).toBe('Cluster (not read in some namespaces); Job (no access)')
|
|
25
|
+
expect(incompleteKindsText([], {}, { state: 'full' })).toBe('')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useId, type InputHTMLAttributes } from 'react'
|
|
2
|
+
import { Input } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import type { CNPGTarget } from './targetModel'
|
|
4
|
+
import { CNPG_FORM_FIELD as FIELD } from '../formFields'
|
|
5
|
+
|
|
6
|
+
export function CNPGTargetFields({
|
|
7
|
+
value,
|
|
8
|
+
onChange,
|
|
9
|
+
idPrefix,
|
|
10
|
+
namespaces = [],
|
|
11
|
+
storageClasses = [],
|
|
12
|
+
namespaceFixed = false,
|
|
13
|
+
imageDescription,
|
|
14
|
+
imageRequired = false,
|
|
15
|
+
defaultClassDisabled = false,
|
|
16
|
+
}: {
|
|
17
|
+
value: CNPGTarget
|
|
18
|
+
onChange: (value: CNPGTarget) => void
|
|
19
|
+
idPrefix: string
|
|
20
|
+
namespaces?: string[]
|
|
21
|
+
storageClasses?: string[]
|
|
22
|
+
namespaceFixed?: boolean
|
|
23
|
+
imageDescription?: string
|
|
24
|
+
imageRequired?: boolean
|
|
25
|
+
defaultClassDisabled?: boolean
|
|
26
|
+
}) {
|
|
27
|
+
const listId = useId()
|
|
28
|
+
const set = (key: keyof CNPGTarget, next: string) => onChange({ ...value, [key]: next })
|
|
29
|
+
const input = (key: keyof CNPGTarget, label: string, props: InputHTMLAttributes<HTMLInputElement> = {}) => (
|
|
30
|
+
<label className="block" htmlFor={`${idPrefix}-${key}`}>
|
|
31
|
+
<span className="text-xs font-medium text-theme-text-secondary">{label}</span>
|
|
32
|
+
<Input
|
|
33
|
+
id={`${idPrefix}-${key}`}
|
|
34
|
+
value={value[key]}
|
|
35
|
+
onChange={(event) => set(key, event.target.value)}
|
|
36
|
+
autoComplete="off"
|
|
37
|
+
spellCheck={false}
|
|
38
|
+
className={FIELD}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
</label>
|
|
42
|
+
)
|
|
43
|
+
return (
|
|
44
|
+
<div className="space-y-4">
|
|
45
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
46
|
+
{input('name', 'Cluster name', { placeholder: 'orders-db' })}
|
|
47
|
+
{namespaceFixed ? (
|
|
48
|
+
<div>
|
|
49
|
+
<span className="text-xs font-medium text-theme-text-secondary">Namespace</span>
|
|
50
|
+
<div className="mt-2 font-mono text-sm text-theme-text-primary">{value.namespace}</div>
|
|
51
|
+
</div>
|
|
52
|
+
) : (
|
|
53
|
+
input('namespace', 'Namespace', { list: `${listId}-namespaces`, placeholder: 'Choose a namespace' })
|
|
54
|
+
)}
|
|
55
|
+
{input('instances', 'Instances', { type: 'number', min: 1, step: 1 })}
|
|
56
|
+
{input('size', 'Data volume size · per instance', { placeholder: '20Gi' })}
|
|
57
|
+
</div>
|
|
58
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
59
|
+
One instance is the primary; the others are standbys. Choose storage for the data you expect to keep.
|
|
60
|
+
</p>
|
|
61
|
+
<div className="grid items-start gap-3 sm:grid-cols-2">
|
|
62
|
+
<div>
|
|
63
|
+
{input('storageClass', 'Storage class · optional', {
|
|
64
|
+
list: `${listId}-storage`,
|
|
65
|
+
placeholder: defaultClassDisabled ? 'No default class' : 'Cluster default',
|
|
66
|
+
})}
|
|
67
|
+
{defaultClassDisabled && !value.storageClass && (
|
|
68
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
69
|
+
The PVC template opts out of the default class. Keep empty to preserve that choice, or choose a class.
|
|
70
|
+
</p>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
<div>
|
|
74
|
+
{imageDescription ? (
|
|
75
|
+
<div>
|
|
76
|
+
<span className="text-xs font-medium text-theme-text-secondary">Recovery image</span>
|
|
77
|
+
<p className="mt-1 break-all font-mono text-sm text-theme-text-primary">{imageDescription}</p>
|
|
78
|
+
<p className="mt-1 text-xs text-theme-text-tertiary">
|
|
79
|
+
Physical recovery requires the PostgreSQL major used by the backup. Advanced YAML exposes the full configuration.
|
|
80
|
+
</p>
|
|
81
|
+
</div>
|
|
82
|
+
) : (
|
|
83
|
+
<>
|
|
84
|
+
{input(
|
|
85
|
+
'image',
|
|
86
|
+
imageRequired ? 'PostgreSQL image · required for recovery' : 'PostgreSQL image · optional',
|
|
87
|
+
{ placeholder: imageRequired ? 'Image matching the source PostgreSQL major' : 'Operator default' },
|
|
88
|
+
)}
|
|
89
|
+
<p className="text-xs text-theme-text-tertiary">
|
|
90
|
+
{imageRequired
|
|
91
|
+
? 'Choose an image for the PostgreSQL major used by the backup before restoring.'
|
|
92
|
+
: 'Leave this empty to use the image selected by the operator.'}
|
|
93
|
+
</p>
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
<datalist id={`${listId}-namespaces`}>
|
|
99
|
+
{namespaces.map((name) => (
|
|
100
|
+
<option key={name} value={name} />
|
|
101
|
+
))}
|
|
102
|
+
</datalist>
|
|
103
|
+
<datalist id={`${listId}-storage`}>
|
|
104
|
+
{storageClasses.map((name) => (
|
|
105
|
+
<option key={name} value={name} />
|
|
106
|
+
))}
|
|
107
|
+
</datalist>
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { expect, it } from 'vitest'
|
|
2
|
+
import yaml from 'yaml'
|
|
3
|
+
import {
|
|
4
|
+
cnpgTargetDraft,
|
|
5
|
+
cnpgTargetFromManifest,
|
|
6
|
+
cnpgTargetIssue,
|
|
7
|
+
onlyCNPGTargetChanged,
|
|
8
|
+
sameCNPGRecoveryIdentity,
|
|
9
|
+
updateCNPGTargetYaml,
|
|
10
|
+
withCNPGTarget,
|
|
11
|
+
} from './targetModel'
|
|
12
|
+
|
|
13
|
+
const cluster = {
|
|
14
|
+
apiVersion: 'postgresql.cnpg.io/v1',
|
|
15
|
+
kind: 'Cluster',
|
|
16
|
+
metadata: { name: 'orders', namespace: 'db', labels: { app: 'orders' } },
|
|
17
|
+
spec: {
|
|
18
|
+
instances: 3,
|
|
19
|
+
storage: { size: '20Gi' },
|
|
20
|
+
imageCatalogRef: { kind: 'ClusterImageCatalog', name: 'pg', major: 17 },
|
|
21
|
+
bootstrap: { recovery: { source: 'archive' } },
|
|
22
|
+
externalClusters: [{ name: 'archive' }],
|
|
23
|
+
postgresql: { parameters: { max_connections: '200' } },
|
|
24
|
+
walStorage: { size: '2Gi' },
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
it('updates common inputs without flattening inherited or advanced configuration', () => {
|
|
29
|
+
const target = {
|
|
30
|
+
...cnpgTargetFromManifest(cluster),
|
|
31
|
+
name: 'restored',
|
|
32
|
+
instances: '2',
|
|
33
|
+
size: '40Gi',
|
|
34
|
+
storageClass: 'fast',
|
|
35
|
+
}
|
|
36
|
+
const next = withCNPGTarget(cluster, target)
|
|
37
|
+
expect(next).toMatchObject({
|
|
38
|
+
metadata: { name: 'restored', labels: { app: 'orders' } },
|
|
39
|
+
spec: {
|
|
40
|
+
instances: 2,
|
|
41
|
+
storage: { size: '40Gi', storageClass: 'fast' },
|
|
42
|
+
imageCatalogRef: cluster.spec.imageCatalogRef,
|
|
43
|
+
bootstrap: cluster.spec.bootstrap,
|
|
44
|
+
postgresql: cluster.spec.postgresql,
|
|
45
|
+
walStorage: cluster.spec.walStorage,
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
expect(cluster.spec.storage.size).toBe('20Gi')
|
|
49
|
+
expect(sameCNPGRecoveryIdentity(next, cluster)).toBe(true)
|
|
50
|
+
expect(onlyCNPGTargetChanged(cluster, next, target)).toBe(true)
|
|
51
|
+
expect(onlyCNPGTargetChanged(cluster, { ...next, spec: { ...next.spec, walStorage: { size: '5Gi' } } }, target)).toBe(
|
|
52
|
+
false,
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('preserves complex PVC templates and explicit no-default-class semantics', () => {
|
|
57
|
+
const source = {
|
|
58
|
+
...cluster,
|
|
59
|
+
spec: {
|
|
60
|
+
...cluster.spec,
|
|
61
|
+
storage: {
|
|
62
|
+
pvcTemplate: {
|
|
63
|
+
accessModes: ['ReadWriteOnce'],
|
|
64
|
+
storageClassName: '',
|
|
65
|
+
volumeMode: 'Block',
|
|
66
|
+
resources: { requests: { storage: '30Gi' } },
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
const target = { ...cnpgTargetFromManifest(source), size: '60Gi' }
|
|
72
|
+
const next = withCNPGTarget(source, target)
|
|
73
|
+
expect(next.spec.storage).toEqual({
|
|
74
|
+
pvcTemplate: {
|
|
75
|
+
accessModes: ['ReadWriteOnce'],
|
|
76
|
+
storageClassName: '',
|
|
77
|
+
volumeMode: 'Block',
|
|
78
|
+
resources: { requests: { storage: '60Gi' } },
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
const selected = withCNPGTarget(next, { ...target, storageClass: 'fast' })
|
|
82
|
+
expect(selected.spec.storage.storageClass).toBe('fast')
|
|
83
|
+
expect(selected.spec.storage.pvcTemplate.storageClassName).toBe('')
|
|
84
|
+
const cleared = withCNPGTarget(selected, { ...target, storageClass: '' })
|
|
85
|
+
expect(cleared.spec.storage.storageClass).toBeUndefined()
|
|
86
|
+
expect(cleared.spec.storage.pvcTemplate.storageClassName).toBe('')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('retains comments and arbitrary settings through a form round-trip', () => {
|
|
90
|
+
const text = `# archive must stay isolated\n${yaml.stringify(cluster, { version: '1.1' })}`
|
|
91
|
+
const next = updateCNPGTargetYaml(text, { ...cnpgTargetFromManifest(cluster), name: 'new-orders', size: '50Gi' })!
|
|
92
|
+
expect(next).toContain('archive must stay isolated')
|
|
93
|
+
expect(cnpgTargetDraft(next)).toMatchObject({
|
|
94
|
+
metadata: { name: 'new-orders' },
|
|
95
|
+
spec: { storage: { size: '50Gi' }, postgresql: { parameters: { max_connections: '200' } } },
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('adds missing storage parents as Kubernetes mappings in YAML 1.1', () => {
|
|
100
|
+
const source = { ...cluster, spec: { instances: 1 } }
|
|
101
|
+
const text = yaml.stringify(source, { version: '1.1' })
|
|
102
|
+
const next = updateCNPGTargetYaml(text, { ...cnpgTargetFromManifest(source), size: '20Gi', storageClass: 'fast' })!
|
|
103
|
+
expect(next).not.toContain('!!omap')
|
|
104
|
+
expect(cnpgTargetDraft(next)?.spec.storage).toEqual({ size: '20Gi', storageClass: 'fast' })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it.each([
|
|
108
|
+
'',
|
|
109
|
+
'kind: [',
|
|
110
|
+
yaml.stringify({ ...cluster, apiVersion: 'cluster.x-k8s.io/v1beta1' }),
|
|
111
|
+
`${yaml.stringify(cluster)}---\nkind: Secret\n`,
|
|
112
|
+
yaml.stringify({ ...cluster, spec: 'bad' }),
|
|
113
|
+
yaml.stringify({ ...cluster, spec: { storage: 'bad' } }),
|
|
114
|
+
yaml.stringify({ ...cluster, spec: { storage: { pvcTemplate: [] } } }),
|
|
115
|
+
])('keeps unsupported YAML outside the form projection: %s', (text) => {
|
|
116
|
+
expect(cnpgTargetDraft(text)).toBeNull()
|
|
117
|
+
expect(updateCNPGTargetYaml(text, cnpgTargetFromManifest(cluster))).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('requires actual storage and a known recovery image while allowing standard quantities', () => {
|
|
121
|
+
const target = cnpgTargetFromManifest(cluster)
|
|
122
|
+
expect(cnpgTargetIssue(target)).toBeUndefined()
|
|
123
|
+
for (const size of ['20Gi', '1e9', '2G']) expect(cnpgTargetIssue({ ...target, size })).toBeUndefined()
|
|
124
|
+
for (const size of ['', '0', '-2Gi', 'banana']) expect(cnpgTargetIssue({ ...target, size })).toContain('volume size')
|
|
125
|
+
for (const instances of ['0', '1.5', '-2', '9007199254740993'])
|
|
126
|
+
expect(cnpgTargetIssue({ ...target, instances })).toContain('whole number')
|
|
127
|
+
expect(cnpgTargetIssue(target, true)).toContain('matching the source')
|
|
128
|
+
expect(cnpgTargetIssue({ ...target, image: 'postgres:17' }, true)).toBeUndefined()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('changes an image override without inventing a catalog or losing the recovery source', () => {
|
|
132
|
+
const source = { ...cluster, spec: { ...cluster.spec, imageCatalogRef: undefined, imageName: 'postgres:17' } }
|
|
133
|
+
const next = withCNPGTarget(source, { ...cnpgTargetFromManifest(source), image: 'postgres:17.6' })
|
|
134
|
+
expect(next.spec.imageName).toBe('postgres:17.6')
|
|
135
|
+
expect(next.spec.bootstrap).toEqual(source.spec.bootstrap)
|
|
136
|
+
expect(sameCNPGRecoveryIdentity(source, next)).toBe(false)
|
|
137
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import yaml from 'yaml'
|
|
2
|
+
import { isApiGroup, parseQuantityToNumber } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { validateRFC1123Label } from '@skyhook-io/k8s-ui/utils/validators'
|
|
4
|
+
|
|
5
|
+
export interface CNPGTarget {
|
|
6
|
+
name: string
|
|
7
|
+
namespace: string
|
|
8
|
+
instances: string
|
|
9
|
+
size: string
|
|
10
|
+
storageClass: string
|
|
11
|
+
image: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function cnpgTargetFromManifest(manifest: Record<string, any>): CNPGTarget {
|
|
15
|
+
const storage = manifest.spec?.storage
|
|
16
|
+
return {
|
|
17
|
+
name: manifest.metadata?.name ?? '',
|
|
18
|
+
namespace: manifest.metadata?.namespace ?? '',
|
|
19
|
+
instances: String(manifest.spec?.instances ?? 1),
|
|
20
|
+
size: storage?.size ?? storage?.pvcTemplate?.resources?.requests?.storage ?? '',
|
|
21
|
+
storageClass: storage?.storageClass ?? storage?.pvcTemplate?.storageClassName ?? '',
|
|
22
|
+
image: manifest.spec?.imageName ?? '',
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function cnpgTargetIssue(target: CNPGTarget, requireImage = false): string | undefined {
|
|
27
|
+
const name = validateRFC1123Label(target.name.trim())
|
|
28
|
+
if (!name.valid) return `Cluster name ${name.error}.`
|
|
29
|
+
const namespace = validateRFC1123Label(target.namespace.trim())
|
|
30
|
+
if (!namespace.valid) return `Namespace ${namespace.error}.`
|
|
31
|
+
if (
|
|
32
|
+
!/^\d+$/.test(target.instances) ||
|
|
33
|
+
Number(target.instances) < 1 ||
|
|
34
|
+
!Number.isSafeInteger(Number(target.instances))
|
|
35
|
+
)
|
|
36
|
+
return 'Choose a positive whole number of instances.'
|
|
37
|
+
if (!(parseQuantityToNumber(target.size.trim()) > 0)) return 'Enter a positive data volume size, such as 20Gi.'
|
|
38
|
+
if (requireImage && !target.image.trim()) return 'Enter the image matching the source PostgreSQL major version.'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function withCNPGTarget(manifest: Record<string, any>, target: CNPGTarget): Record<string, any> {
|
|
42
|
+
const out = structuredClone(manifest)
|
|
43
|
+
out.metadata = { ...out.metadata, name: target.name.trim(), namespace: target.namespace.trim() }
|
|
44
|
+
out.spec.instances = Number(target.instances)
|
|
45
|
+
const storage = out.spec.storage ?? (out.spec.storage = {})
|
|
46
|
+
if (storage.size !== undefined || !storage.pvcTemplate?.resources?.requests?.storage)
|
|
47
|
+
storage.size = target.size.trim()
|
|
48
|
+
else storage.pvcTemplate.resources.requests.storage = target.size.trim()
|
|
49
|
+
if (target.storageClass.trim() !== cnpgTargetFromManifest(manifest).storageClass) {
|
|
50
|
+
if (target.storageClass.trim()) storage.storageClass = target.storageClass.trim()
|
|
51
|
+
else {
|
|
52
|
+
delete storage.storageClass
|
|
53
|
+
if (storage.pvcTemplate && storage.pvcTemplate.storageClassName !== '')
|
|
54
|
+
delete storage.pvcTemplate.storageClassName
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!out.spec.imageCatalogRef) {
|
|
58
|
+
if (target.image.trim()) out.spec.imageName = target.image.trim()
|
|
59
|
+
else delete out.spec.imageName
|
|
60
|
+
}
|
|
61
|
+
return out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function cnpgTargetDraft(text: string): Record<string, any> | null {
|
|
65
|
+
try {
|
|
66
|
+
const documents = yaml.parseAllDocuments(text, { version: '1.1' })
|
|
67
|
+
if (documents.length !== 1 || documents[0].errors.length > 0) return null
|
|
68
|
+
const document = documents[0]
|
|
69
|
+
for (const path of [
|
|
70
|
+
['spec'],
|
|
71
|
+
['spec', 'storage'],
|
|
72
|
+
['spec', 'storage', 'pvcTemplate'],
|
|
73
|
+
['spec', 'storage', 'pvcTemplate', 'resources'],
|
|
74
|
+
['spec', 'storage', 'pvcTemplate', 'resources', 'requests'],
|
|
75
|
+
]) {
|
|
76
|
+
const node = document.getIn(path, true)
|
|
77
|
+
if (node !== undefined && !yaml.isMap(node)) return null
|
|
78
|
+
}
|
|
79
|
+
const manifest = documents[0].toJS()
|
|
80
|
+
if (
|
|
81
|
+
manifest?.kind !== 'Cluster' ||
|
|
82
|
+
!isApiGroup(manifest.apiVersion, 'postgresql.cnpg.io') ||
|
|
83
|
+
!manifest.spec ||
|
|
84
|
+
typeof manifest.metadata?.name !== 'string' ||
|
|
85
|
+
typeof manifest.metadata?.namespace !== 'string'
|
|
86
|
+
)
|
|
87
|
+
return null
|
|
88
|
+
const target = cnpgTargetFromManifest(manifest)
|
|
89
|
+
if ([target.size, target.storageClass, target.image].some((value) => typeof value !== 'string')) return null
|
|
90
|
+
return manifest
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function updateCNPGTargetYaml(text: string, target: CNPGTarget): string | null {
|
|
97
|
+
const current = cnpgTargetDraft(text)
|
|
98
|
+
if (!current) return null
|
|
99
|
+
const next = withCNPGTarget(current, target)
|
|
100
|
+
const document = yaml.parseDocument(text, { version: '1.1' })
|
|
101
|
+
const paths = [
|
|
102
|
+
['metadata', 'name'],
|
|
103
|
+
['metadata', 'namespace'],
|
|
104
|
+
['spec', 'instances'],
|
|
105
|
+
['spec', 'storage', 'size'],
|
|
106
|
+
['spec', 'storage', 'storageClass'],
|
|
107
|
+
['spec', 'storage', 'pvcTemplate', 'resources', 'requests', 'storage'],
|
|
108
|
+
['spec', 'storage', 'pvcTemplate', 'storageClassName'],
|
|
109
|
+
['spec', 'imageName'],
|
|
110
|
+
]
|
|
111
|
+
for (const path of paths) {
|
|
112
|
+
const at = (object: Record<string, any>) => path.reduce<any>((value, key) => value?.[key], object)
|
|
113
|
+
if (at(current) === at(next)) continue
|
|
114
|
+
if (at(next) === undefined) document.deleteIn(path)
|
|
115
|
+
else {
|
|
116
|
+
// YAML 1.1 creates !!omap for missing setIn parents; Kubernetes expects plain mappings.
|
|
117
|
+
for (let depth = 1; depth < path.length; depth++) {
|
|
118
|
+
const parent = path.slice(0, depth)
|
|
119
|
+
if (!document.hasIn(parent)) document.setIn(parent, document.createNode({}))
|
|
120
|
+
}
|
|
121
|
+
document.setIn(path, at(next))
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return document.toString()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function sameCNPGRecoveryIdentity(left: Record<string, any>, right: Record<string, any>): boolean {
|
|
128
|
+
const identity = (manifest: Record<string, any>) => ({
|
|
129
|
+
bootstrap: manifest.spec.bootstrap,
|
|
130
|
+
externalClusters: manifest.spec.externalClusters,
|
|
131
|
+
imageName: manifest.spec.imageName,
|
|
132
|
+
imageCatalogRef: manifest.spec.imageCatalogRef,
|
|
133
|
+
})
|
|
134
|
+
return (
|
|
135
|
+
yaml.stringify(identity(left), { sortMapEntries: true }) ===
|
|
136
|
+
yaml.stringify(identity(right), { sortMapEntries: true })
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function onlyCNPGTargetChanged(
|
|
141
|
+
before: Record<string, any>,
|
|
142
|
+
after: Record<string, any>,
|
|
143
|
+
target: CNPGTarget,
|
|
144
|
+
): boolean {
|
|
145
|
+
return (
|
|
146
|
+
yaml.stringify(withCNPGTarget(before, target), { sortMapEntries: true }) ===
|
|
147
|
+
yaml.stringify(after, { sortMapEntries: true })
|
|
148
|
+
)
|
|
149
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildCNPGFleet, type CNPGFleetRow, type CNPGProblem } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { cnpgInstancePillLabel, cnpgPillsToShow, cnpgRowStatus } from './fleetStatus'
|
|
4
|
+
|
|
5
|
+
const problem = (severity: CNPGProblem['severity'], title: string) => ({ id: title, severity, title }) as CNPGProblem
|
|
6
|
+
const row = (over: Partial<CNPGFleetRow>) =>
|
|
7
|
+
({ attention: false, problems: [], controllerStatus: { text: 'Cluster in healthy state', level: 'healthy' }, ...over }) as CNPGFleetRow
|
|
8
|
+
|
|
9
|
+
describe('cnpgRowStatus', () => {
|
|
10
|
+
it('leads with the most severe problem and counts the rest', () => {
|
|
11
|
+
expect(cnpgRowStatus(row({ attention: true, problems: [problem('warning', 'Backup failed: disk'), problem('critical', 'WAL archiving failing')] }))).toEqual({
|
|
12
|
+
tone: 'unhealthy',
|
|
13
|
+
label: 'Critical: WAL archiving failing (+1 more)',
|
|
14
|
+
})
|
|
15
|
+
expect(cnpgRowStatus(row({ attention: true, problems: [problem('warning', 'Backup failed: disk'), problem('posture', 'No backup schedule')] }))).toEqual({
|
|
16
|
+
tone: 'degraded',
|
|
17
|
+
label: 'Needs attention: Backup failed: disk (+1 more)',
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
it('says no problems only with a readable phase, never healthy when unknown', () => {
|
|
21
|
+
expect(cnpgRowStatus(row({}))).toEqual({ tone: 'healthy', label: 'No concerns detected in what Radar read · CNPG phase: Cluster in healthy state' })
|
|
22
|
+
const unknown = cnpgRowStatus(row({ controllerStatus: { text: '', level: 'unknown' } }))
|
|
23
|
+
expect(unknown).toEqual({ tone: 'unknown', label: 'Status unknown · CNPG phase: not reported' })
|
|
24
|
+
expect(unknown.label).not.toContain('No concerns')
|
|
25
|
+
expect(cnpgRowStatus(row({ controllerStatus: { text: 'Upgrading cluster', level: 'degraded' } })).tone).toBe('degraded')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('cnpgInstancePillLabel', () => {
|
|
30
|
+
it('names a replica as a standby too', () => {
|
|
31
|
+
expect(cnpgInstancePillLabel({ name: 'pg-wal-failing-1', role: 'replica', ready: false })).toBe('pg-wal-failing-1 · replica (standby) · Pod not ready')
|
|
32
|
+
expect(cnpgInstancePillLabel({ name: 'pg-1', role: 'primary', ready: null })).toBe('pg-1 · primary · Pod readiness unknown')
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('cnpgPillsToShow', () => {
|
|
37
|
+
it('shows every pill up to the bound, and otherwise leaves room for "+N"', () => {
|
|
38
|
+
expect(cnpgPillsToShow([1, 2, 3, 4, 5], 5)).toEqual({ shown: [1, 2, 3, 4, 5], hidden: [] })
|
|
39
|
+
expect(cnpgPillsToShow([1, 2, 3, 4, 5, 6, 7], 5)).toEqual({ shown: [1, 2, 3, 4], hidden: [5, 6, 7] })
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('gives a destination-blocked schedule an amber fleet status', () => {
|
|
44
|
+
const fleet = buildCNPGFleet({ installed: true, context: 'test', namespaces: null, coverage: { clusters: { state: 'full' }, pods: { state: 'full' }, scheduledBackups: { state: 'full' } }, objects: { clusters: [{ apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'payments', namespace: 'db' }, spec: { instances: 1 }, status: { phase: 'Cluster in healthy state', readyInstances: 1 } }], scheduledBackups: [{ metadata: { name: 'payments-nightly', namespace: 'db' }, spec: { cluster: { name: 'payments' } } }] }, issues: [{ id: 'blocked-schedule', reason: 'CNPGScheduleDestinationMissing', severity: 'warning', kind: 'ScheduledBackup', group: 'postgresql.cnpg.io', namespace: 'db', name: 'payments-nightly', message: 'Backup schedule payments-nightly cannot run: no backup destination' }], audit: [], backupsOmitted: 0 })
|
|
45
|
+
expect(cnpgRowStatus(fleet.rows[0])).toMatchObject({ tone: 'degraded', label: 'Needs attention: Backup schedule payments-nightly cannot run: no backup destination' })
|
|
46
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { CNPGFleetRow, HealthLevel } from '@skyhook-io/k8s-ui'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A fleet row's status dot and the sentence behind it. Problems (warning or
|
|
5
|
+
* worse) decide it first; without any, the operator's phase does, and a phase
|
|
6
|
+
* Radar cannot read stays unknown rather than healthy.
|
|
7
|
+
*/
|
|
8
|
+
export function cnpgRowStatus(row: Pick<CNPGFleetRow, 'attention' | 'problems' | 'controllerStatus'>): { tone: HealthLevel; label: string } {
|
|
9
|
+
const phase = `CNPG phase: ${row.controllerStatus.text || 'not reported'}`
|
|
10
|
+
const counted = row.problems.filter((p) => p.severity !== 'posture')
|
|
11
|
+
if (row.attention && counted.length > 0) {
|
|
12
|
+
const critical = counted.filter((p) => p.severity === 'critical')
|
|
13
|
+
const lead = critical[0] ?? counted[0]
|
|
14
|
+
// The same count the Needs attention cell shows: every other problem, posture included.
|
|
15
|
+
const more = row.problems.length - 1
|
|
16
|
+
return {
|
|
17
|
+
tone: critical.length > 0 ? 'unhealthy' : 'degraded',
|
|
18
|
+
label: `${critical.length > 0 ? 'Critical' : 'Needs attention'}: ${lead.title}${more > 0 ? ` (+${more} more)` : ''}`,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const level = row.controllerStatus.level
|
|
22
|
+
switch (level) {
|
|
23
|
+
case 'healthy':
|
|
24
|
+
case 'neutral':
|
|
25
|
+
return { tone: level, label: `No concerns detected in what Radar read · ${phase}` }
|
|
26
|
+
case 'unknown':
|
|
27
|
+
return { tone: 'unknown', label: `Status unknown · ${phase}` }
|
|
28
|
+
default:
|
|
29
|
+
return { tone: level, label: `No concerns detected, but the cluster is not healthy · ${phase}` }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** "pg-1 · replica (standby) · not ready": CNPG's docs say standby where the role label says replica. */
|
|
34
|
+
export function cnpgInstancePillLabel(pod: CNPGFleetRow['pods'][number]): string {
|
|
35
|
+
const role = pod.role === 'primary' ? 'primary' : pod.role === 'replica' ? 'replica (standby)' : 'role unknown'
|
|
36
|
+
const ready = pod.ready === true ? 'Pod ready' : pod.ready === false ? 'Pod not ready' : 'Pod readiness unknown'
|
|
37
|
+
return `${pod.name} · ${role} · ${ready}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The instance pills a fleet row shows (primary first, as listed) and those left for the "+N" hover. */
|
|
41
|
+
export function cnpgPillsToShow<T>(pods: T[], max: number): { shown: T[]; hidden: T[] } {
|
|
42
|
+
return pods.length <= max ? { shown: pods, hidden: [] } : { shown: pods.slice(0, max - 1), hidden: pods.slice(max - 1) }
|
|
43
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { GrantText } from '../workspace/layout'
|
|
4
|
+
|
|
5
|
+
describe('GrantText', () => {
|
|
6
|
+
it('keeps the verb and resource together, with the scope as plain text', () => {
|
|
7
|
+
const html = renderToStaticMarkup(<GrantText grant={{ verb: 'get', group: 'admissionregistration.k8s.io', resource: 'mutatingwebhookconfigurations' }} />)
|
|
8
|
+
expect(html).toMatch(/<code[^>]*whitespace-nowrap[^>]*>get mutatingwebhookconfigurations.admissionregistration.k8s.io<\/code> cluster-wide/)
|
|
9
|
+
expect(renderToStaticMarkup(<GrantText grant={{ verb: 'get', resource: 'pods', subresource: 'proxy', namespace: 'pgrt' }} />)).toMatch(/>get pods\/proxy<\/code> in namespace pgrt/)
|
|
10
|
+
})
|
|
11
|
+
})
|