@skyhook-io/radar-app 1.16.3 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/App.tsx +138 -93
- package/src/RadarApp.tsx +27 -17
- package/src/api/actions.errorCode.test.ts +40 -0
- package/src/api/actions.test.ts +48 -0
- package/src/api/actions.ts +79 -0
- package/src/api/apiResources.navigation.test.tsx +232 -0
- package/src/api/apiResources.ts +46 -3
- package/src/api/client.goneResource.test.ts +49 -0
- package/src/api/client.helmSource.test.ts +39 -0
- package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
- package/src/api/client.namespaceSwitch.test.ts +107 -0
- package/src/api/client.ts +381 -47
- package/src/api/cnpg-ha.live.test.ts +140 -0
- package/src/api/cnpg-ha.test.ts +78 -0
- package/src/api/cnpg-ha.ts +19 -0
- package/src/api/cnpg-history.ts +123 -0
- package/src/api/cnpg-inspect.ts +102 -0
- package/src/api/cnpg-protection.ts +112 -0
- package/src/api/cnpg-recovery.test.tsx +60 -0
- package/src/api/cnpg-recovery.ts +262 -0
- package/src/api/cnpg-sessions.ts +175 -0
- package/src/api/cnpg-storage.ts +138 -0
- package/src/api/cnpg.legacy-reads.test.ts +43 -0
- package/src/api/cnpg.pooler-runtime.test.ts +15 -0
- package/src/api/cnpg.ts +428 -10
- package/src/api/diagnose.test.ts +40 -0
- package/src/api/diagnose.ts +12 -1
- package/src/api/radarFeatures.render.test.tsx +65 -3
- package/src/api/radarFeatures.ts +6 -0
- package/src/api/reviewedActionMutation.test.tsx +164 -0
- package/src/api/reviewedActionMutation.ts +66 -0
- package/src/api/timelineSource.test.ts +9 -0
- package/src/api/timelineSource.ts +8 -0
- package/src/api/traffic.sources.test.ts +55 -0
- package/src/api/traffic.ts +173 -14
- package/src/components/applications/ApplicationsView.tsx +3 -0
- package/src/components/audit/AuditView.tsx +9 -0
- package/src/components/capacity/CapacityActivity.tsx +3 -31
- package/src/components/capacity/CapacityDemand.tsx +4 -36
- package/src/components/capacity/CapacityOverview.tsx +217 -56
- package/src/components/capacity/CapacityPoolDetail.tsx +24 -62
- package/src/components/capacity/CapacityView.test.tsx +354 -1
- package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
- package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
- package/src/components/capacity/routes.ts +42 -0
- package/src/components/capacity/shared.tsx +23 -150
- package/src/components/capacity/workspaceRoute.ts +18 -0
- package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
- package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
- package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
- package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
- package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
- package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
- package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
- package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
- package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
- package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
- package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
- package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
- package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
- package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
- package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
- package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
- package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
- package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
- package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
- package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
- package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
- package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
- package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
- package/src/components/cnpg/CNPGOperator.tsx +78 -30
- package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
- package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
- package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
- package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
- package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
- package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
- package/src/components/cnpg/CNPGOverview.tsx +238 -63
- package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
- package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
- package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
- package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
- package/src/components/cnpg/CNPGPerformance.tsx +477 -0
- package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
- package/src/components/cnpg/CNPGPooling.tsx +97 -40
- package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
- package/src/components/cnpg/CNPGProtection.tsx +230 -124
- package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
- package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
- package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
- package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
- package/src/components/cnpg/CNPGStorage.tsx +619 -0
- package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
- package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
- package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
- package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
- package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
- package/src/components/cnpg/CNPGTrends.test.ts +38 -0
- package/src/components/cnpg/CNPGTrends.tsx +702 -0
- package/src/components/cnpg/CNPGView.tsx +34 -21
- package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
- package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
- package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
- package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
- package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
- package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
- package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
- package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
- package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
- package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
- package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
- package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
- package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
- package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
- package/src/components/cnpg/actions/actionModel.test.ts +111 -0
- package/src/components/cnpg/actions/actionModel.ts +127 -0
- package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
- package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
- package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
- package/src/components/cnpg/archivingRepair.test.ts +104 -0
- package/src/components/cnpg/archivingRepair.ts +145 -0
- package/src/components/cnpg/baseBackup.test.ts +36 -0
- package/src/components/cnpg/baseBackup.ts +36 -0
- package/src/components/cnpg/blocking.test.ts +59 -0
- package/src/components/cnpg/blocking.ts +131 -0
- package/src/components/cnpg/coverageText.test.ts +27 -0
- package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
- package/src/components/cnpg/creation/targetModel.test.ts +137 -0
- package/src/components/cnpg/creation/targetModel.ts +149 -0
- package/src/components/cnpg/fleetStatus.test.ts +46 -0
- package/src/components/cnpg/fleetStatus.ts +43 -0
- package/src/components/cnpg/formFields.ts +2 -0
- package/src/components/cnpg/grantText.test.tsx +11 -0
- package/src/components/cnpg/host.test.tsx +24 -0
- package/src/components/cnpg/host.tsx +101 -0
- package/src/components/cnpg/inspectModel.test.ts +120 -0
- package/src/components/cnpg/inspectModel.ts +140 -0
- package/src/components/cnpg/logicalSlots.test.ts +30 -0
- package/src/components/cnpg/logicalSlots.ts +29 -0
- package/src/components/cnpg/lsn.test.ts +40 -0
- package/src/components/cnpg/lsn.ts +43 -0
- package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
- package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
- package/src/components/cnpg/operations/model.test.ts +510 -0
- package/src/components/cnpg/operations/model.ts +499 -0
- package/src/components/cnpg/operations/presentation.test.ts +80 -0
- package/src/components/cnpg/operations/presentation.ts +84 -0
- package/src/components/cnpg/operations/store.ts +98 -0
- package/src/components/cnpg/operatorStatus.test.ts +134 -0
- package/src/components/cnpg/operatorStatus.ts +194 -0
- package/src/components/cnpg/paths.test.ts +54 -0
- package/src/components/cnpg/paths.ts +47 -4
- package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
- package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
- package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
- package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
- package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
- package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
- package/src/components/cnpg/protection/scheduleModel.ts +16 -0
- package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
- package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
- package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
- package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
- package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
- package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
- package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
- package/src/components/cnpg/recovery/restoreModel.ts +685 -0
- package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
- package/src/components/cnpg/refresh.test.ts +38 -0
- package/src/components/cnpg/refresh.ts +17 -0
- package/src/components/cnpg/refreshNotice.test.tsx +29 -0
- package/src/components/cnpg/routes.test.ts +34 -3
- package/src/components/cnpg/routes.ts +60 -48
- package/src/components/cnpg/runtimeAssessment.ts +198 -0
- package/src/components/cnpg/runtimeModel.test.ts +175 -0
- package/src/components/cnpg/runtimeModel.ts +196 -0
- package/src/components/cnpg/runtimeParts.test.tsx +17 -0
- package/src/components/cnpg/runtimeParts.tsx +79 -0
- package/src/components/cnpg/shared.test.tsx +59 -0
- package/src/components/cnpg/shared.tsx +100 -182
- package/src/components/cnpg/storageModel.test.ts +107 -0
- package/src/components/cnpg/storageModel.ts +101 -0
- package/src/components/cnpg/trendSamples.test.ts +148 -0
- package/src/components/cnpg/trendSamples.ts +222 -0
- package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
- package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
- package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
- package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
- package/src/components/cnpg/useCNPGNavigate.ts +21 -0
- package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
- package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
- package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
- package/src/components/cnpg/workspaceRoute.ts +23 -0
- package/src/components/cost/ApplicationCostTab.test.ts +7 -0
- package/src/components/cost/ApplicationCostTab.tsx +7 -7
- package/src/components/cost/CostView.test.ts +2 -1
- package/src/components/cost/CostView.tsx +0 -2
- package/src/components/cost/WorkloadCostTab.tsx +2 -4
- package/src/components/cost/errors.ts +3 -1
- package/src/components/cost/source.test.ts +10 -0
- package/src/components/cost/source.ts +5 -1
- package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
- package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
- package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
- package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
- package/src/components/diagnose/ApplyDialog.tsx +26 -28
- package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
- package/src/components/diagnose/DiagnoseContext.tsx +136 -54
- package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
- package/src/components/diagnose/InvestigationView.tsx +39 -2
- package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
- package/src/components/diagnose/agentCatalog.ts +2 -2
- package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
- package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
- package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
- package/src/components/dock/BottomDock.tsx +2 -0
- package/src/components/dock/TerminalTab.test.tsx +17 -0
- package/src/components/dock/TerminalTab.tsx +7 -3
- package/src/components/dock/TrafficFlowListTab.tsx +27 -6
- package/src/components/execution/BatchExecutionView.tsx +11 -11
- package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
- package/src/components/gitops/GitOpsView.tsx +16 -23
- package/src/components/helm/ChartBrowser.tsx +3 -3
- package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
- package/src/components/helm/HelmCompareRoute.tsx +57 -26
- package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
- package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
- package/src/components/helm/RevisionHistory.tsx +3 -2
- package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
- package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
- package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
- package/src/components/helm/ValuesViewer.test.tsx +301 -0
- package/src/components/helm/ValuesViewer.tsx +169 -78
- package/src/components/home/ActivitySummary.tsx +2 -1
- package/src/components/home/ClusterHealthCard.tsx +7 -0
- package/src/components/home/HomeView.tsx +20 -9
- package/src/components/home/ProblemsPanel.test.tsx +81 -0
- package/src/components/home/TopologyPreview.tsx +2 -2
- package/src/components/home/mcpToolCatalog.ts +1 -1
- package/src/components/issues/IssuesPane.render.test.tsx +122 -0
- package/src/components/issues/IssuesPane.test.ts +65 -1
- package/src/components/issues/IssuesPane.tsx +188 -25
- package/src/components/issues/uncheckedSources.ts +21 -0
- package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
- package/src/components/resource/PVCUsageBar.test.tsx +24 -0
- package/src/components/resource/PVCUsageBar.tsx +15 -6
- package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
- package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
- package/src/components/resources/CompositeRenderer.tsx +4 -4
- package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
- package/src/components/resources/ResourcesView.tsx +26 -12
- package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
- package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
- package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
- package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
- package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
- package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
- package/src/components/settings/SettingsDialog.tsx +150 -34
- package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
- package/src/components/timeline/TimelineList.tsx +25 -24
- package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
- package/src/components/timeline/TimelineLoadError.tsx +96 -0
- package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
- package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
- package/src/components/timeline/TimelineView.tsx +25 -45
- package/src/components/timeline/ViewModeToggle.tsx +20 -0
- package/src/components/timeline/useStickyError.test.tsx +47 -0
- package/src/components/timeline/useStickyError.ts +15 -0
- package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
- package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
- package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
- package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
- package/src/components/traffic/TrafficGraph.tsx +109 -26
- package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
- package/src/components/traffic/TrafficView.tsx +582 -149
- package/src/components/traffic/trafficFilters.test.ts +398 -1
- package/src/components/traffic/trafficFilters.ts +408 -0
- package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
- package/src/components/ui/Markdown.test.tsx +65 -0
- package/src/components/ui/Markdown.tsx +144 -127
- package/src/components/whats-new/releaseNotes.ts +63 -1
- package/src/components/workload/WorkloadView.test.ts +11 -1
- package/src/components/workload/WorkloadView.tsx +162 -529
- package/src/components/workspace/layout.test.tsx +29 -0
- package/src/components/workspace/layout.tsx +259 -0
- package/src/components/workspace/table.ts +7 -0
- package/src/context/TimelineCustomization.tsx +15 -0
- package/src/contexts/CapabilitiesContext.tsx +1 -1
- package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
- package/src/hooks/useGitOpsWriteGuard.ts +193 -0
- package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
- package/src/integrations/resourceHost.test.tsx +55 -0
- package/src/integrations/resourceHost.ts +99 -0
- package/src/integrations/resourceHosts.test.tsx +141 -0
- package/src/integrations/resourceHosts.tsx +119 -0
- package/src/integrations/workspaceRoute.ts +20 -0
- package/src/integrations/workspaceRoutes.test.ts +40 -0
- package/src/integrations/workspaceRoutes.ts +22 -0
- package/src/integrations/workspaceScreens.tsx +14 -0
- package/src/utils/drawer-trail.ts +35 -0
- package/src/utils/kindFilterStore.test.ts +147 -0
- package/src/utils/kindFilterStore.ts +91 -0
- package/src/utils/navigation.test.ts +13 -1
- package/src/utils/navigation.ts +39 -2
- package/src/utils/page-links.ts +21 -0
- package/src/utils/tabStorage.ts +40 -0
- package/src/utils/viewMemory.test.ts +78 -0
- package/src/utils/viewMemory.ts +54 -0
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
import { useLocation, useSearchParams } from 'react-router-dom'
|
|
3
|
+
import { X } from 'lucide-react'
|
|
4
|
+
import { Tooltip, formatGrant, toneTextClass } from '@skyhook-io/k8s-ui'
|
|
5
|
+
import { AreaChart, SeriesLegend, type ChartTimeRange, type ReferenceLine, type TimeSeries } from '@skyhook-io/k8s-ui/components/charts'
|
|
6
|
+
import type { CNPGRuntimeResponse } from '../../api/cnpg'
|
|
7
|
+
import {
|
|
8
|
+
CNPG_HISTORY_RANGES,
|
|
9
|
+
useCNPGClusterHistory,
|
|
10
|
+
type CNPGHistoryChart,
|
|
11
|
+
type CNPGHistoryRange,
|
|
12
|
+
type CNPGClusterHistoryResponse,
|
|
13
|
+
} from '../../api/cnpg-history'
|
|
14
|
+
import { cacheHitSeries, chartedDatabases, rateSeries, sampleFrom, sessionStateSeries, SAMPLE_BUFFER_LIMIT, type Sample } from './trendSamples'
|
|
15
|
+
import { Notice, Segments } from '../workspace/layout'
|
|
16
|
+
|
|
17
|
+
const COLOR = '#60a5fa'
|
|
18
|
+
const FILL = '#60a5fa22'
|
|
19
|
+
|
|
20
|
+
export type { Sample } from './trendSamples'
|
|
21
|
+
|
|
22
|
+
export function useSampleBuffer(data: CNPGRuntimeResponse | undefined): Sample[] {
|
|
23
|
+
const [samples, setSamples] = useState<Sample[]>([])
|
|
24
|
+
const last = useRef<string | null>(null)
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!data || data.sampledAt === last.current) return
|
|
27
|
+
last.current = data.sampledAt
|
|
28
|
+
setSamples((prev) => [...prev, sampleFrom(data)].slice(-SAMPLE_BUFFER_LIMIT))
|
|
29
|
+
}, [data])
|
|
30
|
+
return samples
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type CNPGIntervalTarget = 'logs' | 'activity'
|
|
34
|
+
|
|
35
|
+
function chartUnit(unit: string): string {
|
|
36
|
+
return unit === 'per second' || unit === 'per minute' ? '' : unit
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clock(unix: number): string {
|
|
40
|
+
return new Date(unix * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function span(seconds: number): string {
|
|
44
|
+
if (seconds < 90) return `${Math.round(seconds)} s`
|
|
45
|
+
if (seconds < 5400) return `${Math.round(seconds / 60)} min`
|
|
46
|
+
return `${(seconds / 3600).toFixed(1)} h`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Steps where no series has a sample, as hatched ranges. A run that starts
|
|
51
|
+
* the window is before the series existed or beyond retention; any other is a
|
|
52
|
+
* missed scrape or an instance that was down.
|
|
53
|
+
*/
|
|
54
|
+
export function historyGaps(series: TimeSeries[], start: number, end: number, step: number): (ChartTimeRange & { label: string })[] {
|
|
55
|
+
if (step <= 0 || end <= start) return []
|
|
56
|
+
const present = new Set<number>()
|
|
57
|
+
for (const s of series) for (const p of s.dataPoints) if (p.value != null) present.add(p.timestamp)
|
|
58
|
+
const out: (ChartTimeRange & { label: string })[] = []
|
|
59
|
+
let runStart: number | null = null
|
|
60
|
+
const flush = (last: number) => {
|
|
61
|
+
if (runStart === null) return
|
|
62
|
+
out.push({
|
|
63
|
+
start: Math.max(start, runStart - step / 2),
|
|
64
|
+
end: Math.min(end, last + step / 2),
|
|
65
|
+
label: runStart === start ? 'No sample: before this series existed or beyond Prometheus retention' : 'No sample recorded (instance down, restarting or not scraped)',
|
|
66
|
+
})
|
|
67
|
+
runStart = null
|
|
68
|
+
}
|
|
69
|
+
let prev = start
|
|
70
|
+
for (let t = start; t <= end; t += step) {
|
|
71
|
+
if (present.has(t)) flush(prev)
|
|
72
|
+
else if (runStart === null) runStart = t
|
|
73
|
+
prev = t
|
|
74
|
+
}
|
|
75
|
+
flush(prev)
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Hatched ranges between the samples around each run where no series has a value. */
|
|
80
|
+
export function sampleGaps(series: TimeSeries[]): (ChartTimeRange & { label: string })[] {
|
|
81
|
+
const ts = [...new Set(series.flatMap((s) => s.dataPoints.map((p) => p.timestamp)))].sort((a, b) => a - b)
|
|
82
|
+
const present = new Set<number>()
|
|
83
|
+
for (const s of series) for (const p of s.dataPoints) if (p.value != null) present.add(p.timestamp)
|
|
84
|
+
const out: (ChartTimeRange & { label: string })[] = []
|
|
85
|
+
let i = 0
|
|
86
|
+
while (i < ts.length) {
|
|
87
|
+
if (present.has(ts[i])) {
|
|
88
|
+
i++
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
let j = i
|
|
92
|
+
while (j + 1 < ts.length && !present.has(ts[j + 1])) j++
|
|
93
|
+
out.push({ start: ts[Math.max(0, i - 1)], end: ts[Math.min(ts.length - 1, j + 1)], label: 'No sample: the source did not answer' })
|
|
94
|
+
i = j + 1
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The instances a per-instance chart should have a line for but does not. A
|
|
101
|
+
* chart draws only what reported, so a stopped standby would otherwise vanish
|
|
102
|
+
* from the very chart about it and leave the others reading calm.
|
|
103
|
+
*/
|
|
104
|
+
export function unchartedInstances(expected: readonly string[] | undefined, charted: readonly string[]): string[] {
|
|
105
|
+
if (!expected?.length) return []
|
|
106
|
+
const seen = new Set(charted)
|
|
107
|
+
return expected.filter((p) => !seen.has(p))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Why each instance has no line; `why` reads after "No line for <pods>: ". */
|
|
111
|
+
function MissingLines({ pods, why }: { pods: string[]; why: (them: string) => string }) {
|
|
112
|
+
if (pods.length === 0) return null
|
|
113
|
+
return (
|
|
114
|
+
<p className={`mb-2 text-xs ${toneTextClass('degraded')}`}>
|
|
115
|
+
No line for {pods.join(', ')}: {why(pods.length === 1 ? 'it' : 'them')}.
|
|
116
|
+
</p>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const promMissingWhy = (them: string) => `Prometheus has no samples from ${them} in this range`
|
|
121
|
+
|
|
122
|
+
/** The instances a history chart should have a line for, when its lines are per Pod. */
|
|
123
|
+
function expectedLines(chart: CNPGHistoryChart, standbys?: string[], instancePods?: string[]): string[] | undefined {
|
|
124
|
+
if (chart.id === 'replicationLag') return standbys
|
|
125
|
+
return chart.seriesBy === 'pod' ? instancePods : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function referenceLines(chart: CNPGHistoryChart): ReferenceLine[] | undefined {
|
|
129
|
+
const t = chart.thresholds ?? []
|
|
130
|
+
if (t.length === 0) return undefined
|
|
131
|
+
return t.map((x, i) => ({ value: x.value, label: x.label, kind: i === t.length - 1 ? 'limit' : 'request' }))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ChartCard({ title, meta, children, footer }: { title: ReactNode; meta?: ReactNode; children: ReactNode; footer?: ReactNode }) {
|
|
135
|
+
return (
|
|
136
|
+
<section className="min-w-0 overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
|
|
137
|
+
<div className="flex items-center gap-2 border-b border-theme-border px-4 py-2.5">
|
|
138
|
+
<span className="text-sm font-semibold text-theme-text-primary">{title}</span>
|
|
139
|
+
{meta && <span className="ml-auto text-xs text-theme-text-tertiary">{meta}</span>}
|
|
140
|
+
</div>
|
|
141
|
+
<div className="p-3">{children}</div>
|
|
142
|
+
{footer && <div className="border-t border-theme-border px-4 py-2 text-xs text-theme-text-tertiary">{footer}</div>}
|
|
143
|
+
</section>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function stateText(chart: CNPGHistoryChart): string {
|
|
148
|
+
switch (chart.state) {
|
|
149
|
+
case 'empty':
|
|
150
|
+
return `Nothing to plot: ${chart.reason ?? 'no values in this range'}.`
|
|
151
|
+
case 'noSeries':
|
|
152
|
+
return chart.reason ? chart.reason.charAt(0).toUpperCase() + chart.reason.slice(1) + '.' : 'Not scraped.'
|
|
153
|
+
case 'denied':
|
|
154
|
+
return `No access: needs ${formatGrant(chart.grant) ?? 'more permissions'}.`
|
|
155
|
+
case 'notRead':
|
|
156
|
+
return `Not read: ${chart.reason ?? 'no source'}.`
|
|
157
|
+
default:
|
|
158
|
+
return chart.reason ?? 'Could not be read.'
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function HistoryChartCard({
|
|
163
|
+
chart,
|
|
164
|
+
data,
|
|
165
|
+
selection,
|
|
166
|
+
onSelect,
|
|
167
|
+
expected,
|
|
168
|
+
}: {
|
|
169
|
+
chart: CNPGHistoryChart
|
|
170
|
+
data: CNPGClusterHistoryResponse
|
|
171
|
+
selection: ChartTimeRange | null
|
|
172
|
+
onSelect: (r: ChartTimeRange) => void
|
|
173
|
+
/** The Pods this chart should have a line for, when its lines are per Pod. */
|
|
174
|
+
expected?: string[]
|
|
175
|
+
}) {
|
|
176
|
+
const start = Date.parse(data.start ?? '') / 1000
|
|
177
|
+
const end = Date.parse(data.end ?? '') / 1000
|
|
178
|
+
const step = data.stepSeconds ?? 60
|
|
179
|
+
const series: TimeSeries[] = chart.series
|
|
180
|
+
const labels = series.map((s) => s.labels[chart.seriesBy] ?? chart.title)
|
|
181
|
+
const gaps = useMemo(() => historyGaps(series, start, end, step), [series, start, end, step])
|
|
182
|
+
// Omitted series may include the ones that look missing, so nothing is claimed then.
|
|
183
|
+
const missing = (chart.state === 'ok' || chart.state === 'empty') && !chart.omitted ? unchartedInstances(expected, labels) : []
|
|
184
|
+
const coverage = (
|
|
185
|
+
<Tooltip content="Evaluation steps in this range with at least one sample" position="top">
|
|
186
|
+
<span className="font-mono">
|
|
187
|
+
{chart.covered}/{chart.steps} samples
|
|
188
|
+
</span>
|
|
189
|
+
</Tooltip>
|
|
190
|
+
)
|
|
191
|
+
return (
|
|
192
|
+
<ChartCard
|
|
193
|
+
title={chart.title}
|
|
194
|
+
meta={chart.state === 'ok' ? coverage : undefined}
|
|
195
|
+
footer={
|
|
196
|
+
<>
|
|
197
|
+
Source: <span className="font-mono">{chart.source}</span>
|
|
198
|
+
{chart.id === 'pvcUsed' && data.pvcIsolation ? ` · ${data.pvcIsolation.note}` : ''}
|
|
199
|
+
{chart.omitted ? ` · ${chart.omitted} more series not shown` : ''}
|
|
200
|
+
{chart.state === 'ok' && gaps.length > 0 ? ' · hatched: no sample' : ''}
|
|
201
|
+
</>
|
|
202
|
+
}
|
|
203
|
+
>
|
|
204
|
+
{chart.state === 'ok' ? (
|
|
205
|
+
<>
|
|
206
|
+
<MissingLines pods={missing} why={promMissingWhy} />
|
|
207
|
+
<AreaChart
|
|
208
|
+
series={series}
|
|
209
|
+
seriesLabels={labels}
|
|
210
|
+
color={COLOR}
|
|
211
|
+
fillColor={FILL}
|
|
212
|
+
unit={chartUnit(chart.unit)}
|
|
213
|
+
referenceLines={referenceLines(chart)}
|
|
214
|
+
domain={{ start, end }}
|
|
215
|
+
stepSeconds={step}
|
|
216
|
+
layout="dashboard"
|
|
217
|
+
shadedRanges={gaps}
|
|
218
|
+
selection={selection}
|
|
219
|
+
onSelectRange={onSelect}
|
|
220
|
+
/>
|
|
221
|
+
{series.length > 1 && (
|
|
222
|
+
<div className="mt-1.5">
|
|
223
|
+
<SeriesLegend series={series} color={COLOR} seriesLabels={labels} />
|
|
224
|
+
</div>
|
|
225
|
+
)}
|
|
226
|
+
</>
|
|
227
|
+
) : (
|
|
228
|
+
<div
|
|
229
|
+
className={`flex h-24 items-center justify-center rounded-lg border border-dashed border-theme-border px-4 text-center text-sm ${missing.length > 0 ? toneTextClass('degraded') : 'text-theme-text-tertiary'}`}
|
|
230
|
+
>
|
|
231
|
+
{missing.length > 0 ? `No line for ${missing.join(', ')}: ${promMissingWhy(missing.length === 1 ? 'it' : 'them')}.` : stateText(chart)}
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</ChartCard>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function IntervalChip({ interval, onOpen, onClear }: { interval: ChartTimeRange; onOpen?: (target: CNPGIntervalTarget) => void; onClear: () => void }) {
|
|
239
|
+
return (
|
|
240
|
+
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-3 py-1.5 text-sm">
|
|
241
|
+
<span className="font-medium text-theme-text-primary">
|
|
242
|
+
{clock(interval.start)} – {clock(interval.end)}
|
|
243
|
+
</span>
|
|
244
|
+
<span className="text-xs text-theme-text-tertiary">{span(interval.end - interval.start)} selected</span>
|
|
245
|
+
{onOpen && (
|
|
246
|
+
<>
|
|
247
|
+
<button type="button" onClick={() => onOpen('logs')} className="rounded-md px-2 py-0.5 text-xs font-medium text-accent-text hover:bg-theme-hover">
|
|
248
|
+
Logs in this interval
|
|
249
|
+
</button>
|
|
250
|
+
<button type="button" onClick={() => onOpen('activity')} className="rounded-md px-2 py-0.5 text-xs font-medium text-accent-text hover:bg-theme-hover">
|
|
251
|
+
Activity in this interval
|
|
252
|
+
</button>
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
<button type="button" aria-label="Clear the selected interval" onClick={onClear} className="ml-auto rounded p-0.5 text-theme-text-tertiary hover:bg-theme-hover hover:text-theme-text-primary">
|
|
256
|
+
<X className="h-3.5 w-3.5" />
|
|
257
|
+
</button>
|
|
258
|
+
</div>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The chart groups History filters by; topic tabs link to theirs. */
|
|
263
|
+
export type CNPGChartGroup = 'replication' | 'sessions' | 'throughput' | 'storage'
|
|
264
|
+
|
|
265
|
+
export const CNPG_CHART_GROUPS: { id: CNPGChartGroup | 'all'; label: string }[] = [
|
|
266
|
+
{ id: 'all', label: 'All' },
|
|
267
|
+
{ id: 'replication', label: 'Replication' },
|
|
268
|
+
{ id: 'sessions', label: 'Sessions' },
|
|
269
|
+
{ id: 'throughput', label: 'Throughput' },
|
|
270
|
+
{ id: 'storage', label: 'Storage & WAL' },
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
// Prometheus chart ids, as the server names them.
|
|
274
|
+
const HISTORY_CHART_GROUP: Record<string, CNPGChartGroup> = {
|
|
275
|
+
replicationLag: 'replication',
|
|
276
|
+
sessions: 'sessions',
|
|
277
|
+
waiting: 'sessions',
|
|
278
|
+
tps: 'throughput',
|
|
279
|
+
deadlocks: 'throughput',
|
|
280
|
+
tempBytes: 'throughput',
|
|
281
|
+
checkpoints: 'throughput',
|
|
282
|
+
walArchive: 'storage',
|
|
283
|
+
walSize: 'storage',
|
|
284
|
+
pvcUsed: 'storage',
|
|
285
|
+
databaseSize: 'storage',
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function inGroup(group: CNPGChartGroup | undefined, chart: CNPGChartGroup | undefined): boolean {
|
|
289
|
+
return !group || chart === group
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function BufferCharts({
|
|
293
|
+
samples,
|
|
294
|
+
selection,
|
|
295
|
+
onSelect,
|
|
296
|
+
instance,
|
|
297
|
+
picker,
|
|
298
|
+
group,
|
|
299
|
+
standbys,
|
|
300
|
+
instancePods,
|
|
301
|
+
}: {
|
|
302
|
+
samples: Sample[]
|
|
303
|
+
selection: ChartTimeRange | null
|
|
304
|
+
onSelect: (r: ChartTimeRange) => void
|
|
305
|
+
group?: CNPGChartGroup
|
|
306
|
+
/** The instance whose sessions-by-state chart is shown (the Performance picker's choice). */
|
|
307
|
+
instance?: string
|
|
308
|
+
picker?: ReactNode
|
|
309
|
+
standbys?: string[]
|
|
310
|
+
instancePods?: string[]
|
|
311
|
+
}) {
|
|
312
|
+
const recent = samples.slice(-SAMPLE_BUFFER_LIMIT)
|
|
313
|
+
const [dbPick, setDbPick] = useState<string[] | 'all' | null>(null)
|
|
314
|
+
const pods = useMemo(() => [...new Set(recent.flatMap((s) => Object.keys(s.replayLag)))].sort(), [recent])
|
|
315
|
+
const instances = useMemo(() => [...new Set(recent.flatMap((s) => Object.keys(s.instances ?? {})))].sort(), [recent])
|
|
316
|
+
const dbs = useMemo(() => chartedDatabases(recent, dbPick), [recent, dbPick])
|
|
317
|
+
if (recent.length < 2) {
|
|
318
|
+
return <div className="text-sm text-theme-text-tertiary">Collecting samples… These trends start when this page opens and cover up to the last hour it stays open.</div>
|
|
319
|
+
}
|
|
320
|
+
const domain = { start: recent[0].t / 1000, end: recent[recent.length - 1].t / 1000 }
|
|
321
|
+
const one = (label: string, pick: (s: Sample) => number | undefined): TimeSeries => ({
|
|
322
|
+
labels: { series: label },
|
|
323
|
+
dataPoints: recent.map((s) => ({ timestamp: s.t / 1000, value: pick(s) ?? null })),
|
|
324
|
+
})
|
|
325
|
+
const approximate = recent.some((s) => s.approximate)
|
|
326
|
+
const rateNote = approximate
|
|
327
|
+
? 'approximate: the exporter publishes no cnpg_last_update_timestamp, so rates use the time Radar fetched each reading'
|
|
328
|
+
: 'rates between the exporter\'s query runs (cnpg_last_update_timestamp); a change of primary or a counter reset is a gap'
|
|
329
|
+
const sessionsOf = instance ?? instances[0]
|
|
330
|
+
const dbCapped = recent.some((s) => Object.keys(s.dbSizes ?? {}).length >= 200)
|
|
331
|
+
const charts: {
|
|
332
|
+
group: CNPGChartGroup
|
|
333
|
+
title: string
|
|
334
|
+
unit: string
|
|
335
|
+
series: TimeSeries[]
|
|
336
|
+
labels: string[]
|
|
337
|
+
source: string
|
|
338
|
+
thresholds?: ReferenceLine[]
|
|
339
|
+
rate?: boolean
|
|
340
|
+
control?: ReactNode
|
|
341
|
+
note?: string
|
|
342
|
+
missing?: { pods: string[]; why: (them: string) => string }
|
|
343
|
+
}[] = [
|
|
344
|
+
{
|
|
345
|
+
group: 'replication',
|
|
346
|
+
title: 'Replay lag per standby',
|
|
347
|
+
unit: 'seconds',
|
|
348
|
+
series: pods.map((p) => one(p, (s) => s.replayLag[p])),
|
|
349
|
+
labels: pods,
|
|
350
|
+
source: "the primary's pg_stat_replication",
|
|
351
|
+
missing: { pods: unchartedInstances(standbys, pods), why: (them) => `the primary has not listed ${them} as a connected standby since this page opened` },
|
|
352
|
+
thresholds: [
|
|
353
|
+
{ value: 5, label: '5 s', kind: 'request' },
|
|
354
|
+
{ value: 30, label: '30 s', kind: 'limit' },
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
group: 'sessions',
|
|
359
|
+
title: 'Client sessions per instance',
|
|
360
|
+
unit: 'count',
|
|
361
|
+
series: instances.map((p) => one(p, (s) => s.instances?.[p]?.total)),
|
|
362
|
+
labels: instances,
|
|
363
|
+
source: 'cnpg_backends_total on each instance, platform sessions excluded',
|
|
364
|
+
missing: { pods: unchartedInstances(instancePods, instances), why: (them) => `no reading from ${them} since this page opened` },
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
group: 'sessions',
|
|
368
|
+
title: sessionsOf ? `Sessions by state (${sessionsOf})` : 'Sessions by state',
|
|
369
|
+
unit: 'count',
|
|
370
|
+
series: sessionsOf ? sessionStateSeries(recent, sessionsOf) : [],
|
|
371
|
+
labels: ['active', 'idle', 'idle in transaction', 'other'],
|
|
372
|
+
source: 'cnpg_backends_total by state on the instance picked above',
|
|
373
|
+
control: picker,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
group: 'sessions',
|
|
377
|
+
title: 'Sessions waiting on locks',
|
|
378
|
+
unit: 'count',
|
|
379
|
+
series: instances.map((p) => one(p, (s) => s.instances?.[p]?.waiting)),
|
|
380
|
+
labels: instances,
|
|
381
|
+
source: 'cnpg_backends_waiting_total on each instance',
|
|
382
|
+
},
|
|
383
|
+
{ group: 'throughput', title: 'Transactions per second (primary)', unit: '', series: [rateSeries(recent, 'commits'), rateSeries(recent, 'rollbacks')], labels: ['commits', 'rollbacks'], source: 'xact_commit / xact_rollback', rate: true },
|
|
384
|
+
{ group: 'throughput', title: 'Cache hit ratio (primary)', unit: '%', series: [cacheHitSeries(recent)], labels: ['hit ratio'], source: 'blks_hit / (blks_hit + blks_read)', rate: true },
|
|
385
|
+
{
|
|
386
|
+
group: 'storage',
|
|
387
|
+
title: 'WAL archived / failed per minute',
|
|
388
|
+
unit: '',
|
|
389
|
+
series: [rateSeries(recent, 'archived', 60), rateSeries(recent, 'failed', 60)],
|
|
390
|
+
labels: ['archived', 'failed'],
|
|
391
|
+
source: 'pg_stat_archiver archived_count / failed_count on the primary',
|
|
392
|
+
rate: true,
|
|
393
|
+
},
|
|
394
|
+
{ group: 'storage', title: 'WAL on disk (primary)', unit: 'bytes', series: [one('WAL', (s) => s.walBytes)], labels: ['WAL'], source: 'cnpg_collector_pg_wal{value="size"} on the primary' },
|
|
395
|
+
{
|
|
396
|
+
group: 'storage',
|
|
397
|
+
title: 'Database size (primary)',
|
|
398
|
+
unit: 'bytes',
|
|
399
|
+
series: dbs.shown.map((d) => one(d, (s) => s.dbSizes?.[d])),
|
|
400
|
+
labels: dbs.shown,
|
|
401
|
+
source: `pg_database size_bytes on the primary, ${dbPick === 'all' ? 'every database' : dbPick ? 'the databases picked' : 'the five largest'}`,
|
|
402
|
+
control: dbs.all.length > 5 ? <DatabasePicker all={dbs.all} shown={dbs.shown} pick={dbPick} onPick={setDbPick} /> : undefined,
|
|
403
|
+
note: dbCapped ? 'The exporter reports the 200 largest databases; smaller ones are not sampled.' : undefined,
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
group: 'throughput',
|
|
407
|
+
title: 'Checkpoints per minute (primary)',
|
|
408
|
+
unit: '',
|
|
409
|
+
series: [rateSeries(recent, 'checkpointsTimed', 60, 'timed'), rateSeries(recent, 'checkpointsRequested', 60, 'requested')],
|
|
410
|
+
labels: ['timed', 'requested'],
|
|
411
|
+
source: 'pg_stat_checkpointer (17+) or pg_stat_bgwriter checkpoints_timed / _req',
|
|
412
|
+
rate: true,
|
|
413
|
+
},
|
|
414
|
+
{ group: 'throughput', title: 'Deadlocks per minute (primary)', unit: '', series: [rateSeries(recent, 'deadlocks', 60)], labels: ['deadlocks'], source: 'pg_stat_database deadlocks, all databases', rate: true },
|
|
415
|
+
{ group: 'throughput', title: 'Temporary file writes (primary)', unit: 'bytes', series: [rateSeries(recent, 'tempBytes', 1, 'bytes/s')], labels: ['bytes/s'], source: 'pg_stat_database temp_bytes per second, all databases', rate: true },
|
|
416
|
+
]
|
|
417
|
+
return (
|
|
418
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
419
|
+
{charts.filter((c) => inGroup(group, c.group)).map((c) => {
|
|
420
|
+
const gaps = sampleGaps(c.series)
|
|
421
|
+
const hasValue = c.series.some((s) => s.dataPoints.some((p) => p.value != null))
|
|
422
|
+
return (
|
|
423
|
+
<ChartCard
|
|
424
|
+
key={c.title}
|
|
425
|
+
title={c.title}
|
|
426
|
+
meta={c.rate && approximate ? 'approximate' : undefined}
|
|
427
|
+
footer={
|
|
428
|
+
<>
|
|
429
|
+
Source: {c.source}, sampled by this page · {c.rate ? rateNote : 'gaps break the line, never zero'}
|
|
430
|
+
{c.note ? ` · ${c.note}` : ''}
|
|
431
|
+
</>
|
|
432
|
+
}
|
|
433
|
+
>
|
|
434
|
+
{c.control && <div className="mb-2">{c.control}</div>}
|
|
435
|
+
{c.missing && <MissingLines pods={c.missing.pods} why={c.missing.why} />}
|
|
436
|
+
{hasValue ? (
|
|
437
|
+
<>
|
|
438
|
+
<AreaChart
|
|
439
|
+
series={c.series}
|
|
440
|
+
seriesLabels={c.labels}
|
|
441
|
+
color={COLOR}
|
|
442
|
+
fillColor={FILL}
|
|
443
|
+
unit={c.unit}
|
|
444
|
+
referenceLines={c.thresholds}
|
|
445
|
+
domain={domain}
|
|
446
|
+
layout="dashboard"
|
|
447
|
+
shadedRanges={gaps}
|
|
448
|
+
selection={selection}
|
|
449
|
+
onSelectRange={onSelect}
|
|
450
|
+
/>
|
|
451
|
+
{c.series.length > 1 && (
|
|
452
|
+
<div className="mt-1.5">
|
|
453
|
+
<SeriesLegend series={c.series} color={COLOR} seriesLabels={c.labels} />
|
|
454
|
+
</div>
|
|
455
|
+
)}
|
|
456
|
+
</>
|
|
457
|
+
) : (
|
|
458
|
+
<div className="flex h-24 items-center justify-center rounded-lg border border-dashed border-theme-border px-4 text-center text-sm text-theme-text-tertiary">
|
|
459
|
+
{c.title.startsWith('Replay')
|
|
460
|
+
? 'No standby reported by the primary since this page opened.'
|
|
461
|
+
: c.rate
|
|
462
|
+
? 'Needs two exporter readings from the same primary since this page opened.'
|
|
463
|
+
: 'No sample since this page opened.'}
|
|
464
|
+
</div>
|
|
465
|
+
)}
|
|
466
|
+
</ChartCard>
|
|
467
|
+
)
|
|
468
|
+
})}
|
|
469
|
+
</div>
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function DatabasePicker({ all, shown, pick, onPick }: { all: string[]; shown: string[]; pick: string[] | 'all' | null; onPick: (p: string[] | 'all' | null) => void }) {
|
|
474
|
+
const toggle = (d: string) => {
|
|
475
|
+
const next = shown.includes(d) ? shown.filter((x) => x !== d) : [...shown, d]
|
|
476
|
+
onPick(next.length === 0 ? null : next)
|
|
477
|
+
}
|
|
478
|
+
const chip = (on: boolean) =>
|
|
479
|
+
`rounded-md border px-1.5 py-0.5 font-mono text-[11px] ${on ? 'border-accent bg-accent-muted text-theme-text-primary' : 'border-theme-border text-theme-text-secondary hover:bg-theme-hover'}`
|
|
480
|
+
return (
|
|
481
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
482
|
+
<button type="button" className={chip(pick === null)} onClick={() => onPick(null)}>
|
|
483
|
+
five largest
|
|
484
|
+
</button>
|
|
485
|
+
<button type="button" className={chip(pick === 'all')} onClick={() => onPick('all')}>
|
|
486
|
+
all {all.length}
|
|
487
|
+
</button>
|
|
488
|
+
{all.map((d) => (
|
|
489
|
+
<button key={d} type="button" aria-pressed={pick !== null && pick !== 'all' && shown.includes(d)} className={chip(pick !== null && pick !== 'all' && shown.includes(d))} onClick={() => toggle(d)}>
|
|
490
|
+
{d}
|
|
491
|
+
</button>
|
|
492
|
+
))}
|
|
493
|
+
</div>
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Performance › History: Prometheus range queries when Radar has
|
|
499
|
+
* Prometheus, otherwise the samples this page took since it opened. Selecting
|
|
500
|
+
* a time range on any chart carries both bounds to Logs and Activity.
|
|
501
|
+
*/
|
|
502
|
+
export function CNPGTrends({
|
|
503
|
+
namespace,
|
|
504
|
+
name,
|
|
505
|
+
samples,
|
|
506
|
+
onOpenInterval,
|
|
507
|
+
instance,
|
|
508
|
+
picker,
|
|
509
|
+
samplingDenied,
|
|
510
|
+
group,
|
|
511
|
+
standbys,
|
|
512
|
+
instancePods,
|
|
513
|
+
}: {
|
|
514
|
+
namespace: string
|
|
515
|
+
name: string
|
|
516
|
+
samples: Sample[]
|
|
517
|
+
onOpenInterval?: (target: CNPGIntervalTarget, since: string, until: string) => void
|
|
518
|
+
/** The Performance instance picker's choice, for the per-instance sampled charts. */
|
|
519
|
+
instance?: string
|
|
520
|
+
picker?: ReactNode
|
|
521
|
+
/** The grant the caller lacks for in-page samples (get pods/proxy); no sample can ever arrive. */
|
|
522
|
+
samplingDenied?: string
|
|
523
|
+
/** Show one chart group (from `?charts=`); all when unset. */
|
|
524
|
+
group?: CNPGChartGroup
|
|
525
|
+
/** The instance Pods that are standbys now, and all instance Pods: a per-instance chart names any of them it has no line for. */
|
|
526
|
+
standbys?: string[]
|
|
527
|
+
instancePods?: string[]
|
|
528
|
+
}) {
|
|
529
|
+
const { range, setRange, interval, setSelected } = useTrendParams()
|
|
530
|
+
const q = useCNPGClusterHistory(namespace, name, range)
|
|
531
|
+
const data = q.data
|
|
532
|
+
const fromPrometheus = data?.source === 'prometheus' && data.state === 'ok'
|
|
533
|
+
// Interval selection needs a chart on the page: Prometheus history, or at least two in-page samples.
|
|
534
|
+
const charted = fromPrometheus || (!q.isLoading && !samplingDenied && samples.length >= 2)
|
|
535
|
+
const fallback = samplingDenied ? `In-page samples need ${samplingDenied} too.` : 'Below: samples since this page opened.'
|
|
536
|
+
|
|
537
|
+
const open = onOpenInterval && interval
|
|
538
|
+
? (target: CNPGIntervalTarget) => onOpenInterval(target, new Date(interval.start * 1000).toISOString(), new Date(interval.end * 1000).toISOString())
|
|
539
|
+
: undefined
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<div className="space-y-3">
|
|
543
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
544
|
+
{data?.source === 'prometheus' && (
|
|
545
|
+
<Segments
|
|
546
|
+
label="Trend range"
|
|
547
|
+
value={range}
|
|
548
|
+
onChange={(r) => setRange(r)}
|
|
549
|
+
options={CNPG_HISTORY_RANGES}
|
|
550
|
+
/>
|
|
551
|
+
)}
|
|
552
|
+
<span className="text-xs text-theme-text-tertiary">
|
|
553
|
+
{fromPrometheus
|
|
554
|
+
? `From Prometheus · one point every ${span(data.stepSeconds ?? 60)}${data.isolation ? ` · ${data.isolation.note}` : ''}`
|
|
555
|
+
: q.isLoading
|
|
556
|
+
? 'Checking for Prometheus…'
|
|
557
|
+
: samplingDenied
|
|
558
|
+
? 'No trend source readable'
|
|
559
|
+
: 'Sampled every 5 s since this page opened · gaps are hatched, never zero'}
|
|
560
|
+
</span>
|
|
561
|
+
{charted && <span className="text-xs text-theme-text-tertiary">Drag across a chart, or click a point, to select an interval.</span>}
|
|
562
|
+
</div>
|
|
563
|
+
|
|
564
|
+
{fromPrometheus && data.reason && <Notice>{data.reason}</Notice>}
|
|
565
|
+
{data?.source === 'none' && (
|
|
566
|
+
<Notice>
|
|
567
|
+
History needs Prometheus. {data.reason ?? 'Radar is not connected to one'}.{' '}
|
|
568
|
+
{samplingDenied ? fallback : 'These trends cover only the time since this page opened.'}
|
|
569
|
+
</Notice>
|
|
570
|
+
)}
|
|
571
|
+
{data?.source === 'prometheus' && data.state !== 'ok' && (
|
|
572
|
+
<Notice>
|
|
573
|
+
Prometheus history is not shown: {data.reason}. {fallback}
|
|
574
|
+
</Notice>
|
|
575
|
+
)}
|
|
576
|
+
{q.error && !data && (
|
|
577
|
+
<Notice>
|
|
578
|
+
History could not be loaded: {q.error instanceof Error ? q.error.message : 'unknown error'}. {fallback}
|
|
579
|
+
</Notice>
|
|
580
|
+
)}
|
|
581
|
+
|
|
582
|
+
<ChartGroupFilter group={group} />
|
|
583
|
+
{interval && <IntervalChip interval={interval} onOpen={open} onClear={() => setSelected(null)} />}
|
|
584
|
+
|
|
585
|
+
{fromPrometheus ? (
|
|
586
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
587
|
+
{data.charts.filter((c) => inGroup(group, HISTORY_CHART_GROUP[c.id])).map((c) => (
|
|
588
|
+
<HistoryChartCard key={c.id} chart={c} data={data} selection={interval} onSelect={setSelected} expected={expectedLines(c, standbys, instancePods)} />
|
|
589
|
+
))}
|
|
590
|
+
</div>
|
|
591
|
+
) : (
|
|
592
|
+
!q.isLoading &&
|
|
593
|
+
!samplingDenied && (
|
|
594
|
+
<BufferCharts
|
|
595
|
+
samples={samples}
|
|
596
|
+
selection={interval}
|
|
597
|
+
onSelect={setSelected}
|
|
598
|
+
instance={instance}
|
|
599
|
+
picker={picker}
|
|
600
|
+
group={group}
|
|
601
|
+
standbys={standbys}
|
|
602
|
+
instancePods={instancePods}
|
|
603
|
+
/>
|
|
604
|
+
)
|
|
605
|
+
)}
|
|
606
|
+
</div>
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function ChartGroupFilter({ group }: { group?: CNPGChartGroup }) {
|
|
611
|
+
const [, setParams] = useSearchParams()
|
|
612
|
+
const location = useLocation()
|
|
613
|
+
return (
|
|
614
|
+
<Segments
|
|
615
|
+
label="Chart group"
|
|
616
|
+
value={group ?? 'all'}
|
|
617
|
+
onChange={(g) =>
|
|
618
|
+
setParams(
|
|
619
|
+
(prev) => {
|
|
620
|
+
const next = new URLSearchParams(prev)
|
|
621
|
+
if (g === 'all') next.delete('charts')
|
|
622
|
+
else next.set('charts', g)
|
|
623
|
+
return next
|
|
624
|
+
},
|
|
625
|
+
{ replace: true, state: location.state },
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
options={CNPG_CHART_GROUPS}
|
|
629
|
+
/>
|
|
630
|
+
)
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The trend range and selected interval live in the URL (replaced, keeping
|
|
635
|
+
* the navigation state), so Back from Logs or Activity lands on the same view.
|
|
636
|
+
*/
|
|
637
|
+
function useTrendParams() {
|
|
638
|
+
const [params, setParams] = useSearchParams()
|
|
639
|
+
const location = useLocation()
|
|
640
|
+
const range = CNPG_HISTORY_RANGES.find((r) => r.id === params.get('trendRange'))?.id ?? '1h'
|
|
641
|
+
const [a, b] = (params.get('trendSel') ?? '').split('-').map(Number)
|
|
642
|
+
const interval: ChartTimeRange | null = Number.isFinite(a) && Number.isFinite(b) && b > a ? { start: a, end: b } : null
|
|
643
|
+
const update = (mutate: (next: URLSearchParams) => void) =>
|
|
644
|
+
setParams(
|
|
645
|
+
(prev) => {
|
|
646
|
+
const next = new URLSearchParams(prev)
|
|
647
|
+
mutate(next)
|
|
648
|
+
return next
|
|
649
|
+
},
|
|
650
|
+
{ replace: true, state: location.state },
|
|
651
|
+
)
|
|
652
|
+
return {
|
|
653
|
+
range,
|
|
654
|
+
interval,
|
|
655
|
+
setRange: (r: CNPGHistoryRange) =>
|
|
656
|
+
update((next) => {
|
|
657
|
+
if (r === '1h') next.delete('trendRange')
|
|
658
|
+
else next.set('trendRange', r)
|
|
659
|
+
next.delete('trendSel')
|
|
660
|
+
}),
|
|
661
|
+
setSelected: (sel: ChartTimeRange | null) =>
|
|
662
|
+
update((next) => {
|
|
663
|
+
if (sel) next.set('trendSel', `${Math.floor(sel.start)}-${Math.ceil(sel.end)}`)
|
|
664
|
+
else next.delete('trendSel')
|
|
665
|
+
}),
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** The interval a trend selection carried into Logs or Activity, from `?since=&until=`. */
|
|
670
|
+
export function useCNPGIntervalParams(): { since: string; until: string; clear: () => void } | null {
|
|
671
|
+
const [params, setParams] = useSearchParams()
|
|
672
|
+
const location = useLocation()
|
|
673
|
+
const since = params.get('since')
|
|
674
|
+
const until = params.get('until')
|
|
675
|
+
if (!since || !until || !(Date.parse(until) > Date.parse(since))) return null
|
|
676
|
+
return {
|
|
677
|
+
since,
|
|
678
|
+
until,
|
|
679
|
+
clear: () => {
|
|
680
|
+
const next = new URLSearchParams(params)
|
|
681
|
+
next.delete('since')
|
|
682
|
+
next.delete('until')
|
|
683
|
+
setParams(next, { replace: true, state: location.state })
|
|
684
|
+
},
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export function CNPGIntervalBanner({ since, until, note, onClear }: { since: string; until: string; note: ReactNode; onClear: () => void }) {
|
|
689
|
+
const a = Date.parse(since) / 1000
|
|
690
|
+
const b = Date.parse(until) / 1000
|
|
691
|
+
return (
|
|
692
|
+
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-3 py-1.5 text-sm">
|
|
693
|
+
<span className="font-medium text-theme-text-primary">
|
|
694
|
+
{new Date(a * 1000).toLocaleDateString()} {clock(a)} – {clock(b)}
|
|
695
|
+
</span>
|
|
696
|
+
<span className="text-xs text-theme-text-secondary">{note}</span>
|
|
697
|
+
<button type="button" onClick={onClear} className="ml-auto rounded-md px-2 py-0.5 text-xs font-medium text-accent-text hover:bg-theme-hover">
|
|
698
|
+
Show all
|
|
699
|
+
</button>
|
|
700
|
+
</div>
|
|
701
|
+
)
|
|
702
|
+
}
|