@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,24 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { cnpgHost } from './host'
|
|
3
|
+
|
|
4
|
+
const resource = { kind: 'clusters', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg' }
|
|
5
|
+
|
|
6
|
+
describe('CNPG host composition', () => {
|
|
7
|
+
it('preserves context and query state when mapping a generic detail route', () => {
|
|
8
|
+
const search = new URLSearchParams('apiGroup=postgresql.cnpg.io&ctx=kind-demo&tab=timeline&drawer=pods%3Apg-1')
|
|
9
|
+
const path = cnpgHost.detailRedirect(resource, search)!
|
|
10
|
+
const url = new URL(path, 'http://radar.test')
|
|
11
|
+
expect(url.pathname).toBe('/cnpg/clusters/db/pg')
|
|
12
|
+
expect(Object.fromEntries(url.searchParams)).toEqual({ ctx: 'kind-demo', tab: 'activity', drawer: 'pods:pg-1' })
|
|
13
|
+
expect(search.get('tab')).toBe('timeline')
|
|
14
|
+
expect(search.get('apiGroup')).toBe('postgresql.cnpg.io')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('keeps kind collisions on their own routes and expands CNPG with context', () => {
|
|
18
|
+
const other = { ...resource, group: 'cluster.x-k8s.io' }
|
|
19
|
+
expect(cnpgHost.detailRedirect(other, new URLSearchParams())).toBeNull()
|
|
20
|
+
expect(cnpgHost.expandedPath(other)).toBeNull()
|
|
21
|
+
expect(cnpgHost.expandedPath(resource, 'kind-demo', 'yaml')).toBe('/cnpg/clusters/db/pg?ctx=kind-demo&tab=yaml')
|
|
22
|
+
expect(cnpgHost.logs('Cluster', { apiVersion: 'cluster.x-k8s.io/v1beta1' }, 'db', 'pg')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useRadarFeature } from '../../api/client'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { isApiGroup } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import type { RenderDiagnoseAction } from '../../context/DiagnoseCustomization'
|
|
5
|
+
import { CNPGClustersKindView } from './CNPGClustersKindView'
|
|
6
|
+
import { CNPGCreateClusterDialog } from './CNPGCreateClusterDialog'
|
|
7
|
+
import { CNPGClusterLogs } from './CNPGClusterLogs'
|
|
8
|
+
import { CNPGDrawerTrailBack } from './CNPGDrawerTrail'
|
|
9
|
+
import { CNPGInvestigationAction } from './CNPGClusterTabs'
|
|
10
|
+
import { renderCNPGSummary } from './CNPGSummaryHost'
|
|
11
|
+
import { renderCNPGHeaderActions } from './actions/renderCNPGHeaderActions'
|
|
12
|
+
import { CNPG_DETAIL_KINDS, cnpgClusterKindListMode, cnpgDetailKindFor, cnpgDetailPath } from './routes'
|
|
13
|
+
import type { DetailSlots, HostResource, KindListProps, ResourceHost } from '../../integrations/resourceHost'
|
|
14
|
+
import { kindToPluralWithGroup } from '../../utils/navigation'
|
|
15
|
+
import { CNPGObjectStoreRenderer } from '../resources/renderers/CNPGObjectStoreRenderer'
|
|
16
|
+
import { CNPGClusterRenderer } from '../resources/renderers/CNPGClusterRenderer'
|
|
17
|
+
import { CNPGImageCatalogRenderer } from '../resources/renderers/CNPGImageCatalogRenderer'
|
|
18
|
+
import {
|
|
19
|
+
CNPGDatabaseRenderer,
|
|
20
|
+
CNPGPublicationRenderer,
|
|
21
|
+
CNPGSubscriptionRenderer,
|
|
22
|
+
} from '../resources/renderers/CNPGDeclarativeRenderer'
|
|
23
|
+
|
|
24
|
+
export const cnpgHost = {
|
|
25
|
+
clusterKind: { name: 'clusters', kind: 'Cluster', group: 'postgresql.cnpg.io' },
|
|
26
|
+
|
|
27
|
+
expandedPath(resource: HostResource, context?: string, tab?: 'yaml'): string | null {
|
|
28
|
+
const plural = cnpgDetailKindFor(kindToPluralWithGroup(resource.kind, resource.group ?? ''), resource.group)
|
|
29
|
+
return plural ? cnpgDetailPath({ plural, namespace: resource.namespace, name: resource.name }, context, tab) : null
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
detailRedirect(resource: HostResource, search: URLSearchParams): string | null {
|
|
33
|
+
const plural = cnpgDetailKindFor(kindToPluralWithGroup(resource.kind, resource.group ?? ''), resource.group)
|
|
34
|
+
if (!plural) return null
|
|
35
|
+
const params = new URLSearchParams(search)
|
|
36
|
+
params.delete('apiGroup')
|
|
37
|
+
const tab = params.get('tab')
|
|
38
|
+
if (plural === 'clusters' && (tab === 'timeline' || tab === 'events')) params.set('tab', 'activity')
|
|
39
|
+
const base = cnpgDetailPath({ plural, namespace: resource.namespace, name: resource.name })
|
|
40
|
+
const qs = params.toString()
|
|
41
|
+
return qs ? `${base}?${qs}` : base
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
detailSlots(namespace: string, name: string, group?: string): DetailSlots {
|
|
45
|
+
return {
|
|
46
|
+
renderHeaderActions: ({ resource, context, onNavigate }) => renderCNPGHeaderActions({ resource, namespace, name, compact: context === 'drawer', onNavigate }),
|
|
47
|
+
renderSummary: (props) => renderCNPGSummary({ ...props, group }),
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
diagnoseAction(render: RenderDiagnoseAction | undefined): RenderDiagnoseAction | undefined {
|
|
52
|
+
return render ? (context) => context.kind === 'Cluster' && context.group === 'postgresql.cnpg.io'
|
|
53
|
+
? <CNPGDiagnoseForServer render={render} context={context} />
|
|
54
|
+
: render(context) : undefined
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
logs(kind: string, resource: { apiVersion?: string } | null | undefined, namespace: string, name: string) {
|
|
58
|
+
return kind === 'Cluster' && isApiGroup(resource?.apiVersion, 'postgresql.cnpg.io') ? <CNPGClusterLogs namespace={namespace} name={name} /> : null
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function CNPGDiagnoseForServer({ render, context }: { render: RenderDiagnoseAction; context: Parameters<RenderDiagnoseAction>[0] }) {
|
|
63
|
+
const { support } = useRadarFeature('cnpgWorkspace')
|
|
64
|
+
return support === 'supported'
|
|
65
|
+
? <CNPGInvestigationAction namespace={context.namespace} name={context.name} render={render} context={context} />
|
|
66
|
+
: render(context)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function CNPGKindList(props: KindListProps) {
|
|
70
|
+
const [creating, setCreating] = useState(false)
|
|
71
|
+
return <>
|
|
72
|
+
<CNPGClustersKindView {...props} onCreate={() => setCreating(true)} />
|
|
73
|
+
{creating && <CNPGCreateClusterDialog namespaces={props.namespaces} onClose={() => setCreating(false)} onCreated={props.onInspect} />}
|
|
74
|
+
</>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const cnpgResourceHost: ResourceHost = {
|
|
78
|
+
id: 'cnpg',
|
|
79
|
+
renderers: {
|
|
80
|
+
CNPGObjectStoreRenderer, CNPGClusterRenderer, CNPGDatabaseRenderer,
|
|
81
|
+
CNPGPublicationRenderer, CNPGSubscriptionRenderer, CNPGImageCatalogRenderer,
|
|
82
|
+
},
|
|
83
|
+
resources: Object.entries(CNPG_DETAIL_KINDS).map(([name, kind]) => ({ name, group: kind.group })),
|
|
84
|
+
feature: 'cnpgWorkspace',
|
|
85
|
+
expandedPath: cnpgHost.expandedPath,
|
|
86
|
+
detailRedirect: cnpgHost.detailRedirect,
|
|
87
|
+
detailOwnership: ['summary', 'destination', 'logs'],
|
|
88
|
+
detailSlots: resource => cnpgHost.detailSlots(resource.namespace, resource.name, resource.group),
|
|
89
|
+
diagnoseAction: cnpgHost.diagnoseAction,
|
|
90
|
+
logs: (resource, data) => kindToPluralWithGroup(resource.kind, resource.group ?? '') === 'clusters' ? cnpgHost.logs('Cluster', data, resource.namespace, resource.name) : null,
|
|
91
|
+
drawerNavigation: {
|
|
92
|
+
ownsPath: pathname => pathname === '/cnpg' || pathname.startsWith('/cnpg/'),
|
|
93
|
+
Component: CNPGDrawerTrailBack,
|
|
94
|
+
},
|
|
95
|
+
kindLists: [{
|
|
96
|
+
kind: cnpgHost.clusterKind,
|
|
97
|
+
title: 'CloudNativePG Clusters',
|
|
98
|
+
mode: (support, pending) => cnpgClusterKindListMode(cnpgHost.clusterKind, support, pending),
|
|
99
|
+
Component: CNPGKindList,
|
|
100
|
+
}],
|
|
101
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { CNPGParametersResponse } from '../../api/cnpg-inspect'
|
|
3
|
+
import { cnpgDeclaredTargetText, cnpgParametersView, cnpgRecoveryStopText, cnpgSettingTakesEffect } from './inspectModel'
|
|
4
|
+
|
|
5
|
+
const sw = (reason: string) => ({ from: 1, to: 2, switchLsn: '0/5000A28', reason })
|
|
6
|
+
|
|
7
|
+
describe('cnpgRecoveryStopText', () => {
|
|
8
|
+
it('words every reason PostgreSQL writes when it promotes', () => {
|
|
9
|
+
expect(cnpgRecoveryStopText(sw('before 2026-10-01 12:00:00.123+00'))).toBe('stopped before the transaction that committed at 2026-10-01 12:00:00.123+00')
|
|
10
|
+
expect(cnpgRecoveryStopText(sw('after transaction 1234'))).toBe('stopped after transaction 1234')
|
|
11
|
+
expect(cnpgRecoveryStopText(sw('before LSN 0/3000060'))).toBe('stopped before LSN 0/3000060')
|
|
12
|
+
expect(cnpgRecoveryStopText(sw('at restore point "pre-migration"'))).toBe('stopped at restore point “pre-migration”')
|
|
13
|
+
expect(cnpgRecoveryStopText(sw('reached consistency'))).toMatch(/immediate target/)
|
|
14
|
+
// Also what a failover writes, so it is never read as "the restore reached its target".
|
|
15
|
+
expect(cnpgRecoveryStopText(sw('no recovery target specified'))).toMatch(/failover or switchover reads the same/)
|
|
16
|
+
expect(cnpgRecoveryStopText(sw('something new'))).toBe('something new')
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('cnpgDeclaredTargetText', () => {
|
|
21
|
+
it('reads the declared target, or says there is none', () => {
|
|
22
|
+
expect(cnpgDeclaredTargetText(undefined)).toMatch(/^None/)
|
|
23
|
+
expect(cnpgDeclaredTargetText({ targetTime: '2026-10-01T12:00:00Z', exclusive: true })).toBe('time 2026-10-01T12:00:00Z · exclusive')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('cnpgSettingTakesEffect', () => {
|
|
28
|
+
it('maps pg_settings.context to when a change applies', () => {
|
|
29
|
+
expect(cnpgSettingTakesEffect('postmaster')).toBe('restart')
|
|
30
|
+
expect(cnpgSettingTakesEffect('sighup')).toBe('reload')
|
|
31
|
+
expect(cnpgSettingTakesEffect('user')).toMatch(/session can override/)
|
|
32
|
+
expect(cnpgSettingTakesEffect('backend')).toBe('new connections')
|
|
33
|
+
expect(cnpgSettingTakesEffect('internal')).toMatch(/fixed/)
|
|
34
|
+
expect(cnpgSettingTakesEffect(undefined)).toBe('unknown')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('cnpgParametersView', () => {
|
|
39
|
+
const setting = (name: string, value: string | null, extra: Partial<{ pendingRestart: boolean; source: string; context: string }> = {}) => ({
|
|
40
|
+
name,
|
|
41
|
+
value,
|
|
42
|
+
setByClient: value === null,
|
|
43
|
+
source: extra.source ?? 'configuration file',
|
|
44
|
+
context: extra.context ?? 'sighup',
|
|
45
|
+
pendingRestart: extra.pendingRestart ?? false,
|
|
46
|
+
})
|
|
47
|
+
const resp = (instances: CNPGParametersResponse['instances']): CNPGParametersResponse => ({
|
|
48
|
+
cluster: { namespace: 'db', name: 'pg', uid: 'u' },
|
|
49
|
+
sampledAt: '2026-10-04T00:00:00Z',
|
|
50
|
+
permission: { exec: 'allowed' },
|
|
51
|
+
state: 'ok',
|
|
52
|
+
declared: [
|
|
53
|
+
{ name: 'shared_buffers', value: '256MB' },
|
|
54
|
+
{ name: 'work_mem', value: '8MB' },
|
|
55
|
+
{ name: 'my.custom', value: 'x' },
|
|
56
|
+
],
|
|
57
|
+
instances,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('shows one value when instances agree, each when they differ, and pending restarts', () => {
|
|
61
|
+
const v = cnpgParametersView(
|
|
62
|
+
resp([
|
|
63
|
+
{ pod: 'pg-1', role: 'primary', state: 'ok', settings: [setting('shared_buffers', '256MB', { context: 'postmaster' }), setting('work_mem', '8MB')] },
|
|
64
|
+
{ pod: 'pg-2', role: 'replica', state: 'ok', settings: [setting('shared_buffers', '128MB', { context: 'postmaster', pendingRestart: true }), setting('work_mem', '8MB')] },
|
|
65
|
+
{ pod: 'pg-3', role: 'replica', state: 'unreachable', error: 'did not answer' },
|
|
66
|
+
]),
|
|
67
|
+
)
|
|
68
|
+
const [sb, wm, custom] = v.rows
|
|
69
|
+
expect(sb.perInstance).toEqual([{ pod: 'pg-1', value: '256MB' }, { pod: 'pg-2', value: '128MB' }])
|
|
70
|
+
expect(sb.pendingRestart).toEqual(['pg-2'])
|
|
71
|
+
expect(sb.takesEffect).toBe('restart')
|
|
72
|
+
expect(wm.value).toBe('8MB')
|
|
73
|
+
expect(custom.unreported).toEqual(['pg-1', 'pg-2'])
|
|
74
|
+
expect(v.unread.map((i) => i.pod)).toEqual(['pg-3'])
|
|
75
|
+
expect(v.summary).toMatchObject({ attention: true, tone: 'degraded' })
|
|
76
|
+
expect(v.summary.text).toBe('3 declared · read on pg-1, pg-2 · not read on pg-3 · restart pending on pg-2 · 1 differ between instances')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('names the instances read and the declared instance that is not running', () => {
|
|
80
|
+
const v = cnpgParametersView(resp([{ pod: 'orders-1', role: 'primary', state: 'ok', settings: [setting('work_mem', '8MB')] }]), undefined, { declaredInstances: 2, expectedInstances: ['orders-1', 'orders-2'] })
|
|
81
|
+
expect(v.summary.text).toBe('3 declared · read on orders-1 · orders-2 not running · 2 instances declared')
|
|
82
|
+
expect(v.summary.tone).toBe('unknown')
|
|
83
|
+
expect(cnpgParametersView(resp([{ pod: 'orders-1', role: 'primary', state: 'ok', settings: [setting('work_mem', '8MB')] }]), undefined, { declaredInstances: 1, expectedInstances: ['orders-1', 'orders-2'] }).summary.tone).toBe('unknown')
|
|
84
|
+
})
|
|
85
|
+
it('says so when there is no instance Pod to read', () => {
|
|
86
|
+
expect(cnpgParametersView(resp([])).summary).toEqual({ text: '3 declared · no instance Pod to read', tone: 'unknown', attention: false })
|
|
87
|
+
})
|
|
88
|
+
it('keeps differing change contexts attributed to the instances that reported them', () => {
|
|
89
|
+
const v = cnpgParametersView(resp([
|
|
90
|
+
{ pod: 'pg-1', role: 'primary', state: 'ok', settings: [setting('work_mem', '8MB', { context: 'user' })] },
|
|
91
|
+
{ pod: 'pg-2', role: 'replica', state: 'ok', settings: [setting('work_mem', '8MB', { context: 'backend' })] },
|
|
92
|
+
]))
|
|
93
|
+
expect(v.rows[1].takesEffect).toBe('pg-1: reload · a session can override it · pg-2: new connections')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('never reads a value the connection itself sets', () => {
|
|
97
|
+
const v = cnpgParametersView(
|
|
98
|
+
resp([{ pod: 'pg-1', role: 'primary', state: 'ok', settings: [setting('shared_buffers', null, { source: 'client', context: 'user' })] }]),
|
|
99
|
+
)
|
|
100
|
+
expect(v.rows[0]).toMatchObject({ setByClient: true, value: undefined, perInstance: undefined })
|
|
101
|
+
})
|
|
102
|
+
it('qualifies an existing sample when the Cluster adds an unsampled declaration', () => {
|
|
103
|
+
const r = resp([{ pod: 'pg-1', role: 'primary', state: 'ok', settings: [setting('work_mem', '8MB')] }])
|
|
104
|
+
r.declared = [{ name: 'work_mem', value: '8MB' }]
|
|
105
|
+
const v = cnpgParametersView(r, [...r.declared, { name: 'new_parameter', value: 'on' }])
|
|
106
|
+
expect(v.summary).toEqual({ text: '2 declared · 1 sampled · 1 not sampled · read on pg-1', tone: 'unknown', attention: false })
|
|
107
|
+
expect(v.rows).toHaveLength(2)
|
|
108
|
+
})
|
|
109
|
+
it('does not infer absent Pods when all names were skipped or the earlier spec had no parameters', () => {
|
|
110
|
+
const empty = { ...resp([]), declared: [] }
|
|
111
|
+
expect(cnpgParametersView(empty, [{ name: 'work_mem', value: '8MB' }]).summary.text).toBe('1 declared · no parameters sampled')
|
|
112
|
+
const skipped = { ...resp([]), declared: [{ name: 'invalid name', value: 'x' }], skipped: ['invalid name'] }
|
|
113
|
+
expect(cnpgParametersView(skipped).summary.text).toBe('1 declared · no parameters sampled')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it.each([1, 2])('uses the correct declared-instance noun for %s', (count) => {
|
|
118
|
+
const response = { state: 'ok', declared: [{ name: 'work_mem', value: '8MB' }], instances: [{ pod: 'pg-1', state: 'ok', settings: [{ name: 'work_mem', value: '8MB' }] }] } as CNPGParametersResponse
|
|
119
|
+
expect(cnpgParametersView(response, response.declared, { declaredInstances: count }).summary.text).toContain(`${count} instance${count === 1 ? '' : 's'} declared`)
|
|
120
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { HealthLevel } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import type { CNPGInstanceSettings, CNPGParametersResponse, CNPGTimelineSwitch } from '../../api/cnpg-inspect'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a timeline switch's reason says, in words. PostgreSQL writes the reason
|
|
6
|
+
* into the history file when it promotes: the recovery target it stopped at,
|
|
7
|
+
* or "no recovery target specified" when it replayed all the WAL it had —
|
|
8
|
+
* which is also what a failover or switchover writes.
|
|
9
|
+
*/
|
|
10
|
+
export function cnpgRecoveryStopText(sw: CNPGTimelineSwitch): string {
|
|
11
|
+
const r = sw.reason.trim()
|
|
12
|
+
let m = /^(before|after) transaction (\d+)$/.exec(r)
|
|
13
|
+
if (m) return `stopped ${m[1]} transaction ${m[2]}`
|
|
14
|
+
m = /^(before|after) LSN ([0-9A-Fa-f]+\/[0-9A-Fa-f]+)$/.exec(r)
|
|
15
|
+
if (m) return `stopped ${m[1]} LSN ${m[2]}`
|
|
16
|
+
m = /^at restore point "(.*)"$/.exec(r)
|
|
17
|
+
if (m) return `stopped at restore point “${m[1]}”`
|
|
18
|
+
m = /^(before|after) (\d{4}-\d{2}-\d{2} .+)$/.exec(r)
|
|
19
|
+
if (m) return `stopped ${m[1]} the transaction that committed at ${m[2]}`
|
|
20
|
+
if (r === 'reached consistency') return 'stopped as soon as it was consistent (immediate target)'
|
|
21
|
+
if (r === 'no recovery target specified') return 'promoted without a recovery target: it replayed all the WAL it had (a failover or switchover reads the same)'
|
|
22
|
+
return r || 'no reason recorded'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The declared spec.bootstrap.recovery.recoveryTarget, in words. */
|
|
26
|
+
export function cnpgDeclaredTargetText(target: Record<string, unknown> | undefined): string {
|
|
27
|
+
if (!target) return 'None: replay all the WAL the archive holds'
|
|
28
|
+
const parts: string[] = []
|
|
29
|
+
if (typeof target.targetTime === 'string') parts.push(`time ${target.targetTime}`)
|
|
30
|
+
if (typeof target.targetLSN === 'string') parts.push(`LSN ${target.targetLSN}`)
|
|
31
|
+
if (typeof target.targetXID === 'string') parts.push(`transaction ${target.targetXID}`)
|
|
32
|
+
if (typeof target.targetName === 'string') parts.push(`restore point “${target.targetName}”`)
|
|
33
|
+
if (target.targetImmediate === true) parts.push('as soon as consistent')
|
|
34
|
+
if (typeof target.targetTLI === 'string') parts.push(`timeline ${target.targetTLI}`)
|
|
35
|
+
if (typeof target.backupID === 'string') parts.push(`from backup ${target.backupID}`)
|
|
36
|
+
if (target.exclusive === true) parts.push('exclusive')
|
|
37
|
+
return parts.length > 0 ? parts.join(' · ') : 'Declared without a target field'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** When a change to a parameter takes effect, from pg_settings.context. */
|
|
41
|
+
export function cnpgSettingTakesEffect(context: string | undefined): string {
|
|
42
|
+
switch (context) {
|
|
43
|
+
case 'postmaster':
|
|
44
|
+
return 'restart'
|
|
45
|
+
case 'sighup':
|
|
46
|
+
return 'reload'
|
|
47
|
+
case 'user':
|
|
48
|
+
case 'superuser':
|
|
49
|
+
return 'reload · a session can override it'
|
|
50
|
+
case 'backend':
|
|
51
|
+
case 'superuser-backend':
|
|
52
|
+
return 'new connections'
|
|
53
|
+
case 'internal':
|
|
54
|
+
return 'fixed (cannot be changed)'
|
|
55
|
+
default:
|
|
56
|
+
return 'unknown'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CNPGParameterRow {
|
|
61
|
+
name: string
|
|
62
|
+
declared: string
|
|
63
|
+
/** The value every instance that answered reports, when they agree. */
|
|
64
|
+
value?: string
|
|
65
|
+
/** Pod → value, when instances report different values. */
|
|
66
|
+
perInstance?: { pod: string; value: string }[]
|
|
67
|
+
takesEffect: string
|
|
68
|
+
source?: string
|
|
69
|
+
setByClient: boolean
|
|
70
|
+
pendingRestart: string[]
|
|
71
|
+
/** Instances that answered but did not report this parameter. */
|
|
72
|
+
unreported: string[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface CNPGParametersView {
|
|
76
|
+
rows: CNPGParameterRow[]
|
|
77
|
+
read: CNPGInstanceSettings[]
|
|
78
|
+
unread: CNPGInstanceSettings[]
|
|
79
|
+
summary: { text: string; tone: HealthLevel; attention: boolean }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Declared parameters beside what each instance reports. No declared-vs-
|
|
84
|
+
* effective verdict: PostgreSQL normalizes units ("1024MB" reads "1GB"), and
|
|
85
|
+
* CloudNativePG merges some values itself. Instances disagreeing or a pending
|
|
86
|
+
* restart are observations — a rolling restart passes through both.
|
|
87
|
+
*/
|
|
88
|
+
export function cnpgParametersView(resp: CNPGParametersResponse, declared = resp.declared, scope?: { declaredInstances?: number; expectedInstances?: string[] }): CNPGParametersView {
|
|
89
|
+
const read = resp.instances.filter((i) => i.state === 'ok' && i.settings)
|
|
90
|
+
const unread = resp.instances.filter((i) => !(i.state === 'ok' && i.settings))
|
|
91
|
+
const queriedNames = new Set(resp.declared.filter((p) => !resp.skipped?.includes(p.name)).map((p) => p.name.toLowerCase()))
|
|
92
|
+
const rows = declared.map((d): CNPGParameterRow => {
|
|
93
|
+
const key = d.name.toLowerCase()
|
|
94
|
+
const seen = read.map((i) => ({ pod: i.pod, s: i.settings!.find((x) => x.name.toLowerCase() === key) }))
|
|
95
|
+
const reported = seen.filter((x) => x.s)
|
|
96
|
+
const values = reported.filter((x) => x.s!.value !== null).map((x) => ({ pod: x.pod, value: x.s!.value as string }))
|
|
97
|
+
const distinct = new Set(values.map((v) => v.value))
|
|
98
|
+
const first = reported[0]?.s
|
|
99
|
+
const sources = new Set(reported.map((x) => x.s!.source))
|
|
100
|
+
return {
|
|
101
|
+
name: d.name,
|
|
102
|
+
declared: d.value,
|
|
103
|
+
value: distinct.size === 1 ? values[0].value : undefined,
|
|
104
|
+
perInstance: distinct.size > 1 ? values : undefined,
|
|
105
|
+
takesEffect: new Set(reported.map((x) => x.s!.context)).size > 1
|
|
106
|
+
? reported.map((x) => `${x.pod}: ${cnpgSettingTakesEffect(x.s!.context)}`).join(' · ')
|
|
107
|
+
: cnpgSettingTakesEffect(first?.context),
|
|
108
|
+
source: sources.size === 1 ? [...sources][0] : sources.size > 1 ? 'differs by instance' : undefined,
|
|
109
|
+
setByClient: reported.some((x) => x.s!.setByClient),
|
|
110
|
+
pendingRestart: reported.filter((x) => x.s!.pendingRestart).map((x) => x.pod),
|
|
111
|
+
unreported: seen.filter((x) => !x.s).map((x) => x.pod),
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
const pending = new Set(rows.flatMap((r) => r.pendingRestart))
|
|
115
|
+
const differing = rows.filter((r) => r.perInstance).length
|
|
116
|
+
const unsampled = rows.filter((r) => !queriedNames.has(r.name.toLowerCase())).length
|
|
117
|
+
if (queriedNames.size === 0) {
|
|
118
|
+
return { rows, read, unread, summary: { text: `${rows.length} declared · no parameters sampled`, tone: 'unknown', attention: false } }
|
|
119
|
+
}
|
|
120
|
+
if (resp.instances.length === 0) {
|
|
121
|
+
// Hibernated, or no instance Pod yet: nothing was read, which is not calm.
|
|
122
|
+
return { rows, read, unread, summary: { text: `${rows.length} declared · no instance Pod to read`, tone: 'unknown', attention: false } }
|
|
123
|
+
}
|
|
124
|
+
const bits = [`${rows.length} declared`]
|
|
125
|
+
if (unsampled > 0) bits.push(`${rows.length - unsampled} sampled`, `${unsampled} not sampled`)
|
|
126
|
+
bits.push(read.length > 0 ? `read on ${read.map((i) => i.pod).join(', ')}` : 'no instance answered')
|
|
127
|
+
const missing = (scope?.expectedInstances ?? []).filter((pod) => !resp.instances.some((i) => i.pod === pod))
|
|
128
|
+
if (missing.length > 0) bits.push(`${missing.join(', ')} not running`)
|
|
129
|
+
if (scope?.declaredInstances !== undefined) bits.push(`${scope.declaredInstances} instance${scope.declaredInstances === 1 ? '' : 's'} declared`)
|
|
130
|
+
if (unread.length > 0) bits.push(`not read on ${unread.map((i) => i.pod).join(', ')}`)
|
|
131
|
+
if (pending.size > 0) bits.push(`restart pending on ${[...pending].sort().join(', ')}`)
|
|
132
|
+
if (differing > 0) bits.push(`${differing} differ between instances`)
|
|
133
|
+
const attention = pending.size > 0 || differing > 0
|
|
134
|
+
return {
|
|
135
|
+
rows,
|
|
136
|
+
read,
|
|
137
|
+
unread,
|
|
138
|
+
summary: { text: bits.join(' · '), tone: attention ? 'degraded' : unread.length > 0 || unsampled > 0 || missing.length > 0 || (scope?.declaredInstances !== undefined && read.length < scope.declaredInstances) ? 'unknown' : 'healthy', attention },
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { CNPGRuntimeResponse } from '../../api/cnpg'
|
|
3
|
+
import { cnpgPublisherSlotsFrom } from './logicalSlots'
|
|
4
|
+
|
|
5
|
+
const rt = (status: any, proxy: 'allowed' | 'denied' = 'allowed', role: 'primary' | 'replica' = 'primary'): CNPGRuntimeResponse => ({
|
|
6
|
+
cluster: { namespace: 'src', name: 'src', uid: 'u' },
|
|
7
|
+
sampledAt: '2026-09-30T10:00:00Z',
|
|
8
|
+
permission: { proxy, grant: { verb: 'get', resource: 'pods', subresource: 'proxy', namespace: 'src' } },
|
|
9
|
+
instances: [{ pod: 'src-1', role, status, metrics: { state: 'ok' } }],
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
describe('cnpgPublisherSlotsFrom', () => {
|
|
13
|
+
it('keeps unread, denied and unreachable apart from an empty slot list', () => {
|
|
14
|
+
expect(cnpgPublisherSlotsFrom(undefined).state).toBe('notRead')
|
|
15
|
+
expect(cnpgPublisherSlotsFrom(rt({ state: 'ok' }, 'denied')).state).toBe('denied')
|
|
16
|
+
expect(cnpgPublisherSlotsFrom(rt({ state: 'unreachable', error: 'timeout' })).reason).toBe('timeout')
|
|
17
|
+
expect(cnpgPublisherSlotsFrom(rt({ state: 'ok' }, 'allowed', 'replica')).state).toBe('unavailable')
|
|
18
|
+
})
|
|
19
|
+
it('passes the primary slots through', () => {
|
|
20
|
+
const r = cnpgPublisherSlotsFrom(rt({ state: 'ok', slots: [{ name: 'orders_sub', type: 'logical', active: true, retainedBytes: 10 }] }))
|
|
21
|
+
expect(r).toEqual({ state: 'ok', slots: [{ name: 'orders_sub', type: 'logical', active: true, walStatus: undefined, retainedBytes: 10, database: undefined }] })
|
|
22
|
+
})
|
|
23
|
+
it('keeps a capped report partial and marks cached data after a failed refresh', () => {
|
|
24
|
+
const capped = cnpgPublisherSlotsFrom(rt({ state: 'partial', reason: '250 replication slots; the first 200 are shown', slots: [] }))
|
|
25
|
+
expect(capped.state).toBe('partial')
|
|
26
|
+
expect(capped.reason).toContain('250')
|
|
27
|
+
expect(cnpgPublisherSlotsFrom(rt({ state: 'ok', slots: [] }), new Error('boom'), true).stale).toBe(true)
|
|
28
|
+
expect(cnpgPublisherSlotsFrom(rt({ state: 'partial', incomplete: true, slots: null })).state).toBe('unavailable')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { UseQueryResult } from '@tanstack/react-query'
|
|
2
|
+
import type { CNPGPublisher, CNPGPublisherSlots } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { useCNPGRuntime, type CNPGRuntimeResponse } from '../../api/cnpg'
|
|
4
|
+
import { formatGrant } from '@skyhook-io/k8s-ui'
|
|
5
|
+
|
|
6
|
+
/** The publisher primary's replication slots from its runtime answer, never read as "no slots" when unread. */
|
|
7
|
+
export function cnpgPublisherSlotsFrom(rt: CNPGRuntimeResponse | undefined, error?: unknown, refetchFailed = false): CNPGPublisherSlots {
|
|
8
|
+
if (!rt) return { state: 'notRead', reason: error instanceof Error ? error.message : undefined }
|
|
9
|
+
const stale = refetchFailed || undefined
|
|
10
|
+
if (rt.permission.proxy === 'denied') return { state: 'denied', reason: formatGrant(rt.permission.grant) }
|
|
11
|
+
const primary = rt.instances.find((i) => i.role === 'primary')
|
|
12
|
+
if (!primary) return { state: 'unavailable', reason: 'no primary reported' }
|
|
13
|
+
const st = primary.status
|
|
14
|
+
if (st.state !== 'ok' && st.state !== 'partial') return { state: st.state === 'denied' ? 'denied' : 'unavailable', reason: st.error ?? st.state, stale }
|
|
15
|
+
if (!st.slots) return { state: 'unavailable', reason: st.reason ?? 'the report carried no slots', stale }
|
|
16
|
+
return {
|
|
17
|
+
state: st.state,
|
|
18
|
+
reason: st.state === 'partial' ? st.reason : undefined,
|
|
19
|
+
stale,
|
|
20
|
+
slots: st.slots.map((s) => ({ name: s.name, type: s.type, active: s.active, walStatus: s.walStatus, retainedBytes: s.retainedBytes, database: s.database })),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The slots plus the query, so the caller can say when a refresh failed over cached data. */
|
|
25
|
+
export function useCNPGPublisherSlots(publisher: CNPGPublisher | undefined): { observed: CNPGPublisherSlots; query: UseQueryResult<CNPGRuntimeResponse> } {
|
|
26
|
+
const cluster = publisher?.kind === 'cluster' ? publisher : undefined
|
|
27
|
+
const query = useCNPGRuntime(cluster?.namespace ?? '', cluster?.name ?? '', !!cluster)
|
|
28
|
+
return { observed: cnpgPublisherSlotsFrom(query.data, query.error, query.isRefetchError), query }
|
|
29
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { formatBytes, lsnDistance, parseLsn, standbyOwnBacklog } from './lsn'
|
|
3
|
+
|
|
4
|
+
describe('parseLsn', () => {
|
|
5
|
+
it('reads the high and low halves', () => {
|
|
6
|
+
expect(parseLsn('0/21000000')).toBe(0x21000000)
|
|
7
|
+
expect(parseLsn('16/B374D848')).toBe(0x16 * 2 ** 32 + 0xb374d848)
|
|
8
|
+
})
|
|
9
|
+
it('is unknown, not zero, when it does not parse', () => {
|
|
10
|
+
expect(parseLsn(undefined)).toBeUndefined()
|
|
11
|
+
expect(parseLsn('')).toBeUndefined()
|
|
12
|
+
expect(parseLsn('garbage')).toBeUndefined()
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('lsnDistance', () => {
|
|
17
|
+
it('measures the backlog in bytes', () => {
|
|
18
|
+
expect(lsnDistance('0/21000100', '0/21000000')).toBe(256)
|
|
19
|
+
expect(lsnDistance('1/0', 'FFFFFFFF/0')).toBe(0)
|
|
20
|
+
})
|
|
21
|
+
it('is unknown when either side is', () => {
|
|
22
|
+
expect(lsnDistance('0/1', undefined)).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('formatBytes', () => {
|
|
27
|
+
it('formats and keeps unknown visible', () => {
|
|
28
|
+
expect(formatBytes(undefined)).toBe('—')
|
|
29
|
+
expect(formatBytes(0)).toBe('0 B')
|
|
30
|
+
expect(formatBytes(16 * 1024 * 1024)).toBe('16 MiB')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('standbyOwnBacklog', () => {
|
|
35
|
+
it('compares positions only on the same timeline', () => {
|
|
36
|
+
expect(standbyOwnBacklog({ currentLsn: '0/3000000', timeline: 4 }, { replayLsn: '0/1000000', timeline: 4 })).toBe(0x2000000)
|
|
37
|
+
expect(standbyOwnBacklog({ currentLsn: '0/3000000', timeline: 5 }, { replayLsn: '0/4000000', timeline: 4 })).toBeUndefined()
|
|
38
|
+
expect(standbyOwnBacklog({ currentLsn: '0/3000000' }, { replayLsn: '0/1000000' })).toBeUndefined()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PostgreSQL LSN ("16/B374D848") as a byte position. Undefined when it does
|
|
3
|
+
* not parse: an unreadable position must not read as zero backlog.
|
|
4
|
+
*/
|
|
5
|
+
export function parseLsn(lsn: string | undefined): number | undefined {
|
|
6
|
+
if (!lsn) return undefined
|
|
7
|
+
const m = /^([0-9A-Fa-f]{1,8})\/([0-9A-Fa-f]{1,8})$/.exec(lsn.trim())
|
|
8
|
+
if (!m) return undefined
|
|
9
|
+
return parseInt(m[1], 16) * 0x1_0000_0000 + parseInt(m[2], 16)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Bytes from `behind` to `ahead`; undefined when either is unknown. Never negative. */
|
|
13
|
+
export function lsnDistance(ahead: string | undefined, behind: string | undefined): number | undefined {
|
|
14
|
+
const a = parseLsn(ahead)
|
|
15
|
+
const b = parseLsn(behind)
|
|
16
|
+
if (a === undefined || b === undefined) return undefined
|
|
17
|
+
return Math.max(0, a - b)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A standby's backlog from the position it reports itself, for one with no
|
|
22
|
+
* pg_stat_replication row. Positions on different timelines aren't
|
|
23
|
+
* comparable: after a failover a diverged standby can read as caught up.
|
|
24
|
+
*/
|
|
25
|
+
export function standbyOwnBacklog(
|
|
26
|
+
primary: { currentLsn?: string; timeline?: number } | undefined,
|
|
27
|
+
standby: { replayLsn?: string; timeline?: number } | undefined,
|
|
28
|
+
): number | undefined {
|
|
29
|
+
if (primary?.timeline === undefined || primary.timeline !== standby?.timeline) return undefined
|
|
30
|
+
return lsnDistance(primary.currentLsn, standby.replayLsn)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatBytes(n: number | undefined): string {
|
|
34
|
+
if (n === undefined) return '—'
|
|
35
|
+
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
|
36
|
+
let v = n
|
|
37
|
+
let i = 0
|
|
38
|
+
while (v >= 1024 && i < u.length - 1) {
|
|
39
|
+
v /= 1024
|
|
40
|
+
i++
|
|
41
|
+
}
|
|
42
|
+
return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${u[i]}`
|
|
43
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { StatusDot, Tooltip, formatAge } from '@skyhook-io/k8s-ui'
|
|
2
|
+
import { CNPG_OP_STATE_TEXT, CNPG_OP_STATE_TONE } from './presentation'
|
|
3
|
+
import type { CNPGTrackedOperation } from './model'
|
|
4
|
+
|
|
5
|
+
export function CNPGFleetOperation({ op, onFollow }: { op: CNPGTrackedOperation; onFollow: () => void }) {
|
|
6
|
+
return (
|
|
7
|
+
<div className="mt-1 text-[11px]">
|
|
8
|
+
<Tooltip
|
|
9
|
+
content={`${op.label}: ${CNPG_OP_STATE_TEXT[op.state]}. ${op.detail || ''} ${op.clusterUID ? '' : 'Cluster UID was not recorded; verify identity.'} The fleet does not poll this operation. Open the Cluster to follow it.`}
|
|
10
|
+
>
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
onClick={(event) => {
|
|
14
|
+
event.stopPropagation()
|
|
15
|
+
onFollow()
|
|
16
|
+
}}
|
|
17
|
+
className="inline-flex max-w-full items-center gap-1 rounded text-accent-text hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
18
|
+
>
|
|
19
|
+
<StatusDot tone={CNPG_OP_STATE_TONE[op.state]} size="xs" />
|
|
20
|
+
<span className="truncate">{op.label}</span>
|
|
21
|
+
<span className="shrink-0">· {CNPG_OP_STATE_TEXT[op.state]}</span>
|
|
22
|
+
</button>
|
|
23
|
+
</Tooltip>
|
|
24
|
+
<div className="text-theme-text-tertiary">
|
|
25
|
+
This tab ·{' '}
|
|
26
|
+
{op.lastCheckedAt ? `checked ${formatAge(new Date(op.lastCheckedAt).toISOString())} ago` : 'not yet checked'}
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|