@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,619 @@
|
|
|
1
|
+
import { useState, type ReactNode, type CSSProperties } from 'react'
|
|
2
|
+
import { clsx } from 'clsx'
|
|
3
|
+
import yaml from 'yaml'
|
|
4
|
+
import { HardDrive } from 'lucide-react'
|
|
5
|
+
import {
|
|
6
|
+
ActionConfirmDialog,
|
|
7
|
+
AlertBanner,
|
|
8
|
+
Badge,
|
|
9
|
+
FoldSection,
|
|
10
|
+
type CNPGProtectionFacts,
|
|
11
|
+
CNPG_DISK_SOURCE,
|
|
12
|
+
PaneLoader,
|
|
13
|
+
StatusDot,
|
|
14
|
+
Tooltip,
|
|
15
|
+
cnpgDiskTone,
|
|
16
|
+
cnpgHASlotInstance,
|
|
17
|
+
cnpgVolumeRoleLabel,
|
|
18
|
+
formatAge,
|
|
19
|
+
parseQuantityToNumber,
|
|
20
|
+
toneFillClass,
|
|
21
|
+
toneTextClass,
|
|
22
|
+
formatGrant,
|
|
23
|
+
type Grant,
|
|
24
|
+
} from '@skyhook-io/k8s-ui'
|
|
25
|
+
import { useResource } from '../../api/client'
|
|
26
|
+
import { useCNPGClusterCapabilities, type CNPGRuntimeInstance, type CNPGRuntimeResponse } from '../../api/cnpg'
|
|
27
|
+
import type { UseQueryResult } from '@tanstack/react-query'
|
|
28
|
+
import {
|
|
29
|
+
useCNPGClusterStorage,
|
|
30
|
+
type CNPGClusterStorageResponse,
|
|
31
|
+
type CNPGStorageInstance,
|
|
32
|
+
type CNPGStorageTarget,
|
|
33
|
+
type CNPGStorageVolume,
|
|
34
|
+
type CNPGStorageWAL,
|
|
35
|
+
} from '../../api/cnpg-storage'
|
|
36
|
+
import { CreateResourceDialog } from '../shared/CreateResourceDialog'
|
|
37
|
+
import { useCNPGWriteGuard } from './actions/useCNPGWriteGuard'
|
|
38
|
+
import { buildResizeManifest, cnpgFloorTone, cnpgInstanceDiskTone, cnpgSharedExpansionGap, cnpgSlotRetentionText, cnpgWALUsageFloor } from './storageModel'
|
|
39
|
+
// Binary units throughout, matching claim capacities such as 1Gi.
|
|
40
|
+
import { formatBytes } from './lsn'
|
|
41
|
+
import { cnpgDimensionPath, cnpgWithinDetail } from './paths'
|
|
42
|
+
import type { CNPGRestoreSourceState } from './recovery/restoreModel'
|
|
43
|
+
import { useCNPGNavigate } from './useCNPGNavigate'
|
|
44
|
+
import { useLocation } from 'react-router-dom'
|
|
45
|
+
import { GrantText, Notice, RefreshFailedNotice } from '../workspace/layout'
|
|
46
|
+
|
|
47
|
+
const CNPG_GROUP = 'postgresql.cnpg.io'
|
|
48
|
+
|
|
49
|
+
function Card({ title, children, footer }: { title: ReactNode; children: ReactNode; footer?: ReactNode }) {
|
|
50
|
+
return (
|
|
51
|
+
<section className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
52
|
+
<div className="border-b border-theme-border px-4 py-2.5 text-sm font-semibold text-theme-text-primary">{title}</div>
|
|
53
|
+
<div className="p-4">{children}</div>
|
|
54
|
+
{footer && <div className="border-t border-theme-border px-4 py-2 text-xs text-theme-text-tertiary">{footer}</div>}
|
|
55
|
+
</section>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function roleTitle(v: { role: string; tablespace?: string }): string {
|
|
60
|
+
switch (v.role) {
|
|
61
|
+
case 'PG_DATA':
|
|
62
|
+
return 'Data'
|
|
63
|
+
case 'PG_WAL':
|
|
64
|
+
return 'WAL'
|
|
65
|
+
case 'PG_TABLESPACE':
|
|
66
|
+
return v.tablespace ? `Tablespace ${v.tablespace}` : 'Tablespace'
|
|
67
|
+
default:
|
|
68
|
+
return 'Volume'
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const USAGE_UNMEASURED: Record<string, string> = {
|
|
73
|
+
noSeries: 'Prometheus has no kubelet volume stats for this claim',
|
|
74
|
+
invalid: 'kubelet reported values that are not a measurement',
|
|
75
|
+
noPrometheus: 'used space needs Prometheus with kubelet volume stats',
|
|
76
|
+
denied: 'no access to its usage metrics',
|
|
77
|
+
error: 'the Prometheus query failed',
|
|
78
|
+
notRead: 'not read',
|
|
79
|
+
ambiguous: 'these claim names report under more than one cluster in this Prometheus',
|
|
80
|
+
scopeMismatch: "this cluster's proven identity labels are not on its volume stats",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `stated`: the page's notice already names a cluster-wide cause, so the
|
|
84
|
+
// line only says unknown and keeps the reason on hover.
|
|
85
|
+
function UsageBar({ v, stated, floor }: { v: CNPGStorageVolume; stated?: boolean; floor?: { bytes: number; ratio?: number } }) {
|
|
86
|
+
const u = v.usage
|
|
87
|
+
if (u.state !== 'ok' && floor) {
|
|
88
|
+
const tone = cnpgFloorTone(floor.ratio)
|
|
89
|
+
return (
|
|
90
|
+
<div>
|
|
91
|
+
<div className="h-1.5 overflow-hidden rounded bg-[repeating-linear-gradient(45deg,var(--border-light)_0_2px,transparent_2px_5px)]">
|
|
92
|
+
{floor.ratio !== undefined && <div className={clsx('h-full', toneFillClass(tone === 'unknown' ? 'neutral' : tone))} style={{ width: `${Math.min(100, floor.ratio * 100)}%` }} />}
|
|
93
|
+
</div>
|
|
94
|
+
<div className="mt-1 text-xs">
|
|
95
|
+
<span className={toneTextClass(tone === 'unknown' ? 'neutral' : tone)}>
|
|
96
|
+
≥ {formatBytes(floor.bytes)} used by WAL alone
|
|
97
|
+
{floor.ratio !== undefined && (floor.ratio >= 1 ? `, more than the ${v.capacity} the claim reports` : ` · ≥ ${Math.round(floor.ratio * 100)}% of ${v.capacity}`)}
|
|
98
|
+
</span>
|
|
99
|
+
<span className="text-theme-text-tertiary">{' · '}</span>
|
|
100
|
+
<Tooltip content={USAGE_UNMEASURED[u.state] ?? u.state}>
|
|
101
|
+
<span className="text-theme-text-tertiary">the rest unknown</span>
|
|
102
|
+
</Tooltip>
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
if (u.state !== 'ok' || u.ratio === undefined || u.usedBytes === undefined || u.capacityBytes === undefined) {
|
|
108
|
+
return (
|
|
109
|
+
<div>
|
|
110
|
+
<div className="h-1.5 rounded bg-[repeating-linear-gradient(45deg,var(--border-light)_0_2px,transparent_2px_5px)]" />
|
|
111
|
+
{stated ? (
|
|
112
|
+
<Tooltip content={USAGE_UNMEASURED[u.state] ?? u.state}>
|
|
113
|
+
<div className="mt-1 text-xs text-theme-text-tertiary">Used space unknown</div>
|
|
114
|
+
</Tooltip>
|
|
115
|
+
) : (
|
|
116
|
+
<div className="mt-1 text-xs text-theme-text-tertiary">Used space unknown: {USAGE_UNMEASURED[u.state] ?? u.state}</div>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
const tone = cnpgDiskTone(u.ratio)
|
|
122
|
+
const sharedFilesystem = v.capacityBytes !== undefined && u.capacityBytes > v.capacityBytes * 1.5
|
|
123
|
+
return (
|
|
124
|
+
<div>
|
|
125
|
+
<div className="h-1.5 overflow-hidden rounded bg-theme-elevated">
|
|
126
|
+
<div className={clsx('h-full', toneFillClass(tone))} style={{ width: `${Math.min(100, u.ratio * 100)}%` }} />
|
|
127
|
+
</div>
|
|
128
|
+
<div className="mt-1 flex flex-wrap justify-between gap-x-3 text-xs">
|
|
129
|
+
<span className={toneTextClass(tone === 'healthy' ? 'neutral' : tone)}>
|
|
130
|
+
{Math.round(u.ratio * 100)}% used · {formatBytes(u.usedBytes)} of {formatBytes(u.capacityBytes)}
|
|
131
|
+
</span>
|
|
132
|
+
<Tooltip content={`Filesystem use as kubelet reports it (${CNPG_DISK_SOURCE})`}>
|
|
133
|
+
<span className="text-theme-text-tertiary">kubelet</span>
|
|
134
|
+
</Tooltip>
|
|
135
|
+
</div>
|
|
136
|
+
{sharedFilesystem && (
|
|
137
|
+
<div className="mt-0.5 text-[11.5px] text-theme-text-tertiary">
|
|
138
|
+
kubelet measured a {formatBytes(u.capacityBytes)} filesystem for a {v.capacity} claim: the volume shares a filesystem, so this is that filesystem's use.
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Causes the page's notice already states, so each volume only says "unknown".
|
|
146
|
+
interface StatedOnce {
|
|
147
|
+
usage: boolean
|
|
148
|
+
expansion: boolean
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function ClassFact({ v, expansionStated }: { v: CNPGStorageVolume; expansionStated?: boolean }) {
|
|
152
|
+
const sc = v.storageClass
|
|
153
|
+
if (!sc.name) return <span className="text-theme-text-tertiary">class not named on the claim</span>
|
|
154
|
+
if (sc.allowVolumeExpansion === undefined) {
|
|
155
|
+
return (
|
|
156
|
+
<span>
|
|
157
|
+
class <span className="font-mono">{sc.name}</span>{' '}
|
|
158
|
+
{expansionStated ? (
|
|
159
|
+
<Tooltip content={sc.reason}>
|
|
160
|
+
<span className="text-theme-text-tertiary">· expansion unknown</span>
|
|
161
|
+
</Tooltip>
|
|
162
|
+
) : (
|
|
163
|
+
<span className="text-theme-text-tertiary">· expansion unknown ({sc.reason})</span>
|
|
164
|
+
)}
|
|
165
|
+
</span>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
return (
|
|
169
|
+
<span>
|
|
170
|
+
class <span className="font-mono">{sc.name}</span> ·{' '}
|
|
171
|
+
<span className={sc.allowVolumeExpansion ? undefined : toneTextClass('degraded')}>
|
|
172
|
+
{sc.allowVolumeExpansion ? 'allows expansion' : 'does not allow expansion'}
|
|
173
|
+
</span>
|
|
174
|
+
</span>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const CLUSTER_STATE_BADGE: Record<string, 'warning' | 'error' | 'info'> = {
|
|
179
|
+
resizing: 'info',
|
|
180
|
+
initializing: 'info',
|
|
181
|
+
dangling: 'warning',
|
|
182
|
+
unusable: 'error',
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function VolumeRow({ v, stated, wal }: { v: CNPGStorageVolume; stated: StatedOnce; wal?: CNPGStorageWAL }) {
|
|
186
|
+
const resizing = v.resize.pending || (v.resize.conditions?.length ?? 0) > 0 || !!v.resize.allocatedStatus
|
|
187
|
+
return (
|
|
188
|
+
<div className="grid gap-1 rounded-lg border border-theme-border bg-theme-base p-3 xl:row-span-3 xl:grid-rows-subgrid">
|
|
189
|
+
<div className="mb-2 space-y-1">
|
|
190
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
191
|
+
<span className="text-sm font-medium text-theme-text-primary">{roleTitle(v)}</span>
|
|
192
|
+
<span className="font-mono text-xs text-theme-text-secondary">{v.claim}</span>
|
|
193
|
+
<span className="ml-auto font-mono text-xs text-theme-text-secondary">
|
|
194
|
+
{v.phase === 'Pending' && !v.capacity ? 'No capacity provisioned yet' : `capacity ${v.capacity ?? 'not reported'}`}
|
|
195
|
+
{v.requested && v.requested !== v.capacity ? ` · requested ${v.requested}` : ''}
|
|
196
|
+
</span>
|
|
197
|
+
</div>
|
|
198
|
+
<div className="flex min-h-5 flex-wrap items-center gap-2">
|
|
199
|
+
{v.clusterState && CLUSTER_STATE_BADGE[v.clusterState] && (
|
|
200
|
+
<Tooltip content={`Named in the Cluster's status.${v.clusterState}PVC`}>
|
|
201
|
+
<Badge severity={CLUSTER_STATE_BADGE[v.clusterState]} size="sm">Operator: {v.clusterState}</Badge>
|
|
202
|
+
</Tooltip>
|
|
203
|
+
)}
|
|
204
|
+
{v.phase && <Badge severity={v.phase === 'Bound' ? 'neutral' : 'warning'} size="sm">Claim: {v.phase}</Badge>}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
<UsageBar v={v} stated={stated.usage} floor={cnpgWALUsageFloor(v, wal)} />
|
|
208
|
+
<div className="mt-2 text-xs text-theme-text-secondary">
|
|
209
|
+
<ClassFact v={v} expansionStated={stated.expansion} />
|
|
210
|
+
{resizing && (
|
|
211
|
+
<div className="mt-1.5 text-xs text-theme-text-secondary">
|
|
212
|
+
<span className="font-medium text-theme-text-primary">Resize</span>
|
|
213
|
+
{v.resize.pending && <> · requested {v.requested} is larger than the capacity the claim reports</>}
|
|
214
|
+
{v.resize.conditions?.map((c) => (
|
|
215
|
+
<span key={c.type}>
|
|
216
|
+
{' '}· <span className="font-mono">{c.type}</span>
|
|
217
|
+
{c.message ? `: ${c.message}` : ''}
|
|
218
|
+
{c.since ? ` (${formatAge(c.since)} ago)` : ''}
|
|
219
|
+
</span>
|
|
220
|
+
))}
|
|
221
|
+
{v.resize.allocatedStatus && <> · <span className="font-mono">{v.resize.allocatedStatus}</span></>}
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function WALHolders({ wal, primary, slotStandby, walArchiving }: { walArchiving?: CNPGProtectionFacts['walArchiving']; wal: CNPGStorageWAL; primary: boolean; slotStandby: (slot: string) => string | undefined }) {
|
|
230
|
+
const noArchive = walArchiving?.state === 'no_destination'
|
|
231
|
+
const statusRead = ['ok', 'partial'].includes(wal.status.state)
|
|
232
|
+
if (!noArchive && !statusRead && !['ok', 'partial'].includes(wal.metrics.state)) {
|
|
233
|
+
return (
|
|
234
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
235
|
+
WAL facts unavailable: {wal.status.error || wal.metrics.error || wal.status.state}
|
|
236
|
+
</div>
|
|
237
|
+
)
|
|
238
|
+
}
|
|
239
|
+
const slots = wal.slotInventory ?? []
|
|
240
|
+
return (
|
|
241
|
+
<div>
|
|
242
|
+
<div className="grid gap-3 sm:grid-cols-3">
|
|
243
|
+
<WALFact
|
|
244
|
+
label="WAL on disk"
|
|
245
|
+
value={wal.sizeBytes !== undefined ? formatBytes(wal.sizeBytes) : '—'}
|
|
246
|
+
detail={wal.segments !== undefined ? `${wal.segments} segments` : undefined}
|
|
247
|
+
source="Exporter cnpg_collector_pg_wal"
|
|
248
|
+
missing={wal.metrics.state !== 'ok' ? wal.metrics.error || wal.metrics.reason || wal.metrics.state : undefined}
|
|
249
|
+
/>
|
|
250
|
+
<WALFact
|
|
251
|
+
label="Waiting to archive"
|
|
252
|
+
value={noArchive ? walArchiving.text : wal.readyToArchive !== undefined ? `${wal.readyToArchive} files` : '—'}
|
|
253
|
+
tone={noArchive ? undefined : wal.archivingFailed ? 'unhealthy' : wal.readyToArchive ? 'degraded' : undefined}
|
|
254
|
+
detail={
|
|
255
|
+
noArchive ? <FoldSection title="Instance manager record" summary="" attention={false}>
|
|
256
|
+
<div>{!statusRead ? `Archiving record not read: ${wal.status.error || wal.status.reason || wal.status.state}.` : wal.lastArchivedAt ? `Last archived ${formatAge(wal.lastArchivedAt)} ago, as recorded by the instance manager.` : 'Last archived time not reported by the instance manager.'}</div>
|
|
257
|
+
{statusRead && wal.readyToArchive !== undefined && <div>{wal.readyToArchive} files waiting, as recorded by the instance manager.</div>}
|
|
258
|
+
</FoldSection> : wal.archivingFailed
|
|
259
|
+
? `archiving failing${wal.lastFailedWal ? ` at ${wal.lastFailedWal}` : ''}${wal.lastFailedAt ? `, ${formatAge(wal.lastFailedAt)} ago` : ''}`
|
|
260
|
+
: !primary
|
|
261
|
+
? 'a standby; the primary archives'
|
|
262
|
+
: wal.lastArchivedAt
|
|
263
|
+
? `last archived ${formatAge(wal.lastArchivedAt)} ago${wal.readyToArchive === undefined ? ' · backlog not reported' : ''}`
|
|
264
|
+
: undefined
|
|
265
|
+
}
|
|
266
|
+
source="Instance manager readyWalFiles"
|
|
267
|
+
missing={!noArchive && wal.status.state !== 'ok' ? wal.status.error || wal.status.reason || wal.status.state : undefined}
|
|
268
|
+
/>
|
|
269
|
+
<WALFact
|
|
270
|
+
label="Held by replication slots"
|
|
271
|
+
value={cnpgSlotRetentionText(wal.slotInventory, !wal.slotInventoryTruncated, wal.slots, { separateMissingBytes: true })}
|
|
272
|
+
detail={
|
|
273
|
+
slots.length > 0
|
|
274
|
+
? <>{slots.some((s) => s.retainedBytes === undefined) && <div>retained WAL not reported</div>}<div>{slots
|
|
275
|
+
.map((s) => {
|
|
276
|
+
const standby = slotStandby(s.name)
|
|
277
|
+
return `${s.name}${s.retainedBytes === undefined ? '' : ` ${formatBytes(s.retainedBytes)}`}${standby ? ` (expected instance ${standby})` : ''}`
|
|
278
|
+
})
|
|
279
|
+
.join(' · ')}</div></>
|
|
280
|
+
: wal.slots?.map((s) => `${s.slot} ${formatBytes(s.bytes)} retained WAL`).join(' · ')
|
|
281
|
+
}
|
|
282
|
+
source="Instance manager slot inventory · exporter retained WAL"
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
<div className="mt-2 text-[11.5px] text-theme-text-tertiary">
|
|
286
|
+
{noArchive ? 'Slot retention is part of WAL on disk, so these measurements are not added up.' : 'These overlap (a slot can hold the same segments that wait for the archive), so they are not added up.'}
|
|
287
|
+
{wal.volume && <> WAL lives on <span className="font-mono">{wal.volume}</span>.</>}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function WALFact({
|
|
294
|
+
label,
|
|
295
|
+
value,
|
|
296
|
+
detail,
|
|
297
|
+
source,
|
|
298
|
+
tone,
|
|
299
|
+
missing,
|
|
300
|
+
}: {
|
|
301
|
+
label: string
|
|
302
|
+
value: string
|
|
303
|
+
detail?: ReactNode
|
|
304
|
+
source: string
|
|
305
|
+
tone?: 'degraded' | 'unhealthy'
|
|
306
|
+
missing?: string
|
|
307
|
+
}) {
|
|
308
|
+
return (
|
|
309
|
+
<div className="card-inner">
|
|
310
|
+
<Tooltip content={source}>
|
|
311
|
+
<div className="text-xs text-theme-text-tertiary">{label}</div>
|
|
312
|
+
</Tooltip>
|
|
313
|
+
<div className={clsx('font-mono text-sm', tone ? toneTextClass(tone) : 'text-theme-text-primary')}>{missing ? '—' : value}</div>
|
|
314
|
+
{(missing || detail) && <div className="mt-0.5 break-words text-[11.5px] text-theme-text-tertiary">{missing ? `unavailable: ${missing}` : detail}</div>}
|
|
315
|
+
</div>
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function InstanceCard({
|
|
320
|
+
inst,
|
|
321
|
+
walCoverage,
|
|
322
|
+
stated,
|
|
323
|
+
slotStandby,
|
|
324
|
+
notRunning,
|
|
325
|
+
walArchiving,
|
|
326
|
+
volumeRows,
|
|
327
|
+
}: {
|
|
328
|
+
volumeRows: number
|
|
329
|
+
walArchiving?: CNPGProtectionFacts['walArchiving']
|
|
330
|
+
notRunning?: boolean
|
|
331
|
+
inst: CNPGStorageInstance
|
|
332
|
+
walCoverage: CNPGClusterStorageResponse['wal']
|
|
333
|
+
stated: StatedOnce
|
|
334
|
+
slotStandby: (slot: string) => string | undefined
|
|
335
|
+
}) {
|
|
336
|
+
const roleLabel = notRunning ? inst.role === 'replica' ? 'expected standby · not running' : inst.role === 'primary' ? 'expected primary · not running' : inst.role === 'noInstance' ? 'no instance' : inst.volumes.some((v) => v.clusterState === 'initializing') ? 'expected first instance · not running' : 'expected instance · not running' : inst.role === 'primary' ? 'primary' : inst.role === 'replica' ? 'replica' : inst.role === 'noInstance' ? 'no instance' : 'role unknown'
|
|
337
|
+
const diskTone = cnpgInstanceDiskTone(inst.volumes, inst.wal)
|
|
338
|
+
return (
|
|
339
|
+
<section className="grid gap-y-3 overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm xl:grid-rows-subgrid xl:[grid-row:span_var(--cnpg-instance-rows)]" style={{ '--cnpg-instance-rows': 2 + volumeRows * 3 } as CSSProperties}>
|
|
340
|
+
<div className="flex items-center gap-2 border-b border-theme-border px-4 py-2.5 text-sm font-semibold text-theme-text-primary">
|
|
341
|
+
<StatusDot tone={diskTone} />
|
|
342
|
+
<span className="font-mono">{inst.name}</span>
|
|
343
|
+
<Badge severity="neutral" size="sm">{roleLabel}</Badge>
|
|
344
|
+
</div>
|
|
345
|
+
{inst.volumes.map((v) => <div key={v.claim} className="mx-4 grid xl:row-span-3 xl:grid-rows-subgrid"><VolumeRow v={v} stated={stated} wal={inst.wal} /></div>)}
|
|
346
|
+
{Array.from({ length: Math.max(0, volumeRows - inst.volumes.length) }, (_, i) => <div key={`unread-${i}`} className={clsx('mx-4 xl:row-span-3', !(i === 0 && inst.volumes.length === 0) && 'hidden xl:block')}>{i === 0 && inst.volumes.length === 0 && <div className="text-sm text-theme-text-tertiary">No claims read for this instance.</div>}</div>)}
|
|
347
|
+
<div className="px-4 pb-4">
|
|
348
|
+
<div className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-theme-text-tertiary">What is holding WAL</div>
|
|
349
|
+
{inst.wal ? (
|
|
350
|
+
<WALHolders wal={inst.wal} primary={inst.role === 'primary'} slotStandby={slotStandby} walArchiving={walArchiving} />
|
|
351
|
+
) : notRunning ? (
|
|
352
|
+
<div className="text-xs text-theme-text-tertiary">WAL cannot be measured until the instance starts</div>
|
|
353
|
+
) : walCoverage.state === 'ok' ? (
|
|
354
|
+
<div className="text-xs text-theme-text-tertiary">No running instance to read</div>
|
|
355
|
+
) : (
|
|
356
|
+
<Tooltip content={walCoverage.state === 'denied' ? `No access: needs ${formatGrant(walCoverage.grant)}` : walCoverage.reason ?? walCoverage.state}>
|
|
357
|
+
<div className="text-xs text-theme-text-tertiary">Unknown</div>
|
|
358
|
+
</Tooltip>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
</section>
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// "<label> needs …" lines; `plural` for a label that takes "need".
|
|
366
|
+
function coverageLine(label: string, c: { state: string; grant?: Grant; reason?: string }, plural = false): string | null {
|
|
367
|
+
const needs = plural ? 'need' : 'needs'
|
|
368
|
+
if (c.state === 'ok') return null
|
|
369
|
+
if (c.state === 'noPrometheus') return `${label} ${needs} Prometheus: ${c.reason?.split(/\s+Candidate /)[0] ?? 'Radar is not connected to one'}`
|
|
370
|
+
if (c.state === 'denied') return `${label} ${needs} ${formatGrant(c.grant) ?? 'a grant you do not have'}.`
|
|
371
|
+
return `${label}: ${c.reason ?? c.state}`
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function CNPGStorage({ namespace, name, primary, runtime, clusterObject, restoreState = 'unknown', walArchiving }: { restoreState?: CNPGRestoreSourceState; walArchiving?: CNPGProtectionFacts['walArchiving']; namespace: string; name: string; primary?: CNPGRuntimeInstance; runtime?: UseQueryResult<CNPGRuntimeResponse>; clusterObject?: any }) {
|
|
375
|
+
const q = useCNPGClusterStorage(namespace, name)
|
|
376
|
+
const caps = useCNPGClusterCapabilities(namespace, name)
|
|
377
|
+
const patch = caps.data?.actions.reload
|
|
378
|
+
const resizeReason = caps.error && !caps.data
|
|
379
|
+
? `Permissions could not be checked: ${caps.error.message}`
|
|
380
|
+
: !patch
|
|
381
|
+
? 'Checking permissions…'
|
|
382
|
+
: patch.permission === 'denied'
|
|
383
|
+
? `Needs ${formatGrant(patch.grant)}`
|
|
384
|
+
: patch.permission === 'unknown'
|
|
385
|
+
? 'Patch permission could not be checked.'
|
|
386
|
+
: caps.data?.facts.terminating
|
|
387
|
+
? 'The Cluster is being deleted.'
|
|
388
|
+
: caps.data?.operator?.webhookRejects
|
|
389
|
+
? `The API server would reject it: ${caps.data.operator.webhookReason}`
|
|
390
|
+
: undefined
|
|
391
|
+
const canResize = patch?.permission === 'allowed' && !resizeReason
|
|
392
|
+
const [resize, setResize] = useState<CNPGStorageTarget | null>(null)
|
|
393
|
+
if (!q.data && q.isLoading) return <PaneLoader label="Reading volumes…" className="h-40" />
|
|
394
|
+
if (!q.data) {
|
|
395
|
+
return <Notice>Storage could not be loaded: {q.error instanceof Error ? q.error.message : 'unknown error'}</Notice>
|
|
396
|
+
}
|
|
397
|
+
const data = q.data
|
|
398
|
+
const notes = [
|
|
399
|
+
coverageLine('Volumes', data.volumes, true),
|
|
400
|
+
data.usage.state !== 'notRead' ? coverageLine('Used space', data.usage) : null,
|
|
401
|
+
coverageLine('WAL', data.wal),
|
|
402
|
+
].filter((x): x is string => !!x)
|
|
403
|
+
if (data.usage.isolation?.mode === 'unverified') notes.push(`Used space: ${data.usage.isolation.note}.`)
|
|
404
|
+
const discoveryCandidates = data.usage.state === 'noPrometheus' ? data.usage.reason?.match(/\s+(Candidate [\s\S]*)$/)?.[1] : undefined
|
|
405
|
+
const allVolumes = data.instances.flatMap((i) => i.volumes)
|
|
406
|
+
const volumeRows = Math.max(1, ...data.instances.map((i) => i.volumes.length))
|
|
407
|
+
const notStarted = !!clusterObject && !clusterObject.status?.currentPrimary && !!runtime?.data && runtime.data.instances.length === 0 && data.instances.length > 0 && allVolumes.length > 0 && allVolumes.every((v) => v.clusterState === 'initializing' && v.phase === 'Pending' && !v.capacity)
|
|
408
|
+
// The standby a CloudNativePG HA slot is kept for, so retained WAL names its cause.
|
|
409
|
+
const slotStandby = (slot: string) => cnpgHASlotInstance(clusterObject, slot, data.instances.map((i) => i.name))
|
|
410
|
+
const expansionGap = cnpgSharedExpansionGap(allVolumes)
|
|
411
|
+
if (expansionGap) notes.push(`Volume expansion unknown: ${expansionGap}`)
|
|
412
|
+
const stated: StatedOnce = { usage: data.usage.state !== 'ok' && data.usage.state !== 'notRead', expansion: !!expansionGap }
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<div className="space-y-4">
|
|
416
|
+
<RefreshFailedNotice queries={runtime ? [q, runtime, caps] : [q, caps]} />
|
|
417
|
+
{data.findings.map((f) => (
|
|
418
|
+
<AlertBanner
|
|
419
|
+
key={f.claim}
|
|
420
|
+
variant={f.severity === 'critical' ? 'error' : 'warning'}
|
|
421
|
+
title={f.message}
|
|
422
|
+
message={`Claim ${f.claim}; measured from ${CNPG_DISK_SOURCE}. Check volume expansion below, or find what is growing (WAL held for the archive or by a slot is shown per instance).`}
|
|
423
|
+
/>
|
|
424
|
+
))}
|
|
425
|
+
{notes.length > 0 && (
|
|
426
|
+
<Notice>
|
|
427
|
+
{notes.map((n) => (
|
|
428
|
+
<div key={n} className="whitespace-pre-line">{n}</div>
|
|
429
|
+
))}
|
|
430
|
+
{discoveryCandidates && <div className="mt-2"><FoldSection title="Discovery candidates" summary="" attention={false}><div>{discoveryCandidates}</div></FoldSection></div>}
|
|
431
|
+
</Notice>
|
|
432
|
+
)}
|
|
433
|
+
{data.excluded && data.excluded.length > 0 && (
|
|
434
|
+
<Notice>
|
|
435
|
+
Not counted as this cluster's volumes:{' '}
|
|
436
|
+
{data.excluded.map((e) => (
|
|
437
|
+
<span key={e.claim}>
|
|
438
|
+
<span className="font-mono">{e.claim}</span> ({e.reason}){' '}
|
|
439
|
+
</span>
|
|
440
|
+
))}
|
|
441
|
+
</Notice>
|
|
442
|
+
)}
|
|
443
|
+
|
|
444
|
+
<div className="grid gap-4 xl:grid-cols-2">
|
|
445
|
+
{data.instances.map((inst) => (
|
|
446
|
+
<InstanceCard volumeRows={volumeRows} key={inst.name} inst={inst} notRunning={!!runtime?.data && !runtime.data.instances.some((i) => i.pod === inst.name)} walCoverage={data.wal} stated={stated} slotStandby={slotStandby} walArchiving={walArchiving} />
|
|
447
|
+
))}
|
|
448
|
+
</div>
|
|
449
|
+
|
|
450
|
+
<ExpansionCard restoreState={restoreState} notStarted={notStarted} namespace={namespace} name={name} data={data} volumes={allVolumes} onResize={setResize} canResize={canResize} resizeReason={resizeReason} grant={patch?.permission === 'denied' ? patch.grant : undefined} onRetry={() => void caps.refetch()} />
|
|
451
|
+
|
|
452
|
+
<Card title="Logical database sizes (primary)" footer="pg_database_size for each database: the data PostgreSQL holds, not the space the volume uses.">
|
|
453
|
+
{(primary?.metrics.state === 'ok' || primary?.metrics.state === 'partial') && primary.metrics.databaseSizes?.length ? (
|
|
454
|
+
primary.metrics.databaseSizes.map((d) => (
|
|
455
|
+
<div key={d.database} className="flex justify-between font-mono text-sm">
|
|
456
|
+
<span className="truncate">{d.database}</span>
|
|
457
|
+
<span>{formatBytes(d.bytes)}</span>
|
|
458
|
+
</div>
|
|
459
|
+
))
|
|
460
|
+
) : (
|
|
461
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
462
|
+
{runtime?.data?.permission.proxy === 'denied'
|
|
463
|
+
? <>Database sizes unavailable: needs <GrantText grant={runtime.data.permission.grant ?? { verb: 'get', resource: 'pods', subresource: 'proxy', namespace }} />.</>
|
|
464
|
+
: !primary
|
|
465
|
+
? clusterObject?.status?.currentPrimary ? `Database sizes unavailable: ${clusterObject.status.currentPrimary} has not reported${runtime?.error ? ` (${runtime.error.message})` : ''}.` : 'No primary reported.'
|
|
466
|
+
: primary.metrics.state === 'ok' || primary.metrics.state === 'partial'
|
|
467
|
+
? "Not reported: this sample from the primary's exporter has no database sizes."
|
|
468
|
+
: `Not available from the primary's exporter: ${primary.metrics.error || primary.metrics.reason || (primary.metrics.state === 'denied' ? 'no access (needs get pods/proxy)' : primary.metrics.state)}.`}
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
</Card>
|
|
472
|
+
|
|
473
|
+
{resize && <ResizeDialog restoreState={restoreState} notStarted={notStarted} namespace={namespace} name={name} target={resize} volumes={allVolumes} disabledReason={resizeReason} onClose={() => setResize(null)} />}
|
|
474
|
+
</div>
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function targetVolumes(t: CNPGStorageTarget, volumes: CNPGStorageVolume[]): CNPGStorageVolume[] {
|
|
479
|
+
return volumes.filter((v) => v.role === t.role && (t.role !== 'PG_TABLESPACE' || v.tablespace === t.tablespace))
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function expansionVerdict(vols: CNPGStorageVolume[], restoreState: CNPGRestoreSourceState = 'unknown', notStarted = false): { text: string; tone?: 'degraded' } {
|
|
483
|
+
if (vols.length === 0) return { text: 'No claims read, so whether the class allows expansion is unknown' }
|
|
484
|
+
const blocked = vols.filter((v) => v.storageClass.allowVolumeExpansion === false)
|
|
485
|
+
if (blocked.length > 0) return {
|
|
486
|
+
text: blocked.length === vols.length
|
|
487
|
+
? 'The StorageClass does not allow expansion: a larger size will not resize the existing claims. ' + (notStarted ? 'The first instance has not started. Edit the declared size for future claims; the existing Pending claims keep their size and class. A fresh Cluster can use the new settings.' : restoreState === 'available' ? 'Restore into a new Cluster with a larger size or a class that expands, then move applications to it.' : restoreState === 'none' ? 'An expandable class applies to new claims. Set up backups before moving to a new Cluster.' : 'An expandable class applies to new claims. A restore source has not been verified.')
|
|
488
|
+
: 'Some StorageClasses do not allow expansion: their existing claims will not grow. Editing changes the declared size.',
|
|
489
|
+
tone: 'degraded',
|
|
490
|
+
}
|
|
491
|
+
const known = vols.filter((v) => v.storageClass.allowVolumeExpansion !== undefined)
|
|
492
|
+
if (known.length < vols.length) return { text: 'Whether the class allows expansion is unknown for some claims' }
|
|
493
|
+
return { text: 'The StorageClass allows expansion: the operator can resize each claim' }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function ExpansionCard({ restoreState, notStarted, namespace, name, data, volumes, onResize, canResize, resizeReason, grant, onRetry }: { restoreState: CNPGRestoreSourceState; notStarted: boolean; namespace: string; name: string; data: CNPGClusterStorageResponse; volumes: CNPGStorageVolume[]; onResize: (t: CNPGStorageTarget) => void; canResize: boolean; resizeReason?: string; grant?: Grant; onRetry: () => void }) {
|
|
497
|
+
const navigate = useCNPGNavigate()
|
|
498
|
+
const location = useLocation()
|
|
499
|
+
const inUse = data.expansion.resizeInUseVolumes
|
|
500
|
+
return (
|
|
501
|
+
<Card
|
|
502
|
+
title={
|
|
503
|
+
<span className="flex items-center gap-2">
|
|
504
|
+
<HardDrive className="h-4 w-4" />
|
|
505
|
+
Declared volume sizes
|
|
506
|
+
</span>
|
|
507
|
+
}
|
|
508
|
+
footer={
|
|
509
|
+
inUse === false
|
|
510
|
+
? 'spec.storage.resizeInUseVolumes is false: the operator does not resize claims while their Pod uses them. See the CloudNativePG volume expansion documentation for the offline procedure.'
|
|
511
|
+
: 'Editing changes the Cluster’s declared size. Existing claims can grow only if their StorageClass allows expansion. CloudNativePG does not shrink volumes.'
|
|
512
|
+
}
|
|
513
|
+
>
|
|
514
|
+
{!canResize && <div className="mb-3 text-xs text-theme-text-secondary">
|
|
515
|
+
{grant ? <>Editing size needs <GrantText grant={grant} />.</> : resizeReason}{' '}
|
|
516
|
+
<button type="button" onClick={onRetry} className="text-accent-text hover:underline">Retry</button>
|
|
517
|
+
</div>}
|
|
518
|
+
<div className="space-y-3">
|
|
519
|
+
{data.expansion.targets.map((t) => {
|
|
520
|
+
const verdict = expansionVerdict(targetVolumes(t, volumes), restoreState, notStarted)
|
|
521
|
+
return (
|
|
522
|
+
<div key={t.field} className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
523
|
+
<div className="min-w-0 flex-1">
|
|
524
|
+
<div className="text-sm text-theme-text-primary">
|
|
525
|
+
{roleTitle(t)} · <span className="font-mono text-xs">{t.field}</span>
|
|
526
|
+
<span className="text-theme-text-secondary"> = {t.declared ?? 'not set'}</span>
|
|
527
|
+
</div>
|
|
528
|
+
<div className={clsx('text-xs', verdict.tone ? toneTextClass(verdict.tone) : 'text-theme-text-tertiary')}>{verdict.text}{verdict.tone && restoreState === 'none' && !notStarted && targetVolumes(t, volumes).every((v) => v.storageClass.allowVolumeExpansion === false) && <>{' '}<button type="button" onClick={() => { const target = cnpgDimensionPath(namespace, name, new URLSearchParams(location.search).get('ctx') ?? undefined, 'protection'); const inPlace = cnpgWithinDetail(location.pathname, location.search, target); navigate(inPlace ?? target, { replace: !!inPlace, state: location.state }) }} className="text-accent-text hover:underline">Backups →</button></>}</div>
|
|
529
|
+
</div>
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
disabled={!canResize}
|
|
533
|
+
onClick={() => onResize(t)}
|
|
534
|
+
className="btn-secondary inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap px-2.5 py-1.5 text-xs font-medium"
|
|
535
|
+
>
|
|
536
|
+
{verdict.tone === 'degraded' ? targetVolumes(t, volumes).every((v) => v.storageClass.allowVolumeExpansion === false) ? 'Edit declared size — existing volumes unchanged' : 'Edit declared size…' : 'Edit size…'}
|
|
537
|
+
</button>
|
|
538
|
+
</div>
|
|
539
|
+
)
|
|
540
|
+
})}
|
|
541
|
+
</div>
|
|
542
|
+
</Card>
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function ResizeDialog({
|
|
547
|
+
restoreState,
|
|
548
|
+
notStarted,
|
|
549
|
+
namespace,
|
|
550
|
+
name,
|
|
551
|
+
target,
|
|
552
|
+
volumes,
|
|
553
|
+
disabledReason,
|
|
554
|
+
onClose,
|
|
555
|
+
}: {
|
|
556
|
+
namespace: string
|
|
557
|
+
name: string
|
|
558
|
+
target: CNPGStorageTarget
|
|
559
|
+
restoreState: CNPGRestoreSourceState
|
|
560
|
+
notStarted: boolean
|
|
561
|
+
volumes: CNPGStorageVolume[]
|
|
562
|
+
disabledReason?: string
|
|
563
|
+
onClose: () => void
|
|
564
|
+
}) {
|
|
565
|
+
const { data: cluster } = useResource<any>('clusters', namespace, name, CNPG_GROUP)
|
|
566
|
+
const [size, setSize] = useState(target.declared ?? '')
|
|
567
|
+
const [manifest, setManifest] = useState<string | null>(null)
|
|
568
|
+
const guard = useCNPGWriteGuard({ namespace, name, scope: { kind: 'spec', paths: [target.field] } })
|
|
569
|
+
const verdict = expansionVerdict(targetVolumes(target, volumes), restoreState, notStarted)
|
|
570
|
+
|
|
571
|
+
if (manifest) {
|
|
572
|
+
return <CreateResourceDialog open onClose={onClose} initialYaml={manifest} initialMode="apply" title={`Edit declared ${roleTitle(target).toLowerCase()} size of ${name}`} />
|
|
573
|
+
}
|
|
574
|
+
const next = parseQuantityToNumber(size)
|
|
575
|
+
const current = parseQuantityToNumber(target.declared)
|
|
576
|
+
const incompleteReason = !cluster
|
|
577
|
+
? 'Loading the cluster…'
|
|
578
|
+
: !next
|
|
579
|
+
? 'Enter a size such as 20Gi.'
|
|
580
|
+
: current && next <= current
|
|
581
|
+
? `CloudNativePG only grows volumes; enter more than ${target.declared}.`
|
|
582
|
+
: undefined
|
|
583
|
+
|
|
584
|
+
return (
|
|
585
|
+
<ActionConfirmDialog
|
|
586
|
+
open
|
|
587
|
+
onClose={onClose}
|
|
588
|
+
onConfirm={() => {
|
|
589
|
+
setManifest(
|
|
590
|
+
`# Sets ${target.field} on ${name}. Server-side apply changes only the fields below;\n` +
|
|
591
|
+
`# the operator then resizes each instance's claim if its StorageClass allows expansion.\n` +
|
|
592
|
+
yaml.stringify(buildResizeManifest(cluster, target, size.trim())),
|
|
593
|
+
)
|
|
594
|
+
}}
|
|
595
|
+
title={`Edit declared ${cnpgVolumeRoleLabel(target.role, target.tablespace)} size of ${name}?`}
|
|
596
|
+
subject={{ kind: 'Cluster', namespace, name }}
|
|
597
|
+
effect={
|
|
598
|
+
<>
|
|
599
|
+
Changes <span className="font-mono">{target.field}</span> from {target.declared ?? 'unset'} to the size you enter. {verdict.text}
|
|
600
|
+
</>
|
|
601
|
+
}
|
|
602
|
+
guard={guard.node}
|
|
603
|
+
guardSatisfied={guard.satisfied}
|
|
604
|
+
confirmLabel="Review manifest"
|
|
605
|
+
disabledReason={disabledReason}
|
|
606
|
+
incompleteReason={incompleteReason}
|
|
607
|
+
>
|
|
608
|
+
<div className="grid grid-cols-[8rem_minmax(0,1fr)] items-center gap-x-3 gap-y-2">
|
|
609
|
+
<label className="text-xs text-theme-text-secondary" htmlFor="cnpg-resize-size">New size</label>
|
|
610
|
+
<input
|
|
611
|
+
id="cnpg-resize-size"
|
|
612
|
+
value={size}
|
|
613
|
+
onChange={(e) => setSize(e.target.value)}
|
|
614
|
+
className="w-40 rounded-lg border border-theme-border bg-theme-base px-2 py-1 font-mono text-sm"
|
|
615
|
+
/>
|
|
616
|
+
</div>
|
|
617
|
+
</ActionConfirmDialog>
|
|
618
|
+
)
|
|
619
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { MemoryRouter } from 'react-router-dom'
|
|
4
|
+
import { expect, it, vi } from 'vitest'
|
|
5
|
+
import { renderCNPGSummary } from './CNPGSummaryHost'
|
|
6
|
+
|
|
7
|
+
const state = vi.hoisted(() => ({ framed: undefined as boolean | undefined, row: {} as any, runtime: {} as any }))
|
|
8
|
+
vi.mock('@skyhook-io/k8s-ui', async (original) => ({ ...await original<typeof import('@skyhook-io/k8s-ui')>(), CNPGClusterSummary: ({ framed, operationalFacts }: { framed?: boolean; operationalFacts?: ReactNode }) => { state.framed = framed; return <div>{operationalFacts}</div> } }))
|
|
9
|
+
vi.mock('./useCNPGClusterAssessment', () => ({ useCNPGClusterAssessment: () => ({ query: {}, runtime: state.runtime, ha: {}, dimensions: [], row: state.row }) }))
|
|
10
|
+
vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'test' } }) }))
|
|
11
|
+
vi.mock('../../context/NavCustomization', () => ({ useNavCustomization: () => ({}) }))
|
|
12
|
+
vi.mock('./actions/CNPGMaintenanceBanner', () => ({ CNPGMaintenanceBanner: () => null }))
|
|
13
|
+
vi.mock('./CNPGOperatorBanner', () => ({ CNPGOperatorBanner: () => null }))
|
|
14
|
+
|
|
15
|
+
const resource = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', status: { conditions: [{ type: 'Ready', status: 'True', reason: 'ClusterReady', message: 'Ready to serve', lastTransitionTime: '2026-10-04T00:00:00Z' }] } }
|
|
16
|
+
|
|
17
|
+
it('opts into framing and Operator conditions from the opened object only for the expanded host', () => {
|
|
18
|
+
state.row = { cluster: { status: {} } }
|
|
19
|
+
for (const context of ['drawer', 'expanded'] as const) {
|
|
20
|
+
const html = renderToStaticMarkup(<MemoryRouter>{renderCNPGSummary({ apiKind: 'clusters', namespace: 'db', name: 'pg', resource, context })}</MemoryRouter>)
|
|
21
|
+
expect(state.framed).toBe(context === 'expanded')
|
|
22
|
+
expect(html.includes('Operator conditions')).toBe(context === 'expanded')
|
|
23
|
+
if (context === 'expanded') for (const field of ['Ready', 'True', 'ClusterReady', 'Ready to serve', '2026-10-04T00:00:00Z']) expect(html).toContain(field)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
it('retains raw Operator conditions when the workspace summary cannot be read', () => {
|
|
27
|
+
state.row = undefined
|
|
28
|
+
const html = renderToStaticMarkup(<MemoryRouter>{renderCNPGSummary({ apiKind: 'clusters', namespace: 'db', name: 'pg', resource, context: 'expanded' })}</MemoryRouter>)
|
|
29
|
+
expect(html).toContain('The CloudNativePG workspace summary could not be read.')
|
|
30
|
+
expect(html).toContain('Operator conditions')
|
|
31
|
+
expect(html).toContain('Ready to serve')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('names standby cloning with its joining-only source and preserves the operational summary', () => {
|
|
35
|
+
state.row = { cluster: { status: {} } }
|
|
36
|
+
state.runtime = { data: { permission: { proxy: 'allowed' }, instances: [{ pod: 'pg-1', role: 'primary', status: { state: 'ok', baseBackups: [] } }] } }
|
|
37
|
+
const html = renderToStaticMarkup(<MemoryRouter>{renderCNPGSummary({ apiKind: 'clusters', namespace: 'db', name: 'pg', resource, context: 'expanded' })}</MemoryRouter>)
|
|
38
|
+
expect(html).toContain('Standby cloning')
|
|
39
|
+
expect(html).toContain('None running')
|
|
40
|
+
expect(html).toContain('pg_basebackup on the primary, joining instances only')
|
|
41
|
+
expect(html).not.toContain('Base backup')
|
|
42
|
+
expect(state.framed).toBe(true)
|
|
43
|
+
state.runtime = {}
|
|
44
|
+
})
|