@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,98 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
import { getApiBase } from '../../../api/config'
|
|
3
|
+
import { supersedeFor, type CNPGTrackedOperation } from './model'
|
|
4
|
+
|
|
5
|
+
// Tracked operations for this browser session: they survive tab changes and
|
|
6
|
+
// reloads of the page, and end with the session. Storage can be unavailable
|
|
7
|
+
// (private windows, blocked site data); the tracker then lives in memory. One
|
|
8
|
+
// key holds the records of every cluster a Radar Hub tab shows, so each record
|
|
9
|
+
// carries the API base it was issued against.
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = 'radar.cnpg.operations.v1'
|
|
12
|
+
const MAX_OPS = 50
|
|
13
|
+
// Finished operations stay visible this long, then drop out on their own.
|
|
14
|
+
const KEEP_FINISHED_MS = 30 * 60_000
|
|
15
|
+
|
|
16
|
+
let ops: CNPGTrackedOperation[] = load()
|
|
17
|
+
const listeners = new Set<() => void>()
|
|
18
|
+
|
|
19
|
+
function load(): CNPGTrackedOperation[] {
|
|
20
|
+
try {
|
|
21
|
+
const raw = sessionStorage.getItem(STORAGE_KEY)
|
|
22
|
+
const parsed = raw ? JSON.parse(raw) : []
|
|
23
|
+
return Array.isArray(parsed) ? parsed : []
|
|
24
|
+
} catch {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function save() {
|
|
30
|
+
try {
|
|
31
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(ops))
|
|
32
|
+
} catch {
|
|
33
|
+
// Storage unavailable: keep the in-memory copy.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function emit(next: CNPGTrackedOperation[]) {
|
|
38
|
+
const now = Date.now()
|
|
39
|
+
ops = next.filter((o) => !o.finishedAt || now - o.finishedAt < KEEP_FINISHED_MS).slice(-MAX_OPS)
|
|
40
|
+
save()
|
|
41
|
+
for (const l of listeners) l()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function subscribe(l: () => void) {
|
|
45
|
+
listeners.add(l)
|
|
46
|
+
return () => listeners.delete(l)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface TrackCNPGOperationInput {
|
|
50
|
+
kind: string
|
|
51
|
+
label: string
|
|
52
|
+
context: string
|
|
53
|
+
namespace: string
|
|
54
|
+
cluster: string
|
|
55
|
+
clusterUID?: string
|
|
56
|
+
target?: { name: string; uid?: string }
|
|
57
|
+
baseline?: Record<string, unknown>
|
|
58
|
+
link?: CNPGTrackedOperation['link']
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Start following an operation the server accepted. Any unfinished operation
|
|
63
|
+
* the new one makes moot is marked superseded.
|
|
64
|
+
*/
|
|
65
|
+
export function trackCNPGOperation(input: TrackCNPGOperationInput): string {
|
|
66
|
+
const now = Date.now()
|
|
67
|
+
const op: CNPGTrackedOperation = {
|
|
68
|
+
id: `${now.toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
69
|
+
kind: input.kind,
|
|
70
|
+
label: input.label,
|
|
71
|
+
apiBase: getApiBase(),
|
|
72
|
+
context: input.context,
|
|
73
|
+
namespace: input.namespace,
|
|
74
|
+
cluster: input.cluster,
|
|
75
|
+
clusterUID: input.clusterUID,
|
|
76
|
+
target: input.target,
|
|
77
|
+
startedAt: now,
|
|
78
|
+
baseline: input.baseline ?? {},
|
|
79
|
+
state: 'requested',
|
|
80
|
+
lastProgressAt: now,
|
|
81
|
+
link: input.link,
|
|
82
|
+
}
|
|
83
|
+
emit([...supersedeFor(ops, op), op])
|
|
84
|
+
return op.id
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function updateCNPGOperations(update: (all: CNPGTrackedOperation[]) => CNPGTrackedOperation[]) {
|
|
88
|
+
const next = update(ops)
|
|
89
|
+
if (next !== ops) emit(next)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function dismissCNPGOperation(id: string) {
|
|
93
|
+
emit(ops.filter((o) => o.id !== id))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function useCNPGOperations(): CNPGTrackedOperation[] {
|
|
97
|
+
return useSyncExternalStore(subscribe, () => ops, () => ops)
|
|
98
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { CNPGOperatorResponse, CNPGOperatorVerdict } from '../../api/cnpg'
|
|
3
|
+
import { cnpgOperatorActionNote, cnpgOperatorBannerModel, cnpgOperatorConcerns, cnpgOperatorState, cnpgRestartHistory } from './operatorStatus'
|
|
4
|
+
|
|
5
|
+
const down: CNPGOperatorVerdict = {
|
|
6
|
+
state: 'notReconciling',
|
|
7
|
+
reasons: ['no operator instance has renewed the leader lease cnpg-system/db9c8771.cnpg.io'],
|
|
8
|
+
webhookRejects: true,
|
|
9
|
+
webhookReason: 'the admission webhook Service cnpg-system/cnpg-webhook-service has no ready endpoint and fails closed, so the API server rejects writes to CloudNativePG objects',
|
|
10
|
+
operator: 'cnpg-system/cnpg-controller-manager',
|
|
11
|
+
}
|
|
12
|
+
const up: CNPGOperatorVerdict = { state: 'reconciling', webhookRejects: false }
|
|
13
|
+
const unknown: CNPGOperatorVerdict = { state: 'unknown', unknown: 'the leader lease is not readable (needs get leases in cnpg-system)', webhookRejects: null }
|
|
14
|
+
|
|
15
|
+
describe('cnpgOperatorBannerModel', () => {
|
|
16
|
+
it('names the namespaces whose operator is down and why', () => {
|
|
17
|
+
const m = cnpgOperatorBannerModel({ pg: down, other: up }, ['pg', 'other', 'pg'])
|
|
18
|
+
expect(m?.stale?.namespaces).toEqual(['pg'])
|
|
19
|
+
expect(m?.stale?.reasons[0]).toMatch(/^No operator instance has renewed the leader lease/)
|
|
20
|
+
expect(m?.rejects).toMatch(/no ready endpoint/)
|
|
21
|
+
})
|
|
22
|
+
it('stays quiet when reconciling or unknown', () => {
|
|
23
|
+
expect(cnpgOperatorBannerModel({ pg: up }, ['pg'])).toBeNull()
|
|
24
|
+
expect(cnpgOperatorBannerModel({ pg: unknown }, ['pg'])).toBeNull()
|
|
25
|
+
expect(cnpgOperatorBannerModel(undefined, ['pg'])).toBeNull()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('cnpgOperatorActionNote', () => {
|
|
30
|
+
it('warns when the operator is observed down and notes when unknown', () => {
|
|
31
|
+
expect(cnpgOperatorActionNote(down)?.tone).toBe('warning')
|
|
32
|
+
expect(cnpgOperatorActionNote(unknown)).toEqual({ tone: 'info', text: expect.stringContaining('needs get leases') })
|
|
33
|
+
expect(cnpgOperatorActionNote(up)).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('cnpgOperatorActionNote when unknown', () => {
|
|
38
|
+
it('says briefly what could not be confirmed', () => {
|
|
39
|
+
expect(cnpgOperatorActionNote({ state: 'unknown', unknown: "couldn't read its leader lease" } as never)).toEqual({
|
|
40
|
+
tone: 'info',
|
|
41
|
+
text: "Radar couldn't confirm the operator is running (couldn't read its leader lease).",
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('operator current state', () => {
|
|
47
|
+
const now = Date.parse('2026-10-04T10:00:00Z')
|
|
48
|
+
const op = (pods: NonNullable<CNPGOperatorResponse['components'][number]['pods']>): CNPGOperatorResponse => ({
|
|
49
|
+
coverage: { deployments: { state: 'full' }, services: { state: 'full' } },
|
|
50
|
+
components: [{ role: 'operator', namespace: 'cnpg-system', deployment: 'cnpg-controller-manager', readyReplicas: 1, replicas: 1, pods }],
|
|
51
|
+
config: [],
|
|
52
|
+
})
|
|
53
|
+
it('words restart history with when the last one ended, and only a recent one as trouble', () => {
|
|
54
|
+
const old = op([{ name: 'm-1', ready: true, restarts: 33, lastTermination: { container: 'manager', reason: 'Error', exitCode: 1, finishedAt: '2026-10-04T07:53:31Z' } }])
|
|
55
|
+
expect(cnpgRestartHistory(old.components[0], now)).toMatchObject({ recent: false })
|
|
56
|
+
expect(cnpgRestartHistory(old.components[0], now)!.text).toMatch(/^33 restarts since the Pod was created · last ended .* ago \(Error, exit 1\)$/)
|
|
57
|
+
expect(cnpgOperatorConcerns(old, now)).toEqual([expect.objectContaining({ tone: 'neutral' })])
|
|
58
|
+
const fresh = op([{ name: 'm-1', ready: true, restarts: 2, lastTermination: { container: 'manager', reason: 'OOMKilled', exitCode: 137, finishedAt: '2026-10-04T09:40:00Z' } }])
|
|
59
|
+
expect(cnpgOperatorConcerns(fresh, now)[0]).toMatchObject({ tone: 'degraded' })
|
|
60
|
+
// Ready at this read is not "ready" for a component that just restarted.
|
|
61
|
+
expect(cnpgOperatorState(old, now).confirmed).toContain('every component is ready')
|
|
62
|
+
expect(cnpgOperatorState(fresh, now).confirmed).toContain('every component reports ready right now (see the restarts above)')
|
|
63
|
+
})
|
|
64
|
+
it('says nothing about restarts that never happened, and leads with readiness', () => {
|
|
65
|
+
expect(cnpgRestartHistory({ pods: [{ name: 'm-1', ready: true, restarts: 0 }] }, now)).toBeNull()
|
|
66
|
+
const notReady = op([{ name: 'm-1', ready: false, restarts: 0 }])
|
|
67
|
+
notReady.components[0].readyReplicas = 0
|
|
68
|
+
expect(cnpgOperatorConcerns(notReady, now)).toEqual([{ tone: 'unhealthy', text: 'The operator has 0 of 1 replicas ready.' }])
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('operator current state claims only what it read', () => {
|
|
73
|
+
const diag = (webhooks: any[]) => ({
|
|
74
|
+
namespace: 'cnpg-system',
|
|
75
|
+
deployment: 'cnpg-controller-manager',
|
|
76
|
+
pods: [],
|
|
77
|
+
podCoverage: { state: 'ok' },
|
|
78
|
+
leader: { state: 'ok', holderIsCurrentPod: true, stale: false },
|
|
79
|
+
watch: { all: true, namespaces: [], source: '' },
|
|
80
|
+
webhooks,
|
|
81
|
+
webhookServices: [{ state: 'ok', namespace: 'cnpg-system', name: 'cnpg-webhook-service', readyEndpoints: 1, notReadyEndpoints: 0 }],
|
|
82
|
+
metricsPort: 8080,
|
|
83
|
+
reconcile: [],
|
|
84
|
+
events: { state: 'ok', items: [] },
|
|
85
|
+
})
|
|
86
|
+
const op = (webhooks: any[]): CNPGOperatorResponse =>
|
|
87
|
+
({
|
|
88
|
+
coverage: { deployments: { state: 'full' }, services: { state: 'full' } },
|
|
89
|
+
components: [{ role: 'operator', namespace: 'cnpg-system', deployment: 'cnpg-controller-manager', readyReplicas: 1, replicas: 1, pods: [] }],
|
|
90
|
+
config: [],
|
|
91
|
+
diagnosis: [diag(webhooks)],
|
|
92
|
+
}) as unknown as CNPGOperatorResponse
|
|
93
|
+
const mutating = { state: 'ok', kind: 'MutatingWebhookConfiguration', name: 'cnpg-mutating', webhooks: [{ name: 'm', failurePolicy: 'Fail', caBundleSet: true, service: 'cnpg-system/cnpg-webhook-service', url: false }] }
|
|
94
|
+
it('confirms webhooks only when every configuration was read', () => {
|
|
95
|
+
expect(cnpgOperatorState(op([mutating])).confirmed).toContain('its webhooks have ready endpoints')
|
|
96
|
+
const partial = cnpgOperatorState(op([mutating, { state: 'denied', kind: 'ValidatingWebhookConfiguration', name: 'cnpg-validating', webhooks: [] }]))
|
|
97
|
+
expect(partial.confirmed).not.toContain('its webhooks have ready endpoints')
|
|
98
|
+
expect(partial.unread.join(' ')).toContain('cnpg-validating')
|
|
99
|
+
})
|
|
100
|
+
it('treats an operator scaled to zero as not reconciling', () => {
|
|
101
|
+
const scaled = op([mutating])
|
|
102
|
+
scaled.components[0].readyReplicas = 0
|
|
103
|
+
scaled.components[0].replicas = 0
|
|
104
|
+
expect(cnpgOperatorState(scaled).concerns[0]).toEqual({ tone: 'unhealthy', text: 'The operator is scaled to 0: nothing reconciles.' })
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('clusters blocked on a plugin', () => {
|
|
109
|
+
it('joins the cluster’s plugin phase to that plugin’s Deployment, side by side', () => {
|
|
110
|
+
const op = {
|
|
111
|
+
coverage: { deployments: { state: 'full' }, services: { state: 'full' } },
|
|
112
|
+
components: [{ role: 'plugin', pluginName: 'barman-cloud.cloudnative-pg.io', namespace: 'cnpg-system', deployment: 'barman-cloud', readyReplicas: 1, replicas: 1, pods: [{ name: 'b-1', ready: true, restarts: 0 }] }],
|
|
113
|
+
config: [],
|
|
114
|
+
} as unknown as CNPGOperatorResponse
|
|
115
|
+
const row = {
|
|
116
|
+
namespace: 'pgrt',
|
|
117
|
+
name: 'pg-runtime',
|
|
118
|
+
cluster: { status: { phase: 'Cluster cannot proceed to reconciliation due to an unknown plugin being required' }, spec: { plugins: [{ name: 'barman-cloud.cloudnative-pg.io' }, { name: 'missing.example.io' }] } },
|
|
119
|
+
} as any
|
|
120
|
+
const out = cnpgOperatorState(op, Date.now(), [row]).concerns
|
|
121
|
+
expect(out[0]).toEqual({
|
|
122
|
+
tone: 'unhealthy',
|
|
123
|
+
text: 'Cluster pgrt/pg-runtime requires a plugin the operator does not know — barman-cloud.cloudnative-pg.io: 1/1 ready; missing.example.io: no Deployment serving it was found.',
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('qualifies unseen operator components without contradicting the ready components read', () => {
|
|
129
|
+
const data = { coverage: { deployments: { state: 'partial' }, services: { state: 'full' } }, components: [{ role: 'operator', namespace: 'system', deployment: 'cnpg', replicas: 1, readyReplicas: 1 }], config: [] } as unknown as CNPGOperatorResponse
|
|
130
|
+
const state = cnpgOperatorState(data)
|
|
131
|
+
expect(state.unread.join(' ')).toContain('Other operator or plugin components may be outside this read: Deployments not read in some namespaces')
|
|
132
|
+
expect(state.unread.join(' ')).not.toContain('Operator health cannot be assessed')
|
|
133
|
+
expect(state.confirmed.join(' ')).toContain('every component is ready')
|
|
134
|
+
})
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { coverageLabel } from './shared'
|
|
2
|
+
import type { CNPGOperatorComponent, CNPGOperatorResponse, CNPGOperatorVerdict } from '../../api/cnpg'
|
|
3
|
+
import { cnpgClusterPlugins, cnpgPluginPhase, formatAge, type CNPGFleetRow } from '@skyhook-io/k8s-ui'
|
|
4
|
+
|
|
5
|
+
export interface CNPGOperatorBannerModel {
|
|
6
|
+
/** The operator watching these namespaces is not leading, so their CNPG status may be stale. */
|
|
7
|
+
stale: { reasons: string[]; namespaces: string[] } | null
|
|
8
|
+
/** Why the admission webhook rejects CNPG writes, when it does. */
|
|
9
|
+
rejects: string | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sentence(s: string): string {
|
|
13
|
+
const t = s.trim()
|
|
14
|
+
if (!t) return t
|
|
15
|
+
return t[0].toUpperCase() + t.slice(1) + (/[.!?]$/.test(t) ? '' : '.')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What the fleet and cluster pages say about the operator for the namespaces
|
|
20
|
+
* they show. Only an observed "not leading" or "webhook rejects" raises
|
|
21
|
+
* anything: an unknown or unwatched state stays quiet here and the Operator
|
|
22
|
+
* screen explains it.
|
|
23
|
+
*/
|
|
24
|
+
export function cnpgOperatorBannerModel(verdicts: Record<string, CNPGOperatorVerdict> | undefined, namespaces: string[]): CNPGOperatorBannerModel | null {
|
|
25
|
+
if (!verdicts) return null
|
|
26
|
+
const staleNs: string[] = []
|
|
27
|
+
const reasons = new Set<string>()
|
|
28
|
+
let rejects: string | null = null
|
|
29
|
+
for (const ns of [...new Set(namespaces)].sort()) {
|
|
30
|
+
const v = verdicts[ns]
|
|
31
|
+
if (!v) continue
|
|
32
|
+
if (v.state === 'notReconciling') {
|
|
33
|
+
staleNs.push(ns)
|
|
34
|
+
for (const r of v.reasons ?? []) reasons.add(sentence(r))
|
|
35
|
+
}
|
|
36
|
+
if (v.webhookRejects === true && v.webhookReason) rejects = sentence(v.webhookReason)
|
|
37
|
+
}
|
|
38
|
+
if (staleNs.length === 0 && !rejects) return null
|
|
39
|
+
return { stale: staleNs.length > 0 ? { reasons: [...reasons], namespaces: staleNs } : null, rejects }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A dialog's note about the operator: a warning when it is observed down, a note when unknown. */
|
|
43
|
+
export function cnpgOperatorActionNote(v: CNPGOperatorVerdict | undefined): { tone: 'warning' | 'info'; text: string } | null {
|
|
44
|
+
if (!v) return null
|
|
45
|
+
if (v.state === 'notReconciling') {
|
|
46
|
+
const why = (v.reasons ?? []).map(sentence).join(' ')
|
|
47
|
+
return {
|
|
48
|
+
tone: 'warning',
|
|
49
|
+
text: `The CloudNativePG operator is not reconciling this cluster. ${why} The API server may accept this change, but nothing acts on it until an operator instance leads again.`.replace(/\s+/g, ' ').trim(),
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (v.state === 'unknown' && v.unknown) {
|
|
53
|
+
return { tone: 'info', text: `Radar couldn't confirm the operator is running (${v.unknown}).` }
|
|
54
|
+
}
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A restart that ended within this long is "recent": the operator may still be crash-looping. */
|
|
59
|
+
export const CNPG_OPERATOR_RECENT_RESTART_MS = 60 * 60 * 1000
|
|
60
|
+
|
|
61
|
+
export interface CNPGOperatorConcern {
|
|
62
|
+
tone: 'unhealthy' | 'degraded' | 'neutral'
|
|
63
|
+
text: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A component's restart history in one line, with when the last one ended and why; null when it never restarted. */
|
|
67
|
+
export function cnpgRestartHistory(c: Pick<CNPGOperatorComponent, 'pods'>, now = Date.now()): { text: string; recent: boolean } | null {
|
|
68
|
+
const pods = c.pods ?? []
|
|
69
|
+
const restarts = pods.reduce((n, p) => n + p.restarts, 0)
|
|
70
|
+
if (restarts === 0) return null
|
|
71
|
+
const last = pods
|
|
72
|
+
.map((p) => p.lastTermination)
|
|
73
|
+
.filter((t): t is NonNullable<typeof t> => !!t?.finishedAt)
|
|
74
|
+
.sort((a, b) => Date.parse(b.finishedAt!) - Date.parse(a.finishedAt!))[0]
|
|
75
|
+
const ended = last?.finishedAt ? Date.parse(last.finishedAt) : NaN
|
|
76
|
+
const recent = Number.isFinite(ended) && now - ended <= CNPG_OPERATOR_RECENT_RESTART_MS
|
|
77
|
+
const why = last ? ` (${[last.reason, `exit ${last.exitCode}`].filter(Boolean).join(', ')})` : ''
|
|
78
|
+
const when = last?.finishedAt ? ` · last ended ${formatAge(last.finishedAt)} ago${why}` : ''
|
|
79
|
+
return { text: `${restarts} restart${restarts === 1 ? '' : 's'} since the Pod was created${when}`, recent }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function componentName(c: CNPGOperatorComponent): string {
|
|
83
|
+
return c.role === 'operator' ? 'The operator' : `Plugin ${c.pluginName ?? c.deployment}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface CNPGOperatorState {
|
|
87
|
+
concerns: CNPGOperatorConcern[]
|
|
88
|
+
/** What was read and found in order, said only from evidence. */
|
|
89
|
+
confirmed: string[]
|
|
90
|
+
/** What could not be read, so its absence from `concerns` proves nothing. */
|
|
91
|
+
unread: string[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What the Operator screen leads with: the states that stop or slow
|
|
96
|
+
* reconciliation, then restart history with when it last happened, and what
|
|
97
|
+
* could not be read. Restart totals are cumulative, so only a recent one is
|
|
98
|
+
* worded as current trouble.
|
|
99
|
+
*/
|
|
100
|
+
export function cnpgOperatorConcerns(op: CNPGOperatorResponse, now = Date.now()): CNPGOperatorConcern[] {
|
|
101
|
+
return cnpgOperatorState(op, now).concerns
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function cnpgOperatorState(op: CNPGOperatorResponse, now = Date.now(), clusters: CNPGFleetRow[] = []): CNPGOperatorState {
|
|
105
|
+
const concerns: CNPGOperatorConcern[] = cnpgPluginBlockedClusters(op, clusters, now)
|
|
106
|
+
const confirmed: string[] = []
|
|
107
|
+
const unread: string[] = []
|
|
108
|
+
const operators = op.components.filter((c) => c.role === 'operator')
|
|
109
|
+
const coverageGaps = (['deployments', 'services'] as const).filter((k) => op.coverage[k]?.state !== 'full')
|
|
110
|
+
if (coverageGaps.length > 0) unread.push(`${operators.length === 0 ? 'Operator health cannot be assessed with your access' : 'Other operator or plugin components may be outside this read'}: ${coverageGaps.map((k) => `${k === 'deployments' ? 'Deployments' : 'Services'} ${coverageLabel(op.coverage[k])}`).join('; ')}.`)
|
|
111
|
+
else if (operators.length === 0) unread.push('No operator Deployment was found in the namespaces you can read.')
|
|
112
|
+
let allReady = op.components.length > 0
|
|
113
|
+
for (const c of op.components) {
|
|
114
|
+
if (c.readyReplicas === null || c.replicas === null) {
|
|
115
|
+
allReady = false
|
|
116
|
+
unread.push(`${componentName(c)}: readiness not reported.`)
|
|
117
|
+
} else if (c.replicas === 0) {
|
|
118
|
+
allReady = false
|
|
119
|
+
concerns.push({ tone: c.role === 'operator' ? 'unhealthy' : 'degraded', text: `${componentName(c)} is scaled to 0${c.role === 'operator' ? ': nothing reconciles' : ''}.` })
|
|
120
|
+
} else if (c.readyReplicas < c.replicas) {
|
|
121
|
+
allReady = false
|
|
122
|
+
concerns.push({ tone: c.readyReplicas === 0 ? 'unhealthy' : 'degraded', text: `${componentName(c)} has ${c.readyReplicas} of ${c.replicas} replicas ready.` })
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// Ready now says little about a component that keeps restarting.
|
|
126
|
+
const restartedRecently = op.components.some((c) => cnpgRestartHistory(c, now)?.recent)
|
|
127
|
+
if (allReady) confirmed.push(restartedRecently ? 'every component reports ready right now (see the restarts above)' : 'every component is ready')
|
|
128
|
+
const diagnoses = op.diagnosis ?? []
|
|
129
|
+
if (operators.length > 0 && diagnoses.length === 0) unread.push('Leadership and webhooks were not read.')
|
|
130
|
+
let leading = diagnoses.length > 0
|
|
131
|
+
let webhooksServed = diagnoses.length > 0
|
|
132
|
+
for (const d of diagnoses) {
|
|
133
|
+
if (d.leader.state === 'ok') {
|
|
134
|
+
if (d.leader.stale) {
|
|
135
|
+
leading = false
|
|
136
|
+
concerns.push({ tone: 'unhealthy', text: `No operator instance is leading in ${d.namespace}: the leader Lease was not renewed, so nothing reconciles.` })
|
|
137
|
+
}
|
|
138
|
+
} else if (d.leader.state !== 'disabled') {
|
|
139
|
+
leading = false
|
|
140
|
+
unread.push(`Leadership in ${d.namespace} not read${d.leader.reason ? `: ${d.leader.reason}` : ''}.`)
|
|
141
|
+
}
|
|
142
|
+
for (const svc of d.webhookServices) {
|
|
143
|
+
const fails = d.webhooks.some((w) => w.state === 'ok' && w.webhooks.some((h) => h.failurePolicy === 'Fail' && h.service === `${svc.namespace}/${svc.name}`))
|
|
144
|
+
if (svc.state !== 'ok' || svc.readyEndpoints === null) {
|
|
145
|
+
webhooksServed = false
|
|
146
|
+
unread.push(`Endpoints of the webhook Service ${svc.namespace}/${svc.name} not read.`)
|
|
147
|
+
} else if (svc.readyEndpoints === 0) {
|
|
148
|
+
webhooksServed = false
|
|
149
|
+
if (fails) concerns.push({ tone: 'unhealthy', text: `The admission webhook Service ${svc.namespace}/${svc.name} has no ready endpoint and its failure policy is Fail: every CloudNativePG write is rejected.` })
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const unreadHooks = d.webhooks.filter((w) => w.state !== 'ok')
|
|
153
|
+
if (unreadHooks.length > 0) {
|
|
154
|
+
// Services are found through readable configurations only, so an unread
|
|
155
|
+
// one may name a Service with no endpoint.
|
|
156
|
+
webhooksServed = false
|
|
157
|
+
unread.push(`Admission webhook configurations not read (${unreadHooks.map((w) => w.name).join(', ')}).`)
|
|
158
|
+
}
|
|
159
|
+
if (d.webhookServices.length === 0) webhooksServed = false
|
|
160
|
+
}
|
|
161
|
+
if (leading) confirmed.push('the operator is leading')
|
|
162
|
+
if (webhooksServed) confirmed.push('its webhooks have ready endpoints')
|
|
163
|
+
for (const c of op.components) {
|
|
164
|
+
const h = cnpgRestartHistory(c, now)
|
|
165
|
+
if (h) concerns.push({ tone: h.recent ? 'degraded' : 'neutral', text: `${componentName(c)}: ${h.text}.` })
|
|
166
|
+
else if (!c.pods && c.podCoverage && c.podCoverage.state !== 'ok') unread.push(`${componentName(c)}: restart history not read${c.podCoverage.reason ? ` (${c.podCoverage.reason})` : ''}.`)
|
|
167
|
+
}
|
|
168
|
+
return { concerns, confirmed, unread }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Clusters whose phase says the operator is stuck on a plugin, joined to what
|
|
173
|
+
* this screen knows about that plugin: whether its Deployment is here, ready,
|
|
174
|
+
* and when it last restarted. Facts side by side; which one caused the other
|
|
175
|
+
* is not claimed.
|
|
176
|
+
*/
|
|
177
|
+
export function cnpgPluginBlockedClusters(op: CNPGOperatorResponse, clusters: CNPGFleetRow[], now = Date.now()): CNPGOperatorConcern[] {
|
|
178
|
+
const out: CNPGOperatorConcern[] = []
|
|
179
|
+
for (const r of clusters) {
|
|
180
|
+
const phase = cnpgPluginPhase(r.cluster)
|
|
181
|
+
if (!phase) continue
|
|
182
|
+
const what = phase === 'unknownPlugin' ? 'requires a plugin the operator does not know' : 'hit an error talking to a plugin'
|
|
183
|
+
const plugins = cnpgClusterPlugins(r.cluster)
|
|
184
|
+
const parts = plugins.map((name) => {
|
|
185
|
+
const c = op.components.find((x) => x.role === 'plugin' && x.pluginName === name)
|
|
186
|
+
if (!c) return `${name}: no Deployment serving it was found`
|
|
187
|
+
const ready = c.readyReplicas !== null && c.replicas !== null ? `${c.readyReplicas}/${c.replicas} ready` : 'readiness not reported'
|
|
188
|
+
const h = cnpgRestartHistory(c, now)
|
|
189
|
+
return `${name}: ${ready}${h ? `, ${h.text}` : ''}`
|
|
190
|
+
})
|
|
191
|
+
out.push({ tone: 'unhealthy', text: `Cluster ${r.namespace}/${r.name} ${what}${parts.length ? ` — ${parts.join('; ')}` : ''}.` })
|
|
192
|
+
}
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { cnpgClusterProblemsPath, cnpgDimensionPath, cnpgWithinDetail } from './paths'
|
|
3
|
+
import { issuesPathForSubject } from '../../utils/page-links'
|
|
4
|
+
import { writeViewParams } from '../../utils/navigation'
|
|
5
|
+
import { issueSubjectFromParams } from '../issues/IssuesPane'
|
|
6
|
+
|
|
7
|
+
describe('cnpgDimensionPath', () => {
|
|
8
|
+
it('opens each health dimension where it is explained', () => {
|
|
9
|
+
expect(cnpgDimensionPath('db', 'pg', 'kind', 'replication')).toBe('/cnpg/clusters/db/pg?ctx=kind&tab=replication')
|
|
10
|
+
expect(cnpgDimensionPath('db', 'pg', 'kind', 'serving')).toBe('/cnpg/clusters/db/pg?ctx=kind&tab=replication')
|
|
11
|
+
expect(cnpgDimensionPath('db', 'pg', 'kind', 'storage')).toBe('/cnpg/clusters/db/pg?ctx=kind&tab=storage')
|
|
12
|
+
expect(cnpgDimensionPath('db', 'pg', undefined, 'protection')).toBe('/cnpg/clusters/db/pg?tab=backups')
|
|
13
|
+
})
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
describe('cnpgWithinDetail', () => {
|
|
17
|
+
it('turns a link to the open Cluster page into a tab change that keeps its other params', () => {
|
|
18
|
+
expect(cnpgWithinDetail('/cnpg/clusters/db/pg', '?ctx=kind&tab=performance§ion=history&charts=storage&drawer=x', '/cnpg/clusters/db/pg?ctx=kind&tab=backups')).toBe(
|
|
19
|
+
'/cnpg/clusters/db/pg?ctx=kind&tab=backups&drawer=x',
|
|
20
|
+
)
|
|
21
|
+
expect(cnpgWithinDetail('/cnpg/clusters/db/pg', '?ctx=kind', '/cnpg/clusters/db/pg?ctx=kind&tab=performance§ion=history&charts=replication')).toBe(
|
|
22
|
+
'/cnpg/clusters/db/pg?ctx=kind&tab=performance§ion=history&charts=replication',
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
it('leaves a link to another page alone', () => {
|
|
26
|
+
expect(cnpgWithinDetail('/cnpg', '', '/cnpg/clusters/db/pg?tab=replication')).toBeNull()
|
|
27
|
+
expect(cnpgWithinDetail('/cnpg/clusters/db/other', '', '/cnpg/clusters/db/pg?tab=replication')).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('cnpgClusterProblemsPath', () => {
|
|
32
|
+
it('opens the Cluster with its problems listed', () => {
|
|
33
|
+
expect(cnpgClusterProblemsPath('db', 'pg', 'kind')).toBe('/cnpg/clusters/db/pg?ctx=kind&problems=all')
|
|
34
|
+
expect(cnpgClusterProblemsPath('db', 'pg')).toBe('/cnpg/clusters/db/pg?problems=all')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('issuesPathForSubject', () => {
|
|
39
|
+
it('links to the Issues page narrowed to the subject', () => {
|
|
40
|
+
expect(issuesPathForSubject({ kind: 'Backup', namespace: 'pg', name: 'b-1' })).toBe('/issues?kind=Backup&resource=pg%2Fb-1')
|
|
41
|
+
expect(issuesPathForSubject({ kind: 'ClusterImageCatalog', namespace: '', name: 'pg' })).toBe('/issues?kind=ClusterImageCatalog&resource=pg')
|
|
42
|
+
})
|
|
43
|
+
it('keeps the current namespace view filter', () => {
|
|
44
|
+
expect(issuesPathForSubject({ kind: 'Cluster', namespace: 'pg', name: 'main' }, 'pg,app')).toBe('/issues?namespaces=pg%2Capp&kind=Cluster&resource=pg%2Fmain')
|
|
45
|
+
})
|
|
46
|
+
it('carries the API group through App URL synchronization', () => {
|
|
47
|
+
const subject = { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main' }
|
|
48
|
+
const href = issuesPathForSubject(subject)
|
|
49
|
+
expect(href).toBe('/issues?kind=Cluster&apiGroup=postgresql.cnpg.io&resource=pg%2Fmain')
|
|
50
|
+
const params = new URLSearchParams(href.split('?')[1])
|
|
51
|
+
writeViewParams(params, ['pg'], null)
|
|
52
|
+
expect(issueSubjectFromParams(params)).toEqual(subject)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -1,13 +1,56 @@
|
|
|
1
|
+
import type { CNPGDimension } from '@skyhook-io/k8s-ui'
|
|
1
2
|
import { cnpgDetailPath } from './routes'
|
|
2
3
|
|
|
3
4
|
export function cnpgClusterFullPath(namespace: string, name: string, ctx?: string, tab?: string): string {
|
|
4
5
|
return cnpgDetailPath({ plural: 'clusters', namespace, name }, ctx, tab)
|
|
5
6
|
}
|
|
6
7
|
|
|
8
|
+
/** The Cluster's page with its problems list open (`problems=all`). */
|
|
9
|
+
export function cnpgClusterProblemsPath(namespace: string, name: string, ctx?: string): string {
|
|
10
|
+
const path = cnpgClusterFullPath(namespace, name, ctx)
|
|
11
|
+
return `${path}${path.includes('?') ? '&' : '?'}problems=all`
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The Cluster page's tabs, in order: the task tabs, then the record (activity, logs, configuration, YAML). */
|
|
15
|
+
export const CNPG_CLUSTER_TAB_ORDER = ['overview', 'replication', 'storage', 'performance', 'backups', 'activity', 'logs', 'spec', 'yaml']
|
|
16
|
+
|
|
17
|
+
/** The tab that explains a health dimension: Serving and Replication in Replication, Storage in Storage, Backups in Backups. */
|
|
18
|
+
export function cnpgDimensionTab(id: CNPGDimension['id']): string {
|
|
19
|
+
switch (id) {
|
|
20
|
+
case 'protection':
|
|
21
|
+
return 'backups'
|
|
22
|
+
case 'storage':
|
|
23
|
+
return 'storage'
|
|
24
|
+
default:
|
|
25
|
+
return 'replication'
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const TAB_LABEL: Record<string, string> = { replication: 'Replication', storage: 'Storage', backups: 'Backups' }
|
|
30
|
+
|
|
31
|
+
/** The name of the tab cnpgDimensionTab opens, for a link that leads there. */
|
|
32
|
+
export function cnpgDimensionTabLabel(id: CNPGDimension['id']): string {
|
|
33
|
+
return TAB_LABEL[cnpgDimensionTab(id)]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The Cluster page tab where a health dimension is explained (see cnpgDimensionTab). */
|
|
37
|
+
export function cnpgDimensionPath(namespace: string, name: string, ctx: string | undefined, id: CNPGDimension['id']): string {
|
|
38
|
+
return cnpgClusterFullPath(namespace, name, ctx, cnpgDimensionTab(id))
|
|
39
|
+
}
|
|
40
|
+
|
|
7
41
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
42
|
+
* `target` as a tab change on the detail page already open, or null when it
|
|
43
|
+
* is another page. A tab change keeps the page's other params, drops the
|
|
44
|
+
* previous tab's section unless `target` names one, and is applied like a tab
|
|
45
|
+
* click (replacing the history entry) so the page's return label still leads
|
|
46
|
+
* where it says.
|
|
10
47
|
*/
|
|
11
|
-
export function
|
|
12
|
-
|
|
48
|
+
export function cnpgWithinDetail(currentPathname: string, currentSearch: string, target: string): string | null {
|
|
49
|
+
const [path, query = ''] = target.split('?')
|
|
50
|
+
if (path !== currentPathname) return null
|
|
51
|
+
const params = new URLSearchParams(currentSearch)
|
|
52
|
+
for (const k of ['section', 'charts', 'instance', 'validate']) params.delete(k)
|
|
53
|
+
for (const [k, v] of new URLSearchParams(query)) params.set(k, v)
|
|
54
|
+
const qs = params.toString()
|
|
55
|
+
return qs ? `${path}?${qs}` : path
|
|
13
56
|
}
|