@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,148 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { CNPGRuntimeResponse } from '../../api/cnpg'
|
|
3
|
+
import type { CNPGClusterHistoryResponse } from '../../api/cnpg-history'
|
|
4
|
+
import { cacheHitSeries, chartedDatabases, historyLatest, latestRate, rateSeries, sampleFrom, sessionStateSeries, type Sample } from './trendSamples'
|
|
5
|
+
|
|
6
|
+
const sample = (t: number, metricsAt: number | undefined, over: Partial<Sample>, source = 'pg-1/uid-1'): Sample => ({
|
|
7
|
+
t: t * 1000,
|
|
8
|
+
metricsAt: metricsAt === undefined ? undefined : metricsAt * 1000,
|
|
9
|
+
source,
|
|
10
|
+
replayLag: {},
|
|
11
|
+
...over,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('rates between exporter generations', () => {
|
|
15
|
+
it('uses the generation time and counts a cached reading once', () => {
|
|
16
|
+
// Fetched at 0, 25 and 50 s; the exporter reran its queries at 0 and 50 s.
|
|
17
|
+
const samples = [sample(0, 0, { archived: 100 }), sample(25, 0, { archived: 100 }), sample(50, 50, { archived: 150 })]
|
|
18
|
+
expect(rateSeries(samples, 'archived', 60).dataPoints).toEqual([{ timestamp: 50, value: 60 }])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('never takes a delta across a change of primary', () => {
|
|
22
|
+
const samples = [sample(0, 0, { checkpointsTimed: 10 }, 'pg-1/a'), sample(30, 30, { checkpointsTimed: 1000 }, 'pg-2/b'), sample(60, 60, { checkpointsTimed: 1002 }, 'pg-2/b')]
|
|
23
|
+
expect(rateSeries(samples, 'checkpointsTimed', 60).dataPoints).toEqual([
|
|
24
|
+
{ timestamp: 30, value: null },
|
|
25
|
+
{ timestamp: 60, value: 4 },
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('leaves a gap for a reset and for a sample without a source', () => {
|
|
30
|
+
const samples = [sample(0, 0, { deadlocks: 5 }), sample(30, 30, { deadlocks: 2 }), sample(60, 60, { deadlocks: 3, source: undefined })]
|
|
31
|
+
const pts = rateSeries(samples, 'deadlocks', 60).dataPoints
|
|
32
|
+
expect(pts[0]).toEqual({ timestamp: 30, value: null })
|
|
33
|
+
expect(pts[1].value).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('computes cache hit ratio from deltas and never shows 100 % for no reads', () => {
|
|
37
|
+
const samples = [sample(0, 0, { blksHit: 100, blksRead: 10 }), sample(30, 30, { blksHit: 190, blksRead: 20 }), sample(60, 60, { blksHit: 190, blksRead: 20 })]
|
|
38
|
+
const pts = cacheHitSeries(samples).dataPoints
|
|
39
|
+
expect(pts).toEqual([
|
|
40
|
+
{ timestamp: 30, value: 90 },
|
|
41
|
+
{ timestamp: 60, value: null },
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('gives the latest rate only from two generations of one source', () => {
|
|
46
|
+
expect(latestRate([sample(0, 0, { commits: 10 }), sample(10, 0, { commits: 10 })], 'commits')).toBeUndefined()
|
|
47
|
+
expect(latestRate([sample(0, 0, { commits: 10 }), sample(10, 10, { commits: 30 })], 'commits')).toBe(2)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('sampleFrom', () => {
|
|
52
|
+
const rt = (primaryMetrics: any, replicaMetrics: any = { state: 'ok', sessionsTotal: 1, sessionsByState: { active: 1 } }): CNPGRuntimeResponse => ({
|
|
53
|
+
cluster: { namespace: 'db', name: 'pg', uid: 'u' },
|
|
54
|
+
sampledAt: '2026-09-30T10:00:10Z',
|
|
55
|
+
permission: { proxy: 'allowed' },
|
|
56
|
+
instances: [
|
|
57
|
+
{ pod: 'pg-1', podUID: 'uid-1', role: 'primary', status: { state: 'ok' }, metrics: primaryMetrics },
|
|
58
|
+
{ pod: 'pg-2', podUID: 'uid-2', role: 'replica', status: { state: 'ok' }, metrics: replicaMetrics },
|
|
59
|
+
],
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('records the source Pod and the exporter generation, falling back to the fetch time as approximate', () => {
|
|
63
|
+
const exact = sampleFrom(rt({ state: 'ok', capturedAt: '2026-09-30T10:00:09Z', lastUpdateTimestamp: 1790000000, xactCommitTotal: 5 }))
|
|
64
|
+
expect(exact).toMatchObject({ source: 'pg-1/uid-1', metricsAt: 1790000000000, approximate: false, commits: 5 })
|
|
65
|
+
const approx = sampleFrom(rt({ state: 'ok', capturedAt: '2026-09-30T10:00:09Z' }))
|
|
66
|
+
expect(approx.metricsAt).toBe(Date.parse('2026-09-30T10:00:09Z'))
|
|
67
|
+
expect(approx.approximate).toBe(true)
|
|
68
|
+
expect(sampleFrom(rt({ state: 'denied' })).source).toBeUndefined()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('records sessions for every instance that answered, and none for one that did not', () => {
|
|
72
|
+
const s = sampleFrom(rt({ state: 'ok', sessionsTotal: 0 }, { state: 'unreachable' }))
|
|
73
|
+
expect(s.instances).toEqual({ 'pg-1': { byState: {}, total: 0, waiting: undefined } })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('keeps lock waits when the session-count query is missing, and leaves session states a gap', () => {
|
|
77
|
+
const s = sampleFrom(rt({ state: 'partial', waitingBackends: 3 }, { state: 'unreachable' }))
|
|
78
|
+
expect(s.instances?.['pg-1']).toEqual({ byState: undefined, total: undefined, waiting: 3 })
|
|
79
|
+
const states = sessionStateSeries([s], 'pg-1')
|
|
80
|
+
expect(states.every((series) => series.dataPoints[0].value === null)).toBe(true)
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('sessionStateSeries', () => {
|
|
85
|
+
it('groups states and gaps a sample where the instance did not answer', () => {
|
|
86
|
+
const samples = [
|
|
87
|
+
sample(0, 0, { instances: { 'pg-2': { byState: { active: 2, 'idle in transaction': 1, 'idle in transaction (aborted)': 1, idle: 5, 'fastpath function call': 1 }, total: 10 } } }),
|
|
88
|
+
sample(5, 5, { instances: {} }),
|
|
89
|
+
]
|
|
90
|
+
const series = sessionStateSeries(samples, 'pg-2')
|
|
91
|
+
expect(series.map((x) => [x.labels.series, x.dataPoints[0].value])).toEqual([
|
|
92
|
+
['active', 2],
|
|
93
|
+
['idle', 5],
|
|
94
|
+
['idle in transaction', 2],
|
|
95
|
+
['other', 1],
|
|
96
|
+
])
|
|
97
|
+
expect(series.every((x) => x.dataPoints[1].value === null)).toBe(true)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('chartedDatabases', () => {
|
|
102
|
+
const samples = [
|
|
103
|
+
sample(0, 0, { dbSizes: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, tiny: 0.5 } }),
|
|
104
|
+
sample(5, 5, { dbSizes: { a: 1, b: 2, c: 3, d: 4, e: 5, f: 6 } }),
|
|
105
|
+
]
|
|
106
|
+
it('defaults to the five largest and follows any database the viewer picks', () => {
|
|
107
|
+
expect(chartedDatabases(samples, null).shown).toEqual(['f', 'e', 'd', 'c', 'b'])
|
|
108
|
+
expect(chartedDatabases(samples, ['tiny', 'a']).shown).toEqual(['a', 'tiny'])
|
|
109
|
+
expect(chartedDatabases(samples, 'all').shown).toHaveLength(7)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('historyLatest', () => {
|
|
114
|
+
const history = (over: Partial<CNPGClusterHistoryResponse> = {}, chartState = 'ok') =>
|
|
115
|
+
({
|
|
116
|
+
source: 'prometheus',
|
|
117
|
+
state: 'ok',
|
|
118
|
+
charts: [
|
|
119
|
+
{
|
|
120
|
+
id: 'tps',
|
|
121
|
+
seriesBy: 'series',
|
|
122
|
+
state: chartState,
|
|
123
|
+
source: 'rate of xact_commit',
|
|
124
|
+
series: [{ labels: { series: 'commits' }, dataPoints: [{ timestamp: 1, value: 4 }, { timestamp: 2, value: 7.5 }, { timestamp: 3, value: null }] }],
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
...over,
|
|
128
|
+
}) as unknown as CNPGClusterHistoryResponse
|
|
129
|
+
|
|
130
|
+
it('takes the newest recorded point of the named series', () => {
|
|
131
|
+
expect(historyLatest(history({ end: new Date(3 * 1000).toISOString(), stepSeconds: 1 }), 'tps', 'commits')).toEqual({
|
|
132
|
+
value: 7.5,
|
|
133
|
+
at: 2,
|
|
134
|
+
source: 'rate of xact_commit',
|
|
135
|
+
stale: false,
|
|
136
|
+
})
|
|
137
|
+
expect(historyLatest(history(), 'tps', 'rollbacks')).toBeUndefined()
|
|
138
|
+
})
|
|
139
|
+
it('marks a value stale when the series stopped more than two steps before the end', () => {
|
|
140
|
+
const got = historyLatest(history({ end: new Date(600 * 1000).toISOString(), stepSeconds: 60 }), 'tps', 'commits')
|
|
141
|
+
expect(got).toMatchObject({ value: 7.5, stale: true })
|
|
142
|
+
})
|
|
143
|
+
it('has nothing without Prometheus history', () => {
|
|
144
|
+
expect(historyLatest(history({ source: 'none' }), 'tps', 'commits')).toBeUndefined()
|
|
145
|
+
expect(historyLatest(history({}, 'noSeries'), 'tps', 'commits')).toBeUndefined()
|
|
146
|
+
expect(historyLatest(undefined, 'tps', 'commits')).toBeUndefined()
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { TimeSeries } from '@skyhook-io/k8s-ui/components/charts'
|
|
2
|
+
import type { CNPGRuntimeResponse } from '../../api/cnpg'
|
|
3
|
+
import type { CNPGClusterHistoryResponse } from '../../api/cnpg-history'
|
|
4
|
+
|
|
5
|
+
/** Sessions on one instance at one sample, by pg_stat_activity state. */
|
|
6
|
+
// Session totals and lock waits come from separate exporter queries: each is
|
|
7
|
+
// kept when its own query answered, so one missing never hides the other.
|
|
8
|
+
export interface InstanceSessions {
|
|
9
|
+
byState?: Record<string, number>
|
|
10
|
+
total?: number
|
|
11
|
+
waiting?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// A ring buffer of samples taken while this view is open: the Trends section
|
|
15
|
+
// without Prometheus. It says so, and starts empty.
|
|
16
|
+
export interface Sample {
|
|
17
|
+
t: number
|
|
18
|
+
/**
|
|
19
|
+
* The Pod the primary's counters came from (name and UID). Counters are per
|
|
20
|
+
* instance, so a rate is never taken across a change of source.
|
|
21
|
+
*/
|
|
22
|
+
source?: string
|
|
23
|
+
/**
|
|
24
|
+
* When the exporter generated these values (ms): cnpg_last_update_timestamp
|
|
25
|
+
* when the operator publishes it, else Radar's fetch time.
|
|
26
|
+
*/
|
|
27
|
+
metricsAt?: number
|
|
28
|
+
/** metricsAt is Radar's fetch time: the exporter published no generation time. */
|
|
29
|
+
approximate?: boolean
|
|
30
|
+
replayLag: Record<string, number | undefined>
|
|
31
|
+
commits?: number
|
|
32
|
+
rollbacks?: number
|
|
33
|
+
archived?: number
|
|
34
|
+
failed?: number
|
|
35
|
+
blksHit?: number
|
|
36
|
+
blksRead?: number
|
|
37
|
+
walBytes?: number
|
|
38
|
+
deadlocks?: number
|
|
39
|
+
tempBytes?: number
|
|
40
|
+
checkpointsTimed?: number
|
|
41
|
+
checkpointsRequested?: number
|
|
42
|
+
/** Database sizes by name (gauges), every database the exporter reported. */
|
|
43
|
+
dbSizes?: Record<string, number>
|
|
44
|
+
/** Sessions per instance Pod, for every instance whose exporter answered. */
|
|
45
|
+
instances?: Record<string, InstanceSessions>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type CounterKey = 'commits' | 'rollbacks' | 'archived' | 'failed' | 'blksHit' | 'blksRead' | 'deadlocks' | 'tempBytes' | 'checkpointsTimed' | 'checkpointsRequested'
|
|
49
|
+
|
|
50
|
+
export const SAMPLE_BUFFER_LIMIT = 720
|
|
51
|
+
|
|
52
|
+
export function sampleFrom(data: CNPGRuntimeResponse): Sample {
|
|
53
|
+
const primary = data.instances.find((i) => i.role === 'primary')
|
|
54
|
+
const replayLag: Record<string, number | undefined> = {}
|
|
55
|
+
for (const r of primary?.status.replication ?? []) replayLag[r.applicationName] = r.replayLag
|
|
56
|
+
const m = primary?.metrics
|
|
57
|
+
const read = m && (m.state === 'ok' || m.state === 'partial') ? m : undefined
|
|
58
|
+
const generation = read?.lastUpdateTimestamp !== undefined ? read.lastUpdateTimestamp * 1000 : undefined
|
|
59
|
+
const fetched = read?.capturedAt ? Date.parse(read.capturedAt) || undefined : undefined
|
|
60
|
+
const instances: Record<string, InstanceSessions> = {}
|
|
61
|
+
for (const inst of data.instances) {
|
|
62
|
+
const im = inst.metrics
|
|
63
|
+
if (im.state !== 'ok' && im.state !== 'partial') continue
|
|
64
|
+
if (im.sessionsTotal === undefined && im.waitingBackends === undefined) continue
|
|
65
|
+
instances[inst.pod] = {
|
|
66
|
+
byState: im.sessionsTotal !== undefined ? { ...(im.sessionsByState ?? {}) } : undefined,
|
|
67
|
+
total: im.sessionsTotal,
|
|
68
|
+
waiting: im.waitingBackends,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
t: Date.parse(data.sampledAt) || Date.now(),
|
|
73
|
+
source: read && primary ? `${primary.pod}/${primary.podUID ?? ''}` : undefined,
|
|
74
|
+
metricsAt: generation ?? fetched,
|
|
75
|
+
approximate: read ? generation === undefined : undefined,
|
|
76
|
+
replayLag,
|
|
77
|
+
commits: read?.xactCommitTotal,
|
|
78
|
+
rollbacks: read?.xactRollbackTotal,
|
|
79
|
+
archived: read?.archiver?.archivedCount,
|
|
80
|
+
failed: read?.archiver?.failedCount,
|
|
81
|
+
blksHit: read?.blksHit,
|
|
82
|
+
blksRead: read?.blksRead,
|
|
83
|
+
walBytes: read?.walBytes,
|
|
84
|
+
deadlocks: read?.deadlocksTotal,
|
|
85
|
+
tempBytes: read?.tempBytesTotal,
|
|
86
|
+
checkpointsTimed: read?.checkpoints?.timed,
|
|
87
|
+
checkpointsRequested: read?.checkpoints?.requested,
|
|
88
|
+
dbSizes: read?.databaseSizes ? Object.fromEntries(read.databaseSizes.map((d) => [d.database, d.bytes])) : undefined,
|
|
89
|
+
instances,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Walks consecutive exporter generations of the same source. Samples that
|
|
95
|
+
* share a generation are one reading (the exporter and Radar both cache), so
|
|
96
|
+
* only the first counts; a change of source is a gap, never a delta.
|
|
97
|
+
*/
|
|
98
|
+
function generationPairs(
|
|
99
|
+
samples: Sample[],
|
|
100
|
+
has: (s: Sample) => boolean,
|
|
101
|
+
value: (prev: Sample, s: Sample, seconds: number) => number | null,
|
|
102
|
+
): TimeSeries['dataPoints'] {
|
|
103
|
+
const points: TimeSeries['dataPoints'] = []
|
|
104
|
+
let prev: Sample | undefined
|
|
105
|
+
for (const s of samples) {
|
|
106
|
+
if (!has(s) || s.metricsAt === undefined || s.source === undefined) {
|
|
107
|
+
points.push({ timestamp: s.t / 1000, value: null })
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
if (prev && prev.source !== s.source) {
|
|
111
|
+
points.push({ timestamp: s.t / 1000, value: null })
|
|
112
|
+
prev = s
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
if (prev && prev.metricsAt === s.metricsAt) continue
|
|
116
|
+
if (prev) {
|
|
117
|
+
const dt = ((s.metricsAt as number) - (prev.metricsAt as number)) / 1000
|
|
118
|
+
points.push({ timestamp: s.t / 1000, value: dt > 0 ? value(prev, s, dt) : null })
|
|
119
|
+
}
|
|
120
|
+
prev = s
|
|
121
|
+
}
|
|
122
|
+
return points
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A counter as a rate between generations; a decrease is a reset and leaves a gap. `per` scales to per-minute. */
|
|
126
|
+
export function rateSeries(samples: Sample[], key: CounterKey, per = 1, label: string = key): TimeSeries {
|
|
127
|
+
return {
|
|
128
|
+
labels: { series: label },
|
|
129
|
+
dataPoints: generationPairs(
|
|
130
|
+
samples,
|
|
131
|
+
(s) => s[key] !== undefined,
|
|
132
|
+
(prev, s, dt) => {
|
|
133
|
+
const dv = (s[key] as number) - (prev[key] as number)
|
|
134
|
+
return dv >= 0 ? (dv / dt) * per : null
|
|
135
|
+
},
|
|
136
|
+
),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Cache hit ratio (%) of the blocks read between generations; no reads leaves a gap, not 100 %. */
|
|
141
|
+
export function cacheHitSeries(samples: Sample[]): TimeSeries {
|
|
142
|
+
return {
|
|
143
|
+
labels: { series: 'hit ratio' },
|
|
144
|
+
dataPoints: generationPairs(
|
|
145
|
+
samples,
|
|
146
|
+
(s) => s.blksHit !== undefined && s.blksRead !== undefined,
|
|
147
|
+
(prev, s) => {
|
|
148
|
+
const hit = (s.blksHit as number) - (prev.blksHit as number)
|
|
149
|
+
const read = (s.blksRead as number) - (prev.blksRead as number)
|
|
150
|
+
return hit >= 0 && read >= 0 && hit + read > 0 ? (hit / (hit + read)) * 100 : null
|
|
151
|
+
},
|
|
152
|
+
),
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The latest per-second rate of a counter, or undefined until two generations of one source were seen. */
|
|
157
|
+
export function latestRate(samples: Sample[], key: CounterKey): number | undefined {
|
|
158
|
+
const pts = rateSeries(samples, key).dataPoints
|
|
159
|
+
const last = pts[pts.length - 1]
|
|
160
|
+
return last?.value ?? undefined
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** How many trailing steps without a point still count as the latest reading. */
|
|
164
|
+
export const HISTORY_RECENT_STEPS = 2
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The newest value of one labelled series of a Prometheus history chart, or
|
|
168
|
+
* undefined when history is not from Prometheus or that chart has no point.
|
|
169
|
+
* `stale` when that value is more than HISTORY_RECENT_STEPS steps older than
|
|
170
|
+
* the query's end: the series stopped, so it is not a current rate.
|
|
171
|
+
*/
|
|
172
|
+
export function historyLatest(
|
|
173
|
+
history: CNPGClusterHistoryResponse | undefined,
|
|
174
|
+
chartId: string,
|
|
175
|
+
series: string,
|
|
176
|
+
): { value: number; at: number; source: string; stale: boolean } | undefined {
|
|
177
|
+
if (history?.source !== 'prometheus' || history.state !== 'ok') return undefined
|
|
178
|
+
const chart = history.charts.find((c) => c.id === chartId)
|
|
179
|
+
if (!chart || chart.state !== 'ok') return undefined
|
|
180
|
+
const pts = chart.series.find((s) => s.labels[chart.seriesBy] === series)?.dataPoints ?? []
|
|
181
|
+
const endMs = Date.parse(history.end ?? history.sampledAt)
|
|
182
|
+
const step = history.stepSeconds ?? 60
|
|
183
|
+
for (let i = pts.length - 1; i >= 0; i--) {
|
|
184
|
+
const v = pts[i].value
|
|
185
|
+
if (v === null) continue
|
|
186
|
+
const stale = Number.isFinite(endMs) && endMs / 1000 - pts[i].timestamp > HISTORY_RECENT_STEPS * step
|
|
187
|
+
return { value: v, at: pts[i].timestamp, source: chart.source, stale }
|
|
188
|
+
}
|
|
189
|
+
return undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const SESSION_STATE_GROUPS: { id: string; label: string; states: (state: string) => boolean }[] = [
|
|
193
|
+
{ id: 'active', label: 'active', states: (s) => s === 'active' },
|
|
194
|
+
{ id: 'idle', label: 'idle', states: (s) => s === 'idle' },
|
|
195
|
+
{ id: 'idleInTx', label: 'idle in transaction', states: (s) => s.startsWith('idle in transaction') },
|
|
196
|
+
{ id: 'other', label: 'other', states: (s) => s !== 'active' && s !== 'idle' && !s.startsWith('idle in transaction') },
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
/** One instance's sessions by state group; a sample where the instance did not answer is a gap. */
|
|
200
|
+
export function sessionStateSeries(samples: Sample[], pod: string): TimeSeries[] {
|
|
201
|
+
return SESSION_STATE_GROUPS.map((g) => ({
|
|
202
|
+
labels: { series: g.label },
|
|
203
|
+
dataPoints: samples.map((s) => {
|
|
204
|
+
const inst = s.instances?.[pod]
|
|
205
|
+
if (!inst?.byState) return { timestamp: s.t / 1000, value: null }
|
|
206
|
+
let v = 0
|
|
207
|
+
for (const [state, n] of Object.entries(inst.byState)) if (g.states(state)) v += n
|
|
208
|
+
return { timestamp: s.t / 1000, value: v }
|
|
209
|
+
}),
|
|
210
|
+
}))
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The databases to chart: the five largest in the latest sample unless the viewer picked some. */
|
|
214
|
+
export function chartedDatabases(samples: Sample[], picked: string[] | 'all' | null): { all: string[]; shown: string[] } {
|
|
215
|
+
const seen = new Set<string>()
|
|
216
|
+
for (const s of samples) for (const d of Object.keys(s.dbSizes ?? {})) seen.add(d)
|
|
217
|
+
const latest = [...samples].reverse().find((s) => s.dbSizes)?.dbSizes ?? {}
|
|
218
|
+
const all = [...seen].sort((a, b) => (latest[b] ?? -1) - (latest[a] ?? -1) || a.localeCompare(b))
|
|
219
|
+
if (picked === 'all') return { all, shown: all }
|
|
220
|
+
if (picked && picked.length > 0) return { all, shown: all.filter((d) => picked.includes(d)) }
|
|
221
|
+
return { all, shown: all.slice(0, 5) }
|
|
222
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { cnpgDimensions } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { useCNPGRuntime } from '../../api/cnpg'
|
|
4
|
+
import { useCNPGClusterHA } from '../../api/cnpg-ha'
|
|
5
|
+
import { cnpgReplicationGap, cnpgReplicationLive, withLiveReplication } from './runtimeAssessment'
|
|
6
|
+
import { useCNPGFleet } from './useCNPGSidebarWorkspace'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One Cluster's assessment, shared by the page header's chips, the Overview
|
|
10
|
+
* and the tabs: the fleet row (cached objects, issues, Prometheus) enriched
|
|
11
|
+
* with what the instance managers report, and the four dimensions derived
|
|
12
|
+
* from it. Every consumer reads this, so none can read calmer than another.
|
|
13
|
+
*/
|
|
14
|
+
export function useCNPGClusterAssessment(namespace: string, name: string) {
|
|
15
|
+
// The workspace is read for the object's own namespace: an explicitly opened
|
|
16
|
+
// Cluster shows its facts whatever the namespace filter is.
|
|
17
|
+
const { query, fleet } = useCNPGFleet([namespace])
|
|
18
|
+
const runtime = useCNPGRuntime(namespace, name)
|
|
19
|
+
const ha = useCNPGClusterHA(namespace, name)
|
|
20
|
+
const baseRow = fleet?.rows.find((r) => r.namespace === namespace && r.name === name)
|
|
21
|
+
const row = useMemo(() => (baseRow ? withLiveReplication(baseRow, runtime.data) : undefined), [baseRow, runtime.data])
|
|
22
|
+
const dimensions = useMemo(
|
|
23
|
+
() =>
|
|
24
|
+
row
|
|
25
|
+
? cnpgDimensions({ row, ha: ha.data, replication: cnpgReplicationLive(runtime.data), replicationGap: cnpgReplicationGap(runtime.data, runtime.error) })
|
|
26
|
+
: undefined,
|
|
27
|
+
[row, ha.data, runtime.data, runtime.error],
|
|
28
|
+
)
|
|
29
|
+
return { query, fleet, runtime, ha, row, dimensions }
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server'
|
|
2
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
3
|
+
import { cnpgPortForwardCommand } from '@skyhook-io/k8s-ui'
|
|
4
|
+
import { useCNPGKubectlContext } from './useCNPGKubectlContext'
|
|
5
|
+
const state = vi.hoisted(() => ({ context: 'kind-orders', mode: 'local' as string | undefined, embedded: false, contexts: [{ name: 'kind-orders', source: 'orders' }] as { name: string; originalName?: string; source?: string }[] | undefined }))
|
|
6
|
+
vi.mock('../../api/client', () => ({
|
|
7
|
+
useCapabilities: () => ({ data: state.mode ? { deployment: { mode: state.mode } } : undefined }),
|
|
8
|
+
useContexts: () => ({ data: state.contexts }),
|
|
9
|
+
}))
|
|
10
|
+
vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { context: state.context } }) }))
|
|
11
|
+
vi.mock('../../context/NavCustomization', () => ({ useNavCustomization: () => ({ embedded: state.embedded }) }))
|
|
12
|
+
function Context() {
|
|
13
|
+
const context = useCNPGKubectlContext()
|
|
14
|
+
return <span>{cnpgPortForwardCommand({ name: 'orders-rw', port: 5432 } as any, 'db', 5432, context?.name)} · {context?.source ?? 'current kubectl context'}</span>
|
|
15
|
+
}
|
|
16
|
+
const render = () => renderToStaticMarkup(<Context />)
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
state.context = 'kind-orders'; state.mode = 'local'; state.embedded = false; state.contexts = [{ name: 'kind-orders', source: 'orders' }]
|
|
19
|
+
})
|
|
20
|
+
it('only supplies a known local kubeconfig context, never a Hub or in-cluster identity', () => {
|
|
21
|
+
expect(render()).toContain('kind-orders')
|
|
22
|
+
state.embedded = true; expect(render()).toContain('current kubectl context'); expect(render()).not.toContain('--context')
|
|
23
|
+
state.embedded = false; state.mode = 'in-cluster'; expect(render()).toContain('current kubectl context'); expect(render()).not.toContain('--context')
|
|
24
|
+
state.mode = undefined; expect(render()).toContain('current kubectl context')
|
|
25
|
+
state.mode = 'local'; state.context = ''; expect(render()).toContain('current kubectl context')
|
|
26
|
+
})
|
|
27
|
+
it('uses the in-file name and source for a collision-qualified context', () => {
|
|
28
|
+
state.context = 'prod (staging)'
|
|
29
|
+
state.contexts = [{ name: 'prod', source: 'production' }, { name: 'prod (staging)', originalName: 'prod', source: 'staging' }]
|
|
30
|
+
expect(render()).toContain('kubectl --context prod -n db')
|
|
31
|
+
expect(render()).toContain('staging')
|
|
32
|
+
expect(render()).not.toContain('prod (staging)')
|
|
33
|
+
})
|
|
34
|
+
it('uses a plain context name when it has no originalName', () => {
|
|
35
|
+
expect(render()).toContain('kubectl --context kind-orders -n db')
|
|
36
|
+
expect(render()).toContain('orders')
|
|
37
|
+
})
|
|
38
|
+
it('leaves kubectl context selection to the shell when the context cannot be resolved', () => {
|
|
39
|
+
state.context = 'prod (staging)'
|
|
40
|
+
expect(render()).not.toContain('--context')
|
|
41
|
+
expect(render()).toContain('current kubectl context')
|
|
42
|
+
state.contexts = undefined
|
|
43
|
+
expect(render()).not.toContain('--context')
|
|
44
|
+
expect(render()).not.toContain('prod (staging)')
|
|
45
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { useCapabilities, useContexts } from '../../api/client'
|
|
2
|
+
import { useConnection } from '../../context/ConnectionContext'
|
|
3
|
+
import { useNavCustomization } from '../../context/NavCustomization'
|
|
4
|
+
|
|
5
|
+
export function useCNPGKubectlContext(): { name: string; source?: string } | undefined {
|
|
6
|
+
const { connection } = useConnection()
|
|
7
|
+
const { data } = useCapabilities()
|
|
8
|
+
const { data: contexts } = useContexts()
|
|
9
|
+
const { embedded } = useNavCustomization()
|
|
10
|
+
if (embedded || data?.deployment?.mode !== 'local') return undefined
|
|
11
|
+
const context = contexts?.find((context) => context.name === connection.context)
|
|
12
|
+
return context ? { name: context.originalName || context.name, source: context.source } : undefined
|
|
13
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, useEffect } from 'react'
|
|
3
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
4
|
+
import { MemoryRouter, useLocation } from 'react-router-dom'
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
6
|
+
import { useCNPGNavigate } from './useCNPGNavigate'
|
|
7
|
+
|
|
8
|
+
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
|
|
9
|
+
|
|
10
|
+
let root: Root | null = null
|
|
11
|
+
let container: HTMLDivElement | null = null
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
act(() => root?.unmount())
|
|
14
|
+
container?.remove()
|
|
15
|
+
root = null
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
function Probe({ to, onLocation }: { to: string; onLocation: (path: string) => void }) {
|
|
19
|
+
const navigate = useCNPGNavigate()
|
|
20
|
+
const location = useLocation()
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
onLocation(location.pathname + location.search)
|
|
23
|
+
}, [location, onLocation])
|
|
24
|
+
return (
|
|
25
|
+
<button type="button" onClick={() => navigate(to)}>
|
|
26
|
+
go
|
|
27
|
+
</button>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function landAt(start: string, to: string): string {
|
|
32
|
+
let at = ''
|
|
33
|
+
container = document.createElement('div')
|
|
34
|
+
document.body.appendChild(container)
|
|
35
|
+
root = createRoot(container)
|
|
36
|
+
act(() =>
|
|
37
|
+
root!.render(
|
|
38
|
+
<MemoryRouter initialEntries={[start]}>
|
|
39
|
+
<Probe to={to} onLocation={(p) => (at = p)} />
|
|
40
|
+
</MemoryRouter>,
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
act(() => container!.querySelector('button')!.click())
|
|
44
|
+
return at
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('useCNPGNavigate', () => {
|
|
48
|
+
it('keeps the namespace filter, which the app would otherwise reset to All namespaces', () => {
|
|
49
|
+
expect(landAt('/cnpg?namespaces=pg', '/cnpg/operator')).toBe('/cnpg/operator?namespaces=pg')
|
|
50
|
+
expect(landAt('/cnpg?namespaces=pg', '/workload/services/pg/pg-rw?tab=reachability')).toBe('/workload/services/pg/pg-rw?tab=reachability&namespaces=pg')
|
|
51
|
+
})
|
|
52
|
+
it('leaves a destination that names its own namespaces alone', () => {
|
|
53
|
+
expect(landAt('/cnpg?namespaces=pg', '/cnpg?namespaces=other')).toBe('/cnpg?namespaces=other')
|
|
54
|
+
})
|
|
55
|
+
it('keeps namespace and investigation parameters when opening cluster-scoped Declarations', () => {
|
|
56
|
+
expect(landAt('/cnpg/clusters/db/pg?tab=spec&namespaces=db&ai-run=run-1', '/cnpg/declarations?cluster=db%2Fpg')).toBe('/cnpg/declarations?cluster=db%2Fpg&namespaces=db&ai-run=run-1')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useLocation, useNavigate, type NavigateOptions, type To } from 'react-router-dom'
|
|
3
|
+
import { withCrossViewParams } from '../../utils/navigation'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* navigate() for the CloudNativePG screens: a string path keeps the current
|
|
7
|
+
* cross-view params (the namespace filter, an open investigation), as the
|
|
8
|
+
* app's own navigation does. Without them the app reads the missing
|
|
9
|
+
* ?namespaces= as "All namespaces" and saves that as the user's choice.
|
|
10
|
+
*/
|
|
11
|
+
export function useCNPGNavigate() {
|
|
12
|
+
const navigate = useNavigate()
|
|
13
|
+
const { search } = useLocation()
|
|
14
|
+
return useCallback(
|
|
15
|
+
(to: To | number, options?: NavigateOptions) => {
|
|
16
|
+
if (typeof to === 'number') return navigate(to)
|
|
17
|
+
return navigate(typeof to === 'string' ? withCrossViewParams(to, search) : to, options)
|
|
18
|
+
},
|
|
19
|
+
[navigate, search],
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { UseQueryResult } from '@tanstack/react-query'
|
|
2
|
+
import type { CNPGPoolerLive } from '@skyhook-io/k8s-ui'
|
|
3
|
+
import { useCNPGPoolerRuntime } from '../../api/cnpg'
|
|
4
|
+
import { useCNPGPgBouncerState, useCNPGPoolerCapabilities } from '../../api/cnpg-sessions'
|
|
5
|
+
import { formatGrant } from '@skyhook-io/k8s-ui'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The live reads the Pooler summary shows beside the spec, and the queries
|
|
9
|
+
* behind them so a host can say when a refresh failed over cached values.
|
|
10
|
+
*/
|
|
11
|
+
export function useCNPGPoolerLive(namespace: string, name: string): { live: CNPGPoolerLive; queries: Pick<UseQueryResult<unknown>, 'isRefetchError' | 'error' | 'dataUpdatedAt'>[] } {
|
|
12
|
+
const caps = useCNPGPoolerCapabilities(namespace, name)
|
|
13
|
+
const runtime = useCNPGPoolerRuntime(namespace, name)
|
|
14
|
+
const observeDenied = caps.data?.actions.observeState.permission === 'denied'
|
|
15
|
+
const state = useCNPGPgBouncerState(namespace, name, !!caps.data && !observeDenied)
|
|
16
|
+
|
|
17
|
+
const live: CNPGPoolerLive = {}
|
|
18
|
+
if (caps.data) {
|
|
19
|
+
live.deployment = caps.data.facts.deployment
|
|
20
|
+
live.service = caps.data.facts.service
|
|
21
|
+
}
|
|
22
|
+
if (runtime.data) {
|
|
23
|
+
const denied = runtime.data.permission.proxy === 'denied'
|
|
24
|
+
live.pressure = {
|
|
25
|
+
state: denied ? 'denied' : 'ok',
|
|
26
|
+
reason: denied ? `needs ${formatGrant(runtime.data.permission.grant) ?? 'get pods/proxy'}` : undefined,
|
|
27
|
+
pods: runtime.data.pods,
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
live.pressure = runtime.isLoading
|
|
31
|
+
? { state: 'loading', pods: [] }
|
|
32
|
+
: { state: 'error', reason: runtime.error instanceof Error ? runtime.error.message : 'read failed', pods: [] }
|
|
33
|
+
}
|
|
34
|
+
if (caps.data) {
|
|
35
|
+
if (observeDenied) {
|
|
36
|
+
live.observed = { state: 'denied', grant: caps.data.actions.observeState.grant, pods: [] }
|
|
37
|
+
} else if (state.data) {
|
|
38
|
+
live.observed = { state: state.data.permission.exec === 'denied' ? 'denied' : 'ok', grant: state.data.permission.grant, pods: state.data.pods }
|
|
39
|
+
} else {
|
|
40
|
+
live.observed = state.isLoading
|
|
41
|
+
? { state: 'loading', pods: [] }
|
|
42
|
+
: { state: 'error', reason: state.error instanceof Error ? state.error.message : undefined, pods: [] }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { live, queries: [caps, runtime, state] }
|
|
46
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useCallback } from 'react'
|
|
2
|
+
import { useLocation, useSearchParams } from 'react-router-dom'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A CloudNativePG screen's own URL parameters (`q`, `cat`, `filter`, …),
|
|
6
|
+
* written in place: an empty value removes the parameter, history is replaced
|
|
7
|
+
* and its state (the return to the previous page) is kept.
|
|
8
|
+
*/
|
|
9
|
+
export function useCNPGScreenParams(): [URLSearchParams, (update: Record<string, string | null>) => void] {
|
|
10
|
+
const [searchParams, setSearchParams] = useSearchParams()
|
|
11
|
+
const location = useLocation()
|
|
12
|
+
const setParams = useCallback(
|
|
13
|
+
(update: Record<string, string | null>) => {
|
|
14
|
+
const params = new URLSearchParams(searchParams)
|
|
15
|
+
for (const [k, v] of Object.entries(update)) {
|
|
16
|
+
if (v === null || v === '') params.delete(k)
|
|
17
|
+
else params.set(k, v)
|
|
18
|
+
}
|
|
19
|
+
setSearchParams(params, { replace: true, state: location.state })
|
|
20
|
+
},
|
|
21
|
+
[searchParams, setSearchParams, location.state],
|
|
22
|
+
)
|
|
23
|
+
return [searchParams, setParams]
|
|
24
|
+
}
|