@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,79 @@
|
|
|
1
|
+
// The client half of the reviewed-action contract shared by workspace
|
|
2
|
+
// integrations. A capability says
|
|
3
|
+
// whether an action is offered and why not; a request carries the context,
|
|
4
|
+
// object UID and facts the user confirmed, and the server refuses it with a
|
|
5
|
+
// stable code when any of them no longer holds.
|
|
6
|
+
|
|
7
|
+
import { formatGrant, type Grant, type ActionConfirmResult } from '@skyhook-io/k8s-ui'
|
|
8
|
+
import { ApiError } from './client'
|
|
9
|
+
import { isRadarFeatureUnsupported } from './radarFeatures'
|
|
10
|
+
|
|
11
|
+
export interface ActionCapability {
|
|
12
|
+
allowed: boolean
|
|
13
|
+
reason?: string
|
|
14
|
+
reasonCode?: string
|
|
15
|
+
/** `unknown`: the permission check itself failed; the apiserver decides on submit. */
|
|
16
|
+
permission: 'allowed' | 'denied' | 'unknown'
|
|
17
|
+
grant?: Grant
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ActionRequest<Facts extends object = object, Params extends object = object> {
|
|
21
|
+
reviewedContext: string
|
|
22
|
+
uid: string
|
|
23
|
+
facts: Facts
|
|
24
|
+
params?: Params
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Refusals every action family shares. An integration adds its own codes by
|
|
29
|
+
* widening the type parameter of actionErrorCode.
|
|
30
|
+
*/
|
|
31
|
+
export type ActionErrorCode = 'context_changed' | 'changed' | 'blocked' | 'partial' | 'unavailable' | 'outcome_unknown'
|
|
32
|
+
|
|
33
|
+
// A request that timed out or lost its connection may have been applied by the
|
|
34
|
+
// apiserver anyway. A 503 without a code is just as uncertain: an Envoy-based
|
|
35
|
+
// ingress (Istio, Contour, Gateway API implementations) answers 503 when the
|
|
36
|
+
// upstream connection reset after it forwarded the request. Radar's own
|
|
37
|
+
// refusals before any write carry the code `unavailable`.
|
|
38
|
+
const AMBIGUOUS_STATUSES = new Set([500, 502, 503, 504])
|
|
39
|
+
|
|
40
|
+
export function actionErrorCode<C extends string = ActionErrorCode>(err: unknown): C | ActionErrorCode | undefined {
|
|
41
|
+
if (!err || isRadarFeatureUnsupported(err)) return undefined
|
|
42
|
+
if (!(err instanceof ApiError)) return 'outcome_unknown'
|
|
43
|
+
const code = err.data?.code
|
|
44
|
+
if (typeof code === 'string') return code as C
|
|
45
|
+
return AMBIGUOUS_STATUSES.has(err.status) ? 'outcome_unknown' : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Confirm stays locked when the last attempt may have taken effect (unknown)
|
|
50
|
+
* or partly did (partial): repeating it would act on a target that moved.
|
|
51
|
+
*/
|
|
52
|
+
export function actionOutcomeLocked(err: unknown): boolean {
|
|
53
|
+
const code = actionErrorCode(err)
|
|
54
|
+
return code === 'outcome_unknown' || code === 'partial'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function actionDialogResult(
|
|
58
|
+
error: Error | null | undefined,
|
|
59
|
+
titles: { error?: string; unknown?: string; partial?: string } = {},
|
|
60
|
+
): ActionConfirmResult | undefined {
|
|
61
|
+
if (!error) return undefined
|
|
62
|
+
const code = actionErrorCode(error)
|
|
63
|
+
const state = code === 'outcome_unknown' ? 'unknown' : code === 'partial' ? 'partial' : 'error'
|
|
64
|
+
return { state, message: error.message, title: state === 'error' ? titles.error : state === 'partial' ? titles.partial : titles.unknown }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The mutations a `partial` refusal reports as already done. */
|
|
68
|
+
export function actionCompleted(err: unknown): string[] {
|
|
69
|
+
if (!(err instanceof ApiError) || err.data?.code !== 'partial') return []
|
|
70
|
+
const done = err.data.completed
|
|
71
|
+
return Array.isArray(done) ? done.filter((d): d is string => typeof d === 'string') : []
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Why an action is not offered, or undefined when it is. */
|
|
75
|
+
export function capabilityReason(cap: ActionCapability): string | undefined {
|
|
76
|
+
if (cap.allowed) return undefined
|
|
77
|
+
if (cap.reason) return cap.reason
|
|
78
|
+
return cap.permission === 'denied' ? `Your account may not do this (${formatGrant(cap.grant) ?? 'permission denied'})` : 'Not available'
|
|
79
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
customResourceDefinitionRef,
|
|
8
|
+
kindToPluralWithGroup,
|
|
9
|
+
knownKindForPluralWithGroup,
|
|
10
|
+
resetNavigationMap,
|
|
11
|
+
} from "@skyhook-io/k8s-ui/utils/navigation";
|
|
12
|
+
import { useAPIResources } from "./apiResources";
|
|
13
|
+
import { MetadataSection } from "@skyhook-io/k8s-ui/components/ui/drawer-components";
|
|
14
|
+
|
|
15
|
+
beforeEach(() => vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true));
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.useRealTimers();
|
|
18
|
+
vi.unstubAllGlobals();
|
|
19
|
+
resetNavigationMap();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it.each(["unwatched", "syncing", "denied", "deferred", "unsupported", "synced", undefined])(
|
|
23
|
+
"bounds definition polling for inventory state %s",
|
|
24
|
+
async (state) => {
|
|
25
|
+
vi.useFakeTimers();
|
|
26
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
27
|
+
const root = createRoot(document.createElement("div"));
|
|
28
|
+
const fetch = vi.fn(() => Promise.resolve(Response.json([{
|
|
29
|
+
group: "apiextensions.k8s.io", version: "v1", kind: "CustomResourceDefinition",
|
|
30
|
+
name: "customresourcedefinitions", namespaced: false, isCrd: false, verbs: ["get", "list", "watch"],
|
|
31
|
+
...(state ? { observation: { state } } : {}),
|
|
32
|
+
}])));
|
|
33
|
+
vi.stubGlobal("fetch", fetch);
|
|
34
|
+
function Probe() {
|
|
35
|
+
useAPIResources();
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
|
|
40
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(120_001); });
|
|
41
|
+
const calls = fetch.mock.calls.length;
|
|
42
|
+
if (state === "unwatched" || state === "syncing") expect(calls).toBeGreaterThan(1);
|
|
43
|
+
else expect(calls).toBe(1);
|
|
44
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
|
|
45
|
+
expect(fetch).toHaveBeenCalledTimes(calls);
|
|
46
|
+
} finally {
|
|
47
|
+
await act(async () => root.unmount());
|
|
48
|
+
client.clear();
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
it("refreshes definitions until their inventory syncs, without relying on topology", async () => {
|
|
54
|
+
vi.useFakeTimers();
|
|
55
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
56
|
+
const root = createRoot(document.createElement("div"));
|
|
57
|
+
let synced = false;
|
|
58
|
+
const fetch = vi.fn(() => Promise.resolve(Response.json([
|
|
59
|
+
{
|
|
60
|
+
group: "apiextensions.k8s.io", version: "v1", kind: "CustomResourceDefinition",
|
|
61
|
+
name: "customresourcedefinitions", namespaced: false, isCrd: false, verbs: ["get", "list", "watch"],
|
|
62
|
+
observation: { state: synced ? "synced" : "syncing" },
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
group: "plants.example.io", version: "v1", kind: "Cactus", name: "cacti",
|
|
66
|
+
namespaced: true, isCrd: true, verbs: ["get"],
|
|
67
|
+
...(synced ? { definitionName: "cacti.plants.example.io" } : {}),
|
|
68
|
+
},
|
|
69
|
+
])));
|
|
70
|
+
vi.stubGlobal("fetch", fetch);
|
|
71
|
+
function Probe() {
|
|
72
|
+
useAPIResources();
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
await act(async () => {
|
|
77
|
+
root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>);
|
|
78
|
+
});
|
|
79
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
|
|
80
|
+
expect(fetch).toHaveBeenCalledTimes(1);
|
|
81
|
+
expect(customResourceDefinitionRef("plants.example.io/v1", "Cactus")).toBeNull();
|
|
82
|
+
synced = true;
|
|
83
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(3000); });
|
|
84
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(1); });
|
|
85
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
86
|
+
expect(customResourceDefinitionRef("plants.example.io/v1", "Cactus")?.name).toBe("cacti.plants.example.io");
|
|
87
|
+
await act(async () => { await vi.advanceTimersByTimeAsync(6000); });
|
|
88
|
+
expect(fetch).toHaveBeenCalledTimes(2);
|
|
89
|
+
} finally {
|
|
90
|
+
await act(async () => root.unmount());
|
|
91
|
+
client.clear();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders a definition link when discovery arrives after the instance", async () => {
|
|
96
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
97
|
+
const element = document.createElement("div");
|
|
98
|
+
const root = createRoot(element);
|
|
99
|
+
const resource = {
|
|
100
|
+
group: "plants.example.io", version: "v1", kind: "Cactus", name: "cacti",
|
|
101
|
+
namespaced: true, isCrd: true, verbs: ["get"], definitionName: "cacti.plants.example.io",
|
|
102
|
+
};
|
|
103
|
+
let finishDiscovery!: (response: Response) => void;
|
|
104
|
+
let fail = false;
|
|
105
|
+
vi.stubGlobal("fetch", vi.fn(() => fail
|
|
106
|
+
? Promise.resolve(new Response("{}", { status: 500 }))
|
|
107
|
+
: new Promise<Response>((resolve) => { finishDiscovery = resolve; })));
|
|
108
|
+
function Probe() {
|
|
109
|
+
useAPIResources();
|
|
110
|
+
return <MetadataSection data={{ apiVersion: "plants.example.io/v1", kind: "Cactus", metadata: { name: "live" } }} />;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await act(async () => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
|
|
114
|
+
expect(element.textContent).not.toContain("cacti.plants.example.io");
|
|
115
|
+
await act(async () => { finishDiscovery(Response.json([resource])); });
|
|
116
|
+
await vi.waitFor(async () => {
|
|
117
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
|
|
118
|
+
expect(element.textContent).toContain("cacti.plants.example.io");
|
|
119
|
+
});
|
|
120
|
+
fail = true;
|
|
121
|
+
await act(async () => { await client.invalidateQueries({ queryKey: ["api-resources"] }); });
|
|
122
|
+
await vi.waitFor(async () => {
|
|
123
|
+
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
|
|
124
|
+
expect(element.textContent).not.toContain("cacti.plants.example.io");
|
|
125
|
+
});
|
|
126
|
+
} finally {
|
|
127
|
+
await act(async () => root.unmount());
|
|
128
|
+
client.clear();
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("clears confirmed definitions after a failed refresh and cancels discovery from an old cluster", async () => {
|
|
133
|
+
const client = new QueryClient({
|
|
134
|
+
defaultOptions: { queries: { retry: false } },
|
|
135
|
+
});
|
|
136
|
+
const root = createRoot(document.createElement("div"));
|
|
137
|
+
const resource = {
|
|
138
|
+
group: "plants.example.io",
|
|
139
|
+
version: "v1",
|
|
140
|
+
kind: "Cactus",
|
|
141
|
+
name: "cacti",
|
|
142
|
+
namespaced: true,
|
|
143
|
+
isCrd: true,
|
|
144
|
+
verbs: ["get"],
|
|
145
|
+
definitionName: "cacti.plants.example.io",
|
|
146
|
+
};
|
|
147
|
+
let fail = false;
|
|
148
|
+
let hold = false;
|
|
149
|
+
let pendingSignal: AbortSignal | undefined;
|
|
150
|
+
vi.stubGlobal(
|
|
151
|
+
"fetch",
|
|
152
|
+
vi.fn((_input: unknown, init?: RequestInit) => {
|
|
153
|
+
if (hold) {
|
|
154
|
+
pendingSignal = init?.signal as AbortSignal;
|
|
155
|
+
return new Promise<Response>(() => {});
|
|
156
|
+
}
|
|
157
|
+
return Promise.resolve(
|
|
158
|
+
fail ? new Response("{}", { status: 500 }) : Response.json([resource]),
|
|
159
|
+
);
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
function Probe() {
|
|
163
|
+
useAPIResources();
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const settle = async () => {
|
|
167
|
+
await act(async () => {
|
|
168
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
try {
|
|
172
|
+
await act(async () => {
|
|
173
|
+
root.render(
|
|
174
|
+
<QueryClientProvider client={client}>
|
|
175
|
+
<Probe />
|
|
176
|
+
</QueryClientProvider>,
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
await vi.waitFor(async () => {
|
|
180
|
+
await settle();
|
|
181
|
+
expect(
|
|
182
|
+
customResourceDefinitionRef("plants.example.io/v1", "Cactus")?.name,
|
|
183
|
+
).toBe(resource.definitionName);
|
|
184
|
+
});
|
|
185
|
+
expect(
|
|
186
|
+
knownKindForPluralWithGroup("helmreleases", "helm.toolkit.fluxcd.io"),
|
|
187
|
+
).toBe("HelmRelease");
|
|
188
|
+
expect(
|
|
189
|
+
customResourceDefinitionRef("helm.toolkit.fluxcd.io/v2", "HelmRelease"),
|
|
190
|
+
).toBeNull();
|
|
191
|
+
fail = true;
|
|
192
|
+
await act(async () => {
|
|
193
|
+
await client.invalidateQueries({ queryKey: ["api-resources"] });
|
|
194
|
+
});
|
|
195
|
+
await vi.waitFor(async () => {
|
|
196
|
+
await settle();
|
|
197
|
+
expect(
|
|
198
|
+
customResourceDefinitionRef("plants.example.io/v1", "Cactus"),
|
|
199
|
+
).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
expect(
|
|
202
|
+
knownKindForPluralWithGroup("helmreleases", "helm.toolkit.fluxcd.io"),
|
|
203
|
+
).toBe("HelmRelease");
|
|
204
|
+
expect(kindToPluralWithGroup("Cactus", "plants.example.io")).toBe("cacti");
|
|
205
|
+
fail = false;
|
|
206
|
+
await act(async () => {
|
|
207
|
+
await client.invalidateQueries({ queryKey: ["api-resources"] });
|
|
208
|
+
});
|
|
209
|
+
await vi.waitFor(async () => {
|
|
210
|
+
await settle();
|
|
211
|
+
expect(
|
|
212
|
+
customResourceDefinitionRef("plants.example.io/v1", "Cactus")?.name,
|
|
213
|
+
).toBe(resource.definitionName);
|
|
214
|
+
});
|
|
215
|
+
hold = true;
|
|
216
|
+
await act(async () => {
|
|
217
|
+
void client.invalidateQueries({ queryKey: ["api-resources"] });
|
|
218
|
+
});
|
|
219
|
+
expect(pendingSignal?.aborted).toBe(false);
|
|
220
|
+
resetNavigationMap();
|
|
221
|
+
await act(async () => {
|
|
222
|
+
client.removeQueries();
|
|
223
|
+
});
|
|
224
|
+
expect(pendingSignal?.aborted).toBe(true);
|
|
225
|
+
expect(
|
|
226
|
+
customResourceDefinitionRef("plants.example.io/v1", "Cactus"),
|
|
227
|
+
).toBeNull();
|
|
228
|
+
} finally {
|
|
229
|
+
await act(async () => root.unmount());
|
|
230
|
+
client.clear();
|
|
231
|
+
}
|
|
232
|
+
});
|
package/src/api/apiResources.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { initNavigationMap } from "@skyhook-io/k8s-ui/utils/navigation";
|
|
1
3
|
import { useQuery } from "@tanstack/react-query";
|
|
2
4
|
import type { APIResource } from "../types";
|
|
3
5
|
import { apiUrl, getAuthHeaders, getCredentialsMode } from "./config";
|
|
4
6
|
import { readErrorBody } from "./httpErrors";
|
|
5
7
|
|
|
8
|
+
const definitionPollDeadlines = new WeakMap<object, number>();
|
|
9
|
+
|
|
6
10
|
// Re-export pure functions from package
|
|
7
11
|
export {
|
|
8
12
|
categorizeResources,
|
|
@@ -15,8 +19,9 @@ export {
|
|
|
15
19
|
} from "@skyhook-io/k8s-ui";
|
|
16
20
|
export type { ResourceCategory } from "@skyhook-io/k8s-ui";
|
|
17
21
|
|
|
18
|
-
async function fetchJSON<T>(path: string): Promise<T> {
|
|
22
|
+
async function fetchJSON<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|
19
23
|
const response = await fetch(apiUrl(path), {
|
|
24
|
+
signal,
|
|
20
25
|
credentials: getCredentialsMode(),
|
|
21
26
|
headers: getAuthHeaders(),
|
|
22
27
|
});
|
|
@@ -27,13 +32,51 @@ async function fetchJSON<T>(path: string): Promise<T> {
|
|
|
27
32
|
return response.json();
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
// Known GitOps navigation identities, not installation or CRD-existence evidence.
|
|
36
|
+
export const GITOPS_KINDS: APIResource[] = [
|
|
37
|
+
{ name: 'applications', kind: 'Application', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
|
|
38
|
+
{ name: 'applicationsets', kind: 'ApplicationSet', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
|
|
39
|
+
{ name: 'appprojects', kind: 'AppProject', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
|
|
40
|
+
{ name: 'kustomizations', kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
|
|
41
|
+
{ name: 'helmreleases', kind: 'HelmRelease', group: 'helm.toolkit.fluxcd.io', version: 'v2', namespaced: true, verbs: ['list', 'get'], isCrd: true },
|
|
42
|
+
{ name: 'gitrepositories', kind: 'GitRepository', group: 'source.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
|
|
43
|
+
{ name: 'ocirepositories', kind: 'OCIRepository', group: 'source.toolkit.fluxcd.io', version: 'v1beta2', namespaced: true, verbs: ['list', 'get'], isCrd: true },
|
|
44
|
+
{ name: 'helmrepositories', kind: 'HelmRepository', group: 'source.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
|
|
45
|
+
{ name: 'alerts', kind: 'Alert', group: 'notification.toolkit.fluxcd.io', version: 'v1beta3', namespaced: true, verbs: ['list', 'get'], isCrd: true },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
30
49
|
// Fetch all API resources from the cluster
|
|
31
50
|
export function useAPIResources() {
|
|
32
|
-
|
|
51
|
+
const result = useQuery<APIResource[]>({
|
|
33
52
|
queryKey: ["api-resources"],
|
|
34
|
-
queryFn: () => fetchJSON("/api-resources"),
|
|
53
|
+
queryFn: ({ signal }) => fetchJSON("/api-resources", signal),
|
|
35
54
|
staleTime: 5 * 60 * 1000, // 5 minutes - resources don't change often
|
|
55
|
+
refetchInterval: (query) => {
|
|
56
|
+
const state = query.state.data?.find(resource =>
|
|
57
|
+
resource.group === "apiextensions.k8s.io" && resource.name === "customresourcedefinitions")?.observation?.state;
|
|
58
|
+
if (state !== "unwatched" && state !== "syncing") {
|
|
59
|
+
definitionPollDeadlines.delete(query);
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
let deadline = definitionPollDeadlines.get(query);
|
|
63
|
+
if (deadline === undefined) {
|
|
64
|
+
deadline = Date.now() + 2 * 60 * 1000;
|
|
65
|
+
definitionPollDeadlines.set(query, deadline);
|
|
66
|
+
}
|
|
67
|
+
return Date.now() < deadline ? 3000 : false;
|
|
68
|
+
},
|
|
36
69
|
});
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
// Route identities survive a failed refresh; permission-dependent links do not.
|
|
72
|
+
const actual = result.isError
|
|
73
|
+
? (result.data ?? []).map(resource => ({ ...resource, definitionName: undefined }))
|
|
74
|
+
: result.data ?? [];
|
|
75
|
+
const knownOnly = GITOPS_KINDS.filter(known => !actual.some(resource =>
|
|
76
|
+
resource.group === known.group && (resource.kind === known.kind || resource.name === known.name)));
|
|
77
|
+
initNavigationMap([...actual, ...knownOnly]);
|
|
78
|
+
}, [result.data, result.isError]);
|
|
79
|
+
return result;
|
|
37
80
|
}
|
|
38
81
|
|
|
39
82
|
// The report families the server watches (reportGroups in
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { QueryClient, QueryObserver } from '@tanstack/react-query'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { invalidateHelmAfterSourceChange } from './client'
|
|
4
|
+
|
|
5
|
+
describe('invalidateHelmAfterSourceChange', () => {
|
|
6
|
+
it('refreshes OCI inventory and an open release source status query', async () => {
|
|
7
|
+
const queryClient = new QueryClient()
|
|
8
|
+
const inventoryKey = ['helm-oci-sources']
|
|
9
|
+
const statusKey = ['helm-source-status', 'production', 'release']
|
|
10
|
+
let sourceStatus = {
|
|
11
|
+
configured: true,
|
|
12
|
+
available: true,
|
|
13
|
+
candidates: [{ type: 'oci' as const, reference: 'oci://registry.example.com/charts/example' }],
|
|
14
|
+
}
|
|
15
|
+
const observer = new QueryObserver(queryClient, {
|
|
16
|
+
queryKey: statusKey,
|
|
17
|
+
queryFn: async () => sourceStatus,
|
|
18
|
+
})
|
|
19
|
+
const unsubscribe = observer.subscribe(() => undefined)
|
|
20
|
+
|
|
21
|
+
const versionsKey = ['helm-release-versions', 'production', 'release']
|
|
22
|
+
queryClient.setQueryData(inventoryKey, ['oci://registry.example.com/charts'])
|
|
23
|
+
queryClient.setQueryData(versionsKey, ['1.0.0'])
|
|
24
|
+
await observer.refetch()
|
|
25
|
+
|
|
26
|
+
sourceStatus = { configured: false, available: false, candidates: [] }
|
|
27
|
+
await invalidateHelmAfterSourceChange(queryClient)
|
|
28
|
+
|
|
29
|
+
expect(queryClient.getQueryState(inventoryKey)?.isInvalidated).toBe(true)
|
|
30
|
+
expect(queryClient.getQueryState(versionsKey)?.isInvalidated).toBe(true)
|
|
31
|
+
expect(observer.getCurrentResult().data).toEqual({
|
|
32
|
+
configured: false,
|
|
33
|
+
available: false,
|
|
34
|
+
candidates: [],
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
unsubscribe()
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
6
|
+
import { useSetActiveNamespace } from "./client";
|
|
7
|
+
|
|
8
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
vi.unstubAllGlobals();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// The server already reports the requested pick, but the POST never answers.
|
|
16
|
+
async function timedOutSwitch(cacheScoped: boolean) {
|
|
17
|
+
vi.useFakeTimers();
|
|
18
|
+
const scope = { actives: ["team-a"], kubeconfigNamespace: "", mode: "namespace", accessibleNamespaces: ["team-a"], deniedNamespaces: [], authoritative: true, canClearNamespace: true, cacheScoped };
|
|
19
|
+
const calls: string[] = [];
|
|
20
|
+
vi.stubGlobal("fetch", vi.fn((url: string, init?: RequestInit) => {
|
|
21
|
+
calls.push(`${init?.method ?? "GET"} ${new URL(url, "http://radar").pathname}`);
|
|
22
|
+
if (init?.method === "POST") {
|
|
23
|
+
return new Promise((_, reject) => init.signal?.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" }))));
|
|
24
|
+
}
|
|
25
|
+
return Promise.resolve(new Response(JSON.stringify(scope), { status: 200, headers: { "Content-Type": "application/json" } }));
|
|
26
|
+
}));
|
|
27
|
+
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
|
|
28
|
+
client.setQueryData(["namespace-scope"], { ...scope, actives: [] });
|
|
29
|
+
let mutate!: ReturnType<typeof useSetActiveNamespace>["mutateAsync"];
|
|
30
|
+
function Probe() {
|
|
31
|
+
mutate = useSetActiveNamespace().mutateAsync;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const root = createRoot(document.createElement("div"));
|
|
35
|
+
act(() => root.render(<QueryClientProvider client={client}><Probe /></QueryClientProvider>));
|
|
36
|
+
const outcome = mutate({ namespaces: ["team-a"] }).then(() => "applied", (error: Error) => error.message);
|
|
37
|
+
await vi.advanceTimersByTimeAsync(cacheScoped ? 120_000 : 15_000);
|
|
38
|
+
const result = { outcome: await outcome, calls };
|
|
39
|
+
act(() => root.unmount());
|
|
40
|
+
client.clear();
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
it("accepts a timed-out view-filter switch the server applied", async () => {
|
|
45
|
+
const { outcome, calls } = await timedOutSwitch(false);
|
|
46
|
+
expect(outcome).toBe("applied");
|
|
47
|
+
expect(calls).toContain("GET /api/cluster/namespace-scope");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("keeps a timed-out rescope an error even when the scope already names its target", async () => {
|
|
51
|
+
const { outcome, calls } = await timedOutSwitch(true);
|
|
52
|
+
expect(outcome).toMatch(/rescope timed out/);
|
|
53
|
+
expect(calls).not.toContain("GET /api/cluster/namespace-scope");
|
|
54
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
|
3
|
+
import { reconcileNamespaceSwitch, refreshAfterNamespaceSwitch } from "./client";
|
|
4
|
+
|
|
5
|
+
function serveScope(actives: string[]) {
|
|
6
|
+
vi.stubGlobal(
|
|
7
|
+
"fetch",
|
|
8
|
+
vi.fn(async () =>
|
|
9
|
+
new Response(
|
|
10
|
+
JSON.stringify({
|
|
11
|
+
actives,
|
|
12
|
+
kubeconfigNamespace: "default",
|
|
13
|
+
mode: actives.length ? "namespace" : "cluster-wide",
|
|
14
|
+
accessibleNamespaces: [],
|
|
15
|
+
deniedNamespaces: [],
|
|
16
|
+
authoritative: true,
|
|
17
|
+
canClearNamespace: true,
|
|
18
|
+
}),
|
|
19
|
+
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
20
|
+
),
|
|
21
|
+
),
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("reconcileNamespaceSwitch", () => {
|
|
30
|
+
it("returns the server's scope when a timed-out switch was applied", async () => {
|
|
31
|
+
serveScope([]);
|
|
32
|
+
expect((await reconcileNamespaceSwitch([]))?.actives).toEqual([]);
|
|
33
|
+
serveScope(["b", "a"]);
|
|
34
|
+
expect((await reconcileNamespaceSwitch(["a", "b"]))?.actives).toEqual([
|
|
35
|
+
"b",
|
|
36
|
+
"a",
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("reports nothing when the server still holds another pick", async () => {
|
|
41
|
+
serveScope(["default"]);
|
|
42
|
+
expect(await reconcileNamespaceSwitch([])).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reports nothing when the scope cannot be read", async () => {
|
|
46
|
+
vi.stubGlobal(
|
|
47
|
+
"fetch",
|
|
48
|
+
vi.fn(async () => {
|
|
49
|
+
throw new TypeError("network down");
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
expect(await reconcileNamespaceSwitch([])).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('refreshAfterNamespaceSwitch', () => {
|
|
57
|
+
it('preserves in-flight requests and cached data when switching to All', async () => {
|
|
58
|
+
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
59
|
+
let calls = 0
|
|
60
|
+
const resolvers: ((v: string) => void)[] = []
|
|
61
|
+
const observer = new QueryObserver(client, {
|
|
62
|
+
queryKey: ['fleet', ''],
|
|
63
|
+
queryFn: () => {
|
|
64
|
+
calls++
|
|
65
|
+
return new Promise<string>((resolve) => resolvers.push(resolve))
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
const unsubscribe = observer.subscribe(() => {})
|
|
69
|
+
await Promise.resolve()
|
|
70
|
+
expect(calls).toBe(1)
|
|
71
|
+
|
|
72
|
+
const cancel = vi.spyOn(client, 'cancelQueries')
|
|
73
|
+
const invalidate = vi.spyOn(client, 'invalidateQueries')
|
|
74
|
+
client.setQueryData(['capabilities'], 'unchanged')
|
|
75
|
+
const done = refreshAfterNamespaceSwitch(client, { cacheScoped: false, actives: [] })
|
|
76
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
77
|
+
expect(calls).toBe(1)
|
|
78
|
+
expect(cancel).not.toHaveBeenCalled()
|
|
79
|
+
expect(invalidate).not.toHaveBeenCalled()
|
|
80
|
+
expect(client.getQueryState(['capabilities'])?.isInvalidated).toBe(false)
|
|
81
|
+
resolvers[0]('original request')
|
|
82
|
+
await done
|
|
83
|
+
await vi.waitFor(() => expect(client.getQueryData(['fleet', ''])).toBe('original request'))
|
|
84
|
+
unsubscribe()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('leaves queries alone when switching to a named namespace', async () => {
|
|
88
|
+
const client = new QueryClient()
|
|
89
|
+
client.setQueryData(['fleet', 'db'], 'x')
|
|
90
|
+
await refreshAfterNamespaceSwitch(client, { cacheScoped: false, actives: ['db'] })
|
|
91
|
+
expect(client.getQueryState(['fleet', 'db'])?.isInvalidated).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
it('invalidates inventory after a cache-scoped switch without cancelling requests or the scope query', async () => {
|
|
97
|
+
const client = new QueryClient()
|
|
98
|
+
client.setQueryData(['resource', 'pods', 'db', 'app'], 'pod')
|
|
99
|
+
client.setQueryData(['namespace-scope'], 'scope')
|
|
100
|
+
const cancel = vi.spyOn(client, 'cancelQueries')
|
|
101
|
+
const invalidate = vi.spyOn(client, 'invalidateQueries')
|
|
102
|
+
await refreshAfterNamespaceSwitch(client, { cacheScoped: true, actives: ['db'] })
|
|
103
|
+
expect(cancel).not.toHaveBeenCalled()
|
|
104
|
+
expect(invalidate.mock.calls[0][1]).toEqual({ cancelRefetch: false })
|
|
105
|
+
expect(client.getQueryState(['resource', 'pods', 'db', 'app'])?.isInvalidated).toBe(true)
|
|
106
|
+
expect(client.getQueryState(['namespace-scope'])?.isInvalidated).toBe(false)
|
|
107
|
+
})
|