@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
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
2
|
-
import { useLocation,
|
|
3
|
-
import { ResourcesSidebar, type SelectedKindInfo } from '@skyhook-io/k8s-ui'
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { useLocation, useSearchParams } from 'react-router-dom'
|
|
3
|
+
import { DialogPortal, ResourcesSidebar, type SelectedKindInfo } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { PanelLeft, X } from 'lucide-react'
|
|
4
5
|
import type { SelectedResource } from '../../types'
|
|
5
6
|
import { useAPIResources } from '../../api/apiResources'
|
|
6
7
|
import { usePinnedKinds } from '../../hooks/useFavorites'
|
|
@@ -11,9 +12,14 @@ import { CNPGDeclarations } from './CNPGDeclarations'
|
|
|
11
12
|
import { CNPGPooling } from './CNPGPooling'
|
|
12
13
|
import { CNPGOperator } from './CNPGOperator'
|
|
13
14
|
import { CNPGScreenGate } from './shared'
|
|
15
|
+
import { CNPGCreateClusterDialog } from './CNPGCreateClusterDialog'
|
|
14
16
|
import { CNPGDetailPage } from './CNPGDetailPage'
|
|
15
|
-
import {
|
|
17
|
+
import { parseCNPGRoute } from './routes'
|
|
16
18
|
import { useCNPGFleet, useCNPGSidebarWorkspace } from './useCNPGSidebarWorkspace'
|
|
19
|
+
import { decodeDrawerTrail, encodeDrawerTrail, sameSelectedResource } from '../../utils/drawer-trail'
|
|
20
|
+
import { useCNPGNavigate } from './useCNPGNavigate'
|
|
21
|
+
import { useCNPGScreenParams } from './useCNPGScreenParams'
|
|
22
|
+
import { useMediaQuery } from '../../hooks/useMediaQuery'
|
|
17
23
|
|
|
18
24
|
interface CNPGViewProps {
|
|
19
25
|
namespaces: string[]
|
|
@@ -33,9 +39,14 @@ interface CNPGViewProps {
|
|
|
33
39
|
*/
|
|
34
40
|
export function CNPGView({ namespaces, selectedResource, onOpenResource, onCloseResource, onClearNamespaces }: CNPGViewProps) {
|
|
35
41
|
const location = useLocation()
|
|
36
|
-
const navigate =
|
|
42
|
+
const navigate = useCNPGNavigate()
|
|
37
43
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
38
44
|
const route = parseCNPGRoute(location.pathname)
|
|
45
|
+
const laptop = useMediaQuery('(max-width: 1300px)')
|
|
46
|
+
const narrow = useMediaQuery('(max-width: 1100px)')
|
|
47
|
+
const compactNavigation = narrow || (laptop && !!route.detail)
|
|
48
|
+
const [navigationOpen, setNavigationOpen] = useState(false)
|
|
49
|
+
useEffect(() => setNavigationOpen(false), [location.pathname, location.search, compactNavigation])
|
|
39
50
|
const { data: apiResources } = useAPIResources()
|
|
40
51
|
const { data: counts } = useResourceCounts(namespaces)
|
|
41
52
|
const { pinned, togglePin, isPinned } = usePinnedKinds()
|
|
@@ -59,7 +70,7 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
|
|
|
59
70
|
useEffect(() => {
|
|
60
71
|
if (targetKey !== (lastSynced.current ?? '')) {
|
|
61
72
|
lastSynced.current = targetKey
|
|
62
|
-
if (drawerTarget && !
|
|
73
|
+
if (drawerTarget && !sameSelectedResource(drawerTarget, selectedResource)) onOpenResource(drawerTarget)
|
|
63
74
|
else if (!drawerTarget && selectedResource) onCloseResource()
|
|
64
75
|
return
|
|
65
76
|
}
|
|
@@ -69,7 +80,7 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
|
|
|
69
80
|
if (!selectedResource) {
|
|
70
81
|
params.delete('drawer')
|
|
71
82
|
} else {
|
|
72
|
-
const idx = trail.findIndex((r) =>
|
|
83
|
+
const idx = trail.findIndex((r) => sameSelectedResource(r, selectedResource))
|
|
73
84
|
const next = idx >= 0 ? trail.slice(0, idx + 1) : [...trail, selectedResource]
|
|
74
85
|
params.set('drawer', encodeDrawerTrail(next))
|
|
75
86
|
}
|
|
@@ -86,17 +97,8 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
|
|
|
86
97
|
[searchParams, setSearchParams, location.state],
|
|
87
98
|
)
|
|
88
99
|
|
|
89
|
-
const setParams =
|
|
90
|
-
|
|
91
|
-
const params = new URLSearchParams(searchParams)
|
|
92
|
-
for (const [k, v] of Object.entries(update)) {
|
|
93
|
-
if (v === null || v === '') params.delete(k)
|
|
94
|
-
else params.set(k, v)
|
|
95
|
-
}
|
|
96
|
-
setSearchParams(params, { replace: true, state: location.state })
|
|
97
|
-
},
|
|
98
|
-
[searchParams, setSearchParams, location.state],
|
|
99
|
-
)
|
|
100
|
+
const [, setParams] = useCNPGScreenParams()
|
|
101
|
+
const [creating, setCreating] = useState(false)
|
|
100
102
|
|
|
101
103
|
const selectKind = useCallback(
|
|
102
104
|
(kind: SelectedKindInfo) => {
|
|
@@ -105,9 +107,7 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
|
|
|
105
107
|
[navigate],
|
|
106
108
|
)
|
|
107
109
|
|
|
108
|
-
|
|
109
|
-
<div className="flex h-full min-h-0 w-full">
|
|
110
|
-
<ResourcesSidebar
|
|
110
|
+
const sidebar = <ResourcesSidebar
|
|
111
111
|
selectedKind={null}
|
|
112
112
|
onSelectedKindChange={selectKind}
|
|
113
113
|
apiResources={apiResources}
|
|
@@ -118,8 +118,17 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
|
|
|
118
118
|
togglePin={togglePin}
|
|
119
119
|
isPinned={(kind: string, group?: string) => isPinned(kind, group ?? '')}
|
|
120
120
|
categoryWorkspaces={sidebarWorkspace}
|
|
121
|
+
className={compactNavigation ? '!w-full !border-r-0' : undefined}
|
|
121
122
|
/>
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex h-full min-h-0 w-full">
|
|
125
|
+
{!compactNavigation && sidebar}
|
|
126
|
+
<DialogPortal open={navigationOpen && compactNavigation} onClose={() => setNavigationOpen(false)} ariaLabel="Resource navigation" className="flex h-[min(80vh,44rem)] w-full max-w-md flex-col overflow-hidden">
|
|
127
|
+
<div className="flex items-center justify-between border-b border-theme-border px-4 py-3"><h2 className="font-medium text-theme-text-primary">Resources</h2><button type="button" aria-label="Close resource navigation" onClick={() => setNavigationOpen(false)} className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover"><X className="h-4 w-4" /></button></div>
|
|
128
|
+
<div className="flex min-h-0 flex-1">{sidebar}</div>
|
|
129
|
+
</DialogPortal>
|
|
122
130
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col bg-theme-base">
|
|
131
|
+
{compactNavigation && <div className="border-b border-theme-border px-5 py-2"><button type="button" aria-haspopup="dialog" aria-expanded={navigationOpen} onClick={() => setNavigationOpen(true)} className="btn-secondary inline-flex items-center gap-1.5 px-2.5 py-1 text-xs"><PanelLeft className="h-3.5 w-3.5" />Resources</button></div>}
|
|
123
132
|
{route.detail ? (
|
|
124
133
|
<CNPGDetailPage target={route.detail} namespaces={namespaces} onOpenResource={onOpenResource} />
|
|
125
134
|
) : (
|
|
@@ -134,6 +143,7 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
|
|
|
134
143
|
onInspect: inspect,
|
|
135
144
|
inspected: drawerTarget,
|
|
136
145
|
onClearNamespaces,
|
|
146
|
+
onCreate: () => setCreating(true),
|
|
137
147
|
}
|
|
138
148
|
switch (route.screen) {
|
|
139
149
|
case 'protection':
|
|
@@ -151,6 +161,9 @@ export function CNPGView({ namespaces, selectedResource, onOpenResource, onClose
|
|
|
151
161
|
</CNPGScreenGate>
|
|
152
162
|
)}
|
|
153
163
|
</div>
|
|
164
|
+
{creating && (
|
|
165
|
+
<CNPGCreateClusterDialog namespaces={namespaces} onClose={() => setCreating(false)} onCreated={inspect} />
|
|
166
|
+
)}
|
|
154
167
|
</div>
|
|
155
168
|
)
|
|
156
169
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { beforeEach, expect, it, vi } from 'vitest'
|
|
3
|
+
import { CNPG_WORKSPACE_KEYS, ResourcesSidebar } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { useCNPGFleet, useCNPGSidebarWorkspace } from './useCNPGSidebarWorkspace'
|
|
5
|
+
import { radarFeatureSupport } from '../../api/radarFeatures'
|
|
6
|
+
|
|
7
|
+
const state = vi.hoisted(() => ({ capabilities: { features: { resourceIssues: true } } as any, diskEnabled: [] as boolean[], metricsEnabled: [] as boolean[], result: undefined as any, workspace: undefined as any }))
|
|
8
|
+
const oldKinds = ['clusters', 'backups', 'scheduledBackups', 'poolers', 'databases', 'publications', 'subscriptions', 'imageCatalogs', 'clusterImageCatalogs', 'objectStores', 'pods']
|
|
9
|
+
const cluster = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { namespace: 'db', name: 'pg' }, spec: { instances: 1 }, status: { phase: 'Cluster in healthy state', readyInstances: 1, currentPrimary: 'pg-1' } }
|
|
10
|
+
const workspace = { installed: true, context: 'test', namespaces: ['db'], coverage: Object.fromEntries(oldKinds.map(k => [k, { state: 'full' }])), objects: { ...Object.fromEntries(oldKinds.map(k => [k, []])), clusters: [cluster], pods: [{ apiVersion: 'v1', kind: 'Pod', metadata: { namespace: 'db', name: 'pg-1', labels: { 'cnpg.io/cluster': 'pg', 'cnpg.io/instanceRole': 'primary' } }, status: { conditions: [{ type: 'Ready', status: 'True' }] } }] }, issues: [], audit: [], backupsOmitted: 0 }
|
|
11
|
+
vi.mock('../../api/cnpg', () => ({ useCNPGWorkspace: () => ({ data: state.workspace ?? workspace }) }))
|
|
12
|
+
vi.mock('../../api/cnpg-storage', () => ({ useCNPGFleetDisk: (_ns: string[], enabled: boolean) => { state.diskEnabled.push(enabled); return { error: new Error('Live disk needs a newer Radar') } } }))
|
|
13
|
+
vi.mock('../../api/cnpg-history', () => ({ useCNPGFleetMetrics: (_ns: string[], enabled: boolean) => { state.metricsEnabled.push(enabled); return {} } }))
|
|
14
|
+
vi.mock('../../api/client', () => ({ useRadarFeature: () => ({ support: radarFeatureSupport('cnpgWorkspace', state.capabilities, 'v1.16.0') }) }))
|
|
15
|
+
vi.mock('./useCNPGNavigate', () => ({ useCNPGNavigate: () => vi.fn() }))
|
|
16
|
+
beforeEach(() => { state.capabilities = { features: { resourceIssues: true } }; state.diskEnabled = []; state.metricsEnabled = []; state.workspace = undefined })
|
|
17
|
+
function Host() {
|
|
18
|
+
const { fleet } = useCNPGFleet(['db'])
|
|
19
|
+
const sidebar = useCNPGSidebarWorkspace({ apiResources: [{ name: 'clusters', kind: 'Cluster', group: 'postgresql.cnpg.io', namespaced: true }] as any, namespaces: ['db'] })
|
|
20
|
+
state.result = { fleet, sidebar }
|
|
21
|
+
return <div>{fleet?.rows[0].cluster.status.phase}{sidebar?.CloudNativePG.destinations.map(d => <span key={d.id}>{d.label}</span>)}</div>
|
|
22
|
+
}
|
|
23
|
+
it('keeps the ordinary workspace destinations and CRD facts without new measurement failures', () => {
|
|
24
|
+
const html = renderToStaticMarkup(<Host />)
|
|
25
|
+
expect(html).toContain('Cluster in healthy state')
|
|
26
|
+
expect(html).toContain('Clusters')
|
|
27
|
+
expect(html).toContain('Backups')
|
|
28
|
+
expect(state.diskEnabled).toEqual([false, false])
|
|
29
|
+
expect(state.metricsEnabled).toEqual([false, false])
|
|
30
|
+
expect(state.result.fleet.rows[0].disk).toBeUndefined()
|
|
31
|
+
expect(state.result.fleet.incompleteKinds).toContain('databaseRoles')
|
|
32
|
+
expect(state.result.fleet.rows[0].declarations.summary.text).toContain('DatabaseRoles not read by this Radar')
|
|
33
|
+
expect(state.result.sidebar.CloudNativePG.destinations[0]).toMatchObject({ count: null, countLowerBound: true })
|
|
34
|
+
})
|
|
35
|
+
it('enables new measurements only once the server advertises their contracts', () => {
|
|
36
|
+
state.capabilities = { features: { cnpgWorkspace: true } }
|
|
37
|
+
renderToStaticMarkup(<Host />)
|
|
38
|
+
expect(state.diskEnabled).toEqual([true, false])
|
|
39
|
+
expect(state.metricsEnabled).toEqual([true, false])
|
|
40
|
+
expect(state.result.fleet.rows[0].disk).toMatchObject({ text: 'Unavailable', tone: 'unknown', source: 'Disk usage could not be read: Live disk needs a newer Radar' })
|
|
41
|
+
})
|
|
42
|
+
it('the Resources sidebar reads disk and lag only on the CloudNativePG views', () => {
|
|
43
|
+
state.capabilities = { features: { cnpgWorkspace: true } }
|
|
44
|
+
const apiResources = [{ name: 'clusters', kind: 'Cluster', group: 'postgresql.cnpg.io', namespaced: true }] as any
|
|
45
|
+
function Sidebar({ active }: { active: boolean }) {
|
|
46
|
+
state.result = useCNPGSidebarWorkspace({ apiResources, namespaces: ['db'], active: active ? { screen: 'overview' } : undefined })
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
renderToStaticMarkup(<Sidebar active={false} />)
|
|
50
|
+
expect(state.diskEnabled).toEqual([false])
|
|
51
|
+
renderToStaticMarkup(<Sidebar active />)
|
|
52
|
+
expect(state.diskEnabled).toEqual([false, true])
|
|
53
|
+
expect(state.metricsEnabled).toEqual([false, true])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('outside the CloudNativePG views the Clusters badge is a lower bound, and its zero is not none', () => {
|
|
57
|
+
state.capabilities = { features: { cnpgWorkspace: true } }
|
|
58
|
+
const apiResources = [{ name: 'clusters', kind: 'Cluster', group: 'postgresql.cnpg.io', namespaced: true }] as any
|
|
59
|
+
const fullCoverage = Object.fromEntries(CNPG_WORKSPACE_KEYS.map((k) => [k, { state: 'full' }]))
|
|
60
|
+
const clusterIssue = { id: 'i1', severity: 'warning', kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg', reason: 'CNPGNotReady', message: 'not ready' }
|
|
61
|
+
function Sidebar({ active }: { active: boolean }) {
|
|
62
|
+
state.result = useCNPGSidebarWorkspace({ apiResources, namespaces: ['db'], active: active ? { screen: 'overview' } : undefined })
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
const overview = (active: boolean) => {
|
|
66
|
+
renderToStaticMarkup(<Sidebar active={active} />)
|
|
67
|
+
return state.result.CloudNativePG.destinations.find((d: any) => d.id === 'overview')
|
|
68
|
+
}
|
|
69
|
+
const crd = [{ group: 'postgresql.cnpg.io', version: 'v1', kind: 'Cluster', name: 'clusters', namespaced: true, isCrd: true, verbs: ['list'] }] as any
|
|
70
|
+
const badge = (destination: any) => {
|
|
71
|
+
const html = renderToStaticMarkup(
|
|
72
|
+
<ResourcesSidebar selectedKind={null} onSelectedKindChange={() => {}} apiResources={crd} resourceCounts={{ 'postgresql.cnpg.io/Cluster': 1 }} categoryWorkspaces={{ CloudNativePG: { destinations: [destination] } }} />,
|
|
73
|
+
)
|
|
74
|
+
return html.match(new RegExp(`>${destination.label}</span>(.*?)</button>`))?.[1] ?? ''
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
state.workspace = { ...workspace, coverage: fullCoverage }
|
|
78
|
+
const calm = overview(false)
|
|
79
|
+
expect(calm).toMatchObject({ count: null, countLowerBound: true })
|
|
80
|
+
expect(calm.countTitle).toBe('None found in the workspace read. Disk use and replication lag are counted on the CloudNativePG views.')
|
|
81
|
+
expect(badge(calm)).toMatch(/>–</)
|
|
82
|
+
expect(overview(true)).toMatchObject({ count: 0, countLowerBound: false })
|
|
83
|
+
|
|
84
|
+
state.workspace = { ...workspace, coverage: fullCoverage, issues: [clusterIssue] }
|
|
85
|
+
const flagged = overview(false)
|
|
86
|
+
expect(flagged).toMatchObject({ count: 1, countLowerBound: true })
|
|
87
|
+
expect(flagged.countTitle).toBe('At least 1 clusters need attention. Disk use and replication lag are counted on the CloudNativePG views.')
|
|
88
|
+
expect(badge(flagged)).toMatch(/≥(<!-- -->)?1/)
|
|
89
|
+
expect(overview(true)).toMatchObject({ count: 1, countLowerBound: false })
|
|
90
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import { ClusterActionDialog } from './CNPGClusterActions'
|
|
6
|
+
const state = vi.hoisted(() => ({ dialog: {} as any, mutate: vi.fn() }))
|
|
7
|
+
vi.mock('../../../api/cnpg', () => ({ useCNPGAction: () => ({ mutate: state.mutate }), useCNPGRuntime: () => ({}), useCNPGClusterCapabilities: () => ({}) }))
|
|
8
|
+
vi.mock('../../../api/cnpg-ha', () => ({ useCNPGClusterHA: () => ({}) }))
|
|
9
|
+
vi.mock('../useCNPGSidebarWorkspace', () => ({ useCNPGFleet: () => ({}) }))
|
|
10
|
+
vi.mock('../useCNPGNavigate', () => ({ useCNPGNavigate: () => vi.fn() }))
|
|
11
|
+
vi.mock('../../ui/Toast', () => ({ useToast: () => ({ showSuccess: vi.fn() }) }))
|
|
12
|
+
vi.mock('./useCNPGWriteGuard', () => ({ useCNPGWriteGuard: () => ({ node: null, satisfied: true }) }))
|
|
13
|
+
vi.mock('@skyhook-io/k8s-ui', async (original) => ({ ...(await original<typeof import('@skyhook-io/k8s-ui')>()), ActionConfirmDialog: (props: any) => { state.dialog = props; return <div>{props.children}<button disabled={!!props.disabledReason || !!props.incompleteReason} onClick={props.onConfirm}>Confirm</button></div> } }))
|
|
14
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
15
|
+
let host: HTMLDivElement, root: ReturnType<typeof createRoot>
|
|
16
|
+
afterEach(() => { act(() => root.unmount()); host.remove(); state.mutate.mockClear() })
|
|
17
|
+
const facts = { maintenance: {}, backupMethods: [], currentPrimary: 'orders-1', fencedInstances: { all: false, instances: [] }, instances: [{ pod: 'orders-1', podExists: true, ready: true }, { pod: 'orders-2', podExists: false, ready: false }] }
|
|
18
|
+
function render(kind: 'fence' | 'unfence' | 'hibernate', extra = {}, initialPod?: string) {
|
|
19
|
+
host = document.createElement('div'); root = createRoot(host)
|
|
20
|
+
act(() => root.render(<ClusterActionDialog kind={kind} caps={{ facts, actions: { [kind]: { allowed: true } }, context: 'kind-orders', uid: 'uid', ...extra } as any} namespace="db" name="orders" initialPod={initialPod} onClose={() => {}} />))
|
|
21
|
+
}
|
|
22
|
+
it('requires an explicit fencing choice, puts All last and keeps primary typed confirmation', () => {
|
|
23
|
+
render('fence', {}, 'orders-2')
|
|
24
|
+
const select = host.querySelector('select')!
|
|
25
|
+
expect(select.value).toBe('')
|
|
26
|
+
expect(host.querySelector('button')!.disabled).toBe(true)
|
|
27
|
+
expect(state.dialog.disabledReason).toBeUndefined()
|
|
28
|
+
expect(state.dialog.incompleteReason).toBe('Choose an instance to fence.')
|
|
29
|
+
expect([...select.options].map((o) => o.textContent)).toEqual(['Choose an instance', 'orders-1 (primary)', 'orders-2', 'All instances — stops service'])
|
|
30
|
+
act(() => { select.value = 'orders-2'; select.dispatchEvent(new Event('change', { bubbles: true })) })
|
|
31
|
+
expect(host.querySelector('button')!.disabled).toBe(false)
|
|
32
|
+
expect(state.dialog.incompleteReason).toBeUndefined()
|
|
33
|
+
expect(state.dialog.typedConfirmation).toBeUndefined()
|
|
34
|
+
act(() => host.querySelector('button')!.click())
|
|
35
|
+
expect(state.mutate.mock.calls[0][0].request.params.instances).toEqual(['orders-2'])
|
|
36
|
+
act(() => { select.value = '*'; select.dispatchEvent(new Event('change', { bubbles: true })) })
|
|
37
|
+
expect(state.dialog.typedConfirmation).toBe('orders')
|
|
38
|
+
expect(state.dialog.effect).toContain('Writes stop')
|
|
39
|
+
})
|
|
40
|
+
it('disables fencing an instance the server refuses on its own, with the reason, and keeps All', () => {
|
|
41
|
+
const target = 'It is the target of a switchover or failover in progress'
|
|
42
|
+
render('fence', { instanceActions: { 'orders-1': { fence: { allowed: true } }, 'orders-2': { fence: { allowed: false, reason: target, permission: 'allowed' } } } })
|
|
43
|
+
const select = host.querySelector('select')!
|
|
44
|
+
const option = (value: string) => [...select.options].find((o) => o.value === value)!
|
|
45
|
+
expect(option('orders-2').disabled).toBe(true)
|
|
46
|
+
expect(option('orders-2').textContent).toBe('orders-2 — cannot be fenced')
|
|
47
|
+
expect(option('orders-1').disabled).toBe(false)
|
|
48
|
+
expect(option('*').disabled).toBe(false)
|
|
49
|
+
expect(host.textContent).toContain(`orders-2 cannot be fenced: ${target}`)
|
|
50
|
+
act(() => { select.value = 'orders-2'; select.dispatchEvent(new Event('change', { bubbles: true })) })
|
|
51
|
+
expect(state.dialog.disabledReason).toBe(`orders-2 cannot be fenced: ${target}`)
|
|
52
|
+
act(() => host.querySelector('button')!.click())
|
|
53
|
+
expect(state.mutate).not.toHaveBeenCalled()
|
|
54
|
+
act(() => { select.value = '*'; select.dispatchEvent(new Event('change', { bubbles: true })) })
|
|
55
|
+
expect(state.dialog.disabledReason).toBeUndefined()
|
|
56
|
+
act(() => host.querySelector('button')!.click())
|
|
57
|
+
expect(state.mutate.mock.calls[0][0].request.params.instances).toBe('*')
|
|
58
|
+
})
|
|
59
|
+
it('keeps a malformed fencing annotation as a real blocker after selecting an instance', () => {
|
|
60
|
+
render('fence', { facts: { ...facts, fencedInstances: { all: false, instances: [], malformed: true } } })
|
|
61
|
+
const select = host.querySelector('select')!
|
|
62
|
+
act(() => { select.value = 'orders-2'; select.dispatchEvent(new Event('change', { bubbles: true })) })
|
|
63
|
+
expect(state.dialog.disabledReason).toBe('The cnpg.io/fencedInstances annotation is not valid JSON; fix it in YAML first.')
|
|
64
|
+
expect(state.dialog.incompleteReason).toBeUndefined()
|
|
65
|
+
expect(host.querySelector('button')!.disabled).toBe(true)
|
|
66
|
+
act(() => host.querySelector('button')!.click())
|
|
67
|
+
expect(state.mutate).not.toHaveBeenCalled()
|
|
68
|
+
})
|
|
69
|
+
it('preserves the lift-all fencing default', () => {
|
|
70
|
+
render('unfence', { facts: { ...facts, fencedInstances: { all: true, instances: [] } } })
|
|
71
|
+
expect(host.querySelector('select')!.value).toBe('*')
|
|
72
|
+
expect(host.querySelector('button')!.disabled).toBe(false)
|
|
73
|
+
})
|
|
74
|
+
it('labels kept PVCs and separates capacity from requested size', () => {
|
|
75
|
+
const list = { available: true, names: [] }
|
|
76
|
+
render('hibernate', { hibernateEffects: { volumes: { available: true, items: [{ name: 'orders-1', capacity: '1Gi', requested: '2Gi' }, { name: 'orders-2', requested: '2Gi' }, { name: 'orders-3' }] }, poolers: list, unsuspendedScheduledBackups: list, databases: list, publications: list, subscriptions: list } })
|
|
77
|
+
expect(host.textContent).toContain('Kept PVCs: orders-1 (capacity 1Gi; requested 2Gi), orders-2 (capacity not reported; requested 2Gi), orders-3 (capacity not reported)')
|
|
78
|
+
expect(state.dialog.typedConfirmation).toBe('orders')
|
|
79
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import { CNPGClusterActions, CNPGRestartReview } from './CNPGClusterActions'
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
7
|
+
|
|
8
|
+
const state = vi.hoisted(() => ({ caps: {} as any, workspace: {} as any, refetch: vi.fn() }))
|
|
9
|
+
vi.mock('../../../api/cnpg', () => ({ useCNPGClusterCapabilities: () => state.caps }))
|
|
10
|
+
vi.mock('../useCNPGSidebarWorkspace', () => ({ useCNPGFleet: () => state.workspace }))
|
|
11
|
+
vi.mock('./useOpenCNPGPsql', () => ({ useOpenCNPGPsql: () => vi.fn() }))
|
|
12
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
13
|
+
let root: ReturnType<typeof createRoot>
|
|
14
|
+
let host: HTMLDivElement
|
|
15
|
+
afterEach(() => { act(() => root?.unmount()); host?.remove(); vi.clearAllMocks(); vi.useRealTimers() })
|
|
16
|
+
function render() {
|
|
17
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
18
|
+
act(() => root.render(<CNPGClusterActions namespace="db" name="pg" />))
|
|
19
|
+
}
|
|
20
|
+
function menu() { act(() => host.querySelector<HTMLButtonElement>('[aria-label="More cluster actions"]')!.click()) }
|
|
21
|
+
it('shows a failed capability read with Retry instead of checking forever', () => {
|
|
22
|
+
state.workspace = { query: {} }
|
|
23
|
+
state.caps = { error: new Error('Gateway timeout'), refetch: state.refetch, isFetching: false }
|
|
24
|
+
render()
|
|
25
|
+
expect(host.textContent).toContain('Actions could not be checked: Gateway timeout')
|
|
26
|
+
expect(host.textContent).not.toContain('Checking permissions')
|
|
27
|
+
const retry = [...host.querySelectorAll('button')].find((b) => b.textContent === 'Retry')!
|
|
28
|
+
act(() => retry.click())
|
|
29
|
+
expect(state.refetch).toHaveBeenCalledTimes(1)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('keeps blocked header buttons focusable, explains on focus and click, and prints menu reasons', () => {
|
|
33
|
+
vi.useFakeTimers()
|
|
34
|
+
const cap = { allowed: false, reason: 'Configure a backup destination on orders first' }
|
|
35
|
+
state.caps = { data: { actions: { backup: cap, switchover: { allowed: false, reason: 'No ready standby' }, unfence: { allowed: false, reason: 'No instances are fenced' }, psql: { allowed: false, reason: 'Needs create pods/exec' }, restore: { allowed: false, reason: 'Needs create clusters' } }, facts: { maintenance: {} } } }
|
|
36
|
+
state.workspace = { query: {} }
|
|
37
|
+
render()
|
|
38
|
+
const backup = host.querySelector<HTMLButtonElement>('[aria-label="Back up now"]')!
|
|
39
|
+
expect(backup.disabled).toBe(false)
|
|
40
|
+
expect(backup.getAttribute('aria-disabled')).toBe('true')
|
|
41
|
+
act(() => { backup.focus(); vi.advanceTimersByTime(350) })
|
|
42
|
+
expect(document.activeElement).toBe(backup)
|
|
43
|
+
expect(document.body.textContent).toContain(cap.reason)
|
|
44
|
+
act(() => backup.click())
|
|
45
|
+
expect(host.querySelector('[role="status"]')?.textContent).toBe(cap.reason)
|
|
46
|
+
const switchover = [...host.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent === 'Switchover')!
|
|
47
|
+
expect(switchover.disabled).toBe(false)
|
|
48
|
+
expect(switchover.getAttribute('aria-disabled')).toBe('true')
|
|
49
|
+
act(() => switchover.click())
|
|
50
|
+
expect(host.querySelector('[role="status"]')?.textContent).toBe('No ready standby')
|
|
51
|
+
state.caps.data.actions.switchover.allowed = true
|
|
52
|
+
act(() => root.render(<CNPGClusterActions namespace="db" name="pg" />))
|
|
53
|
+
expect(host.querySelector('[role="status"]')).toBeNull()
|
|
54
|
+
menu()
|
|
55
|
+
const dialog = document.querySelector('[role=dialog][aria-label="Cluster actions"]')!
|
|
56
|
+
expect(dialog.textContent).toContain('No instances are fenced')
|
|
57
|
+
expect(dialog.textContent).toContain('Needs create pods/exec')
|
|
58
|
+
expect(dialog.textContent).toContain('Needs create clusters')
|
|
59
|
+
expect(host.contains(dialog)).toBe(false)
|
|
60
|
+
act(() => dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })))
|
|
61
|
+
expect(host.querySelector('[aria-label="More cluster actions"]')?.getAttribute('aria-expanded')).toBe('false')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('reviews each restart step with current readiness, scheduling blockers and primary downtime', () => {
|
|
65
|
+
const caps = { facts: { currentPrimary: 'orders-1', instances: [{ pod: 'orders-1', ready: true, podReadable: true, podExists: true }, { pod: 'orders-2', ready: false, podReadable: true, podExists: false }] }, restartPlan: { primaryUpdateStrategy: 'unsupervised', primaryUpdateMethod: 'restart', steps: [{ instance: 'orders-2', role: 'standby', effect: 'recreate' }, { instance: 'orders-1', role: 'primary', effect: 'restart' }] } } as any
|
|
66
|
+
const problem = { id: 'join', instance: 'orders-2', title: "New standby orders-2: Can't be scheduled", detail: '2 nodes insufficient pods', subject: { kind: 'Pod', name: 'orders-2-join' } } as any
|
|
67
|
+
const html = renderToStaticMarkup(<CNPGRestartReview caps={caps} problems={[problem]} onOpenOverview={() => {}} />)
|
|
68
|
+
for (const text of ['currently ready', 'currently absent', 'insufficient pods', 'Restarting the primary interrupts its connections.', 'The rolling restart waits for every instance to be ready.', 'Currently blocked by orders-2.', 'If the primary restarts without another ready instance, the cluster stops serving until it is back.']) expect(html).toContain(text)
|
|
69
|
+
expect(html).toContain('See Overview’s problems →')
|
|
70
|
+
caps.facts.instances[1].ready = true
|
|
71
|
+
expect(renderToStaticMarkup(<CNPGRestartReview caps={caps} />)).not.toContain('cluster stops serving')
|
|
72
|
+
caps.facts.instances[1].ready = false; caps.facts.instances[1].podReadable = false
|
|
73
|
+
expect(renderToStaticMarkup(<CNPGRestartReview caps={caps} />)).toContain('service continuity is unknown')
|
|
74
|
+
caps.restartPlan.steps[1].effect = 'wait_for_user'
|
|
75
|
+
const supervised = renderToStaticMarkup(<CNPGRestartReview caps={caps} />)
|
|
76
|
+
expect(supervised).toContain('primary waits for your manual promotion or restart')
|
|
77
|
+
expect(supervised).not.toContain('Restarting the primary interrupts its connections.')
|
|
78
|
+
caps.restartPlan.steps[1].effect = 'skipped_fenced'
|
|
79
|
+
expect(renderToStaticMarkup(<CNPGRestartReview caps={caps} />)).toContain('fenced primary is skipped')
|
|
80
|
+
caps.restartPlan.steps = [{ instance: 'orders-1', role: 'primary', effect: 'restart_only_instance' }]
|
|
81
|
+
caps.facts.instances = [caps.facts.instances[0]]
|
|
82
|
+
expect(renderToStaticMarkup(<CNPGRestartReview caps={caps} />)).toContain('There is no other instance: the cluster stops serving until the primary is back.')
|
|
83
|
+
})
|
|
84
|
+
it('disables header restore for known no sources and keeps unread sources enabled', () => {
|
|
85
|
+
const cluster = { metadata: { name: 'pg', namespace: 'db' }, spec: {} }
|
|
86
|
+
state.caps = { data: { actions: { restore: { allowed: true }, backup: { allowed: false }, switchover: { allowed: false }, psql: { allowed: false } }, facts: { maintenance: {} } } }
|
|
87
|
+
state.workspace = { query: { data: { coverage: { backups: { state: 'full' } }, objects: { backups: [] } } }, fleet: { rows: [{ name: 'pg', namespace: 'db', cluster }] } }
|
|
88
|
+
render(); menu()
|
|
89
|
+
let restore = [...document.querySelectorAll<HTMLButtonElement>('[role=menu] button')].find((b) => b.textContent?.includes('Restore to a new cluster'))!
|
|
90
|
+
expect(restore.disabled).toBe(true)
|
|
91
|
+
expect(document.querySelector('[role=menu]')?.textContent).toContain('Nothing to restore from yet')
|
|
92
|
+
state.workspace.query.data.coverage.backups.state = 'denied'
|
|
93
|
+
act(() => root.render(<CNPGClusterActions namespace="db" name="pg" />))
|
|
94
|
+
restore = [...document.querySelectorAll<HTMLButtonElement>('[role=menu] button')].find((b) => b.textContent?.includes('Restore to a new cluster'))!
|
|
95
|
+
expect(restore.disabled).toBe(false)
|
|
96
|
+
expect(document.querySelector('[role=menu]')?.textContent).not.toContain('Nothing to restore from yet')
|
|
97
|
+
state.caps.isRefetchError = true
|
|
98
|
+
state.caps.error = new Error('Refresh timeout')
|
|
99
|
+
state.caps.dataUpdatedAt = Date.now() - 180_000
|
|
100
|
+
act(() => root.render(<CNPGClusterActions namespace="db" name="pg" />))
|
|
101
|
+
expect(host.textContent).toContain('Last refresh failed: Refresh timeout')
|
|
102
|
+
expect(host.textContent).toContain('3m ago')
|
|
103
|
+
expect(host.textContent).not.toContain('Actions could not be checked')
|
|
104
|
+
expect(restore.disabled).toBe(false)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('carries a verified join-Job blocker into the matching restart step and opens Overview', () => {
|
|
108
|
+
const open = vi.fn()
|
|
109
|
+
const caps = { facts: { currentPrimary: 'orders-1', instances: [{ pod: 'orders-2', ready: false, podReadable: true, podExists: false }] }, restartPlan: { steps: [{ instance: 'orders-2', role: 'standby', effect: 'recreate' }] } } as any
|
|
110
|
+
const problem = { id: 'join', instance: 'orders-2', title: "New standby orders-2: Can't be scheduled", detail: 'Cannot be scheduled: both nodes have reached their Pod limit', subject: { kind: 'Pod', name: 'orders-2-join-dz4fc' } } as any
|
|
111
|
+
host = document.createElement('div'); document.body.append(host); root = createRoot(host)
|
|
112
|
+
act(() => root.render(<CNPGRestartReview caps={caps} problems={[problem]} onOpenOverview={open} />))
|
|
113
|
+
const step = host.querySelector('li')!
|
|
114
|
+
expect(step.textContent).toContain('orders-2 : Will recreate the standby Pod (currently absent)')
|
|
115
|
+
expect(step.textContent).toContain('both nodes have reached their Pod limit')
|
|
116
|
+
act(() => step.querySelector('button')!.click())
|
|
117
|
+
expect(open).toHaveBeenCalledOnce()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('states restart intentions separately from observations and folds the raw update policy', () => {
|
|
121
|
+
const caps = { facts: { currentPrimary: 'orders-1', instances: [{ pod: 'orders-1', ready: true, podReadable: true, podExists: true }, { pod: 'orders-2', podReadable: true, podExists: false }] }, restartPlan: { primaryUpdateStrategy: 'unsupervised', primaryUpdateMethod: 'restart', steps: [{ instance: 'orders-2', role: 'standby', effect: 'recreate' }, { instance: 'orders-1', role: 'primary', effect: 'restart' }] } } as any
|
|
122
|
+
const html = renderToStaticMarkup(<CNPGRestartReview caps={caps} />)
|
|
123
|
+
expect(html).toContain('Will recreate the standby Pod')
|
|
124
|
+
expect(html).toContain('(currently absent)')
|
|
125
|
+
expect(html).toContain('Will restart the primary in place')
|
|
126
|
+
expect(html).toContain('(currently ready)')
|
|
127
|
+
expect(html).toContain('The operator updates the primary automatically after the standbys')
|
|
128
|
+
expect(html).toContain('Operator update settings')
|
|
129
|
+
expect(html).toContain('aria-expanded="false"')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it.each(['skipped_fenced', 'restart_only_instance', 'wait_for_user'])('explains the %s plan without promising a configured switchover', (effect) => {
|
|
133
|
+
const caps = { facts: { currentPrimary: 'orders-1', instances: [{ pod: 'orders-1', ready: true, podReadable: true, podExists: true }] }, restartPlan: { primaryUpdateStrategy: 'unsupervised', primaryUpdateMethod: 'switchover', steps: [{ instance: 'orders-1', role: 'primary', effect }] } } as any
|
|
134
|
+
const html = renderToStaticMarkup(<CNPGRestartReview caps={caps} />)
|
|
135
|
+
expect(html).not.toContain('updates the primary automatically after the standbys')
|
|
136
|
+
expect(html).not.toContain('it promotes an updated standby')
|
|
137
|
+
expect(html).toContain(effect === 'skipped_fenced' ? 'The fenced primary is skipped.' : effect === 'restart_only_instance' ? 'There is no other instance' : 'The primary waits for your manual promotion or restart.')
|
|
138
|
+
})
|
|
139
|
+
it('explains an automatic switchover from the planned effect rather than the raw method', () => {
|
|
140
|
+
const caps = { facts: { currentPrimary: 'orders-1', instances: [] }, restartPlan: { primaryUpdateStrategy: 'unsupervised', primaryUpdateMethod: 'restart', steps: [{ instance: 'orders-1', role: 'primary', effect: 'switchover' }] } } as any
|
|
141
|
+
expect(renderToStaticMarkup(<CNPGRestartReview caps={caps} />)).toContain('it promotes an updated standby, then recreates the former primary Pod.')
|
|
142
|
+
})
|