@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.
Files changed (276) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +125 -87
  4. package/src/api/actions.errorCode.test.ts +40 -0
  5. package/src/api/actions.test.ts +48 -0
  6. package/src/api/actions.ts +79 -0
  7. package/src/api/apiResources.navigation.test.tsx +232 -0
  8. package/src/api/apiResources.ts +46 -3
  9. package/src/api/client.helmSource.test.ts +39 -0
  10. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  11. package/src/api/client.namespaceSwitch.test.ts +107 -0
  12. package/src/api/client.ts +266 -39
  13. package/src/api/cnpg-ha.live.test.ts +140 -0
  14. package/src/api/cnpg-ha.test.ts +78 -0
  15. package/src/api/cnpg-ha.ts +19 -0
  16. package/src/api/cnpg-history.ts +123 -0
  17. package/src/api/cnpg-inspect.ts +102 -0
  18. package/src/api/cnpg-protection.ts +112 -0
  19. package/src/api/cnpg-recovery.test.tsx +60 -0
  20. package/src/api/cnpg-recovery.ts +262 -0
  21. package/src/api/cnpg-sessions.ts +175 -0
  22. package/src/api/cnpg-storage.ts +138 -0
  23. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  24. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  25. package/src/api/cnpg.ts +428 -10
  26. package/src/api/radarFeatures.render.test.tsx +47 -3
  27. package/src/api/radarFeatures.ts +4 -1
  28. package/src/api/reviewedActionMutation.test.tsx +164 -0
  29. package/src/api/reviewedActionMutation.ts +66 -0
  30. package/src/api/timelineSource.test.ts +9 -0
  31. package/src/api/timelineSource.ts +8 -0
  32. package/src/components/audit/AuditView.tsx +9 -0
  33. package/src/components/capacity/CapacityActivity.tsx +3 -31
  34. package/src/components/capacity/CapacityDemand.tsx +4 -36
  35. package/src/components/capacity/CapacityOverview.tsx +217 -56
  36. package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
  37. package/src/components/capacity/CapacityView.test.tsx +323 -0
  38. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  39. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  40. package/src/components/capacity/routes.ts +42 -0
  41. package/src/components/capacity/shared.tsx +11 -129
  42. package/src/components/capacity/workspaceRoute.ts +18 -0
  43. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  44. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  45. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  46. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  47. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  48. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  49. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  50. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  51. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  52. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  53. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  54. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  55. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  56. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  57. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  58. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  59. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  60. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  61. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  62. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  63. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  64. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  65. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  66. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  67. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  68. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  69. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  70. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  71. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  72. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  73. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  74. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  75. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  76. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  77. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  78. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  79. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  80. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  81. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  82. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  83. package/src/components/cnpg/CNPGProtection.tsx +228 -122
  84. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  85. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  86. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  87. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  88. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  89. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  90. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  91. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  92. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  93. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  94. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  95. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  96. package/src/components/cnpg/CNPGView.tsx +34 -21
  97. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  98. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  99. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  100. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  101. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  102. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  103. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  104. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  105. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  106. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  107. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  108. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  109. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  110. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  111. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  112. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  113. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  114. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  115. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  116. package/src/components/cnpg/actions/actionModel.ts +127 -0
  117. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  118. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  119. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  120. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  121. package/src/components/cnpg/archivingRepair.ts +145 -0
  122. package/src/components/cnpg/baseBackup.test.ts +36 -0
  123. package/src/components/cnpg/baseBackup.ts +36 -0
  124. package/src/components/cnpg/blocking.test.ts +59 -0
  125. package/src/components/cnpg/blocking.ts +131 -0
  126. package/src/components/cnpg/coverageText.test.ts +27 -0
  127. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  128. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  129. package/src/components/cnpg/creation/targetModel.ts +149 -0
  130. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  131. package/src/components/cnpg/fleetStatus.ts +43 -0
  132. package/src/components/cnpg/formFields.ts +2 -0
  133. package/src/components/cnpg/grantText.test.tsx +11 -0
  134. package/src/components/cnpg/host.test.tsx +24 -0
  135. package/src/components/cnpg/host.tsx +101 -0
  136. package/src/components/cnpg/inspectModel.test.ts +120 -0
  137. package/src/components/cnpg/inspectModel.ts +140 -0
  138. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  139. package/src/components/cnpg/logicalSlots.ts +29 -0
  140. package/src/components/cnpg/lsn.test.ts +40 -0
  141. package/src/components/cnpg/lsn.ts +43 -0
  142. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  143. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  144. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  145. package/src/components/cnpg/operations/model.test.ts +510 -0
  146. package/src/components/cnpg/operations/model.ts +499 -0
  147. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  148. package/src/components/cnpg/operations/presentation.ts +84 -0
  149. package/src/components/cnpg/operations/store.ts +98 -0
  150. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  151. package/src/components/cnpg/operatorStatus.ts +194 -0
  152. package/src/components/cnpg/paths.test.ts +54 -0
  153. package/src/components/cnpg/paths.ts +47 -4
  154. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  155. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  156. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  157. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  158. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  159. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  160. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  161. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  162. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  163. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  164. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  165. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  166. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  167. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  168. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  169. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  172. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  173. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  174. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  175. package/src/components/cnpg/refresh.test.ts +38 -0
  176. package/src/components/cnpg/refresh.ts +17 -0
  177. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  178. package/src/components/cnpg/routes.test.ts +34 -3
  179. package/src/components/cnpg/routes.ts +60 -48
  180. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  181. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  182. package/src/components/cnpg/runtimeModel.ts +196 -0
  183. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  184. package/src/components/cnpg/runtimeParts.tsx +79 -0
  185. package/src/components/cnpg/shared.test.tsx +59 -0
  186. package/src/components/cnpg/shared.tsx +100 -182
  187. package/src/components/cnpg/storageModel.test.ts +107 -0
  188. package/src/components/cnpg/storageModel.ts +101 -0
  189. package/src/components/cnpg/trendSamples.test.ts +148 -0
  190. package/src/components/cnpg/trendSamples.ts +222 -0
  191. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  192. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  193. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  194. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  195. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  196. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  197. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  198. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  199. package/src/components/cnpg/workspaceRoute.ts +23 -0
  200. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  201. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  202. package/src/components/cost/CostView.test.ts +2 -1
  203. package/src/components/cost/CostView.tsx +0 -2
  204. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  205. package/src/components/cost/errors.ts +3 -1
  206. package/src/components/cost/source.test.ts +10 -0
  207. package/src/components/cost/source.ts +5 -1
  208. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  209. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  210. package/src/components/dock/BottomDock.tsx +2 -0
  211. package/src/components/dock/TerminalTab.test.tsx +17 -0
  212. package/src/components/dock/TerminalTab.tsx +7 -3
  213. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  214. package/src/components/gitops/GitOpsView.tsx +16 -23
  215. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  216. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  217. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  218. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  219. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  220. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  221. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  222. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  223. package/src/components/helm/ValuesViewer.tsx +169 -78
  224. package/src/components/home/ActivitySummary.tsx +2 -1
  225. package/src/components/home/HomeView.tsx +20 -9
  226. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  227. package/src/components/home/TopologyPreview.tsx +2 -2
  228. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  229. package/src/components/issues/IssuesPane.test.ts +65 -1
  230. package/src/components/issues/IssuesPane.tsx +188 -25
  231. package/src/components/issues/uncheckedSources.ts +21 -0
  232. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  233. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  234. package/src/components/resource/PVCUsageBar.tsx +15 -6
  235. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  236. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  237. package/src/components/resources/CompositeRenderer.tsx +4 -4
  238. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  239. package/src/components/resources/ResourcesView.tsx +26 -12
  240. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  241. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  242. package/src/components/settings/SettingsDialog.tsx +28 -15
  243. package/src/components/timeline/TimelineList.tsx +22 -24
  244. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  245. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  246. package/src/components/timeline/TimelineView.tsx +18 -45
  247. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  248. package/src/components/timeline/useStickyError.test.tsx +47 -0
  249. package/src/components/timeline/useStickyError.ts +15 -0
  250. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  251. package/src/components/ui/Markdown.test.tsx +65 -0
  252. package/src/components/ui/Markdown.tsx +144 -127
  253. package/src/components/whats-new/releaseNotes.ts +63 -1
  254. package/src/components/workload/WorkloadView.test.ts +10 -1
  255. package/src/components/workload/WorkloadView.tsx +27 -239
  256. package/src/components/workspace/layout.test.tsx +29 -0
  257. package/src/components/workspace/layout.tsx +259 -0
  258. package/src/components/workspace/table.ts +7 -0
  259. package/src/contexts/CapabilitiesContext.tsx +1 -1
  260. package/src/integrations/resourceHost.test.tsx +55 -0
  261. package/src/integrations/resourceHost.ts +99 -0
  262. package/src/integrations/resourceHosts.test.tsx +141 -0
  263. package/src/integrations/resourceHosts.tsx +119 -0
  264. package/src/integrations/workspaceRoute.ts +20 -0
  265. package/src/integrations/workspaceRoutes.test.ts +40 -0
  266. package/src/integrations/workspaceRoutes.ts +22 -0
  267. package/src/integrations/workspaceScreens.tsx +14 -0
  268. package/src/utils/drawer-trail.ts +35 -0
  269. package/src/utils/kindFilterStore.test.ts +147 -0
  270. package/src/utils/kindFilterStore.ts +91 -0
  271. package/src/utils/navigation.test.ts +13 -1
  272. package/src/utils/navigation.ts +39 -2
  273. package/src/utils/page-links.ts +21 -0
  274. package/src/utils/tabStorage.ts +40 -0
  275. package/src/utils/viewMemory.test.ts +78 -0
  276. 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
+ });
@@ -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
- return useQuery<APIResource[]>({
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
+ })