@skyhook-io/radar-app 1.16.3 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/App.tsx +138 -93
- package/src/RadarApp.tsx +27 -17
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +381 -47
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +65 -3
- package/src/api/radarFeatures.ts +6 -0
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +24 -62
- package/src/components/capacity/CapacityView.test.tsx +354 -1
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +23 -150
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +230 -124
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/ChartBrowser.tsx +3 -3
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +150 -34
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +25 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +25 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +11 -1
- package/src/components/workload/WorkloadView.tsx +162 -529
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from 'react'
|
|
3
|
+
import { createRoot } from 'react-dom/client'
|
|
4
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
5
|
+
import { MemoryRouter } from 'react-router-dom'
|
|
6
|
+
import { expect, it, vi } from 'vitest'
|
|
7
|
+
import { CNPGPerformance } from './CNPGPerformance'
|
|
8
|
+
const state = vi.hoisted(() => ({ denied: false, execDenied: false, navigate: vi.fn() }))
|
|
9
|
+
vi.mock('./useCNPGNavigate', () => ({ useCNPGNavigate: () => state.navigate }))
|
|
10
|
+
vi.mock('../../api/cnpg', () => ({ useCNPGRuntime: () => ({ data: { permission: { proxy: state.denied ? 'denied' : 'allowed', grant: { verb: 'get', resource: 'pods', subresource: 'proxy', namespace: 'db' } }, instances: [] } }), useCNPGClusterCapabilities: () => ({}) }))
|
|
11
|
+
vi.mock('../../api/cnpg-sessions', () => ({ useCNPGSessions: () => ({ data: { pod: '', state: state.denied || state.execDenied ? 'denied' : 'unavailable', permission: { exec: state.denied || state.execDenied ? 'denied' : 'unknown', grant: { verb: 'create', resource: 'pods', subresource: 'exec', namespace: 'db' } }, instances: [] } }) }))
|
|
12
|
+
vi.mock('../../api/cnpg-history', () => ({ useCNPGClusterHistory: () => ({}) }))
|
|
13
|
+
vi.mock('./CNPGTrends', () => ({ CNPGTrends: () => null, useSampleBuffer: () => [] }))
|
|
14
|
+
const view = () => <MemoryRouter initialEntries={[{ pathname: '/cnpg/clusters/db/pg', search: '?ctx=test&tab=performance&drawer=x', state: { returnLabel: 'Clusters' } }]}><CNPGPerformance namespace="db" name="pg" /></MemoryRouter>
|
|
15
|
+
it('shares one primary prerequisite and preserves the Overview navigation context', () => {
|
|
16
|
+
const html = renderToStaticMarkup(view())
|
|
17
|
+
expect(html.match(/Available once the primary is running/g)).toHaveLength(1)
|
|
18
|
+
expect(html.match(/See Overview/g)).toHaveLength(1)
|
|
19
|
+
expect(html).toContain('Session aggregates not measured')
|
|
20
|
+
expect(html).toContain('Blocking detail not measured')
|
|
21
|
+
expect(html).not.toContain('Query text')
|
|
22
|
+
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
|
23
|
+
const host = document.createElement('div'); const root = createRoot(host)
|
|
24
|
+
act(() => root.render(view()))
|
|
25
|
+
act(() => [...host.querySelectorAll<HTMLButtonElement>('button')].find((b) => b.textContent?.includes('See Overview'))!.click())
|
|
26
|
+
expect(state.navigate).toHaveBeenCalledWith('/cnpg/clusters/db/pg?ctx=test&tab=overview&drawer=x&problems=all', { replace: true, state: { returnLabel: 'Clusters' } })
|
|
27
|
+
act(() => root.unmount())
|
|
28
|
+
})
|
|
29
|
+
it('explains proxy access once and blocking exec access once', () => {
|
|
30
|
+
state.denied = true
|
|
31
|
+
const html = renderToStaticMarkup(view())
|
|
32
|
+
expect(html.match(/get pods\/proxy/g)).toHaveLength(1)
|
|
33
|
+
expect(html.match(/create pods\/exec/g)).toHaveLength(1)
|
|
34
|
+
expect(html).not.toContain('aggregate session counts are unavailable too')
|
|
35
|
+
expect(html).not.toContain('Available once the primary is running')
|
|
36
|
+
state.denied = false
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps the startup prerequisite and only Blocking exec denial when proxy is allowed', () => {
|
|
40
|
+
state.execDenied = true
|
|
41
|
+
const html = renderToStaticMarkup(view())
|
|
42
|
+
expect(html.match(/Available once the primary is running/g)).toHaveLength(1)
|
|
43
|
+
expect(html.match(/create pods\/exec/g)).toHaveLength(1)
|
|
44
|
+
expect(html).toContain('Session aggregates not measured')
|
|
45
|
+
expect(html).not.toContain('Session detail needs exec')
|
|
46
|
+
expect(html).not.toContain('get pods/proxy')
|
|
47
|
+
state.execDenied = false
|
|
48
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { MemoryRouter } from 'react-router-dom'
|
|
3
|
+
import { expect, it, vi } from 'vitest'
|
|
4
|
+
import { CNPGPerformance } from './CNPGPerformance'
|
|
5
|
+
import type { CNPGRuntimeInstance } from '../../api/cnpg'
|
|
6
|
+
|
|
7
|
+
const live = vi.hoisted(() => ({ denied: false, empty: false, count: 1, sessionRead: undefined as any, metrics: { state: 'partial', reason: '150 session groups; the largest 100 are listed and sessionsTotal counts all', sessionsTotal: 150, sessions: [{ state: 'active', database: 'app', user: 'app', application: 'client', count: 3 }], sessionsByState: { 'idle in transaction': 12 }, waitingBackends: 2, databases: [{ database: 'app', xactCommit: 90, xactRollback: 10 }], checkpoints: { source: 'pg_stat_checkpointer', timed: 7, requested: 2 }, extensionUpdates: [] } as CNPGRuntimeInstance['metrics'] }))
|
|
8
|
+
vi.mock('../../api/cnpg', () => ({ useCNPGRuntime: () => ({ data: { sampledAt: '2026-10-05T12:00:00Z', permission: { proxy: live.denied ? 'denied' : 'allowed' }, instances: live.empty ? [] : Array.from({ length: live.count }, (_, i) => ({ pod: `pg-${i + 1}`, role: i === 0 ? 'primary' : 'replica', metrics: live.metrics })) } }) }))
|
|
9
|
+
vi.mock('../../api/cnpg-sessions', () => ({ useCNPGSessions: () => ({ data: live.sessionRead }) }))
|
|
10
|
+
vi.mock('../../api/cnpg-history', () => ({ useCNPGClusterHistory: () => ({}) }))
|
|
11
|
+
vi.mock('./CNPGBlockingSessions', () => ({ CNPGBlockingSessions: () => live.sessionRead?.state === 'denied' ? <span>create pods/exec</span> : null }))
|
|
12
|
+
vi.mock('./CNPGTrends', () => ({ CNPGTrends: () => null, useSampleBuffer: () => [] }))
|
|
13
|
+
function render(section: string) {
|
|
14
|
+
return renderToStaticMarkup(<MemoryRouter initialEntries={[`/?section=${section}`]}><CNPGPerformance namespace="pg" name="pg" /></MemoryRouter>)
|
|
15
|
+
}
|
|
16
|
+
it('keeps partial session measurements and the producer reason visible', () => {
|
|
17
|
+
const html = render('sessions')
|
|
18
|
+
expect(html).toContain('150 in use')
|
|
19
|
+
expect(html).toContain('client')
|
|
20
|
+
expect(html).toContain('12')
|
|
21
|
+
expect(html).toContain(live.metrics.reason)
|
|
22
|
+
expect(html).toContain('Sessions: partial')
|
|
23
|
+
})
|
|
24
|
+
it('keeps partial database and checkpoint measurements and qualifies each card', () => {
|
|
25
|
+
const html = render('health')
|
|
26
|
+
expect(html).toContain('10.0 %')
|
|
27
|
+
expect(html).toContain('Timed')
|
|
28
|
+
expect(html).toContain('>7<')
|
|
29
|
+
expect(html).toContain('Database health: partial')
|
|
30
|
+
expect(html).toContain('Checkpoints: partial')
|
|
31
|
+
expect(html).toContain(live.metrics.reason)
|
|
32
|
+
expect(html).not.toContain('Every installed extension')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('keeps the Sessions card title and full-width shell when proxy access is denied', () => {
|
|
36
|
+
live.denied = true
|
|
37
|
+
const html = render('sessions')
|
|
38
|
+
expect(html).toMatch(/<section[^>]*>.*Sessions on pg-1.*No access to live instance data.*<\/section>/)
|
|
39
|
+
expect(html).toContain('needs access you do not have')
|
|
40
|
+
live.denied = false
|
|
41
|
+
})
|
|
42
|
+
it('says checked when no instance answered and sampled when a measurement was captured', () => {
|
|
43
|
+
live.empty = true
|
|
44
|
+
const html = render('sessions')
|
|
45
|
+
expect(html).toContain('No instance data yet; checked')
|
|
46
|
+
expect(html).not.toContain('sampled')
|
|
47
|
+
live.empty = false
|
|
48
|
+
live.metrics.capturedAt = '2026-10-05T12:00:00Z'
|
|
49
|
+
expect(render('sessions')).toContain('Live instance data · sampled')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('hides a single-instance picker, names that instance in the card, and retains multi-instance selection', () => {
|
|
53
|
+
live.count = 1
|
|
54
|
+
const single = render('sessions')
|
|
55
|
+
expect(single).not.toContain('<select')
|
|
56
|
+
expect(single).toContain('Sessions on pg-1')
|
|
57
|
+
live.count = 2
|
|
58
|
+
expect(render('sessions')).toContain('<select')
|
|
59
|
+
live.count = 1
|
|
60
|
+
})
|
|
61
|
+
it('uses one primary prerequisite for empty Sessions while Blocking owns exec denial', () => {
|
|
62
|
+
live.empty = true
|
|
63
|
+
const html = render('sessions')
|
|
64
|
+
expect(html).toContain('Available once the primary is running')
|
|
65
|
+
expect(html).toContain('See Overview')
|
|
66
|
+
expect(html).not.toContain('No instance is reported')
|
|
67
|
+
live.sessionRead = { state: 'denied', permission: { exec: 'denied', grant: { verb: 'create', resource: 'pods', subresource: 'exec', namespace: 'pg' } } }
|
|
68
|
+
const denied = render('sessions')
|
|
69
|
+
expect(denied).toContain('create pods/exec')
|
|
70
|
+
expect(denied).toContain('Available once the primary is running')
|
|
71
|
+
expect(denied.match(/create pods\/exec/g)).toHaveLength(1)
|
|
72
|
+
live.empty = false; live.sessionRead = undefined
|
|
73
|
+
})
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { useLocation, useSearchParams } from 'react-router-dom'
|
|
3
|
+
import { clsx } from 'clsx'
|
|
4
|
+
import { PaneLoader, Tooltip, formatAge, toneTextClass, formatGrant } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { useCNPGRuntime, type CNPGRuntimeInstance } from '../../api/cnpg'
|
|
6
|
+
import { useCNPGSessions, type CNPGSessionsResponse } from '../../api/cnpg-sessions'
|
|
7
|
+
import { useCNPGClusterHistory } from '../../api/cnpg-history'
|
|
8
|
+
import { CNPGBlockingSessions } from './CNPGBlockingSessions'
|
|
9
|
+
import { cnpgConnectionFigure } from './blocking'
|
|
10
|
+
import { cnpgRuntimeMeasured, cnpgCheckpointView, cnpgDatabaseHealthRows, cnpgIdAge, cnpgPickedInstance, cnpgSessionsCardShowsConnections, cnpgTransactionRates, type CNPGTransactionRates } from './runtimeModel'
|
|
11
|
+
import { formatBytes } from './lsn'
|
|
12
|
+
import { historyLatest, latestRate } from './trendSamples'
|
|
13
|
+
import { CNPGTrends, useSampleBuffer, type CNPGChartGroup, type CNPGIntervalTarget, type Sample } from './CNPGTrends'
|
|
14
|
+
import { Notice, RefreshFailedNotice, Segments } from '../workspace/layout'
|
|
15
|
+
import { Card, Metric, ProxyDenied, PrimaryPrerequisite, Unavailable, SourceState, seconds } from './runtimeParts'
|
|
16
|
+
|
|
17
|
+
type Section = 'sessions' | 'health' | 'history'
|
|
18
|
+
|
|
19
|
+
const SECTIONS: { id: Section; label: string }[] = [
|
|
20
|
+
{ id: 'sessions', label: 'Sessions' },
|
|
21
|
+
{ id: 'health', label: 'Database health' },
|
|
22
|
+
{ id: 'history', label: 'History' },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Performance: who is connected and what is blocked (Sessions), how the
|
|
27
|
+
* databases are doing (Database health), and how it changed (History). The
|
|
28
|
+
* instance picker drives Sessions and Database health; History covers the
|
|
29
|
+
* cluster.
|
|
30
|
+
*/
|
|
31
|
+
export function CNPGPerformance({
|
|
32
|
+
namespace,
|
|
33
|
+
name,
|
|
34
|
+
onOpenInterval,
|
|
35
|
+
}: {
|
|
36
|
+
namespace: string
|
|
37
|
+
name: string
|
|
38
|
+
/** Opens Logs or Activity bounded to an interval selected on a trend chart. */
|
|
39
|
+
onOpenInterval?: (target: CNPGIntervalTarget, since: string, until: string) => void
|
|
40
|
+
}) {
|
|
41
|
+
const q = useCNPGRuntime(namespace, name)
|
|
42
|
+
// In the URL so Back from Logs or Activity returns to the same section.
|
|
43
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
44
|
+
const location = useLocation()
|
|
45
|
+
const section = SECTIONS.find((x) => x.id === searchParams.get('section'))?.id ?? 'sessions'
|
|
46
|
+
const setSection = (next: Section) =>
|
|
47
|
+
setSearchParams(
|
|
48
|
+
(prev) => {
|
|
49
|
+
const params = new URLSearchParams(prev)
|
|
50
|
+
if (next === 'sessions') params.delete('section')
|
|
51
|
+
else params.set('section', next)
|
|
52
|
+
return params
|
|
53
|
+
},
|
|
54
|
+
{ replace: true, state: location.state },
|
|
55
|
+
)
|
|
56
|
+
const samples = useSampleBuffer(q.data)
|
|
57
|
+
|
|
58
|
+
if (!q.data && q.isLoading) return <PaneLoader label="Reading live state…" className="h-40" />
|
|
59
|
+
if (!q.data) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="p-4">
|
|
62
|
+
<Notice>Live instance data could not be loaded: {q.error instanceof Error ? q.error.message : 'unknown error'}</Notice>
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
const data = q.data
|
|
67
|
+
const denied = data.permission.proxy === 'denied'
|
|
68
|
+
const grant = formatGrant(data.permission.grant) ?? `get pods/proxy in namespace ${namespace}`
|
|
69
|
+
const primary = data.instances.find((i) => i.role === 'primary')
|
|
70
|
+
// Sessions and Database health read one instance's exporter; the primary
|
|
71
|
+
// unless another is picked (kept in the URL like the section).
|
|
72
|
+
// With no primary reported, the first instance, so standbys stay reachable.
|
|
73
|
+
const picked = cnpgPickedInstance(data.instances, searchParams.get('instance'))
|
|
74
|
+
const setPicked = (pod: string) =>
|
|
75
|
+
setSearchParams(
|
|
76
|
+
(prev) => {
|
|
77
|
+
const params = new URLSearchParams(prev)
|
|
78
|
+
if (primary && pod === primary.pod) params.delete('instance')
|
|
79
|
+
else params.set('instance', pod)
|
|
80
|
+
return params
|
|
81
|
+
},
|
|
82
|
+
{ replace: true, state: location.state },
|
|
83
|
+
)
|
|
84
|
+
const picker =
|
|
85
|
+
data.instances.length > 1 && picked ? (
|
|
86
|
+
<label className="flex items-center gap-2 text-xs text-theme-text-secondary">
|
|
87
|
+
Instance
|
|
88
|
+
<select
|
|
89
|
+
value={picked.pod}
|
|
90
|
+
onChange={(e) => setPicked(e.target.value)}
|
|
91
|
+
className="rounded border border-theme-border bg-theme-base px-1.5 py-0.5 font-mono text-xs text-theme-text-primary"
|
|
92
|
+
>
|
|
93
|
+
{data.instances.map((i) => (
|
|
94
|
+
<option key={i.pod} value={i.pod}>
|
|
95
|
+
{i.pod}
|
|
96
|
+
{i.role === 'primary' ? ' (primary)' : i.role === 'replica' ? ' (standby)' : ''}
|
|
97
|
+
</option>
|
|
98
|
+
))}
|
|
99
|
+
</select>
|
|
100
|
+
</label>
|
|
101
|
+
) : null
|
|
102
|
+
const charts = (searchParams.get('charts') as CNPGChartGroup | null) ?? undefined
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div className="space-y-4 p-4">
|
|
106
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
107
|
+
<Segments label="Performance section" value={section} onChange={setSection} options={SECTIONS} />
|
|
108
|
+
{section !== 'history' && (
|
|
109
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
110
|
+
{denied ? 'Live instance data needs access you do not have' : cnpgRuntimeMeasured(data.instances, 'metrics') ? `Live instance data · sampled ${formatAge(data.sampledAt)} ago` : `No instance data yet; checked ${formatAge(data.sampledAt)} ago`}
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
<RefreshFailedNotice queries={[q]} />
|
|
115
|
+
|
|
116
|
+
{section === 'sessions' &&
|
|
117
|
+
(denied ? (
|
|
118
|
+
<>
|
|
119
|
+
{picker}
|
|
120
|
+
<Card title={picked ? `Sessions on ${picked.pod}` : 'Sessions'}><ProxyDenied what="Session counts by state, lock waits and connection headroom" grant={grant} /></Card>
|
|
121
|
+
<CNPGBlockingSessions namespace={namespace} cluster={name} pod={picked?.pod} />
|
|
122
|
+
</>
|
|
123
|
+
) : (
|
|
124
|
+
<SessionsView namespace={namespace} cluster={name} instance={picked} picker={picker} />
|
|
125
|
+
))}
|
|
126
|
+
{section === 'health' && (
|
|
127
|
+
<TransactionsView namespace={namespace} cluster={name} primary={primary} instance={picked} picker={picker} samples={samples} deniedGrant={denied ? grant : undefined} />
|
|
128
|
+
)}
|
|
129
|
+
{section === 'history' && (
|
|
130
|
+
<CNPGTrends
|
|
131
|
+
namespace={namespace}
|
|
132
|
+
name={name}
|
|
133
|
+
samples={samples}
|
|
134
|
+
onOpenInterval={onOpenInterval}
|
|
135
|
+
instance={picked?.pod}
|
|
136
|
+
picker={picker}
|
|
137
|
+
samplingDenied={denied ? grant : undefined}
|
|
138
|
+
group={charts}
|
|
139
|
+
standbys={data.instances.filter((i) => i.role === 'replica').map((i) => i.pod)}
|
|
140
|
+
instancePods={data.instances.map((i) => i.pod)}
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function SessionsView({ namespace, cluster, instance, picker }: { namespace: string; cluster: string; instance?: CNPGRuntimeInstance; picker?: ReactNode }) {
|
|
148
|
+
// The pod is always named: left to the server, "the primary" is its own
|
|
149
|
+
// reading of status.currentPrimary, which can differ from the one picked.
|
|
150
|
+
const blocking = useCNPGSessions(namespace, cluster, instance?.pod)
|
|
151
|
+
const exec = blocking.data?.state === 'ok' && blocking.data.pod === instance?.pod ? blocking.data : undefined
|
|
152
|
+
if (!instance) return (
|
|
153
|
+
<div className="space-y-4">
|
|
154
|
+
<PrimaryPrerequisite namespace={namespace} cluster={cluster} />
|
|
155
|
+
<Card title="Sessions">Session aggregates not measured.</Card>
|
|
156
|
+
<CNPGBlockingSessions namespace={namespace} cluster={cluster} />
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
return (
|
|
160
|
+
<div className="space-y-4">
|
|
161
|
+
{picker}
|
|
162
|
+
<SessionAggregates primary={instance} exec={exec} />
|
|
163
|
+
<CNPGBlockingSessions namespace={namespace} cluster={cluster} pod={instance?.pod} headroom={!cnpgSessionsCardShowsConnections(instance, exec)} />
|
|
164
|
+
</div>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// `primary` is the instance shown: the primary unless another was picked.
|
|
169
|
+
function SessionAggregates({ primary, exec }: { primary?: CNPGRuntimeInstance; exec?: CNPGSessionsResponse }) {
|
|
170
|
+
const m = primary?.metrics
|
|
171
|
+
if (!primary) return <Card title="Sessions">Session aggregates not measured.</Card>
|
|
172
|
+
if (!m || (m.state !== 'ok' && m.state !== 'partial')) return <Card title={`Sessions on ${primary.pod}`}><Unavailable inst={primary} what="Sessions" /></Card>
|
|
173
|
+
const rows = [...(m.sessions ?? [])].sort((a, b) => b.count - a.count)
|
|
174
|
+
const measured = m.sessionsTotal !== undefined
|
|
175
|
+
const idleTx = measured
|
|
176
|
+
? Object.entries(m.sessionsByState ?? {}).filter(([state]) => state.startsWith('idle in transaction')).reduce((s, [, count]) => s + count, 0)
|
|
177
|
+
: undefined
|
|
178
|
+
const connections = cnpgConnectionFigure(exec, m)
|
|
179
|
+
return (
|
|
180
|
+
<Card
|
|
181
|
+
title={<>Sessions on {primary!.pod}</>}
|
|
182
|
+
footer="Counts by state, database, user and application from the metrics exporter (platform users excluded). Individual blocking sessions, with their query text, are below when you can exec into the instance."
|
|
183
|
+
>
|
|
184
|
+
<SourceState label="Sessions" state={m.state} reason={m.reason} error={m.error} />
|
|
185
|
+
<div className="mb-3 flex flex-wrap gap-6 text-sm">
|
|
186
|
+
<Metric
|
|
187
|
+
label="Connections"
|
|
188
|
+
value={connections ? <Tooltip content={connections.detail}>{connections.value}</Tooltip> : '—'}
|
|
189
|
+
caption={connections?.limit}
|
|
190
|
+
tone={connections?.tone}
|
|
191
|
+
/>
|
|
192
|
+
<Metric label="Waiting on locks" value={m.waitingBackends ?? '—'} tone={m.waitingBackends ? 'degraded' : undefined} />
|
|
193
|
+
<Metric label="Idle in transaction" value={idleTx ?? '—'} tone={idleTx ? 'degraded' : undefined} />
|
|
194
|
+
<Metric label="Oldest transaction" value={seconds(m.oldestXactSeconds)} tone={m.oldestXactSeconds !== undefined && m.oldestXactSeconds > 300 ? 'degraded' : undefined} />
|
|
195
|
+
</div>
|
|
196
|
+
{rows.length === 0 ? (
|
|
197
|
+
<div className="text-sm text-theme-text-tertiary">
|
|
198
|
+
{measured
|
|
199
|
+
? m.state === 'partial'
|
|
200
|
+
? 'No client sessions seen in what was read.'
|
|
201
|
+
: 'No client sessions.'
|
|
202
|
+
: 'Sessions unknown: this sample from the exporter has no cnpg_backends_total, so sessions were not measured. PostgreSQL may not be accepting connections, or the exporter does not collect it.'}
|
|
203
|
+
</div>
|
|
204
|
+
) : (
|
|
205
|
+
<table className="w-full text-sm">
|
|
206
|
+
<thead className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
|
|
207
|
+
<tr><th className="py-1.5 pr-3">State</th><th className="pr-3">Database</th><th className="pr-3">User</th><th className="pr-3">Application</th><th className="text-right">Sessions</th></tr>
|
|
208
|
+
</thead>
|
|
209
|
+
<tbody className="table-divide-subtle">
|
|
210
|
+
{rows.map((r, i) => (
|
|
211
|
+
<tr key={i}>
|
|
212
|
+
<td className={clsx('py-1.5 pr-3', r.state.startsWith('idle in transaction') && toneTextClass('degraded'))}>{r.state}</td>
|
|
213
|
+
<td className="pr-3 font-mono text-xs">{r.database}</td>
|
|
214
|
+
<td className="pr-3 font-mono text-xs">{r.user}</td>
|
|
215
|
+
<td className="pr-3 text-xs text-theme-text-secondary">{r.application || '—'}</td>
|
|
216
|
+
<td className="text-right font-mono">{r.count}</td>
|
|
217
|
+
</tr>
|
|
218
|
+
))}
|
|
219
|
+
</tbody>
|
|
220
|
+
</table>
|
|
221
|
+
)}
|
|
222
|
+
</Card>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function TransactionsView({
|
|
227
|
+
namespace,
|
|
228
|
+
cluster,
|
|
229
|
+
primary,
|
|
230
|
+
instance,
|
|
231
|
+
picker,
|
|
232
|
+
samples,
|
|
233
|
+
deniedGrant,
|
|
234
|
+
}: {
|
|
235
|
+
namespace: string
|
|
236
|
+
cluster: string
|
|
237
|
+
primary?: CNPGRuntimeInstance
|
|
238
|
+
instance?: CNPGRuntimeInstance
|
|
239
|
+
picker?: ReactNode
|
|
240
|
+
samples: Sample[]
|
|
241
|
+
/** pods/proxy is denied: no exporter figures and no sampling, but Prometheus rates may still be readable. */
|
|
242
|
+
deniedGrant?: string
|
|
243
|
+
}) {
|
|
244
|
+
const history = useCNPGClusterHistory(namespace, cluster, '15m')
|
|
245
|
+
// The page samples the primary's counters whichever instance is picked, so
|
|
246
|
+
// sampled rates are the primary's and do not follow the picker.
|
|
247
|
+
const canSample = !deniedGrant && (primary?.metrics.state === 'ok' || primary?.metrics.state === 'partial')
|
|
248
|
+
const rates = cnpgTransactionRates(
|
|
249
|
+
{ commits: historyLatest(history.data, 'tps', 'commits'), rollbacks: historyLatest(history.data, 'tps', 'rollbacks') },
|
|
250
|
+
canSample ? { commits: latestRate(samples, 'commits'), rollbacks: latestRate(samples, 'rollbacks') } : null,
|
|
251
|
+
deniedGrant ? `needs ${deniedGrant}` : '—',
|
|
252
|
+
)
|
|
253
|
+
const throughputTitle = rates.source === 'prometheus' ? 'Cluster throughput' : 'Throughput on the primary'
|
|
254
|
+
return (
|
|
255
|
+
<div className="space-y-4">
|
|
256
|
+
<RefreshFailedNotice queries={[history]} />
|
|
257
|
+
{deniedGrant ? (
|
|
258
|
+
<>
|
|
259
|
+
{rates.source !== 'none' && (
|
|
260
|
+
<Card title="Cluster throughput" footer={RATES_FOOTER.prometheus}>
|
|
261
|
+
<Rates rates={rates} />
|
|
262
|
+
</Card>
|
|
263
|
+
)}
|
|
264
|
+
<ProxyDenied what="Per-instance transaction figures, cache hit ratio, deadlocks, transaction and multixact ID age, and extension versions" grant={deniedGrant} />
|
|
265
|
+
</>
|
|
266
|
+
) : (
|
|
267
|
+
<>
|
|
268
|
+
<Card title={throughputTitle} footer={RATES_FOOTER[rates.source]}>
|
|
269
|
+
<Rates rates={rates} />
|
|
270
|
+
</Card>
|
|
271
|
+
{picker}
|
|
272
|
+
<TransactionsCard inst={instance} />
|
|
273
|
+
<CheckpointsCard inst={instance} />
|
|
274
|
+
</>
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const RATES_FOOTER = {
|
|
281
|
+
prometheus: 'Prometheus’s latest commit and rollback rates across every instance and database of the cluster.',
|
|
282
|
+
sampled: "Rates are the change between the primary exporter's last two query runs seen while this page is open, never across a change of primary.",
|
|
283
|
+
none: 'No current commit or rollback rate: Prometheus has no recent point, and this page samples only the primary through its exporter.',
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function Rates({ rates }: { rates: CNPGTransactionRates }) {
|
|
287
|
+
return (
|
|
288
|
+
<div>
|
|
289
|
+
<div className="flex gap-8">
|
|
290
|
+
<Metric label="Commits / s" value={rates.commits} />
|
|
291
|
+
<Metric label="Rollbacks / s" value={rates.rollbacks} />
|
|
292
|
+
</div>
|
|
293
|
+
{rates.at !== undefined && (
|
|
294
|
+
<div className="mt-0.5 text-[11px] text-theme-text-tertiary">
|
|
295
|
+
{rates.source === 'prometheus' ? 'From Prometheus, point' : 'Last Prometheus point'} {formatAge(new Date(rates.at * 1000).toISOString())} ago
|
|
296
|
+
</div>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function TransactionsCard({ inst }: { inst?: CNPGRuntimeInstance }) {
|
|
303
|
+
const m = inst?.metrics
|
|
304
|
+
if (!m || (m.state !== 'ok' && m.state !== 'partial')) {
|
|
305
|
+
return (
|
|
306
|
+
<Card title="Database health">
|
|
307
|
+
<Unavailable inst={inst} what="Database health" />
|
|
308
|
+
</Card>
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
const hit = m.blksHit !== undefined && m.blksRead !== undefined && m.blksHit + m.blksRead > 0 ? (m.blksHit / (m.blksHit + m.blksRead)) * 100 : undefined
|
|
312
|
+
return (
|
|
313
|
+
<Card title={<>Database health on {inst!.pod}</>} footer="This instance’s exporter. Counters are cumulative since the last statistics reset.">
|
|
314
|
+
<SourceState label="Database health" state={m.state} reason={m.reason} error={m.error} />
|
|
315
|
+
<div className="flex flex-wrap gap-8">
|
|
316
|
+
<Metric label="Cache hit ratio" value={hit !== undefined ? `${hit.toFixed(1)} %` : '—'} />
|
|
317
|
+
<Metric label="Deadlocks (total)" value={m.deadlocksTotal ?? '—'} tone={m.deadlocksTotal ? 'degraded' : undefined} />
|
|
318
|
+
<Metric label="Oldest transaction" value={seconds(m.oldestXactSeconds)} />
|
|
319
|
+
</div>
|
|
320
|
+
<DatabaseHealth m={m} />
|
|
321
|
+
<ExtensionUpdates rows={m.extensionUpdates} missing={m.missing} partial={m.state === 'partial'} />
|
|
322
|
+
</Card>
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function CheckpointsCard({ inst }: { inst?: CNPGRuntimeInstance }) {
|
|
327
|
+
const m = inst?.metrics
|
|
328
|
+
if (!m || (m.state !== 'ok' && m.state !== 'partial')) return <Card title="Checkpoints"><Unavailable inst={inst} what="Checkpoints" /></Card>
|
|
329
|
+
const c = m.checkpoints
|
|
330
|
+
if (!c) {
|
|
331
|
+
return (
|
|
332
|
+
<Card title={<>Checkpoints on {inst!.pod}</>}>
|
|
333
|
+
<SourceState label="Checkpoints" state={m.state} reason={m.reason} error={m.error} />
|
|
334
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
335
|
+
Unknown: the exporter reported neither pg_stat_checkpointer (PostgreSQL 17+) nor pg_stat_bgwriter checkpoint counters.
|
|
336
|
+
</div>
|
|
337
|
+
</Card>
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
const view = cnpgCheckpointView(c, inst!.role)
|
|
341
|
+
const n = (v?: number) => (v === undefined ? '—' : v.toLocaleString())
|
|
342
|
+
return (
|
|
343
|
+
<Card
|
|
344
|
+
title={<>Checkpoints on {inst!.pod}</>}
|
|
345
|
+
footer={
|
|
346
|
+
<>
|
|
347
|
+
Cumulative since the statistics were last reset, from {c.source}.{' '}
|
|
348
|
+
{c.source === 'pg_stat_checkpointer'
|
|
349
|
+
? 'A standby performs restartpoints instead of checkpoints; they are counted separately.'
|
|
350
|
+
: 'Before PostgreSQL 17 a standby counts its restartpoints as checkpoints.'}{' '}
|
|
351
|
+
Many requested checkpoints usually mean max_wal_size is too small for the write load.
|
|
352
|
+
</>
|
|
353
|
+
}
|
|
354
|
+
>
|
|
355
|
+
<SourceState label="Checkpoints" state={m.state} reason={m.reason} error={m.error} />
|
|
356
|
+
<div className="flex flex-wrap gap-8">
|
|
357
|
+
<Metric label="Timed" value={n(c.timed)} />
|
|
358
|
+
<Metric label="Requested" value={n(c.requested)} tone={view.pressure ? 'degraded' : undefined} />
|
|
359
|
+
{view.share !== undefined && <Metric label="Requested share" value={view.share} />}
|
|
360
|
+
<Metric label="Buffers written" value={n(c.buffersWritten)} />
|
|
361
|
+
{view.showRestartpoints ? (
|
|
362
|
+
<>
|
|
363
|
+
<Metric label="Restartpoints timed" value={n(c.restartpointsTimed)} />
|
|
364
|
+
<Metric label="Restartpoints requested" value={n(c.restartpointsRequested)} />
|
|
365
|
+
<Metric label="Restartpoints done" value={n(c.restartpointsDone)} />
|
|
366
|
+
</>
|
|
367
|
+
) : null}
|
|
368
|
+
</div>
|
|
369
|
+
</Card>
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function NotExported({ what, family }: { what: string; family: string }) {
|
|
374
|
+
return (
|
|
375
|
+
<div className="text-xs text-theme-text-tertiary">
|
|
376
|
+
{what} unknown: the exporter did not report <span className="font-mono">{family}</span> (a custom monitoring configuration may have replaced the default query).
|
|
377
|
+
</div>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const AGE_FAMILIES: { family: string; what: string }[] = [
|
|
382
|
+
{ family: 'cnpg_pg_stat_database_xact_rollback', what: 'Rollback ratio' },
|
|
383
|
+
{ family: 'cnpg_pg_stat_database_temp_bytes', what: 'Temporary files' },
|
|
384
|
+
{ family: 'cnpg_pg_database_xid_age', what: 'Transaction ID age' },
|
|
385
|
+
{ family: 'cnpg_pg_database_mxid_age', what: 'Multixact ID age' },
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
function DatabaseHealth({ m }: { m: CNPGRuntimeInstance['metrics'] }) {
|
|
389
|
+
const rows = cnpgDatabaseHealthRows(m)
|
|
390
|
+
const absent = AGE_FAMILIES.filter((f) => m.missing?.includes(f.family))
|
|
391
|
+
// Whether a database accepts connections is not in the exporter's output, so
|
|
392
|
+
// a missing value is only said to be unreported, never explained.
|
|
393
|
+
const cell = (v: number | undefined, fmt: (v: number) => ReactNode) =>
|
|
394
|
+
v !== undefined ? (
|
|
395
|
+
fmt(v)
|
|
396
|
+
) : (
|
|
397
|
+
<Tooltip content="Not reported by the exporter">
|
|
398
|
+
<span className="text-theme-text-tertiary">—</span>
|
|
399
|
+
</Tooltip>
|
|
400
|
+
)
|
|
401
|
+
const age = (v: number) => (
|
|
402
|
+
<Tooltip content={`${v.toLocaleString()} of ~2 billion before wraparound`}>
|
|
403
|
+
<span className={v > 1_000_000_000 ? toneTextClass('degraded') : undefined}>{cnpgIdAge(v)}</span>
|
|
404
|
+
</Tooltip>
|
|
405
|
+
)
|
|
406
|
+
return (
|
|
407
|
+
<div className="mt-4 text-sm">
|
|
408
|
+
<div className="text-xs text-theme-text-tertiary">Per database · counters since the last statistics reset · ID wraparound at ~2 billion</div>
|
|
409
|
+
{rows.length > 0 && (
|
|
410
|
+
<table className="mt-1 w-full text-sm">
|
|
411
|
+
<thead className="text-left text-[11px] uppercase tracking-wide text-theme-text-tertiary">
|
|
412
|
+
<tr>
|
|
413
|
+
<th className="py-1.5 pr-3">Database</th>
|
|
414
|
+
<th className="pr-3 text-right">Rollback ratio</th>
|
|
415
|
+
<th className="pr-3 text-right">Temp files</th>
|
|
416
|
+
<th className="pr-3 text-right">Temp bytes</th>
|
|
417
|
+
<th className="pr-3 text-right">Transaction ID age</th>
|
|
418
|
+
<th className="text-right">Multixact ID age</th>
|
|
419
|
+
</tr>
|
|
420
|
+
</thead>
|
|
421
|
+
<tbody className="table-divide-subtle">
|
|
422
|
+
{rows.map((r) => (
|
|
423
|
+
<tr key={r.database}>
|
|
424
|
+
<td className="py-1.5 pr-3 font-mono text-xs">{r.database}</td>
|
|
425
|
+
<td className="pr-3 text-right font-mono text-xs">
|
|
426
|
+
{r.noTransactions ? (
|
|
427
|
+
<span className="text-theme-text-tertiary">no transactions</span>
|
|
428
|
+
) : (
|
|
429
|
+
cell(r.rollbackRatio, (v) => <span className={v > 0.1 ? toneTextClass('degraded') : undefined}>{(v * 100).toFixed(1)} %</span>)
|
|
430
|
+
)}
|
|
431
|
+
</td>
|
|
432
|
+
<td className="pr-3 text-right font-mono text-xs">{cell(r.tempFiles, (v) => v.toLocaleString())}</td>
|
|
433
|
+
<td className="pr-3 text-right font-mono text-xs">{cell(r.tempBytes, formatBytes)}</td>
|
|
434
|
+
<td className="pr-3 text-right font-mono text-xs">{cell(r.xidAge, age)}</td>
|
|
435
|
+
<td className="text-right font-mono text-xs">{cell(r.mxidAge, age)}</td>
|
|
436
|
+
</tr>
|
|
437
|
+
))}
|
|
438
|
+
</tbody>
|
|
439
|
+
</table>
|
|
440
|
+
)}
|
|
441
|
+
{absent.map((f) => (
|
|
442
|
+
<NotExported key={f.family} what={f.what} family={f.family} />
|
|
443
|
+
))}
|
|
444
|
+
{rows.length === 0 && absent.length === 0 && <div className="text-xs text-theme-text-tertiary">The exporter reported no per-database rows.</div>}
|
|
445
|
+
</div>
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const EXTENSIONS_FAMILY = 'cnpg_pg_extensions_update_available'
|
|
450
|
+
|
|
451
|
+
function ExtensionUpdates({ rows, missing, partial }: { partial?: boolean; rows?: { database: string; extension: string; installedVersion: string; defaultVersion: string }[] | null; missing?: string[] }) {
|
|
452
|
+
return (
|
|
453
|
+
<div className="mt-4 text-sm">
|
|
454
|
+
<div className="text-xs text-theme-text-tertiary">Extensions with updates available</div>
|
|
455
|
+
{missing?.includes(EXTENSIONS_FAMILY) || !rows ? (
|
|
456
|
+
<NotExported what="Extension versions" family={EXTENSIONS_FAMILY} />
|
|
457
|
+
) : rows.length === 0 ? (
|
|
458
|
+
<div className="text-xs text-theme-text-secondary">{partial ? 'No extension updates seen in what was read.' : 'Every installed extension is at its default version.'}</div>
|
|
459
|
+
) : (
|
|
460
|
+
<>
|
|
461
|
+
{rows.map((x) => (
|
|
462
|
+
<div key={`${x.database}/${x.extension}`} className="flex gap-3 font-mono text-xs">
|
|
463
|
+
<span className="w-40 truncate">{x.database}</span>
|
|
464
|
+
<span className="w-40 truncate">{x.extension}</span>
|
|
465
|
+
<span>
|
|
466
|
+
{x.installedVersion} → {x.defaultVersion}
|
|
467
|
+
</span>
|
|
468
|
+
</div>
|
|
469
|
+
))}
|
|
470
|
+
<div className="mt-1 text-[11.5px] text-theme-text-tertiary">
|
|
471
|
+
From the primary's metrics exporter (default query pg_extensions). The installed version differs from the default version the server's packages provide; <span className="font-mono">ALTER EXTENSION … UPDATE</span> applies it.
|
|
472
|
+
</div>
|
|
473
|
+
</>
|
|
474
|
+
)}
|
|
475
|
+
</div>
|
|
476
|
+
)
|
|
477
|
+
}
|