@skyhook-io/radar-app 1.17.0 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/App.tsx +125 -87
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +266 -39
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/radarFeatures.render.test.tsx +47 -3
- package/src/api/radarFeatures.ts +4 -1
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
- package/src/components/capacity/CapacityView.test.tsx +323 -0
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +11 -129
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +228 -122
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +28 -15
- package/src/components/timeline/TimelineList.tsx +22 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineView.tsx +18 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +10 -1
- package/src/components/workload/WorkloadView.tsx +27 -239
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { beforeEach, expect, it, vi } from 'vitest'
|
|
5
|
+
import { CNPG_OP_UNOBSERVABLE_MS, type CNPGTrackedOperation } from './model'
|
|
6
|
+
|
|
7
|
+
const state = vi.hoisted(() => ({
|
|
8
|
+
operations: [] as CNPGTrackedOperation[],
|
|
9
|
+
invalidateQueries: vi.fn(),
|
|
10
|
+
caps: vi.fn(() => ({ data: undefined as { uid: string } | undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })),
|
|
11
|
+
ha: vi.fn(() => ({ data: undefined as { cluster: { uid: string } } | undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })),
|
|
12
|
+
workspace: vi.fn(() => ({ dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })),
|
|
13
|
+
runtime: vi.fn(() => ({ data: undefined as { cluster: { uid: string } } | undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })),
|
|
14
|
+
}))
|
|
15
|
+
vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: state.invalidateQueries }) }))
|
|
16
|
+
vi.mock('../../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: 'kind-db' } }) }))
|
|
17
|
+
vi.mock('../../../api/cnpg', () => ({
|
|
18
|
+
useCNPGClusterCapabilities: state.caps,
|
|
19
|
+
useCNPGRuntime: state.runtime,
|
|
20
|
+
useCNPGWorkspace: state.workspace,
|
|
21
|
+
}))
|
|
22
|
+
vi.mock('../../../api/cnpg-ha', () => ({ useCNPGClusterHA: state.ha }))
|
|
23
|
+
vi.mock('./store', () => ({
|
|
24
|
+
useCNPGOperations: () => state.operations,
|
|
25
|
+
updateCNPGOperations: (update: (all: CNPGTrackedOperation[]) => CNPGTrackedOperation[]) => { state.operations = update(state.operations) },
|
|
26
|
+
dismissCNPGOperation: vi.fn(),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
const { CNPGOperationTracker } = await import('./CNPGOperationTracker')
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
state.invalidateQueries.mockClear()
|
|
33
|
+
state.workspace.mockReturnValue({ dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
|
|
34
|
+
state.caps.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
|
|
35
|
+
state.ha.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
|
|
36
|
+
state.runtime.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('uses a fresh runtime identity to supersede a hidden predecessor when other reads fail', () => {
|
|
40
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
41
|
+
const startedAt = Date.now() - 1_000
|
|
42
|
+
state.operations = [{ id: 'predecessor', kind: 'restart', label: 'Restart', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: { instances: ['pg-1'], podUIDs: { 'pg-1': 'old-pod' } } }]
|
|
43
|
+
state.caps.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: true, isFetching: false, isFetchedAfterMount: true })
|
|
44
|
+
state.ha.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: true, isFetching: false, isFetchedAfterMount: true })
|
|
45
|
+
state.runtime.mockReturnValue({ data: { cluster: { uid: 'new-uid' } }, dataUpdatedAt: startedAt + 1, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
|
|
46
|
+
const host = document.createElement('div')
|
|
47
|
+
const root = createRoot(host)
|
|
48
|
+
try {
|
|
49
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
|
|
50
|
+
expect(state.operations[0].state).toBe('superseded')
|
|
51
|
+
expect(state.operations[0].finishedAt).toBeDefined()
|
|
52
|
+
} finally {
|
|
53
|
+
act(() => root.unmount())
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('observes a lone predecessor record even when identity filtering hides it', () => {
|
|
58
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
59
|
+
const op: CNPGTrackedOperation = { id: 'old', kind: 'reload', label: 'Reload', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt: 1, lastProgressAt: 1, state: 'requested', baseline: {} }
|
|
60
|
+
const completed = { ...op, id: 'finished', state: 'completed' as const, finishedAt: 2 }
|
|
61
|
+
const otherContext = { ...op, id: 'other-context', context: 'prod' }
|
|
62
|
+
const otherCluster = { ...op, id: 'other-cluster', cluster: 'other' }
|
|
63
|
+
state.operations = [op, completed, otherContext, otherCluster]
|
|
64
|
+
state.caps.mockReturnValue({ data: { uid: 'new-uid' }, dataUpdatedAt: Date.now(), errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
|
|
65
|
+
const host = document.createElement('div')
|
|
66
|
+
const root = createRoot(host)
|
|
67
|
+
try {
|
|
68
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
|
|
69
|
+
expect(host.textContent).toBe('')
|
|
70
|
+
expect(state.ha).toHaveBeenCalledWith('db', 'pg', { enabled: true, refetchInterval: 5_000 })
|
|
71
|
+
expect(state.operations[0]).toMatchObject({ state: 'superseded', clusterUID: 'old-uid' })
|
|
72
|
+
expect(state.operations[0].finishedAt).toBeGreaterThan(2)
|
|
73
|
+
expect(state.operations.slice(1)).toEqual([completed, otherContext, otherCluster])
|
|
74
|
+
} finally {
|
|
75
|
+
act(() => root.unmount())
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it.each([true, false])('keeps the current operation when an old capability UID has failed=%s', (failed) => {
|
|
80
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
81
|
+
const startedAt = Date.now() - 1_000
|
|
82
|
+
state.operations = [{ id: 'current', kind: 'switchover', label: 'Switch over', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'new-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }]
|
|
83
|
+
state.caps.mockReturnValue({ data: { uid: 'old-uid' }, dataUpdatedAt: startedAt - 1, errorUpdatedAt: 0, isError: failed, isFetching: false, isFetchedAfterMount: true })
|
|
84
|
+
state.ha.mockReturnValue({ data: { cluster: { uid: 'new-uid' } }, dataUpdatedAt: startedAt + 1, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
|
|
85
|
+
const host = document.createElement('div')
|
|
86
|
+
const root = createRoot(host)
|
|
87
|
+
try {
|
|
88
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
|
|
89
|
+
expect(state.operations[0].state).not.toBe('superseded')
|
|
90
|
+
expect(state.operations[0].finishedAt).toBeUndefined()
|
|
91
|
+
expect(state.operations[0].clusterUID).toBe('new-uid')
|
|
92
|
+
} finally {
|
|
93
|
+
act(() => root.unmount())
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
it.each(['reload', 'restart'])('waits for the first identity response before finalizing an old hidden %s', (kind) => {
|
|
99
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
100
|
+
const startedAt = Date.now() - CNPG_OP_UNOBSERVABLE_MS - 1_000
|
|
101
|
+
state.operations = [{ id: 'predecessor', kind, label: kind, apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }]
|
|
102
|
+
state.caps.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: false, isFetching: true, isFetchedAfterMount: false })
|
|
103
|
+
const host = document.createElement('div')
|
|
104
|
+
const root = createRoot(host)
|
|
105
|
+
try {
|
|
106
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
|
|
107
|
+
expect(host.textContent).toBe('')
|
|
108
|
+
expect(state.operations[0].state).toBe('unobservable')
|
|
109
|
+
expect(state.operations[0].finishedAt).toBeUndefined()
|
|
110
|
+
state.caps.mockReturnValue({ data: { uid: 'new-uid' }, dataUpdatedAt: Date.now(), errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
|
|
111
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
|
|
112
|
+
expect(state.operations[0].state).toBe('superseded')
|
|
113
|
+
expect(state.operations[0].finishedAt).toBeDefined()
|
|
114
|
+
} finally {
|
|
115
|
+
act(() => root.unmount())
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
it('bounds old unobservable operations during background refetches after initial reads settled', () => {
|
|
121
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
122
|
+
const startedAt = Date.now() - CNPG_OP_UNOBSERVABLE_MS - 1_000
|
|
123
|
+
state.operations = [{ id: 'predecessor', kind: 'reload', label: 'Reload', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }]
|
|
124
|
+
state.caps.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: true, isFetching: true, isFetchedAfterMount: true })
|
|
125
|
+
state.ha.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: 0, isError: true, isFetching: true, isFetchedAfterMount: true })
|
|
126
|
+
state.workspace.mockReturnValue({ dataUpdatedAt: 0, errorUpdatedAt: 0, isError: true, isFetching: true, isFetchedAfterMount: true })
|
|
127
|
+
const host = document.createElement('div')
|
|
128
|
+
const root = createRoot(host)
|
|
129
|
+
try {
|
|
130
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
|
|
131
|
+
expect(state.operations[0].state).toBe('unobservable')
|
|
132
|
+
expect(state.operations[0].detail).toContain('identity')
|
|
133
|
+
expect(state.operations[0].finishedAt).toBeDefined()
|
|
134
|
+
} finally {
|
|
135
|
+
act(() => root.unmount())
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
it('advances the follow-window clock when only repeated failure timestamps change', () => {
|
|
141
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
142
|
+
const startedAt = Date.now()
|
|
143
|
+
const clock = vi.spyOn(Date, 'now').mockReturnValue(startedAt + 1_000)
|
|
144
|
+
state.operations = [{ id: 'outage', kind: 'restart', label: 'Restart', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }]
|
|
145
|
+
const failed = { data: undefined, dataUpdatedAt: 0, errorUpdatedAt: startedAt + 1_000, isError: true, isFetching: false, isFetchedAfterMount: true }
|
|
146
|
+
state.caps.mockReturnValue(failed)
|
|
147
|
+
state.ha.mockReturnValue(failed)
|
|
148
|
+
state.runtime.mockReturnValue(failed)
|
|
149
|
+
state.workspace.mockReturnValue(failed)
|
|
150
|
+
const host = document.createElement('div')
|
|
151
|
+
const root = createRoot(host)
|
|
152
|
+
try {
|
|
153
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
|
|
154
|
+
expect(state.operations[0].finishedAt).toBeUndefined()
|
|
155
|
+
const settledAt = startedAt + CNPG_OP_UNOBSERVABLE_MS + 1
|
|
156
|
+
clock.mockReturnValue(settledAt)
|
|
157
|
+
state.caps.mockReturnValue({ ...failed, errorUpdatedAt: settledAt })
|
|
158
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
|
|
159
|
+
expect(state.operations[0].state).toBe('unobservable')
|
|
160
|
+
expect(state.operations[0].finishedAt).toBe(settledAt)
|
|
161
|
+
expect(state.operations[0].lastCheckedAt).toBe(settledAt)
|
|
162
|
+
} finally {
|
|
163
|
+
act(() => root.unmount())
|
|
164
|
+
clock.mockRestore()
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('preserves an in-flight initial identity read when its next poll is due', () => {
|
|
169
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
170
|
+
vi.useFakeTimers()
|
|
171
|
+
const startedAt = Date.now() - CNPG_OP_UNOBSERVABLE_MS - 1_000
|
|
172
|
+
state.operations = [{ id: 'slow', kind: 'reload', label: 'Reload', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'old-uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }]
|
|
173
|
+
state.caps.mockReturnValue({ data: { uid: 'old-uid' }, dataUpdatedAt: startedAt - 1, errorUpdatedAt: 0, isError: false, isFetching: true, isFetchedAfterMount: false })
|
|
174
|
+
const host = document.createElement('div')
|
|
175
|
+
const root = createRoot(host)
|
|
176
|
+
try {
|
|
177
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="new-uid" />))
|
|
178
|
+
act(() => vi.advanceTimersByTime(5_000))
|
|
179
|
+
expect(state.operations[0].finishedAt).toBeUndefined()
|
|
180
|
+
expect(state.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cnpg', 'capabilities', 'clusters', 'db', 'pg'] }, { cancelRefetch: false })
|
|
181
|
+
expect(state.invalidateQueries).toHaveBeenCalledWith({ queryKey: ['cnpg', 'workspace', 'db'] }, { cancelRefetch: false })
|
|
182
|
+
} finally {
|
|
183
|
+
act(() => root.unmount())
|
|
184
|
+
vi.useRealTimers()
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('reads "requested", not "cannot be followed", until the first read since the request lands', () => {
|
|
189
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
190
|
+
const startedAt = Date.now() - 1_000
|
|
191
|
+
state.operations = [{ id: 'fresh', kind: 'switchover', label: 'Switch over', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'uid', target: { name: 'pg-2' }, startedAt, lastProgressAt: startedAt, state: 'requested', baseline: { currentPrimary: 'pg-1' } }]
|
|
192
|
+
state.caps.mockReturnValue({ data: { uid: 'uid' }, dataUpdatedAt: startedAt - 1, errorUpdatedAt: 0, isError: false, isFetching: true, isFetchedAfterMount: true })
|
|
193
|
+
const host = document.createElement('div')
|
|
194
|
+
const root = createRoot(host)
|
|
195
|
+
try {
|
|
196
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="uid" />))
|
|
197
|
+
expect(state.operations[0]).toMatchObject({ state: 'requested', detail: 'Waiting for the first read since the request' })
|
|
198
|
+
expect(host.textContent).toContain('requested')
|
|
199
|
+
state.caps.mockReturnValue({ data: undefined, dataUpdatedAt: startedAt - 1, errorUpdatedAt: Date.now(), isError: true, isFetching: false, isFetchedAfterMount: true })
|
|
200
|
+
state.ha.mockReturnValue({ data: undefined, dataUpdatedAt: 0, errorUpdatedAt: Date.now(), isError: true, isFetching: false, isFetchedAfterMount: true })
|
|
201
|
+
state.workspace.mockReturnValue({ dataUpdatedAt: 0, errorUpdatedAt: Date.now(), isError: true, isFetching: false, isFetchedAfterMount: true })
|
|
202
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="uid" />))
|
|
203
|
+
expect(state.operations[0].state).toBe('unobservable')
|
|
204
|
+
expect(state.operations[0].finishedAt).toBeUndefined()
|
|
205
|
+
} finally {
|
|
206
|
+
act(() => root.unmount())
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('does not let runtime cached by another view stand in for the identity while the polled reads fail', () => {
|
|
211
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
212
|
+
const startedAt = Date.now() - 1_000
|
|
213
|
+
state.operations = [{ id: 'fence', kind: 'fence', label: 'Fence pg-2', apiBase: '/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: { instances: ['pg-2'] } }]
|
|
214
|
+
const failing = { data: undefined, dataUpdatedAt: 0, errorUpdatedAt: Date.now(), isError: true, isFetching: false, isFetchedAfterMount: true }
|
|
215
|
+
state.caps.mockReturnValue(failing)
|
|
216
|
+
state.ha.mockReturnValue(failing)
|
|
217
|
+
state.workspace.mockReturnValue(failing)
|
|
218
|
+
state.runtime.mockReturnValue({ data: { cluster: { uid: 'uid' } }, dataUpdatedAt: startedAt - 1, errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
|
|
219
|
+
const host = document.createElement('div')
|
|
220
|
+
const root = createRoot(host)
|
|
221
|
+
try {
|
|
222
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="uid" />))
|
|
223
|
+
expect(state.runtime).toHaveBeenLastCalledWith('db', 'pg', false)
|
|
224
|
+
expect(state.operations[0].state).toBe('unobservable')
|
|
225
|
+
expect(state.operations[0].detail).toContain('identity')
|
|
226
|
+
expect(state.operations[0].finishedAt).toBeUndefined()
|
|
227
|
+
} finally {
|
|
228
|
+
act(() => root.unmount())
|
|
229
|
+
}
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('leaves records issued against another Radar alone', () => {
|
|
233
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
234
|
+
const startedAt = Date.now() - 1_000
|
|
235
|
+
const other: CNPGTrackedOperation = { id: 'east', kind: 'reload', label: 'Reload', apiBase: '/c/east/api', context: 'kind-db', namespace: 'db', cluster: 'pg', clusterUID: 'uid', startedAt, lastProgressAt: startedAt, state: 'requested', baseline: {} }
|
|
236
|
+
state.operations = [other]
|
|
237
|
+
state.caps.mockReturnValue({ data: { uid: 'uid' }, dataUpdatedAt: Date.now(), errorUpdatedAt: 0, isError: false, isFetching: false, isFetchedAfterMount: true })
|
|
238
|
+
const host = document.createElement('div')
|
|
239
|
+
const root = createRoot(host)
|
|
240
|
+
try {
|
|
241
|
+
act(() => root.render(<CNPGOperationTracker namespace="db" name="pg" uid="uid" />))
|
|
242
|
+
expect(host.textContent).toBe('')
|
|
243
|
+
expect(state.operations).toEqual([other])
|
|
244
|
+
} finally {
|
|
245
|
+
act(() => root.unmount())
|
|
246
|
+
}
|
|
247
|
+
})
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { Check, Circle, CircleHelp, Copy, X } from 'lucide-react'
|
|
4
|
+
import { Badge, DialogPortal, StatusDot, coverageReadable, formatAge } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { copyText } from '@skyhook-io/k8s-ui/utils/clipboard'
|
|
6
|
+
import { getApiBase, routePath } from '../../../api/config'
|
|
7
|
+
import { cnpgClusterFullPath } from '../paths'
|
|
8
|
+
import { useToast } from '../../ui/Toast'
|
|
9
|
+
import { useConnection } from '../../../context/ConnectionContext'
|
|
10
|
+
import { useCNPGClusterCapabilities, useCNPGRuntime, useCNPGWorkspace } from '../../../api/cnpg'
|
|
11
|
+
import { useCNPGClusterHA } from '../../../api/cnpg-ha'
|
|
12
|
+
import {
|
|
13
|
+
CNPG_OP_TERMINAL,
|
|
14
|
+
advanceCNPGOperation,
|
|
15
|
+
cnpgOperationFollowed,
|
|
16
|
+
type CNPGObservation,
|
|
17
|
+
type CNPGTrackedOperation,
|
|
18
|
+
} from './model'
|
|
19
|
+
import {
|
|
20
|
+
CNPG_OP_STATE_TEXT as STATE_TEXT,
|
|
21
|
+
CNPG_OP_STATE_TONE as STATE_TONE,
|
|
22
|
+
CNPG_OP_STATE_SEVERITY as STATE_SEVERITY,
|
|
23
|
+
cnpgOperationHandoff,
|
|
24
|
+
cnpgOperationsForCluster,
|
|
25
|
+
} from './presentation'
|
|
26
|
+
import { dismissCNPGOperation, updateCNPGOperations, useCNPGOperations } from './store'
|
|
27
|
+
|
|
28
|
+
const POLL_MS = 5_000
|
|
29
|
+
|
|
30
|
+
function sourceFreshness(q: { dataUpdatedAt: number; errorUpdatedAt: number; isError: boolean; isFetching: boolean; isFetchedAfterMount: boolean }) {
|
|
31
|
+
return { updatedAt: q.dataUpdatedAt, checkedAt: Math.max(q.dataUpdatedAt, q.errorUpdatedAt), failed: q.isError, initialPending: q.isFetching && !q.isFetchedAfterMount }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Follows the operations requested on this Cluster in this browser session
|
|
36
|
+
* and shows them in the header until they finish. Reads only what the caller
|
|
37
|
+
* can read; a step it cannot see stays "unknown".
|
|
38
|
+
*/
|
|
39
|
+
export function CNPGOperationTracker({ namespace, name, uid }: { namespace: string; name: string; uid: string }) {
|
|
40
|
+
const { connection } = useConnection()
|
|
41
|
+
const context = connection.context
|
|
42
|
+
const apiBase = getApiBase()
|
|
43
|
+
const allOps = useCNPGOperations()
|
|
44
|
+
const ops = cnpgOperationsForCluster(allOps, { apiBase, namespace, name, context, uid })
|
|
45
|
+
const active = ops.filter(cnpgOperationFollowed)
|
|
46
|
+
const followed = cnpgOperationsForCluster(allOps, { apiBase, namespace, name, context }).filter(cnpgOperationFollowed)
|
|
47
|
+
const following = followed.length > 0
|
|
48
|
+
const needsRuntime = followed.some((o) => ['switchover', 'unfence', 'restart', 'restartInstance', 'destroyInstance'].includes(o.kind))
|
|
49
|
+
|
|
50
|
+
const queryClient = useQueryClient()
|
|
51
|
+
const caps = useCNPGClusterCapabilities(namespace, name, following)
|
|
52
|
+
const ha = useCNPGClusterHA(namespace, name, { enabled: following, refetchInterval: following ? POLL_MS : false })
|
|
53
|
+
const runtime = useCNPGRuntime(namespace, name, following && needsRuntime)
|
|
54
|
+
const workspace = useCNPGWorkspace([namespace], { enabled: following })
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!following) return
|
|
58
|
+
const t = setInterval(() => {
|
|
59
|
+
if (document.visibilityState === 'hidden') return
|
|
60
|
+
queryClient.invalidateQueries({ queryKey: ['cnpg', 'capabilities', 'clusters', namespace, name] }, { cancelRefetch: false })
|
|
61
|
+
queryClient.invalidateQueries({ queryKey: ['cnpg', 'workspace', namespace] }, { cancelRefetch: false })
|
|
62
|
+
}, POLL_MS)
|
|
63
|
+
return () => clearInterval(t)
|
|
64
|
+
}, [following, queryClient, namespace, name])
|
|
65
|
+
|
|
66
|
+
const capsFresh = sourceFreshness(caps)
|
|
67
|
+
const haFresh = sourceFreshness(ha)
|
|
68
|
+
const runtimeFresh = sourceFreshness(runtime)
|
|
69
|
+
const workspaceFresh = sourceFreshness(workspace)
|
|
70
|
+
const observation = useMemo<CNPGObservation | null>(() => {
|
|
71
|
+
if (!following) return null
|
|
72
|
+
const ws = workspace.data
|
|
73
|
+
const cluster = ws?.objects.clusters?.find(
|
|
74
|
+
(c: any) => c?.metadata?.namespace === namespace && c?.metadata?.name === name,
|
|
75
|
+
)
|
|
76
|
+
const backupsReadable = ws?.coverage.backups ? coverageReadable(ws.coverage.backups, namespace) : false
|
|
77
|
+
// Runtime that is not polled is a cached answer from another view, not a
|
|
78
|
+
// check of the Cluster now.
|
|
79
|
+
const identity = [
|
|
80
|
+
{ uid: caps.data?.uid, updatedAt: capsFresh.updatedAt, failed: capsFresh.failed },
|
|
81
|
+
{ uid: ha.data?.cluster.uid, updatedAt: haFresh.updatedAt, failed: haFresh.failed },
|
|
82
|
+
...(needsRuntime ? [{ uid: runtime.data?.cluster.uid, updatedAt: runtimeFresh.updatedAt, failed: runtimeFresh.failed }] : []),
|
|
83
|
+
{ uid: cluster?.metadata?.uid, updatedAt: workspaceFresh.updatedAt, failed: workspaceFresh.failed },
|
|
84
|
+
].filter((source) => source.uid && !source.failed).sort((a, b) => b.updatedAt - a.updatedAt)[0]
|
|
85
|
+
return {
|
|
86
|
+
// Failed polls must advance elapsed-time checks even when no data changes.
|
|
87
|
+
now: Math.max(Date.now(), capsFresh.checkedAt, haFresh.checkedAt, runtimeFresh.checkedAt, workspaceFresh.checkedAt),
|
|
88
|
+
clusterUID: identity?.uid,
|
|
89
|
+
identityPending: capsFresh.initialPending || haFresh.initialPending || workspaceFresh.initialPending || (needsRuntime && runtimeFresh.initialPending),
|
|
90
|
+
identityReadFailed: capsFresh.failed || haFresh.failed || workspaceFresh.failed || (needsRuntime && runtimeFresh.failed),
|
|
91
|
+
facts: caps.data?.facts,
|
|
92
|
+
cluster,
|
|
93
|
+
ha: ha.data,
|
|
94
|
+
runtime: runtime.data,
|
|
95
|
+
backups: backupsReadable ? (ws?.objects.backups ?? []) : undefined,
|
|
96
|
+
freshness: {
|
|
97
|
+
clusterUID: identity ? { updatedAt: identity.updatedAt, failed: false } : undefined,
|
|
98
|
+
facts: { updatedAt: capsFresh.updatedAt, failed: capsFresh.failed, clusterUID: caps.data?.uid },
|
|
99
|
+
cluster: { updatedAt: workspaceFresh.updatedAt, failed: workspaceFresh.failed, clusterUID: cluster?.metadata?.uid },
|
|
100
|
+
backups: { updatedAt: workspaceFresh.updatedAt, failed: workspaceFresh.failed },
|
|
101
|
+
ha: { updatedAt: haFresh.updatedAt, failed: haFresh.failed, clusterUID: ha.data?.cluster.uid },
|
|
102
|
+
runtime: { updatedAt: runtimeFresh.updatedAt, failed: runtimeFresh.failed, clusterUID: runtime.data?.cluster.uid },
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}, [
|
|
106
|
+
following,
|
|
107
|
+
needsRuntime,
|
|
108
|
+
namespace,
|
|
109
|
+
name,
|
|
110
|
+
caps.data,
|
|
111
|
+
ha.data,
|
|
112
|
+
runtime.data,
|
|
113
|
+
workspace.data,
|
|
114
|
+
capsFresh.updatedAt,
|
|
115
|
+
capsFresh.failed,
|
|
116
|
+
haFresh.updatedAt,
|
|
117
|
+
haFresh.failed,
|
|
118
|
+
runtimeFresh.updatedAt,
|
|
119
|
+
runtimeFresh.failed,
|
|
120
|
+
workspaceFresh.updatedAt,
|
|
121
|
+
workspaceFresh.failed,
|
|
122
|
+
capsFresh.initialPending,
|
|
123
|
+
haFresh.initialPending,
|
|
124
|
+
workspaceFresh.initialPending,
|
|
125
|
+
runtimeFresh.initialPending,
|
|
126
|
+
capsFresh.checkedAt,
|
|
127
|
+
haFresh.checkedAt,
|
|
128
|
+
workspaceFresh.checkedAt,
|
|
129
|
+
runtimeFresh.checkedAt,
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (!observation) return
|
|
134
|
+
updateCNPGOperations((all) => {
|
|
135
|
+
let changed = false
|
|
136
|
+
const next = all.map((op) => {
|
|
137
|
+
if (op.apiBase !== apiBase || op.namespace !== namespace || op.cluster !== name || op.context !== context || !cnpgOperationFollowed(op))
|
|
138
|
+
return op
|
|
139
|
+
const adv = advanceCNPGOperation(op, observation)
|
|
140
|
+
if (JSON.stringify(adv) !== JSON.stringify(op)) changed = true
|
|
141
|
+
return adv
|
|
142
|
+
})
|
|
143
|
+
return changed ? next : all
|
|
144
|
+
})
|
|
145
|
+
}, [observation, apiBase, namespace, name, context])
|
|
146
|
+
|
|
147
|
+
const [open, setOpen] = useState(false)
|
|
148
|
+
if (ops.length === 0) return null
|
|
149
|
+
const lead = active[active.length - 1] ?? ops[ops.length - 1]
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className="relative">
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
aria-haspopup="dialog"
|
|
156
|
+
aria-expanded={open}
|
|
157
|
+
onClick={() => setOpen((v) => !v)}
|
|
158
|
+
className="btn-secondary inline-flex max-w-[22rem] items-center gap-1.5 px-2.5 py-1.5 text-xs"
|
|
159
|
+
>
|
|
160
|
+
<StatusDot tone={STATE_TONE[lead.state]} />
|
|
161
|
+
<span className="truncate">{lead.label}</span>
|
|
162
|
+
<span className="shrink-0 text-theme-text-tertiary">· {STATE_TEXT[lead.state]}</span>
|
|
163
|
+
{ops.length > 1 && <span className="shrink-0 text-theme-text-tertiary">+{ops.length - 1}</span>}
|
|
164
|
+
</button>
|
|
165
|
+
<DialogPortal
|
|
166
|
+
open={open}
|
|
167
|
+
onClose={() => setOpen(false)}
|
|
168
|
+
ariaLabel="Operations on this cluster"
|
|
169
|
+
className="w-full max-w-xl p-4"
|
|
170
|
+
>
|
|
171
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
172
|
+
<h3 className="text-sm font-semibold text-theme-text-primary">Operations in this tab</h3>
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={() => setOpen(false)}
|
|
176
|
+
aria-label="Close operations"
|
|
177
|
+
className="rounded p-1 text-theme-text-secondary hover:bg-theme-hover"
|
|
178
|
+
>
|
|
179
|
+
<X className="h-4 w-4" />
|
|
180
|
+
</button>
|
|
181
|
+
</div>
|
|
182
|
+
<div className="max-h-[65vh] space-y-2 overflow-y-auto">
|
|
183
|
+
{[...ops].reverse().map((op) => (
|
|
184
|
+
<OperationRow key={op.id} op={op} />
|
|
185
|
+
))}
|
|
186
|
+
</div>
|
|
187
|
+
</DialogPortal>
|
|
188
|
+
</div>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function OperationRow({ op }: { op: CNPGTrackedOperation }) {
|
|
193
|
+
const terminal = CNPG_OP_TERMINAL.has(op.state)
|
|
194
|
+
const { showSuccess, showError } = useToast()
|
|
195
|
+
const handoff = async () => {
|
|
196
|
+
const path = cnpgClusterFullPath(op.namespace, op.cluster, op.context)
|
|
197
|
+
const url = new URL(routePath(path), window.location.origin).href
|
|
198
|
+
const copied = await copyText(cnpgOperationHandoff(op, url))
|
|
199
|
+
if (copied) showSuccess('Handoff copied · the link opens current Cluster facts')
|
|
200
|
+
else showError('Could not copy the handoff')
|
|
201
|
+
}
|
|
202
|
+
return (
|
|
203
|
+
<div className="rounded-md border border-theme-border bg-theme-base p-2 text-xs">
|
|
204
|
+
<div className="flex items-center gap-2">
|
|
205
|
+
<span className="min-w-0 flex-1 truncate font-medium text-theme-text-primary">{op.label}</span>
|
|
206
|
+
<Badge severity={STATE_SEVERITY[op.state]} size="sm">
|
|
207
|
+
{STATE_TEXT[op.state]}
|
|
208
|
+
</Badge>
|
|
209
|
+
{(terminal || op.state === 'unobservable') && (
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
aria-label={`Dismiss ${op.label}`}
|
|
213
|
+
onClick={() => dismissCNPGOperation(op.id)}
|
|
214
|
+
className="rounded p-0.5 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary"
|
|
215
|
+
>
|
|
216
|
+
<X className="h-3.5 w-3.5" />
|
|
217
|
+
</button>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
<div className="mt-0.5 text-theme-text-tertiary">
|
|
221
|
+
requested {formatAge(new Date(op.startedAt).toISOString())} ago
|
|
222
|
+
</div>
|
|
223
|
+
<div className="mt-0.5 text-theme-text-tertiary">
|
|
224
|
+
{op.lastCheckedAt
|
|
225
|
+
? `Last checked in this tab ${formatAge(new Date(op.lastCheckedAt).toISOString())} ago`
|
|
226
|
+
: 'Not yet checked in this tab'}
|
|
227
|
+
</div>
|
|
228
|
+
{op.detail && <div className="mt-1 text-theme-text-secondary">{op.detail}</div>}
|
|
229
|
+
{op.steps && op.steps.length > 0 && (
|
|
230
|
+
<ul className="mt-1.5 space-y-0.5">
|
|
231
|
+
{op.steps.map((s) => (
|
|
232
|
+
<li key={s.label} className="flex items-center gap-1.5 text-theme-text-secondary">
|
|
233
|
+
{s.done === true ? (
|
|
234
|
+
<Check className="h-3 w-3 shrink-0 text-accent" aria-label="done" />
|
|
235
|
+
) : s.done === false ? (
|
|
236
|
+
<Circle className="h-3 w-3 shrink-0 text-theme-text-tertiary" aria-label="not yet" />
|
|
237
|
+
) : (
|
|
238
|
+
<CircleHelp className="h-3 w-3 shrink-0 text-theme-text-tertiary" aria-label="not observable" />
|
|
239
|
+
)}
|
|
240
|
+
<span>{s.label}</span>
|
|
241
|
+
</li>
|
|
242
|
+
))}
|
|
243
|
+
</ul>
|
|
244
|
+
)}
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={() => void handoff()}
|
|
248
|
+
className="mt-2 inline-flex items-center gap-1 text-accent-text hover:underline"
|
|
249
|
+
>
|
|
250
|
+
<Copy className="h-3 w-3" />
|
|
251
|
+
Copy handoff
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
)
|
|
255
|
+
}
|