@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
@@ -1,5 +1,8 @@
1
+ // @vitest-environment jsdom
1
2
  import { describe, expect, it, vi } from "vitest";
2
3
  import { renderToString } from "react-dom/server";
4
+ import { act } from "react";
5
+ import { createRoot } from "react-dom/client";
3
6
  import { MemoryRouter } from "react-router-dom";
4
7
  import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
8
  import type {
@@ -1337,6 +1340,326 @@ describe("CapacityView overview", () => {
1337
1340
  });
1338
1341
  });
1339
1342
 
1343
+ describe("Capacity scale-down observations", () => {
1344
+ const noCandidatesExplanation =
1345
+ "The autoscaler found no node it can remove. Usually every node is above its utilization threshold, or pods on emptier nodes can't move (PodDisruptionBudgets, local storage, kube-system pods, safe-to-evict annotations). A node's drain plan shows what would block it.";
1346
+ const candidateExplanation =
1347
+ "Scale-down candidates are observations, not guaranteed removals. PodDisruptionBudgets, local storage, kube-system pods, or safe-to-evict annotations can block removal. Radar can't tell which nodes the autoscaler picked, but a node's drain plan shows what would block removing it.";
1348
+
1349
+ function renderScaleDown(child: CapacityAutoscalerChildObservation) {
1350
+ return renderCapacity("/capacity", (client) =>
1351
+ client.setQueryData(["capacity", "overview"], {
1352
+ ...comprehensiveOverview(),
1353
+ groups: [
1354
+ {
1355
+ ...gkeGroup,
1356
+ scaling: [
1357
+ {
1358
+ code: "at_min_size",
1359
+ summary: "at minimum size — can't scale down",
1360
+ },
1361
+ ],
1362
+ children: [child],
1363
+ childrenMeta: boundedMeta(1),
1364
+ },
1365
+ ],
1366
+ orphanAutoscalerGroups: [child],
1367
+ }),
1368
+ );
1369
+ }
1370
+
1371
+ it("explains the floor and keeps published zero candidates", () => {
1372
+ const clock = vi.spyOn(Date, "now").mockReturnValue(Date.parse(generatedAt) + 6 * 60 * 1000);
1373
+ const html = renderScaleDown({
1374
+ ...gkeChild,
1375
+ scaleDown: {
1376
+ status: "NoCandidates",
1377
+ candidates: 0,
1378
+ lastTransitionTime: generatedAt,
1379
+ asOf: generatedAt,
1380
+ },
1381
+ });
1382
+ clock.mockRestore();
1383
+ expect(html).toContain("at minimum size");
1384
+ expect(html).toContain(
1385
+ "Every observed autoscaler group has its target at its published minimum.",
1386
+ );
1387
+ expect(html).toContain(
1388
+ "Lowering the pool's minimum is required for it to shrink below this size.",
1389
+ );
1390
+ expect(html).not.toContain("No node can be removed right now");
1391
+ expect(html).toContain(noCandidatesExplanation.replaceAll("'", "'"));
1392
+ expect(html).not.toContain("autoscaler child");
1393
+ expect(html).toContain("No scale-down candidates");
1394
+ expect(html).toContain("Candidate count published by the autoscaler: 0.");
1395
+ expect(html).not.toContain("Candidates:");
1396
+ expect(html).toContain("Status since");
1397
+ expect(html).toContain("(for 6m)");
1398
+ expect(html).not.toContain("As of");
1399
+ expect(html).toContain("PodDisruptionBudgets");
1400
+ expect(html).not.toContain("ETA");
1401
+ });
1402
+
1403
+ it("explains no candidates above the minimum on the collapsed pool and both child tables", () => {
1404
+ const child: CapacityAutoscalerChildObservation = {
1405
+ ...gkeChild,
1406
+ scaleDown: { status: "NoCandidates", candidates: 0 },
1407
+ };
1408
+ const html = renderCapacity("/capacity", (client) =>
1409
+ client.setQueryData(["capacity", "overview"], {
1410
+ ...comprehensiveOverview(),
1411
+ groups: [{ ...gkeGroup, children: [child], childrenMeta: boundedMeta(1) }],
1412
+ orphanAutoscalerGroups: [child],
1413
+ }),
1414
+ );
1415
+ expect(html).toContain('aria-expanded="false"');
1416
+ const explanation = noCandidatesExplanation.replaceAll("'", "'");
1417
+ expect(html).toContain(
1418
+ `tabindex="0" role="note" aria-label="No node can be removed right now. ${explanation}"`,
1419
+ );
1420
+ expect(html.split(`No scale-down candidates. ${explanation}`)).toHaveLength(3);
1421
+ expect(html).not.toContain("Scale-down candidates are observations");
1422
+ });
1423
+
1424
+ it.each([
1425
+ ["missing count", { scaleDown: { status: "NoCandidates" } }, false],
1426
+ ["missing observation", { scaleDown: undefined }, false],
1427
+ ["candidates present", { scaleDown: { status: "CandidatesPresent", candidates: 1 } }, false],
1428
+ ["missing minimum", { minSize: undefined }, false],
1429
+ ["missing target", { target: undefined }, false],
1430
+ ["below the minimum", { target: -1 }, false],
1431
+ ["truncated children", {}, true],
1432
+ ])("does not assert no removable nodes with %s", (_label, override, truncated) => {
1433
+ const html = renderCapacity("/capacity", (client) =>
1434
+ client.setQueryData(["capacity", "overview"], {
1435
+ ...comprehensiveOverview(),
1436
+ groups: [{
1437
+ ...gkeGroup,
1438
+ children: [
1439
+ { ...gkeChild, scaleDown: { status: "NoCandidates", candidates: 0 } },
1440
+ { ...gkeChildBackoff, scaleDown: { status: "NoCandidates", candidates: 0 }, ...override },
1441
+ ],
1442
+ childrenMeta: { total: truncated ? 3 : 2, returned: 2, truncated },
1443
+ }],
1444
+ }),
1445
+ );
1446
+ expect(html).not.toContain("No node can be removed right now");
1447
+ });
1448
+
1449
+ it("keeps scale-down probe freshness separate from health in both tables", () => {
1450
+ const clock = vi
1451
+ .spyOn(Date, "now")
1452
+ .mockReturnValue(Date.parse(generatedAt) + 6 * 60 * 1000);
1453
+ try {
1454
+ const html = renderScaleDown({
1455
+ ...gkeChild,
1456
+ scaleDown: {
1457
+ status: "InProgress",
1458
+ asOf: "2026-07-13T07:06:00Z",
1459
+ },
1460
+ });
1461
+ expect(html.match(/Health observed<\/th>/g)).toHaveLength(2);
1462
+ expect(html.match(/Probe: <span[^>]*>1h ago<\/span>/g)).toHaveLength(2);
1463
+ expect(html.match(/>6m ago<\/span><\/td>/g)).toHaveLength(2);
1464
+ } finally {
1465
+ clock.mockRestore();
1466
+ }
1467
+ });
1468
+
1469
+ it("shows an absent scale-down probe independently of published health", () => {
1470
+ const html = renderScaleDown({
1471
+ ...gkeChild,
1472
+ scaleDown: { status: "InProgress" },
1473
+ });
1474
+ expect(html.match(/Probe: <span[^>]*>not probed<\/span>/g)).toHaveLength(2);
1475
+ });
1476
+
1477
+ it.each([
1478
+ "InProgress",
1479
+ "CandidatesPresent",
1480
+ "NoCandidates",
1481
+ "NewControllerStatus",
1482
+ ])(
1483
+ "makes the %s removal caveat keyboard reachable without a count glyph",
1484
+ (status) => {
1485
+ const html = renderScaleDown({
1486
+ ...gkeChild,
1487
+ scaleDown: { status },
1488
+ });
1489
+ expect(
1490
+ html.match(
1491
+ /tabindex="0" role="note" aria-label="[^"]*PodDisruptionBudgets[^"]*"/g,
1492
+ ),
1493
+ ).toHaveLength(2);
1494
+ },
1495
+ );
1496
+
1497
+ it("never invents a zero count from NoCandidates or missing observations", () => {
1498
+ const html = renderScaleDown({
1499
+ ...gkeChild,
1500
+ scaleDown: { status: "NoCandidates" },
1501
+ });
1502
+ expect(html).toMatch(/Candidates:(?:<!-- -->|\s)*not published/);
1503
+ expect(html).toContain("Candidate count not published by the autoscaler.");
1504
+ expect(html).not.toContain("Probe time not published");
1505
+ expect(html).not.toContain("Status since");
1506
+ expect(html).not.toMatch(/Candidates:(?:<!-- -->|\s)*0/);
1507
+ const older = renderScaleDown(gkeChild);
1508
+ expect(older).not.toContain("Scale-down</th>");
1509
+ expect(older).not.toContain("Not published");
1510
+ expect(older).not.toContain("PodDisruptionBudgets");
1511
+ });
1512
+
1513
+ it("keeps a status-only CandidatesPresent count unknown", () => {
1514
+ const html = renderScaleDown({
1515
+ ...gkeChild,
1516
+ scaleDown: { status: "CandidatesPresent" },
1517
+ });
1518
+ expect(html).toContain("Scale-down candidates present");
1519
+ expect(html).toContain("Candidates: not published");
1520
+ expect(html).not.toContain("0 scale-down candidates");
1521
+ });
1522
+
1523
+ it.each(["joined", "orphan"])(
1524
+ "shows both tables when only a %s child has scale-down data",
1525
+ (source) => {
1526
+ const withScaleDown = {
1527
+ ...gkeChild,
1528
+ scaleDown: { status: "NoCandidates", candidates: 0 },
1529
+ };
1530
+ const html = renderCapacity("/capacity", (client) =>
1531
+ client.setQueryData(["capacity", "overview"], {
1532
+ ...comprehensiveOverview(),
1533
+ groups: [
1534
+ {
1535
+ ...gkeGroup,
1536
+ children: [source === "joined" ? withScaleDown : gkeChild],
1537
+ },
1538
+ ],
1539
+ orphanAutoscalerGroups: [
1540
+ source === "orphan" ? withScaleDown : gkeChild,
1541
+ ],
1542
+ }),
1543
+ );
1544
+ expect(html.match(/Scale-down<\/th>/g)).toHaveLength(2);
1545
+ expect(html).toContain("Not published");
1546
+ expect(html).toContain("No scale-down candidates");
1547
+ },
1548
+ );
1549
+
1550
+ it("opens the removal caveat on status focus and dismisses it on blur", async () => {
1551
+ const client = new QueryClient({
1552
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
1553
+ });
1554
+ client.setQueryData(["capacity", "overview"], {
1555
+ ...comprehensiveOverview(),
1556
+ groups: [],
1557
+ orphanAutoscalerGroups: [
1558
+ {
1559
+ ...gkeChild,
1560
+ scaleDown: { status: "InProgress", candidates: 2 },
1561
+ },
1562
+ ],
1563
+ });
1564
+ const container = document.createElement("div");
1565
+ document.body.append(container);
1566
+ const root = createRoot(container);
1567
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
1568
+ vi.useFakeTimers();
1569
+ try {
1570
+ await act(async () => {
1571
+ root.render(
1572
+ <MemoryRouter initialEntries={["/capacity"]}>
1573
+ <QueryClientProvider client={client}>
1574
+ <CapacityView onOpenResource={() => {}} />
1575
+ </QueryClientProvider>
1576
+ </MemoryRouter>,
1577
+ );
1578
+ });
1579
+ const trigger = container.querySelector<HTMLElement>(
1580
+ '[role="note"][aria-label^="Scale-down in progress."]',
1581
+ );
1582
+ expect(trigger).not.toBeNull();
1583
+ expect(trigger!.textContent).toBe("Scale-down in progress");
1584
+ expect(trigger!.querySelector('[tabindex="0"]')).toBeNull();
1585
+ await act(async () => {
1586
+ trigger!.focus();
1587
+ await vi.advanceTimersByTimeAsync(400);
1588
+ });
1589
+ expect(document.activeElement).toBe(trigger);
1590
+ expect(document.querySelector('[role="tooltip"]')?.textContent).toContain(
1591
+ "PodDisruptionBudgets",
1592
+ );
1593
+ await act(async () => {
1594
+ trigger!.blur();
1595
+ });
1596
+ expect(document.querySelector('[role="tooltip"]')).toBeNull();
1597
+ } finally {
1598
+ await act(async () => {
1599
+ root.unmount();
1600
+ });
1601
+ client.clear();
1602
+ container.remove();
1603
+ vi.useRealTimers();
1604
+ vi.unstubAllGlobals();
1605
+ }
1606
+ });
1607
+
1608
+ it("renders the server candidate fact on the collapsed pool", () => {
1609
+ const html = renderCapacity("/capacity", (client) =>
1610
+ client.setQueryData(["capacity", "overview"], {
1611
+ ...comprehensiveOverview(),
1612
+ groups: [
1613
+ {
1614
+ ...gkeGroup,
1615
+ scaling: [
1616
+ {
1617
+ code: "scale_down_candidates",
1618
+ summary: "3 scale-down candidates",
1619
+ },
1620
+ ],
1621
+ },
1622
+ ],
1623
+ }),
1624
+ );
1625
+ expect(html).toContain("3 scale-down candidates");
1626
+ expect(html).toMatch(/tabindex="0" role="note" aria-label="3 scale-down candidates\. [^"]*PodDisruptionBudgets/);
1627
+ expect(html).toContain(candidateExplanation.replaceAll("'", "&#x27;"));
1628
+ expect(html).not.toContain("scale-down delay");
1629
+ expect(html).not.toContain("Radar can&#x27;t see those blockers");
1630
+ expect(html).toContain('aria-expanded="false"');
1631
+ });
1632
+
1633
+ it.each([
1634
+ ["CandidatesPresent", "2 scale-down candidates"],
1635
+ ["InProgress", "Scale-down in progress"],
1636
+ ["NewControllerStatus", "NewControllerStatus"],
1637
+ ["constructor", "constructor"],
1638
+ ])("renders %s without promising a removal time", (status, label) => {
1639
+ const html = renderScaleDown({
1640
+ ...gkeChild,
1641
+ scaleDown: { status, candidates: 2 },
1642
+ });
1643
+ expect(html).toContain(label);
1644
+ expect(html).toContain("2 scale-down candidates");
1645
+ expect(html).not.toContain("unneeded");
1646
+ expect(html).not.toContain("Candidates:");
1647
+ expect(html).not.toContain("Status since");
1648
+ });
1649
+
1650
+ it("names a single published candidate the same way the pool row does", () => {
1651
+ const html = renderScaleDown({
1652
+ ...gkeChild,
1653
+ scaleDown: { status: "CandidatesPresent", candidates: 1 },
1654
+ });
1655
+ expect(html).toMatch(
1656
+ /role="note" aria-label="1 scale-down candidate\. Scale-down candidates are observations/,
1657
+ );
1658
+ expect(html).toContain(candidateExplanation.replaceAll("'", "&#x27;"));
1659
+ expect(html).not.toContain("1 scale-down candidates");
1660
+ });
1661
+ });
1662
+
1340
1663
  describe("CapacityView pool detail", () => {
1341
1664
  it("renders the capacity ledger with headers and a certainty legend", () => {
1342
1665
  const html = renderCapacity("/capacity/pools/default", (client) =>
@@ -9,16 +9,8 @@ import {
9
9
  parseCPUToNanocores,
10
10
  parseQuantityToNumber,
11
11
  } from "@skyhook-io/k8s-ui/utils/format";
12
- import {
13
- CertaintyGlyph,
14
- LinkButton,
15
- SectionCard,
16
- certaintyGlyph,
17
- certaintyValueLabel,
18
- formatQuantity,
19
- quantityResourceRank,
20
- resourceLabel,
21
- } from "./shared";
12
+ import { LinkButton, SectionCard, formatQuantity, quantityResourceRank, resourceLabel } from "./shared";
13
+ import { CertaintyGlyph, certaintyGlyph, certaintyValueLabel } from "@skyhook-io/k8s-ui";
22
14
 
23
15
  // Karpenter scheduling capacity — the fleet-level "how full is Karpenter, and
24
16
  // how much is knocking at the door" bar. Karpenter-pooled nodes only: on mixed
@@ -4,15 +4,8 @@ import type {
4
4
  CapacityActivityState,
5
5
  CapacityQuantityObservation,
6
6
  } from "@skyhook-io/k8s-ui";
7
- import {
8
- DeniedCapacityState,
9
- ActivityStateBadge,
10
- CertaintyGlyph,
11
- InventoryQuantityCell,
12
- QuantityInline,
13
- certaintyGlyph,
14
- managerStatusRank,
15
- } from "./shared";
7
+ import { DeniedCapacityState, ActivityStateBadge, InventoryQuantityCell, QuantityInline, managerStatusRank } from "./shared";
8
+ import { CertaintyGlyph, certaintyGlyph } from "@skyhook-io/k8s-ui";
16
9
 
17
10
  function observation(
18
11
  certainty: CapacityQuantityObservation["certainty"],
@@ -0,0 +1,42 @@
1
+ export type CapacityTopTab = "overview" | "demand" | "activity";
2
+ export type PoolSection = "summary" | "workloads" | "members" | "configuration";
3
+
4
+ export interface CapacityRoute {
5
+ topTab: CapacityTopTab;
6
+ poolName?: string;
7
+ poolSection: PoolSection;
8
+ }
9
+
10
+ export function parseCapacityRoute(pathname: string): CapacityRoute {
11
+ const segments = pathname.replace(/^\/+|\/+$/g, "").split("/");
12
+ if (segments[0] !== "capacity")
13
+ return { topTab: "overview", poolSection: "summary" };
14
+ if (segments[1] === "pools" && segments[2]) {
15
+ const section = segments[3];
16
+ return {
17
+ topTab: "overview",
18
+ poolName: decodePathSegment(segments[2]),
19
+ poolSection:
20
+ section === "workloads" ||
21
+ section === "members" ||
22
+ section === "configuration"
23
+ ? section
24
+ : "summary",
25
+ };
26
+ }
27
+ if (segments[1] === "demand" || segments[1] === "activity")
28
+ return { topTab: segments[1], poolSection: "summary" };
29
+ return { topTab: "overview", poolSection: "summary" };
30
+ }
31
+
32
+ function decodePathSegment(value: string): string {
33
+ try {
34
+ return decodeURIComponent(value);
35
+ } catch {
36
+ return value;
37
+ }
38
+ }
39
+
40
+ export function capacityPoolPath(name: string): string {
41
+ return `/capacity/pools/${encodeURIComponent(name)}`;
42
+ }
@@ -2,7 +2,6 @@ import {
2
2
  useCallback,
3
3
  useEffect,
4
4
  useState,
5
- type ComponentType,
6
5
  type ReactNode,
7
6
  } from "react";
8
7
  import {
@@ -25,6 +24,8 @@ import {
25
24
  ResourceBar,
26
25
  Tooltip,
27
26
  WithTooltip,
27
+ certaintyValueLabel,
28
+ CertaintyGlyph,
28
29
  type CapacityActivityEpisode,
29
30
  type CapacityCertainty,
30
31
  type CapacityClaimStage,
@@ -61,22 +62,19 @@ import {
61
62
  } from "../../api/client";
62
63
  import type { SelectedResource } from "../../types";
63
64
  import { refToSelectedResource } from "../../utils/navigation";
65
+ import { Notice, ScreenEmptyState } from "../workspace/layout";
64
66
 
65
67
  // ============================================================================
66
68
  // Route model + navigation
67
69
  // ============================================================================
68
70
 
69
- export type CapacityTopTab = "overview" | "demand" | "activity";
70
- export type PoolSection = "summary" | "workloads" | "members" | "configuration";
71
+ import type { PoolSection } from './routes'
72
+ export { capacityPoolPath, parseCapacityRoute } from './routes'
73
+ export type { CapacityRoute, CapacityTopTab, PoolSection } from './routes'
74
+
71
75
  export type CapacityConnectionState =
72
76
  "connected" | "disconnected" | "connecting";
73
77
 
74
- export interface CapacityRoute {
75
- topTab: CapacityTopTab;
76
- poolName?: string;
77
- poolSection: PoolSection;
78
- }
79
-
80
78
  export const POOL_SECTIONS: { id: PoolSection; label: string }[] = [
81
79
  { id: "summary", label: "Summary" },
82
80
  { id: "workloads", label: "Workloads" },
@@ -84,40 +82,6 @@ export const POOL_SECTIONS: { id: PoolSection; label: string }[] = [
84
82
  { id: "configuration", label: "Configuration" },
85
83
  ];
86
84
 
87
- export function parseCapacityRoute(pathname: string): CapacityRoute {
88
- const segments = pathname.replace(/^\/+|\/+$/g, "").split("/");
89
- if (segments[0] !== "capacity")
90
- return { topTab: "overview", poolSection: "summary" };
91
- if (segments[1] === "pools" && segments[2]) {
92
- const section = segments[3];
93
- return {
94
- topTab: "overview",
95
- poolName: decodePathSegment(segments[2]),
96
- poolSection:
97
- section === "workloads" ||
98
- section === "members" ||
99
- section === "configuration"
100
- ? section
101
- : "summary",
102
- };
103
- }
104
- if (segments[1] === "demand" || segments[1] === "activity")
105
- return { topTab: segments[1], poolSection: "summary" };
106
- return { topTab: "overview", poolSection: "summary" };
107
- }
108
-
109
- function decodePathSegment(value: string): string {
110
- try {
111
- return decodeURIComponent(value);
112
- } catch {
113
- return value;
114
- }
115
- }
116
-
117
- export function capacityPoolPath(name: string): string {
118
- return `/capacity/pools/${encodeURIComponent(name)}`;
119
- }
120
-
121
85
  export function identityToSelectedResource(
122
86
  identity: CapacityResourceIdentity,
123
87
  ): SelectedResource {
@@ -266,20 +230,6 @@ function missingUsageMessage(coverage?: CapacitySourceCoverage): string {
266
230
  // = exact · ≥ lower bound · ≤ upper bound · ? unknown
267
231
  // ============================================================================
268
232
 
269
- export function certaintyGlyph(certainty: CapacityCertainty): string {
270
- if (certainty === "exact") return "=";
271
- if (certainty === "lower_bound") return "≥";
272
- if (certainty === "upper_bound") return "≤";
273
- return "?";
274
- }
275
-
276
- export function certaintyValueLabel(certainty: CapacityCertainty): string {
277
- if (certainty === "exact") return "Exact";
278
- if (certainty === "lower_bound") return "Lower bound";
279
- if (certainty === "upper_bound") return "Upper bound";
280
- return "Unknown certainty";
281
- }
282
-
283
233
  export function observationTitle(
284
234
  observation: CapacityQuantityObservation,
285
235
  definition?: string,
@@ -295,28 +245,6 @@ export function observationTitle(
295
245
  return parts.join(" ");
296
246
  }
297
247
 
298
- /** Small bordered mono glyph chip; hover reveals certainty/source/asOf. */
299
- export function CertaintyGlyph({
300
- certainty,
301
- title,
302
- }: {
303
- certainty: CapacityCertainty;
304
- title?: string;
305
- }) {
306
- return (
307
- <WithTooltip tip={title ?? certaintyValueLabel(certainty)}>
308
- <span
309
- tabIndex={0}
310
- role="note"
311
- aria-label={title ?? certaintyValueLabel(certainty)}
312
- className="cursor-help rounded border border-theme-border-light px-1 font-mono text-[10px] leading-tight text-theme-text-tertiary focus-visible:outline focus-visible:outline-2 focus-visible:outline-skyhook-500"
313
- >
314
- {certaintyGlyph(certainty)}
315
- </span>
316
- </WithTooltip>
317
- );
318
- }
319
-
320
248
  // ============================================================================
321
249
  // Quantity / resource formatting
322
250
  // ============================================================================
@@ -1241,15 +1169,6 @@ export function TokenGroup({
1241
1169
  );
1242
1170
  }
1243
1171
 
1244
- export function Notice({ children }: { children: ReactNode }) {
1245
- return (
1246
- <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-text-secondary">
1247
- <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
1248
- <div>{children}</div>
1249
- </div>
1250
- );
1251
- }
1252
-
1253
1172
  export function RefreshError({ message }: { message: string }) {
1254
1173
  return (
1255
1174
  <Notice>
@@ -1327,31 +1246,6 @@ export function InlineEmpty({
1327
1246
  );
1328
1247
  }
1329
1248
 
1330
- export function EmptyState({
1331
- icon: Icon,
1332
- title,
1333
- detail,
1334
- action,
1335
- }: {
1336
- icon: ComponentType<{ className?: string }>;
1337
- title: string;
1338
- detail: ReactNode;
1339
- action?: ReactNode;
1340
- }) {
1341
- return (
1342
- <div className="flex min-h-0 flex-1 items-center justify-center bg-theme-base p-6">
1343
- <div className="max-w-md text-center">
1344
- <Icon className="mx-auto h-9 w-9 text-theme-text-tertiary/50" />
1345
- <h2 className="mt-3 text-lg font-medium text-theme-text-primary">
1346
- {title}
1347
- </h2>
1348
- <p className="mt-1 text-sm text-theme-text-secondary">{detail}</p>
1349
- {action}
1350
- </div>
1351
- </div>
1352
- );
1353
- }
1354
-
1355
1249
  export function ScrollableContent({ children }: { children: ReactNode }) {
1356
1250
  // Expanding/collapsing cards can toggle the scrollbar; a stable gutter
1357
1251
  // keeps the centered column from shifting sideways when that happens.
@@ -1460,7 +1354,7 @@ rules:
1460
1354
  export function DeniedCapacityState({ detail }: { detail: string }) {
1461
1355
  const [copied, setCopied] = useState(false);
1462
1356
  return (
1463
- <EmptyState
1357
+ <ScreenEmptyState
1464
1358
  icon={Shield}
1465
1359
  title="Capacity access denied"
1466
1360
  detail={detail}
@@ -1519,7 +1413,7 @@ export function integrationBlock(
1519
1413
  const upgrade = getRadarUpgradeRequirement(error);
1520
1414
  if (upgrade)
1521
1415
  return (
1522
- <EmptyState
1416
+ <ScreenEmptyState
1523
1417
  icon={Gauge}
1524
1418
  title={radarUpgradeHeadline("Capacity")}
1525
1419
  detail={radarUpgradeDetail(upgrade)}
@@ -1531,7 +1425,7 @@ export function integrationBlock(
1531
1425
  />
1532
1426
  );
1533
1427
  return (
1534
- <EmptyState
1428
+ <ScreenEmptyState
1535
1429
  icon={AlertTriangle}
1536
1430
  title="Capacity unavailable"
1537
1431
  detail={errorMessage(error)}
@@ -1549,7 +1443,7 @@ export function integrationBlock(
1549
1443
  );
1550
1444
  if (response.state === "not_detected")
1551
1445
  return (
1552
- <EmptyState
1446
+ <ScreenEmptyState
1553
1447
  icon={Gauge}
1554
1448
  title="Karpenter not detected"
1555
1449
  detail="Capacity appears automatically when this cluster exposes Karpenter NodePools."
@@ -1838,15 +1732,3 @@ export function PoolSelector({
1838
1732
  </div>
1839
1733
  );
1840
1734
  }
1841
-
1842
- // ============================================================================
1843
- // Shared table cell classes (keeps every capacity table visually identical)
1844
- // ============================================================================
1845
-
1846
- export const TABLE_WRAP = "overflow-x-auto";
1847
- export const TABLE_HEAD =
1848
- "border-b border-theme-border bg-theme-base/60 text-[11px] uppercase tracking-wide text-theme-text-tertiary";
1849
- export const TH = "px-3 py-2.5 text-left font-medium whitespace-nowrap";
1850
- export const TD = "px-3 py-2.5 align-top text-sm text-theme-text-primary";
1851
- export const TBODY = "table-divide-subtle";
1852
- export const ROW_HOVER = "transition-colors hover:bg-theme-hover/50";
@@ -0,0 +1,18 @@
1
+ import type { WorkspaceRoute } from '../../integrations/workspaceRoute'
2
+ import { parseCapacityRoute } from './routes'
3
+
4
+ export const capacityWorkspaceRoute: WorkspaceRoute = {
5
+ id: 'capacity',
6
+ path: '/capacity',
7
+ label: 'Capacity',
8
+ navView: 'capacity',
9
+ namespaceScope: 'cluster',
10
+ namespaceFilterTooltip: 'Capacity is reported across the cluster — the namespace filter doesn’t apply here.',
11
+ pageTitle(pathname) {
12
+ const route = parseCapacityRoute(pathname)
13
+ if (route.poolName) return route.poolName
14
+ if (route.topTab === 'demand') return 'Capacity Demand'
15
+ if (route.topTab === 'activity') return 'Capacity Activity'
16
+ return 'Capacity'
17
+ },
18
+ }