@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,30 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { beforeEach, expect, it, vi } from 'vitest'
|
|
3
|
+
import { cnpgHost } from './host'
|
|
4
|
+
import { renderCNPGHeaderActions } from './actions/renderCNPGHeaderActions'
|
|
5
|
+
import { radarFeatureSupport } from '../../api/radarFeatures'
|
|
6
|
+
const state = vi.hoisted(() => ({ capabilities: { features: { resourceIssues: true } } as any }))
|
|
7
|
+
vi.mock('../../api/client', async original => ({ ...await original<typeof import('../../api/client')>(), useRadarFeature: () => ({ support: radarFeatureSupport('cnpgWorkspace', state.capabilities, 'v1.16.0') }) }))
|
|
8
|
+
vi.mock('./CNPGClusterTabs', () => ({ CNPGInvestigationAction: () => <span>CNPG investigation</span> }))
|
|
9
|
+
vi.mock('./actions/CNPGClusterActions', () => ({ CNPGClusterActions: () => <span>CNPG write actions</span> }))
|
|
10
|
+
vi.mock('./actions/CNPGConnectButton', () => ({ CNPGConnectButton: () => null }))
|
|
11
|
+
vi.mock('./actions/CNPGScheduleActions', () => ({ CNPGScheduleActions: () => null }))
|
|
12
|
+
vi.mock('./actions/CNPGPoolerActions', () => ({ CNPGPoolerActions: () => null }))
|
|
13
|
+
vi.mock('./operations/CNPGOperationTracker', () => ({ CNPGOperationTracker: () => null }))
|
|
14
|
+
vi.mock('./recovery/CNPGRestoreButton', () => ({ CNPGRestoreButton: () => null }))
|
|
15
|
+
beforeEach(() => { state.capabilities = { features: { resourceIssues: true } } })
|
|
16
|
+
const context = { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg', compact: false }
|
|
17
|
+
const resource = { apiVersion: 'postgresql.cnpg.io/v1', kind: 'Cluster', metadata: { name: 'pg', namespace: 'db', uid: 'uid-pg' } }
|
|
18
|
+
it('preserves ordinary Diagnose and omits new action affordances on the shipped missing-flag state', () => {
|
|
19
|
+
const original = vi.fn(() => <span>ordinary investigation</span>)
|
|
20
|
+
expect(renderToStaticMarkup(cnpgHost.diagnoseAction(original)!(context))).toContain('ordinary investigation')
|
|
21
|
+
expect(original).toHaveBeenCalledWith(context)
|
|
22
|
+
expect(renderToStaticMarkup(renderCNPGHeaderActions({ resource, namespace: 'db', name: 'pg', compact: false }))).toBe('')
|
|
23
|
+
})
|
|
24
|
+
it('mounts the CNPG decorations once their contracts are advertised', () => {
|
|
25
|
+
state.capabilities = { features: { cnpgWorkspace: true } }
|
|
26
|
+
const original = vi.fn(() => <span>ordinary investigation</span>)
|
|
27
|
+
expect(renderToStaticMarkup(cnpgHost.diagnoseAction(original)!(context))).toContain('CNPG investigation')
|
|
28
|
+
expect(original).not.toHaveBeenCalled()
|
|
29
|
+
expect(renderToStaticMarkup(renderCNPGHeaderActions({ resource, namespace: 'db', name: 'pg', compact: false }))).toContain('CNPG write actions')
|
|
30
|
+
})
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { cnpgClusterPlugins, cnpgPluginPhase, formatAge, getCNPGClusterBarmanPlugin, StatusDot, toneTextClass, type CNPGFleetRow } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { SelectedResource } from '../../types'
|
|
3
|
+
import type { CNPGRuntimeInstance } from '../../api/cnpg'
|
|
4
|
+
import { backupMethod, cnpgArchiveDestination, cnpgRecoveryBase, resumeBoundary, type CNPGRecoveryBase } from './archivingRepair'
|
|
5
|
+
|
|
6
|
+
const RESUMED_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* WAL archiving, as a repair: why it fails, where the destination and its
|
|
10
|
+
* credentials are declared, where the archiver logs, then whether archiving
|
|
11
|
+
* resumed and a fresh base backup exists. Shown while archiving fails, and
|
|
12
|
+
* for a day after it resumes so the fix can be confirmed here.
|
|
13
|
+
*/
|
|
14
|
+
export function CNPGArchivingRepair({
|
|
15
|
+
row,
|
|
16
|
+
primary,
|
|
17
|
+
objectStores,
|
|
18
|
+
backups,
|
|
19
|
+
onOpenLogs,
|
|
20
|
+
onOpenOperator,
|
|
21
|
+
onInspect,
|
|
22
|
+
}: {
|
|
23
|
+
row: CNPGFleetRow
|
|
24
|
+
primary?: CNPGRuntimeInstance
|
|
25
|
+
objectStores: any[]
|
|
26
|
+
/** This namespace's CNPG Backups, or null when they could not be read. */
|
|
27
|
+
backups: any[] | null
|
|
28
|
+
onOpenLogs?: (pod: string, container: string) => void
|
|
29
|
+
onOpenOperator?: () => void
|
|
30
|
+
onInspect: (r: SelectedResource) => void
|
|
31
|
+
}) {
|
|
32
|
+
const wal = row.protection.walArchiving
|
|
33
|
+
const arch = primary && (primary.status.state === 'ok' || primary.status.state === 'partial') ? primary.status.archiving : undefined
|
|
34
|
+
const failedAt = arch?.lastFailedAt ? Date.parse(arch.lastFailedAt) : NaN
|
|
35
|
+
const archivedAt = arch?.lastArchivedAt ? Date.parse(arch.lastArchivedAt) : NaN
|
|
36
|
+
const failingNow = wal.tone === 'unhealthy' || (Number.isFinite(failedAt) && (!Number.isFinite(archivedAt) || failedAt > archivedAt))
|
|
37
|
+
// Only the instance manager records that archiving failed: the condition
|
|
38
|
+
// turning True also happens when archiving is first set up, so it marks a
|
|
39
|
+
// resume only after a failure the instance manager saw. Its stats restart
|
|
40
|
+
// with the primary, and the panel then has nothing left to show.
|
|
41
|
+
const runtimeResumed = Number.isFinite(failedAt) && Number.isFinite(archivedAt) && archivedAt > failedAt
|
|
42
|
+
const boundary = resumeBoundary(row.cluster, failedAt)
|
|
43
|
+
const resumed = !failingNow && wal.tone === 'healthy' && runtimeResumed && Number.isFinite(boundary) && Date.now() - boundary < RESUMED_WINDOW_MS
|
|
44
|
+
if (!failingNow && !resumed) return null
|
|
45
|
+
|
|
46
|
+
// A plugin archives WAL only when it is marked isWALArchiver; otherwise the
|
|
47
|
+
// in-tree barmanObjectStore (if any) does, from the postgres container.
|
|
48
|
+
const barman = getCNPGClusterBarmanPlugin(row.cluster)
|
|
49
|
+
const plugin = barman?.isWALArchiver ? barman : null
|
|
50
|
+
const storeName = plugin?.barmanObjectName
|
|
51
|
+
const store = storeName ? objectStores.find((s) => s?.metadata?.namespace === row.namespace && s?.metadata?.name === storeName) : undefined
|
|
52
|
+
const inTree = row.cluster?.spec?.backup?.barmanObjectStore
|
|
53
|
+
const dest = store
|
|
54
|
+
? cnpgArchiveDestination(store.spec?.configuration, `ObjectStore ${storeName}`)
|
|
55
|
+
: inTree
|
|
56
|
+
? cnpgArchiveDestination(inTree, 'the Cluster’s spec.backup.barmanObjectStore')
|
|
57
|
+
: undefined
|
|
58
|
+
const archiverContainer = plugin ? 'plugin-barman-cloud' : 'postgres'
|
|
59
|
+
const pluginPhase = cnpgPluginPhase(row.cluster)
|
|
60
|
+
const primaryPod = primary?.pod ?? row.cluster?.status?.currentPrimary
|
|
61
|
+
const lastBackup = row.protection.lastSuccessfulBackup
|
|
62
|
+
const base = resumed ? cnpgRecoveryBase(row, backups, boundary, plugin ? { method: 'plugin', plugin: plugin.name } : { method: 'barmanObjectStore' }, arch?.lastFailedWal) : undefined
|
|
63
|
+
const baseDone = base?.state === 'verified'
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<section className="rounded-xl border border-theme-border bg-theme-surface px-4 py-3 shadow-theme-sm">
|
|
67
|
+
<h3 className={`text-sm font-semibold ${failingNow ? toneTextClass('unhealthy') : toneTextClass('healthy')}`}>
|
|
68
|
+
{failingNow ? 'WAL archiving is failing' : 'WAL archiving resumed'}
|
|
69
|
+
</h3>
|
|
70
|
+
<div className="mt-1.5 space-y-1.5 text-sm">
|
|
71
|
+
{failingNow && (
|
|
72
|
+
<p className="text-theme-text-primary">
|
|
73
|
+
{wal.tone === 'unhealthy' ? (
|
|
74
|
+
wal.detail ? (
|
|
75
|
+
<>
|
|
76
|
+
The operator reports: <span className="font-mono text-xs">{wal.detail}</span>
|
|
77
|
+
</>
|
|
78
|
+
) : (
|
|
79
|
+
'The operator reports ContinuousArchiving False without a message.'
|
|
80
|
+
)
|
|
81
|
+
) : (
|
|
82
|
+
`The primary’s instance manager reports a failed archive after its last archived WAL${wal.tone === 'healthy' ? '; the operator’s ContinuousArchiving condition still reads True' : ''}.`
|
|
83
|
+
)}
|
|
84
|
+
</p>
|
|
85
|
+
)}
|
|
86
|
+
{arch && (
|
|
87
|
+
<p className="text-xs text-theme-text-secondary">
|
|
88
|
+
{arch.lastArchivedAt ? `Last archived ${formatAge(arch.lastArchivedAt)} ago${arch.lastArchivedWal ? ` (${arch.lastArchivedWal})` : ''}` : 'Nothing archived since the instance manager started'}
|
|
89
|
+
{arch.lastFailedAt ? ` · last failure ${formatAge(arch.lastFailedAt)} ago${arch.lastFailedWal ? ` (${arch.lastFailedWal})` : ''}` : ''}
|
|
90
|
+
{arch.readyWalFiles !== undefined ? ` · ${arch.readyWalFiles} files waiting` : ''} · from the primary’s instance manager
|
|
91
|
+
</p>
|
|
92
|
+
)}
|
|
93
|
+
{failingNow && dest && (
|
|
94
|
+
<div className="text-xs text-theme-text-secondary">
|
|
95
|
+
<div>
|
|
96
|
+
Destination <span className="font-mono text-theme-text-primary">{dest.path ?? 'path not declared'}</span>
|
|
97
|
+
{dest.endpointURL && <> via <span className="font-mono">{dest.endpointURL}</span></>}, declared in{' '}
|
|
98
|
+
{store ? (
|
|
99
|
+
<button type="button" className="text-accent-text hover:underline" onClick={() => onInspect({ kind: 'objectstores', group: 'barmancloud.cnpg.io', namespace: row.namespace, name: storeName! })}>
|
|
100
|
+
{dest.declaredIn}
|
|
101
|
+
</button>
|
|
102
|
+
) : (
|
|
103
|
+
dest.declaredIn
|
|
104
|
+
)}
|
|
105
|
+
.
|
|
106
|
+
</div>
|
|
107
|
+
<div className="mt-0.5">
|
|
108
|
+
Credentials:{' '}
|
|
109
|
+
{dest.identity && (
|
|
110
|
+
<>
|
|
111
|
+
{dest.identity}
|
|
112
|
+
{dest.secrets.length > 0 && ' · '}
|
|
113
|
+
</>
|
|
114
|
+
)}
|
|
115
|
+
{!dest.identity && dest.secrets.length === 0
|
|
116
|
+
? 'none referenced'
|
|
117
|
+
: dest.secrets.map((s, i) => (
|
|
118
|
+
<span key={`${s.secret}/${s.key}/${s.what}`}>
|
|
119
|
+
{i > 0 && ' · '}
|
|
120
|
+
{s.what} in Secret{' '}
|
|
121
|
+
<button type="button" className="font-mono text-accent-text hover:underline" onClick={() => onInspect({ kind: 'secrets', group: '', namespace: row.namespace, name: s.secret })}>
|
|
122
|
+
{s.secret}
|
|
123
|
+
</button>
|
|
124
|
+
{s.key ? <span className="font-mono">/{s.key}</span> : null}
|
|
125
|
+
</span>
|
|
126
|
+
))}
|
|
127
|
+
. Radar names them and does not read them.
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)}
|
|
131
|
+
{failingNow && primaryPod && (
|
|
132
|
+
<p className="text-xs text-theme-text-secondary">
|
|
133
|
+
The primary archives from its <span className="font-mono">{archiverContainer}</span> container; the failing command’s output is in its logs.{' '}
|
|
134
|
+
{onOpenLogs && (
|
|
135
|
+
<button type="button" className="text-accent-text hover:underline" onClick={() => onOpenLogs(primaryPod, archiverContainer)}>
|
|
136
|
+
{archiverContainer} logs of {primaryPod} →
|
|
137
|
+
</button>
|
|
138
|
+
)}
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
{failingNow && pluginPhase && (
|
|
142
|
+
<p className="text-xs text-theme-text-secondary">
|
|
143
|
+
Separately, the Cluster’s phase says the operator {pluginPhase === 'unknownPlugin' ? 'does not know a plugin this cluster requires' : 'hit an error talking to one of this cluster’s plugins'} (spec.plugins:{' '}
|
|
144
|
+
{cnpgClusterPlugins(row.cluster).join(', ') || 'none listed'}), so it cannot reconcile this cluster until that clears.{' '}
|
|
145
|
+
{onOpenOperator && (
|
|
146
|
+
<button type="button" className="text-accent-text hover:underline" onClick={onOpenOperator}>
|
|
147
|
+
Operator and plugins →
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
</p>
|
|
151
|
+
)}
|
|
152
|
+
<ol className="mt-1 space-y-1 text-xs">
|
|
153
|
+
<li className="flex items-start gap-2">
|
|
154
|
+
<span className="flex h-4 shrink-0 items-center"><StatusDot tone={failingNow ? 'unknown' : 'healthy'} /></span>
|
|
155
|
+
<span className={failingNow ? 'text-theme-text-secondary' : 'text-theme-text-primary'}>
|
|
156
|
+
Archiving resumes: the operator reports ContinuousArchiving True and a WAL file archives after the last failure.
|
|
157
|
+
{failingNow ? ' Not yet.' : ` Done${arch?.lastArchivedAt ? `, ${formatAge(arch.lastArchivedAt)} ago` : ''}.`}
|
|
158
|
+
</span>
|
|
159
|
+
</li>
|
|
160
|
+
<li className="flex items-start gap-2">
|
|
161
|
+
<span className="flex h-4 shrink-0 items-center"><StatusDot tone={baseDone ? 'healthy' : 'unknown'} /></span>
|
|
162
|
+
<span className={baseDone ? 'text-theme-text-primary' : 'text-theme-text-secondary'}>
|
|
163
|
+
A base backup that begins after the last WAL that failed to archive completes (Back up now, above), so a restore from it needs none of that WAL.{' '}
|
|
164
|
+
{failingNow || !base
|
|
165
|
+
? `Not yet. Newest successful backup: ${lastBackup.at ? `${formatAge(lastBackup.at)} ago` : lastBackup.text.toLowerCase()}.`
|
|
166
|
+
: recoveryBaseText(base)}
|
|
167
|
+
</span>
|
|
168
|
+
</li>
|
|
169
|
+
</ol>
|
|
170
|
+
</div>
|
|
171
|
+
</section>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function recoveryBaseText(base: CNPGRecoveryBase): string {
|
|
176
|
+
const label = (b: any) => `Backup ${b?.metadata?.name} (${backupMethod(b)})`
|
|
177
|
+
switch (base.state) {
|
|
178
|
+
case 'unread':
|
|
179
|
+
return 'Radar cannot read this namespace’s Backups, so it cannot tell.'
|
|
180
|
+
case 'none':
|
|
181
|
+
return 'No completed Backup of this cluster has started since archiving resumed.'
|
|
182
|
+
case 'verified':
|
|
183
|
+
return `Done: ${label(base.backup)} begins at WAL ${base.backup.status.beginWal}, after ${base.failedWal}, and completed ${formatAge(base.backup.status.stoppedAt ?? base.backup.status.startedAt)} ago.`
|
|
184
|
+
case 'beginsBefore':
|
|
185
|
+
return `${label(base.backup)} started after archiving resumed but begins at WAL ${base.backup?.status?.beginWal ?? '(not reported)'}, not after ${base.failedWal} — likely from a standby that had not replayed past it. Take another.`
|
|
186
|
+
case 'unverifiable':
|
|
187
|
+
return base.missing === 'beginWal'
|
|
188
|
+
? `${label(base.backup)} started after archiving resumed and completed, but its status does not say which WAL it begins at, so Radar cannot compare it with the last failed one.`
|
|
189
|
+
: `${label(base.backup)} started after archiving resumed and completed; Radar cannot compare where it begins with the last failed WAL, which only the primary’s instance manager reports and is not reporting now.`
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
const state = vi.hoisted(() => ({ data: { pod: 'pg-1', state: 'denied', permission: { exec: 'denied', grant: { verb: 'create', resource: 'pods', subresource: 'exec', namespace: 'db' } }, instances: [] } as any, navigate: vi.fn() }))
|
|
6
|
+
vi.mock('./useCNPGNavigate', () => ({ useCNPGNavigate: () => state.navigate }))
|
|
7
|
+
vi.mock('react-router-dom', async (original) => ({ ...await original<typeof import('react-router-dom')>(), useLocation: () => ({ pathname: '/cnpg/clusters/db/pg', search: '?ctx=test&tab=performance&drawer=x', state: { returnLabel: 'Clusters' } }) }))
|
|
8
|
+
vi.mock('@tanstack/react-query', async (original) => ({ ...await original<typeof import('@tanstack/react-query')>(), useQueries: () => [{ data: { containers: [] } }] }))
|
|
9
|
+
vi.mock('../../api/client', () => ({ podMetricsQuery: () => ({}), usePodMetrics: () => ({ data: { containers: [{ name: 'postgres', usage: { cpu: '30000000n', memory: '59Mi' } }] } }) }))
|
|
10
|
+
vi.mock('../../api/cnpg-sessions', () => ({
|
|
11
|
+
useCNPGSessions: () => ({
|
|
12
|
+
data: state.data,
|
|
13
|
+
isLoading: false,
|
|
14
|
+
error: null,
|
|
15
|
+
isRefetchError: false,
|
|
16
|
+
dataUpdatedAt: Date.now(),
|
|
17
|
+
}),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
const { CNPGBlockingSessions } = await import('./CNPGBlockingSessions')
|
|
21
|
+
|
|
22
|
+
describe('CNPGBlockingSessions with exec denied', () => {
|
|
23
|
+
it('explains only the exec grant for blocking detail', () => {
|
|
24
|
+
const html = renderToStaticMarkup(<CNPGBlockingSessions namespace="db" cluster="pg" />)
|
|
25
|
+
expect(html).toContain('Who blocks whom is read inside PostgreSQL')
|
|
26
|
+
expect(html).toContain('create pods/exec')
|
|
27
|
+
expect(html).not.toContain('aggregate')
|
|
28
|
+
expect(html).not.toContain('get pods/proxy')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('keeps query-text prose out of unavailable states while the host owns the prerequisite', () => {
|
|
33
|
+
expect(renderToStaticMarkup(<CNPGBlockingSessions namespace="db" cluster="pg" />)).not.toContain('Query text')
|
|
34
|
+
state.data = { pod: '', state: 'unavailable', permission: { exec: 'unknown' }, instances: [] }
|
|
35
|
+
const html = renderToStaticMarkup(<CNPGBlockingSessions namespace="db" cluster="pg" />)
|
|
36
|
+
expect(html).toContain('Blocking detail not measured')
|
|
37
|
+
expect(html).not.toContain('Available once the primary is running')
|
|
38
|
+
expect(html).not.toContain('See Overview')
|
|
39
|
+
expect(html).not.toContain('could not be read')
|
|
40
|
+
expect(html).not.toContain('Query text')
|
|
41
|
+
})
|
|
42
|
+
it('keeps CPU and memory measurements and limits on separate rows with unbreakable units', () => {
|
|
43
|
+
state.data = { pod: 'pg-1', state: 'ok', permission: { exec: 'allowed' }, sessions: [], instances: [{ pod: 'pg-1', role: 'primary', memoryLimit: '384Mi' }] }
|
|
44
|
+
const html = renderToStaticMarkup(<CNPGBlockingSessions namespace="db" cluster="pg" />)
|
|
45
|
+
expect(html).toMatch(/CPU <span class="whitespace-nowrap">0.03 cores<\/span>/)
|
|
46
|
+
expect(html).toMatch(/Memory <span class="whitespace-nowrap">59 MiB<\/span>/)
|
|
47
|
+
expect(html).toContain('limit <span class="whitespace-nowrap">384 MiB</span>')
|
|
48
|
+
expect(html).not.toContain('Query text')
|
|
49
|
+
expect(html.indexOf('No session is waiting')).toBeLessThan(html.indexOf('Resource usage'))
|
|
50
|
+
})
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { useQueries } from '@tanstack/react-query'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { ActionConfirmDialog, PaneLoader, Tooltip, formatAge, toneFillClass, toneTextClass, formatGrant } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { formatCPUString, formatMemoryString, parseCPUToNanocores, parseMemoryToBytes } from '@skyhook-io/k8s-ui/utils/format'
|
|
6
|
+
import { podMetricsQuery, usePodMetrics } from '../../api/client'
|
|
7
|
+
import { useCNPGAction, useCNPGClusterCapabilities } from '../../api/cnpg'
|
|
8
|
+
import { actionDialogResult } from '../../api/actions'
|
|
9
|
+
import { useCNPGSessions, type CNPGBackend, type CNPGSessionInstance, type CNPGSessionsResponse } from '../../api/cnpg-sessions'
|
|
10
|
+
import { useToast } from '../ui/Toast'
|
|
11
|
+
import { buildBlockingTree, cnpgConnectionFigure, cnpgNoMetricsReadings, countVictims, type BlockingNode } from './blocking'
|
|
12
|
+
import { RefreshFailedNotice } from '../workspace/layout'
|
|
13
|
+
import { Denied } from './runtimeParts'
|
|
14
|
+
|
|
15
|
+
function age(s?: number): string {
|
|
16
|
+
if (s === undefined || s === null) return '—'
|
|
17
|
+
if (s < 1) return `${Math.round(s * 1000)} ms`
|
|
18
|
+
if (s < 90) return `${s.toFixed(0)} s`
|
|
19
|
+
if (s < 5400) return `${Math.round(s / 60)} min`
|
|
20
|
+
return `${(s / 3600).toFixed(1)} h`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Who blocks whom, on one instance, read from pg_stat_activity with
|
|
25
|
+
* pg_blocking_pids over the caller's pods/exec — plus connection headroom and
|
|
26
|
+
* each instance's CPU and memory, so lock waits and resource pressure can be
|
|
27
|
+
* told apart. The instance is chosen by the Sessions section's picker.
|
|
28
|
+
*/
|
|
29
|
+
export function CNPGBlockingSessions({
|
|
30
|
+
namespace,
|
|
31
|
+
cluster,
|
|
32
|
+
pod,
|
|
33
|
+
headroom = true,
|
|
34
|
+
}: {
|
|
35
|
+
namespace: string
|
|
36
|
+
cluster: string
|
|
37
|
+
/** The instance to read; undefined reads the primary. */
|
|
38
|
+
pod?: string
|
|
39
|
+
/** false when the host already shows the connection figure. */
|
|
40
|
+
headroom?: boolean
|
|
41
|
+
}) {
|
|
42
|
+
const q = useCNPGSessions(namespace, cluster, pod)
|
|
43
|
+
const data = q.data
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<section className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
47
|
+
<div className="flex flex-wrap items-center gap-3 border-b border-theme-border px-4 py-2.5">
|
|
48
|
+
<div className="text-sm font-semibold text-theme-text-primary">Blocking{data?.pod ? <> on <span className="font-mono">{data.pod}</span></> : null}</div>
|
|
49
|
+
{data?.capturedAt && <span className="text-xs text-theme-text-tertiary">read {formatAge(data.capturedAt)} ago</span>}
|
|
50
|
+
</div>
|
|
51
|
+
<div className="space-y-4 p-4">
|
|
52
|
+
{!data && q.isLoading && <PaneLoader label="Reading pg_stat_activity…" className="h-20" />}
|
|
53
|
+
{!data && !q.isLoading && <div className="text-sm text-theme-text-tertiary">Sessions could not be read: {q.error instanceof Error ? q.error.message : 'unknown error'}</div>}
|
|
54
|
+
<RefreshFailedNotice queries={[q]} />
|
|
55
|
+
{data && <Body namespace={namespace} cluster={cluster} data={data} headroom={headroom} />}
|
|
56
|
+
</div>
|
|
57
|
+
<div className="border-t border-theme-border px-4 py-2 text-xs text-theme-text-tertiary">
|
|
58
|
+
Source: pg_stat_activity and pg_blocking_pids over pods/exec.
|
|
59
|
+
{data?.state === 'ok' && !!data.sessions?.length && <> Query text is cut at 200 characters.</>}
|
|
60
|
+
</div>
|
|
61
|
+
</section>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function Body({
|
|
66
|
+
namespace,
|
|
67
|
+
cluster,
|
|
68
|
+
data,
|
|
69
|
+
headroom,
|
|
70
|
+
}: {
|
|
71
|
+
namespace: string
|
|
72
|
+
cluster: string
|
|
73
|
+
data: CNPGSessionsResponse
|
|
74
|
+
headroom: boolean
|
|
75
|
+
}) {
|
|
76
|
+
if (data.state === 'denied') return <Denied title="No access to blocking detail" grant={formatGrant(data.permission.grant) ?? 'create pods/exec'}>
|
|
77
|
+
Who blocks whom is read inside PostgreSQL, which needs exec into the instance.
|
|
78
|
+
</Denied>
|
|
79
|
+
if (data.state === 'unavailable') return <div className="text-sm text-theme-text-tertiary">Blocking detail not measured.</div>
|
|
80
|
+
if (data.state !== 'ok') {
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
84
|
+
Sessions on {data.pod || 'the primary'} could not be read ({data.state}){data.error ? `: ${data.error}` : ''}.
|
|
85
|
+
</div>
|
|
86
|
+
<Resources namespace={namespace} instances={data.instances} />
|
|
87
|
+
</>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
const sessions = data.sessions ?? []
|
|
91
|
+
const roots = buildBlockingTree(sessions)
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
{headroom && <Headroom data={data} />}
|
|
95
|
+
{roots.length === 0 ? (
|
|
96
|
+
<div className="text-sm text-theme-text-secondary">No session is waiting on another session’s lock on {data.pod}.</div>
|
|
97
|
+
) : (
|
|
98
|
+
<div className="space-y-2">
|
|
99
|
+
{data.truncated && (
|
|
100
|
+
<div className={clsx('text-xs', toneTextClass('degraded'))}>
|
|
101
|
+
Showing {sessions.length} of {data.involvedTotal} sessions involved in blocking (oldest transactions first).
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
{roots.map((r) => (
|
|
105
|
+
<Tree key={`${r.session.pid}`} node={r} depth={0} namespace={namespace} cluster={cluster} data={data} />
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
<Resources namespace={namespace} instances={data.instances} />
|
|
110
|
+
</>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function Headroom({ data }: { data: CNPGSessionsResponse }) {
|
|
115
|
+
const figure = cnpgConnectionFigure(data)
|
|
116
|
+
if (!figure) return null
|
|
117
|
+
return (
|
|
118
|
+
<div>
|
|
119
|
+
<div className="flex flex-wrap items-baseline gap-x-3 text-sm">
|
|
120
|
+
<span className="text-xs text-theme-text-tertiary">Connections</span>
|
|
121
|
+
<span className={clsx('font-mono', figure.tone && toneTextClass(figure.tone))}>{figure.value}</span>
|
|
122
|
+
<Tooltip content={figure.detail}>
|
|
123
|
+
<span className="text-xs text-theme-text-secondary">{figure.limit}</span>
|
|
124
|
+
</Tooltip>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="mt-1 h-1.5 overflow-hidden rounded bg-theme-elevated">
|
|
127
|
+
<div className={clsx('h-full', figure.tone ? toneFillClass(figure.tone) : 'bg-accent')} style={{ width: `${Math.min(100, (figure.ratio ?? 0) * 100)}%` }} />
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function Resources({ namespace, instances }: { namespace: string; instances: CNPGSessionInstance[] }) {
|
|
134
|
+
const metrics = useQueries({ queries: instances.map((i) => podMetricsQuery(namespace, i.pod)) })
|
|
135
|
+
if (instances.length === 0) return null
|
|
136
|
+
// No readings for any Pod is said once rather than on every card; it may be
|
|
137
|
+
// a missing metrics API or a cluster too new to have been sampled.
|
|
138
|
+
if (cnpgNoMetricsReadings(metrics.map((m) => m.data))) {
|
|
139
|
+
return <div><h3 className="mb-1 text-sm font-semibold text-theme-text-primary">Resource usage</h3><div className="text-xs text-theme-text-tertiary">CPU and memory not measured: the metrics API has no readings for these Pods (metrics-server may be missing, or has not sampled them yet).</div></div>
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
<div>
|
|
143
|
+
<h3 className="mb-1 text-sm font-semibold text-theme-text-primary">Resource usage</h3>
|
|
144
|
+
<div className="mb-1 text-xs text-theme-text-tertiary">CPU and memory of each instance’s postgres container (metrics-server)</div>
|
|
145
|
+
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
146
|
+
{instances.map((i) => (
|
|
147
|
+
<InstanceResources key={i.pod} namespace={namespace} inst={i} />
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function usage(value: number, limit: number | undefined): { pct?: number; tone?: 'degraded' | 'unhealthy' } {
|
|
155
|
+
if (!limit) return {}
|
|
156
|
+
const pct = value / limit
|
|
157
|
+
return { pct, tone: pct >= 0.9 ? 'unhealthy' : pct >= 0.75 ? 'degraded' : undefined }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function InstanceResources({ namespace, inst }: { namespace: string; inst: CNPGSessionInstance }) {
|
|
161
|
+
const m = usePodMetrics(namespace, inst.pod)
|
|
162
|
+
const c = m.data?.containers.find((x) => x.name === 'postgres')
|
|
163
|
+
let body: ReactNode
|
|
164
|
+
if (m.isLoading) body = <span className="text-theme-text-tertiary">reading…</span>
|
|
165
|
+
else if (m.data === null) body = <span className="text-theme-text-tertiary">not measured yet: no metrics for this Pod</span>
|
|
166
|
+
else if (!c) body = <span className="text-theme-text-tertiary">not measured{m.error instanceof Error ? `: ${m.error.message}` : ''}</span>
|
|
167
|
+
else {
|
|
168
|
+
const cpu = usage(parseCPUToNanocores(c.usage.cpu), inst.cpuLimit ? parseCPUToNanocores(inst.cpuLimit) : undefined)
|
|
169
|
+
const mem = usage(parseMemoryToBytes(c.usage.memory), inst.memoryLimit ? parseMemoryToBytes(inst.memoryLimit) : undefined)
|
|
170
|
+
body = (
|
|
171
|
+
<div className="space-y-0.5 font-mono text-xs">
|
|
172
|
+
<div className={cpu.tone && toneTextClass(cpu.tone)}>
|
|
173
|
+
CPU <span className="whitespace-nowrap">{formatCPUString(c.usage.cpu)}</span>
|
|
174
|
+
· {inst.cpuLimit ? <>limit <span className="whitespace-nowrap">{formatCPUString(inst.cpuLimit)}</span></> : 'no limit'}
|
|
175
|
+
</div>
|
|
176
|
+
<div className={mem.tone && toneTextClass(mem.tone)}>
|
|
177
|
+
Memory <span className="whitespace-nowrap">{formatMemoryString(c.usage.memory)}</span>
|
|
178
|
+
· {inst.memoryLimit ? <>limit <span className="whitespace-nowrap">{formatMemoryString(inst.memoryLimit)}</span></> : 'no limit'}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
return (
|
|
184
|
+
<div className="rounded-lg border border-theme-border bg-theme-base px-3 py-2 text-sm">
|
|
185
|
+
<div className="font-mono text-xs font-semibold">
|
|
186
|
+
{inst.pod} <span className="font-sans font-normal text-theme-text-tertiary">{inst.role}</span>
|
|
187
|
+
</div>
|
|
188
|
+
<div className="mt-0.5">{body}</div>
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function Tree({ node, depth, namespace, cluster, data }: { node: BlockingNode; depth: number; namespace: string; cluster: string; data: CNPGSessionsResponse }) {
|
|
194
|
+
return (
|
|
195
|
+
<div className={clsx(depth > 0 && 'ml-5 border-l border-theme-border pl-3')}>
|
|
196
|
+
<SessionRow node={node} depth={depth} namespace={namespace} cluster={cluster} data={data} />
|
|
197
|
+
{node.children.map((c) => (
|
|
198
|
+
<Tree key={`${node.session.pid}-${c.session.pid}`} node={c} depth={depth + 1} namespace={namespace} cluster={cluster} data={data} />
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function SessionRow({ node, depth, namespace, cluster, data }: { node: BlockingNode; depth: number; namespace: string; cluster: string; data: CNPGSessionsResponse }) {
|
|
205
|
+
const s = node.session
|
|
206
|
+
const [signal, setSignal] = useState<'cancelBackend' | 'terminateBackend' | null>(null)
|
|
207
|
+
const victims = countVictims(node)
|
|
208
|
+
const waiting = s.waitEventType === 'Lock'
|
|
209
|
+
return (
|
|
210
|
+
<div className="my-1 rounded-lg border border-theme-border bg-theme-base p-3">
|
|
211
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
|
212
|
+
<span className="font-mono font-semibold">pid {s.pid}</span>
|
|
213
|
+
{depth === 0 && victims > 0 && <span className={clsx('text-xs font-medium', toneTextClass('unhealthy'))}>blocks {victims}</span>}
|
|
214
|
+
{waiting && <span className={clsx('text-xs', toneTextClass('degraded'))}>waiting on {s.waitEvent ?? 'a lock'}</span>}
|
|
215
|
+
{node.cycle && <span className={clsx('text-xs', toneTextClass('unhealthy'))}>lock cycle</span>}
|
|
216
|
+
{node.alsoWaitsOn.length > 0 && <span className="text-xs text-theme-text-tertiary">also waits on {node.alsoWaitsOn.join(', ')}</span>}
|
|
217
|
+
<span className={clsx('text-xs', s.state?.startsWith('idle in transaction') ? toneTextClass('degraded') : 'text-theme-text-secondary')}>{s.state ?? '—'}</span>
|
|
218
|
+
<span className="ml-auto flex gap-3 text-xs">
|
|
219
|
+
<Tooltip content="pg_cancel_backend: stops the statement it is running now. An idle-in-transaction session is running nothing, so it keeps its locks.">
|
|
220
|
+
<button type="button" className="text-accent-text hover:underline" onClick={() => setSignal('cancelBackend')}>
|
|
221
|
+
Stop query
|
|
222
|
+
</button>
|
|
223
|
+
</Tooltip>
|
|
224
|
+
<Tooltip content="pg_terminate_backend: ends the session and rolls back its open transaction.">
|
|
225
|
+
<button type="button" className="text-accent-text hover:underline" onClick={() => setSignal('terminateBackend')}>
|
|
226
|
+
Terminate…
|
|
227
|
+
</button>
|
|
228
|
+
</Tooltip>
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
<div className="mt-1 flex flex-wrap gap-x-3 text-xs text-theme-text-secondary">
|
|
232
|
+
<span className="font-mono">
|
|
233
|
+
{s.user ?? '—'}@{s.database ?? '—'}
|
|
234
|
+
</span>
|
|
235
|
+
{s.application && <span>{s.application}</span>}
|
|
236
|
+
{s.clientAddr && <span className="font-mono">{s.clientAddr}</span>}
|
|
237
|
+
{s.waitEventType && !waiting && (
|
|
238
|
+
<span>
|
|
239
|
+
wait {s.waitEventType}/{s.waitEvent}
|
|
240
|
+
</span>
|
|
241
|
+
)}
|
|
242
|
+
<span>transaction {age(s.xactAgeSeconds)}</span>
|
|
243
|
+
<span>query {age(s.queryAgeSeconds)}</span>
|
|
244
|
+
<span>connected {age(s.backendAgeSeconds)}</span>
|
|
245
|
+
</div>
|
|
246
|
+
{s.query && (
|
|
247
|
+
<pre className="mt-1.5 whitespace-pre-wrap break-all rounded bg-theme-elevated px-2 py-1 font-mono text-[11.5px] text-theme-text-primary">
|
|
248
|
+
{s.query}
|
|
249
|
+
{s.queryTruncated ? ' …' : ''}
|
|
250
|
+
</pre>
|
|
251
|
+
)}
|
|
252
|
+
{signal && <SignalDialog kind={signal} session={s} namespace={namespace} cluster={cluster} data={data} onClose={() => setSignal(null)} />}
|
|
253
|
+
</div>
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function SignalDialog({
|
|
258
|
+
kind,
|
|
259
|
+
session,
|
|
260
|
+
namespace,
|
|
261
|
+
cluster,
|
|
262
|
+
data,
|
|
263
|
+
onClose,
|
|
264
|
+
}: {
|
|
265
|
+
kind: 'cancelBackend' | 'terminateBackend'
|
|
266
|
+
session: CNPGBackend
|
|
267
|
+
namespace: string
|
|
268
|
+
cluster: string
|
|
269
|
+
data: CNPGSessionsResponse
|
|
270
|
+
onClose: () => void
|
|
271
|
+
}) {
|
|
272
|
+
const caps = useCNPGClusterCapabilities(namespace, cluster)
|
|
273
|
+
const mutation = useCNPGAction('clusters', namespace, cluster, caps.data?.context)
|
|
274
|
+
const { showSuccess } = useToast()
|
|
275
|
+
const terminate = kind === 'terminateBackend'
|
|
276
|
+
const fn = terminate ? 'pg_terminate_backend' : 'pg_cancel_backend'
|
|
277
|
+
const idle = session.state?.startsWith('idle in transaction')
|
|
278
|
+
const ctx = caps.data?.context
|
|
279
|
+
return (
|
|
280
|
+
<ActionConfirmDialog
|
|
281
|
+
open
|
|
282
|
+
onClose={onClose}
|
|
283
|
+
onConfirm={() => {
|
|
284
|
+
if (!caps.data) return
|
|
285
|
+
mutation.mutate(
|
|
286
|
+
{
|
|
287
|
+
action: kind,
|
|
288
|
+
request: {
|
|
289
|
+
reviewedContext: caps.data.context,
|
|
290
|
+
uid: caps.data.uid,
|
|
291
|
+
facts: {},
|
|
292
|
+
params: { pod: data.pod, podUID: data.podUID, pid: session.pid, backendStart: session.backendStart },
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
onSuccess: (r) => {
|
|
297
|
+
showSuccess(r.message)
|
|
298
|
+
onClose()
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
}}
|
|
303
|
+
title={terminate ? `Terminate backend ${session.pid}?` : `Cancel the query of backend ${session.pid}?`}
|
|
304
|
+
subject={{ kind: 'Cluster', namespace, name: cluster }}
|
|
305
|
+
context={ctx}
|
|
306
|
+
effect={
|
|
307
|
+
terminate
|
|
308
|
+
? `Ends the session of ${session.user ?? 'this user'} on ${data.pod}. Its open transaction is rolled back — work it did since the transaction began is lost — and its locks are released. The client sees its connection close.`
|
|
309
|
+
: `Stops the statement backend ${session.pid} is running on ${data.pod}. The client gets an error for that statement; the session and its transaction stay open.`
|
|
310
|
+
}
|
|
311
|
+
warnings={[
|
|
312
|
+
!terminate && idle ? 'This session is idle in a transaction: it is running no statement, so cancelling does nothing and it keeps its locks. Terminate it to release them.' : null,
|
|
313
|
+
terminate && session.xactAgeSeconds !== undefined ? `The transaction has been open for ${age(session.xactAgeSeconds)}.` : null,
|
|
314
|
+
].filter(Boolean) as string[]}
|
|
315
|
+
notes={[
|
|
316
|
+
`Sent only if backend ${session.pid} still has the start time you reviewed (${session.backendStart}); a reused pid is a different session and is left alone.`,
|
|
317
|
+
]}
|
|
318
|
+
writes={[
|
|
319
|
+
{
|
|
320
|
+
summary: `exec psql in Pod ${namespace}/${data.pod} (container postgres)`,
|
|
321
|
+
detail: `SELECT ${fn}(pid) FROM pg_stat_activity\nWHERE pid = ${session.pid} AND backend_start = '${session.backendStart}'\n AND backend_type = 'client backend'`,
|
|
322
|
+
},
|
|
323
|
+
]}
|
|
324
|
+
typedConfirmation={terminate ? String(session.pid) : undefined}
|
|
325
|
+
confirmLabel={terminate ? 'Terminate backend' : 'Stop query'}
|
|
326
|
+
disruptive={terminate}
|
|
327
|
+
disabledReason={caps.data && data.permission.exec === 'denied' ? `Needs ${formatGrant(data.permission.grant) ?? 'create pods/exec'}` : undefined}
|
|
328
|
+
incompleteReason={!caps.data ? 'Reading the cluster…' : undefined}
|
|
329
|
+
isLoading={mutation.isPending}
|
|
330
|
+
result={actionDialogResult(mutation.outcomeError ?? mutation.error, { unknown: "An earlier write to this Cluster may have taken effect" })}
|
|
331
|
+
/>
|
|
332
|
+
)
|
|
333
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { expect, it, vi } from 'vitest'
|
|
3
|
+
import { CNPGClusterActivity } from './CNPGClusterActivity'
|
|
4
|
+
vi.mock('../../api/cnpg', () => ({ useCNPGClusterActivity: () => ({ data: { events: [], oldest: null, attributionSince: null }, isRefetchError: true, error: new Error('Activity timeout'), dataUpdatedAt: Date.now() - 180_000 }) }))
|
|
5
|
+
vi.mock('../../api/cnpg-history', () => ({ useCNPGClusterActivityWindow: () => ({}) }))
|
|
6
|
+
vi.mock('./CNPGTrends', () => ({ useCNPGIntervalParams: () => null }))
|
|
7
|
+
it('labels retained activity after a failed refresh', () => {
|
|
8
|
+
const html = renderToStaticMarkup(<CNPGClusterActivity namespace="db" name="pg" />)
|
|
9
|
+
expect(html).toContain('Last refresh failed: Activity timeout')
|
|
10
|
+
expect(html).toContain('3m ago')
|
|
11
|
+
})
|