@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,259 @@
1
+ import type { ComponentType, ReactNode } from 'react'
2
+ import type { UseQueryResult } from '@tanstack/react-query'
3
+ import { clsx } from 'clsx'
4
+ import { AlertTriangle, X } from 'lucide-react'
5
+ import { Tooltip, formatUpdatedAgo, grantParts, toneTextClass, type Grant } from '@skyhook-io/k8s-ui'
6
+ import type { SelectedResource } from '../../types'
7
+ import { sameSelectedResource } from '../../utils/drawer-trail'
8
+ import { ROW_HOVER, TABLE_HEAD, TABLE_WRAP, TBODY, TD, TH } from './table'
9
+
10
+ export function Notice({ children }: { children: ReactNode }) {
11
+ return (
12
+ <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">
13
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-theme-text-tertiary" />
14
+ <div>{children}</div>
15
+ </div>
16
+ );
17
+ }
18
+
19
+ /** A whole screen with nothing to show: not installed, unreadable, or empty. */
20
+ export function ScreenEmptyState({
21
+ icon: Icon,
22
+ title,
23
+ detail,
24
+ action,
25
+ }: {
26
+ icon: ComponentType<{ className?: string }>;
27
+ title: string;
28
+ detail: ReactNode;
29
+ action?: ReactNode;
30
+ }) {
31
+ return (
32
+ <div className="flex min-h-0 flex-1 items-center justify-center bg-theme-base p-6">
33
+ <div className="max-w-md text-center">
34
+ <Icon className="mx-auto h-9 w-9 text-theme-text-tertiary/50" />
35
+ <h2 className="mt-3 text-lg font-medium text-theme-text-primary">
36
+ {title}
37
+ </h2>
38
+ <p className="mt-1 text-sm text-theme-text-secondary">{detail}</p>
39
+ {action}
40
+ </div>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export function ScreenBody({ children }: { children: ReactNode }) {
46
+ return (
47
+ <div className="min-h-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
48
+ <div className="space-y-5 px-5 pb-6 pt-3 xl:px-7">{children}</div>
49
+ </div>
50
+ )
51
+ }
52
+
53
+ export function FilterChips({ chips }: { chips: { label: string; onClear: () => void }[] }) {
54
+ if (chips.length === 0) return null
55
+ return (
56
+ <div className="flex flex-wrap gap-1.5">
57
+ {chips.map((c) => (
58
+ <span key={c.label} className="inline-flex items-center gap-1 rounded-full bg-theme-elevated px-2.5 py-0.5 text-xs text-theme-text-secondary">
59
+ {c.label}
60
+ <button type="button" onClick={c.onClear} aria-label={`Remove ${c.label}`} className="rounded-full p-0.5 hover:bg-theme-hover">
61
+ <X className="h-3 w-3" />
62
+ </button>
63
+ </span>
64
+ ))}
65
+ </div>
66
+ )
67
+ }
68
+
69
+ export function namespaceChip(namespaces: string[], onClear: () => void) {
70
+ return namespaces.length > 0 ? [{ label: `Namespace: ${namespaces.join(', ')}`, onClear }] : []
71
+ }
72
+
73
+ export function Segments<T extends string>({
74
+ value,
75
+ options,
76
+ onChange,
77
+ label,
78
+ }: {
79
+ value: T
80
+ options: { id: T; label: string; count?: number | string }[]
81
+ onChange: (id: T) => void
82
+ label: string
83
+ }) {
84
+ return (
85
+ <div role="tablist" aria-label={label} className="inline-flex rounded-lg bg-theme-elevated p-0.5">
86
+ {options.map((o) => {
87
+ const on = o.id === value
88
+ return (
89
+ <button
90
+ key={o.id}
91
+ type="button"
92
+ role="tab"
93
+ aria-selected={on}
94
+ onClick={() => onChange(o.id)}
95
+ className={clsx(
96
+ 'inline-flex items-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium transition-colors',
97
+ on ? 'bg-theme-surface text-theme-text-primary shadow-theme-sm' : 'text-theme-text-secondary hover:text-theme-text-primary',
98
+ )}
99
+ >
100
+ {o.label}
101
+ {o.count !== undefined && <span className="font-mono text-xs text-theme-text-tertiary">{o.count}</span>}
102
+ </button>
103
+ )
104
+ })}
105
+ </div>
106
+ )
107
+ }
108
+
109
+ export interface TableColumn<T> {
110
+ header: ReactNode
111
+ width?: string
112
+ cell: (row: T) => ReactNode
113
+ className?: string
114
+ }
115
+
116
+ /** A workspace table. Rows inspect in the drawer; the inspected row is highlighted. */
117
+ export function SectionTable<T>({
118
+ title,
119
+ subtitle,
120
+ columns,
121
+ rows,
122
+ rowKey,
123
+ rowResource,
124
+ onInspect,
125
+ inspected,
126
+ empty,
127
+ minWidth = 760,
128
+ footer,
129
+ }: {
130
+ title: ReactNode
131
+ subtitle?: ReactNode
132
+ columns: TableColumn<T>[]
133
+ rows: T[]
134
+ rowKey: (row: T) => string
135
+ rowResource?: (row: T) => SelectedResource | null
136
+ onInspect?: (resource: SelectedResource) => void
137
+ inspected?: SelectedResource | null
138
+ empty: ReactNode
139
+ minWidth?: number
140
+ footer?: ReactNode
141
+ }) {
142
+ return (
143
+ <section>
144
+ <div className="mb-2 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
145
+ <h2 className="text-sm font-semibold text-theme-text-primary">{title}</h2>
146
+ {subtitle && <span className="text-xs text-theme-text-tertiary">{subtitle}</span>}
147
+ </div>
148
+ <div className="overflow-hidden rounded-xl border border-theme-border bg-theme-surface shadow-theme-sm">
149
+ {rows.length === 0 ? (
150
+ <div className="px-4 py-5 text-sm text-theme-text-tertiary">{empty}</div>
151
+ ) : (
152
+ <div className={TABLE_WRAP}>
153
+ <table className="w-full table-fixed" style={{ minWidth }}>
154
+ <colgroup>
155
+ {columns.map((c, i) => (
156
+ <col key={i} style={c.width ? { width: c.width } : undefined} />
157
+ ))}
158
+ </colgroup>
159
+ <thead className={TABLE_HEAD}>
160
+ <tr>
161
+ {columns.map((c, i) => (
162
+ <th key={i} className={TH}>{c.header}</th>
163
+ ))}
164
+ </tr>
165
+ </thead>
166
+ <tbody className={TBODY}>
167
+ {rows.map((row) => {
168
+ const res = rowResource?.(row) ?? null
169
+ const active = !!res && sameSelectedResource(inspected, res)
170
+ return (
171
+ <tr
172
+ key={rowKey(row)}
173
+ onClick={res && onInspect ? () => onInspect(res) : undefined}
174
+ className={clsx(res && onInspect && 'cursor-pointer', ROW_HOVER, active && 'selection')}
175
+ aria-selected={res ? active : undefined}
176
+ >
177
+ {columns.map((c, i) => (
178
+ <td key={i} className={clsx(TD, c.className)}>{c.cell(row)}</td>
179
+ ))}
180
+ </tr>
181
+ )
182
+ })}
183
+ </tbody>
184
+ </table>
185
+ </div>
186
+ )}
187
+ </div>
188
+ {footer && <div className="mt-1.5 text-xs text-theme-text-tertiary">{footer}</div>}
189
+ </section>
190
+ )
191
+ }
192
+
193
+ /**
194
+ * Text that wraps only after `after` ("/" for a path, "-" for a resource
195
+ * name), never mid-word; a single segment too long for its cell is cut with an
196
+ * ellipsis. The whole value shows on hover.
197
+ */
198
+ export function BreakText({ value, after, className }: { value: string; after: '/' | '-'; className?: string }) {
199
+ const parts = value.split(after === '/' ? /(?<=\/)/ : /(?<=-)/)
200
+ return (
201
+ <Tooltip content={value} wrapperClassName="max-w-full">
202
+ <span className={clsx('block max-w-full', className)}>
203
+ {parts.map((p, i) => (
204
+ <span key={i} className="inline-block max-w-full truncate align-top">
205
+ {p}
206
+ </span>
207
+ ))}
208
+ </span>
209
+ </Tooltip>
210
+ )
211
+ }
212
+
213
+ /** A URL or path; see BreakText. */
214
+ export function PathText({ value, className }: { value: string; className?: string }) {
215
+ return <BreakText value={value} after="/" className={clsx('font-mono text-[12.5px]', className)} />
216
+ }
217
+
218
+ /**
219
+ * A grant as one unit: the verb and resource in a code span that does not
220
+ * wrap, its scope ("cluster-wide", "in namespace pg") as plain text after it.
221
+ */
222
+ export function GrantText({ grant }: { grant: Grant }) {
223
+ const { what, scope } = grantParts(grant)
224
+ return (
225
+ <>
226
+ <code className="whitespace-nowrap rounded bg-theme-elevated px-1 font-mono text-[12px]">{what}</code>
227
+ {scope}
228
+ </>
229
+ )
230
+ }
231
+
232
+ export function Mono({ children }: { children: ReactNode }) {
233
+ return <Tooltip content={children} wrapperClassName="min-w-0"><span className="font-mono text-[12.5px] break-normal [overflow-wrap:anywhere]">{children}</span></Tooltip>
234
+ }
235
+
236
+ export function Sub({ children }: { children: ReactNode }) {
237
+ return <div className="mt-0.5 text-xs text-theme-text-tertiary break-words">{children}</div>
238
+ }
239
+
240
+ type RefreshableQuery = Pick<UseQueryResult<unknown>, 'isRefetchError' | 'error' | 'dataUpdatedAt'>
241
+
242
+ /**
243
+ * A refetch failed while the last good answer stays on screen: say so, why,
244
+ * and how old that answer is, so cached values are not read as current.
245
+ */
246
+ export function RefreshFailedNotice({ queries, className }: { queries: RefreshableQuery[]; className?: string }) {
247
+ const failed = queries.filter((q) => q.isRefetchError)
248
+ if (failed.length === 0) return null
249
+ const oldest = failed.reduce((a, b) => (b.dataUpdatedAt < a.dataUpdatedAt ? b : a))
250
+ const reason = oldest.error instanceof Error ? oldest.error.message : 'unknown error'
251
+ return (
252
+ <div role="status" className={clsx('flex items-start gap-1.5 text-xs text-theme-text-secondary', className)}>
253
+ <AlertTriangle className={clsx('mt-px h-3.5 w-3.5 shrink-0', toneTextClass('degraded'))} />
254
+ <span>
255
+ Last refresh failed: {reason.length > 160 ? `${reason.slice(0, 160)}…` : reason} · showing data from {formatUpdatedAgo(Date.now() - oldest.dataUpdatedAt)}
256
+ </span>
257
+ </div>
258
+ )
259
+ }
@@ -0,0 +1,7 @@
1
+ export const TABLE_WRAP = 'overflow-x-auto';
2
+ export const TABLE_HEAD =
3
+ 'border-b border-theme-border bg-theme-base/60 text-[11px] uppercase tracking-wide text-theme-text-tertiary';
4
+ export const TH = 'px-3 py-2.5 text-left font-medium whitespace-nowrap';
5
+ export const TD = 'px-3 py-2.5 align-top text-sm text-theme-text-primary';
6
+ export const TBODY = 'table-divide-subtle';
7
+ export const ROW_HOVER = 'transition-colors hover:bg-theme-hover/50';
@@ -155,7 +155,7 @@ export function useNamespacedCapabilities(namespace: string | undefined) {
155
155
  const { data: nsCaps, error, isPending } = useNamespaceCapabilities(namespace, globalCaps)
156
156
 
157
157
  if (error) {
158
- console.warn(`Failed to fetch namespace capabilities for ${namespace}, using global:`, error)
158
+ console.warn('Failed to fetch namespace capabilities, using global:', namespace, error)
159
159
  }
160
160
 
161
161
  return useMemo(() => ({
@@ -0,0 +1,55 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import {
4
+ composeDetailSlots, composeResourceHosts, hostsForResource, resourceHostForSlot,
5
+ type ResourceHost,
6
+ } from './resourceHost'
7
+
8
+ const job = { kind: 'Job', group: 'batch', namespace: 'ml', name: 'run' }
9
+ const jobs = [{ name: 'jobs', group: 'batch' }]
10
+ const Admission = () => <span>admission</span>
11
+
12
+ describe('resource host composition', () => {
13
+ it('allows batch and admission to extend the same Job without hiding the destination owner', () => {
14
+ const hosts: ResourceHost[] = [
15
+ { id: 'admission', resources: jobs, detailSlots: () => ({ renderHeaderActions: () => <Admission /> }) },
16
+ { id: 'execution', resources: jobs, detailOwnership: ['destination'], expandedPath: () => '/execution/run', detailSlots: () => ({ renderHeaderActions: () => <span>execution</span>, extraTabs: [{ id: 'runs', label: 'Runs', render: () => null }] }) },
17
+ ]
18
+ composeResourceHosts(hosts)
19
+ expect(hostsForResource(hosts, job).map(host => host.id)).toEqual(['admission', 'execution'])
20
+ expect(resourceHostForSlot(hosts, job, 'destination')?.expandedPath?.(job)).toBe('/execution/run')
21
+ const slots = composeDetailSlots(hosts.map(host => ({ id: host.id, slots: host.detailSlots?.(job) })), [{ id: 'history', label: 'History', render: () => null }])
22
+ const html = renderToStaticMarkup(<>{slots.renderHeaderActions?.({ resource: {}, context: 'drawer' })}</>)
23
+ expect(html).toBe('<span>admission</span><span>execution</span>')
24
+ expect(slots.extraTabs?.map(tab => tab.id)).toEqual(['history', 'runs'])
25
+ })
26
+
27
+ it('requires resolved group identity and normalizes Kind versus resource plural', () => {
28
+ const hosts: ResourceHost[] = [{ id: 'batch', resources: jobs }]
29
+ expect(hostsForResource(hosts, job)).toHaveLength(1)
30
+ expect(hostsForResource(hosts, { ...job, kind: 'jobs' })).toHaveLength(1)
31
+ expect(hostsForResource(hosts, { ...job, group: undefined })).toEqual([])
32
+ expect(hostsForResource(hosts, { ...job, group: '' })).toEqual([])
33
+ expect(hostsForResource(hosts, { ...job, group: 'batch.volcano.sh' })).toEqual([])
34
+ expect(hostsForResource([{ id: 'core', resources: [{ name: 'pods', group: '' }] }], { ...job, kind: 'Pod', group: '' })).toHaveLength(1)
35
+ })
36
+
37
+ it('rejects competing exclusive ownership instead of letting registration order win', () => {
38
+ const owner: ResourceHost = { id: 'execution', resources: jobs, detailOwnership: ['destination'], expandedPath: () => '/execution' }
39
+ const other: ResourceHost = { ...owner, id: 'other' }
40
+ expect(() => composeResourceHosts([owner, other])).toThrow('ownership conflict')
41
+ expect(() => composeResourceHosts([{ id: 'missing', resources: jobs, logs: () => null }])).toThrow('must declare logs ownership')
42
+ expect(() => composeResourceHosts([{ id: 'missing', resources: jobs, expandedPath: () => '/' }])).toThrow('must declare destination ownership')
43
+ expect(() => composeResourceHosts([
44
+ { id: 'one', renderers: { JobRenderer: Admission } },
45
+ { id: 'two', renderers: { JobRenderer: Admission } },
46
+ ])).toThrow('renderer:JobRenderer')
47
+ })
48
+
49
+ it('catches summary and tab conflicts even if a contribution omitted its declaration', () => {
50
+ expect(() => composeDetailSlots(['one', 'two'].map(id => ({ id, slots: { renderSummary: () => null } })))).toThrow('summary ownership conflict')
51
+ const tab = { id: 'runs', label: 'Runs', render: () => null }
52
+ expect(composeDetailSlots([], [tab, tab]).extraTabs).toEqual([tab, tab])
53
+ expect(() => composeDetailSlots([{ id: 'execution', slots: { extraTabs: [tab] } }], [tab])).toThrow('tab ownership conflict: runs')
54
+ })
55
+ })
@@ -0,0 +1,99 @@
1
+ import { createElement, Fragment, type ComponentProps, type ComponentType, type ReactNode } from 'react'
2
+ import type { RendererOverrides, WorkloadExtraTab, WorkloadView } from '@skyhook-io/k8s-ui'
3
+ import type { RadarFeature, RadarFeatureSupport } from '../api/radarFeatures'
4
+ import type { RenderDiagnoseAction } from '../context/DiagnoseCustomization'
5
+ import type { SelectedResource } from '../types'
6
+ import { kindToPluralWithGroup } from '../utils/navigation'
7
+
8
+ export type HostResource = Pick<SelectedResource, 'kind' | 'group' | 'namespace' | 'name'>
9
+ export type DetailSlots = Pick<ComponentProps<typeof WorkloadView>, 'renderSummary' | 'renderHeaderActions' | 'extraTabs'>
10
+ export type HostFeatures = Partial<Record<RadarFeature, RadarFeatureSupport>>
11
+ export type KindListMode = 'table' | 'wait' | 'view'
12
+ export interface HostKind { name: string; kind: string; group: string }
13
+ export interface KindListProps {
14
+ namespaces: string[]
15
+ inspected: SelectedResource | null
16
+ onInspect: (resource: SelectedResource) => void
17
+ onClearNamespaces: () => void
18
+ onCreate: (kind: HostKind) => void
19
+ }
20
+ type ExclusiveDetailSlot = 'summary' | 'destination' | 'logs'
21
+ export interface ResourceHost {
22
+ id: string
23
+ feature?: RadarFeature
24
+ diagnoseAction?: (render: RenderDiagnoseAction | undefined) => RenderDiagnoseAction | undefined
25
+ kindLists?: readonly {
26
+ kind: HostKind
27
+ title: string
28
+ mode: (support: RadarFeatureSupport, pending: boolean) => KindListMode
29
+ Component: ComponentType<KindListProps>
30
+ }[]
31
+ renderers?: RendererOverrides
32
+ resources?: readonly { name: string; group: string }[]
33
+ expandedPath?: (resource: HostResource, context?: string, tab?: 'yaml') => string | null
34
+ detailRedirect?: (resource: HostResource, search: URLSearchParams) => string | null
35
+ detailSlots?: (resource: HostResource) => DetailSlots
36
+ detailOwnership?: readonly ExclusiveDetailSlot[]
37
+ logs?: (resource: HostResource, data: { apiVersion?: string } | null | undefined) => ReactNode
38
+ drawerNavigation?: {
39
+ ownsPath: (pathname: string) => boolean
40
+ Component: ComponentType<{ resource: SelectedResource }>
41
+ }
42
+ }
43
+
44
+ export function hostSupport(host: ResourceHost, features: HostFeatures): RadarFeatureSupport {
45
+ return host.feature ? features[host.feature] ?? 'unknown' : 'supported'
46
+ }
47
+
48
+ function resourceKey(name: string, group: string): string { return `${group}\u0000${name}` }
49
+
50
+ export function hostsForResource(hosts: readonly ResourceHost[], resource: HostResource): ResourceHost[] {
51
+ // An unresolved group must not select a colliding CRD, or be treated as core.
52
+ if (resource.group === undefined) return []
53
+ const plural = kindToPluralWithGroup(resource.kind, resource.group)
54
+ return hosts.filter(host => host.resources?.some(kind => kind.name === plural && kind.group === resource.group))
55
+ }
56
+
57
+ export function resourceHostForSlot(hosts: readonly ResourceHost[], resource: HostResource, slot: ExclusiveDetailSlot): ResourceHost | undefined {
58
+ return hostsForResource(hosts, resource).find(host => host.detailOwnership?.includes(slot))
59
+ }
60
+
61
+ export function composeResourceHosts(hosts: readonly ResourceHost[]): RendererOverrides {
62
+ const renderers: RendererOverrides = {}
63
+ const owners = new Map<string, string>()
64
+ const claim = (key: string, id: string) => {
65
+ const previous = owners.get(key)
66
+ if (previous) throw new Error(`Resource host ownership conflict: ${previous} and ${id} own ${key}`)
67
+ owners.set(key, id)
68
+ }
69
+ for (const host of hosts) {
70
+ claim(`host:${host.id}`, host.id)
71
+ if ((host.expandedPath || host.detailRedirect) && !host.detailOwnership?.includes('destination')) throw new Error(`Resource host ${host.id} must declare destination ownership`)
72
+ if (host.logs && !host.detailOwnership?.includes('logs')) throw new Error(`Resource host ${host.id} must declare logs ownership`)
73
+ for (const name of Object.keys(host.renderers ?? {})) claim(`renderer:${name}`, host.id)
74
+ Object.assign(renderers, host.renderers)
75
+ for (const kind of host.resources ?? []) {
76
+ for (const slot of host.detailOwnership ?? []) claim(`${slot}:${resourceKey(kind.name, kind.group)}`, host.id)
77
+ }
78
+ for (const list of host.kindLists ?? []) claim(`kind-list:${resourceKey(list.kind.name, list.kind.group)}`, host.id)
79
+ }
80
+ return renderers
81
+ }
82
+
83
+ export function composeDetailSlots(contributions: readonly { id: string; slots: DetailSlots | undefined }[], inheritedTabs: WorkloadExtraTab[] = []): DetailSlots {
84
+ const summaries = contributions.filter(host => host.slots?.renderSummary)
85
+ if (summaries.length > 1) throw new Error(`Resource host summary ownership conflict: ${summaries.map(host => host.id).join(', ')}`)
86
+ const summary = summaries[0]?.slots?.renderSummary
87
+ const actions = contributions.filter(host => host.slots?.renderHeaderActions)
88
+ const tabs = [...inheritedTabs, ...contributions.flatMap(host => host.slots?.extraTabs ?? [])]
89
+ const tabIds = new Set(inheritedTabs.map(tab => tab.id))
90
+ for (const tab of contributions.flatMap(host => host.slots?.extraTabs ?? [])) {
91
+ if (tabIds.has(tab.id)) throw new Error(`Resource host tab ownership conflict: ${tab.id}`)
92
+ tabIds.add(tab.id)
93
+ }
94
+ return {
95
+ ...(summary ? { renderSummary: summary } : {}),
96
+ ...(actions.length ? { renderHeaderActions: (props: Parameters<NonNullable<DetailSlots['renderHeaderActions']>>[0]) => actions.map(host => createElement(Fragment, { key: host.id }, host.slots!.renderHeaderActions!(props))) } : {}),
97
+ ...(tabs.length ? { extraTabs: tabs } : {}),
98
+ }
99
+ }
@@ -0,0 +1,141 @@
1
+ import { isValidElement } from 'react'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { MemoryRouter } from 'react-router-dom'
4
+ import { describe, expect, it, vi } from 'vitest'
5
+ import { ResourceRendererDispatch } from '@skyhook-io/k8s-ui'
6
+ import { encodeDrawerTrail } from '../utils/drawer-trail'
7
+ import { kindToPluralWithGroup } from '../utils/navigation'
8
+ import {
9
+ decorateResourceDiagnose, renderResourceKindList, resourceKindListMode, resourceKindListTitle, ResourceDrawerNavigation,
10
+ resourceDetailRedirect, resourceDetailSlots, resourceExpandedPath,
11
+ resourceLogs, resourceRendererOverrides,
12
+ } from './resourceHosts'
13
+
14
+ vi.mock('../api/client', () => ({
15
+ ApiError: class extends Error { status = 500 },
16
+ useKueueAdmission: () => ({ data: { uid: 'job-id', installed: true, workloads: [], total: 0 }, isLoading: false, refetch: () => {} }),
17
+ }))
18
+
19
+ import type { HostFeatures } from './resourceHost'
20
+
21
+ const cluster = { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'db', name: 'pg' }
22
+
23
+ const cnpgKind = { name: 'clusters', group: cluster.group }
24
+ const supported: HostFeatures = { cnpgWorkspace: 'supported' }
25
+ const unsupported: HostFeatures = { cnpgWorkspace: 'unsupported' }
26
+ const unknown: HostFeatures = { cnpgWorkspace: 'unknown' }
27
+ const listProps = { namespaces: ['db'], inspected: null, onInspect: () => {}, onClearNamespaces: () => {}, onCreate: () => {} }
28
+
29
+ function renderResource(data: any) {
30
+ const group = data.apiVersion.split('/')[0]
31
+ return renderToStaticMarkup(<MemoryRouter><ResourceRendererDispatch
32
+ resource={{ kind: kindToPluralWithGroup(data.kind, group), namespace: 'ml', name: 'run', group }}
33
+ data={data} onCopy={() => {}} copied={null} showCommonSections={false}
34
+ rendererOverrides={resourceRendererOverrides}
35
+ /></MemoryRouter>)
36
+ }
37
+
38
+ describe('application resource hosts', () => {
39
+ it('preserves existing CNPG access and exact group collisions without a new feature flag', () => {
40
+ expect(resourceExpandedPath(cluster, 'kind-demo', 'yaml')).toBe('/cnpg/clusters/db/pg?ctx=kind-demo&tab=yaml')
41
+ expect(resourceDetailSlots(cluster).renderSummary).toBeDefined()
42
+ for (const group of [undefined, '', 'cluster.x-k8s.io']) {
43
+ expect(resourceExpandedPath({ ...cluster, group })).toBeNull()
44
+ expect(resourceDetailSlots({ ...cluster, group })).toEqual({})
45
+ }
46
+ expect(resourceExpandedPath({ kind: 'NodePool', group: 'karpenter.sh', namespace: '', name: 'pool' })).toBeNull()
47
+ })
48
+
49
+ it('retains every existing renderer override at a stable module reference', () => {
50
+ expect(Object.keys(resourceRendererOverrides).sort()).toEqual([
51
+ 'RayJobRenderer', 'JobRenderer', 'JobSetRenderer', 'RayServiceRenderer', 'RayClusterRenderer',
52
+ 'KueueWorkloadRenderer', 'CAPIClusterRenderer', 'PodRenderer', 'KarpenterNodePoolRenderer',
53
+ 'NodeRenderer', 'ServiceRenderer', 'WorkloadRenderer', 'CompositeRenderer', 'ServiceAccountRenderer',
54
+ 'RoleRenderer', 'RoleBindingRenderer', 'NamespaceRenderer', 'HPARenderer', 'PVCRenderer',
55
+ 'RolloutRenderer', 'KyvernoPolicyCoverage', 'KyvernoPolicyQueued', 'CNPGObjectStoreRenderer',
56
+ 'VeleroBSLRenderer', 'VeleroBackupRenderer', 'VeleroRestoreRenderer', 'CNPGClusterRenderer',
57
+ 'CNPGDatabaseRenderer', 'CNPGPublicationRenderer', 'CNPGSubscriptionRenderer', 'CNPGImageCatalogRenderer',
58
+ ].sort())
59
+ const inherited = [{ id: 'local', label: 'Local', render: () => null }]
60
+ expect(resourceDetailSlots({ kind: 'Pod', group: '', namespace: 'db', name: 'pg-1' }, inherited).extraTabs).toEqual(inherited)
61
+ expect(resourceExpandedPath({ ...cluster, group: undefined })).toBeNull()
62
+ expect(resourceDetailRedirect({ ...cluster, group: '' }, new URLSearchParams('tab=events'))).toBeNull()
63
+ })
64
+
65
+ it('keeps query, tab, context and drawer state when redirecting a generic detail link', () => {
66
+ const search = new URLSearchParams('apiGroup=postgresql.cnpg.io&ctx=kind-demo&tab=events&drawer=pods%3Apg-1&ai-run=run')
67
+ const path = resourceDetailRedirect({ ...cluster, kind: 'clusters' }, search)!
68
+ const url = new URL(path, 'http://radar.test')
69
+ expect(url.pathname).toBe('/cnpg/clusters/db/pg')
70
+ expect(Object.fromEntries(url.searchParams)).toEqual({ ctx: 'kind-demo', tab: 'activity', drawer: 'pods:pg-1', 'ai-run': 'run' })
71
+ expect(search.get('tab')).toBe('events')
72
+ expect(search.get('apiGroup')).toBe('postgresql.cnpg.io')
73
+ })
74
+
75
+ it('keeps integration log selection confined to observed CNPG Clusters and falls back for other resources', () => {
76
+ expect(isValidElement(resourceLogs(cluster, { apiVersion: 'postgresql.cnpg.io/v1' }))).toBe(true)
77
+ expect(isValidElement(resourceLogs({ ...cluster, group: undefined }, { apiVersion: 'postgresql.cnpg.io/v1' }))).toBe(true)
78
+ expect(resourceLogs(cluster, { apiVersion: 'cluster.x-k8s.io/v1beta1' })).toBeNull()
79
+ expect(resourceLogs({ ...cluster, kind: 'Pooler' }, { apiVersion: 'postgresql.cnpg.io/v1' })).toBeNull()
80
+ expect(resourceLogs({ kind: 'Pod', group: '', namespace: 'db', name: 'pg-1' }, { apiVersion: 'v1' })).toBeNull()
81
+ })
82
+
83
+ it('mounts drawer trail navigation for any inspected resource in its owning workspace, including embeds', () => {
84
+ const previous = { kind: 'backups', group: 'postgresql.cnpg.io', namespace: 'db', name: 'base' }
85
+ const pod = { kind: 'pods', group: '', namespace: 'db', name: 'pg-1' }
86
+ const search = `?drawer=${encodeURIComponent(encodeDrawerTrail([previous, pod]))}`
87
+ const html = renderToStaticMarkup(<MemoryRouter basename="/c/demo" initialEntries={[`/c/demo/cnpg/protection${search}`]}><ResourceDrawerNavigation resource={pod} /></MemoryRouter>)
88
+ expect(html).toContain('Backup base')
89
+ const ordinary = renderToStaticMarkup(<MemoryRouter initialEntries={[`/resources/pods${search}`]}><ResourceDrawerNavigation resource={pod} /></MemoryRouter>)
90
+ expect(ordinary).toBe('')
91
+ })
92
+
93
+ it('preserves shared version dispatch and simultaneous batch/Kueue detail in the stable override map', () => {
94
+ const data = { kind: 'Job', apiVersion: 'batch/v1', metadata: { name: 'run', namespace: 'ml', uid: 'job-id', labels: { 'kueue.x-k8s.io/queue-name': 'queue' } }, spec: { completions: 1 }, status: { active: 1 } }
95
+ const job = renderResource(data)
96
+ expect(job).toContain('Completions')
97
+ expect(job).toContain('No controller-owned Kueue Workload observed')
98
+ const jobSet = { ...data, kind: 'JobSet', apiVersion: 'jobset.x-k8s.io/v1alpha2', spec: { replicatedJobs: [] } }
99
+ const current = renderResource(jobSet)
100
+ expect(current).toContain('JobSet status')
101
+ expect(current).toContain('No controller-owned Kueue Workload observed')
102
+ for (const apiVersion of ['jobset.x-k8s.io/v1beta1', 'example.io/v1alpha2']) {
103
+ const generic = renderResource({ ...jobSet, apiVersion })
104
+ expect(generic).not.toContain('JobSet status')
105
+ expect(generic).not.toContain('No controller-owned Kueue Workload observed')
106
+ }
107
+ const foreignJob = renderResource({ ...data, apiVersion: 'batch.volcano.sh/v1alpha1' })
108
+ expect(foreignJob).toContain('Specification')
109
+ expect(foreignJob).not.toContain('No controller-owned Kueue Workload observed')
110
+ })
111
+
112
+ it('makes the same kind-list decision for fetching and rendering across feature states', () => {
113
+ for (const [features, pending, mode] of [
114
+ [supported, true, 'view'], [supported, false, 'view'],
115
+ [unknown, true, 'wait'], [unknown, false, 'table'],
116
+ [unsupported, true, 'table'], [unsupported, false, 'table'],
117
+ ] as const) {
118
+ expect(resourceKindListMode(cnpgKind, features, pending)).toBe(mode)
119
+ const view = renderResourceKindList(cnpgKind, features, pending, listProps)
120
+ expect(view !== null).toBe(mode !== 'table')
121
+ if (mode === 'wait') expect(renderToStaticMarkup(view)).toContain('Loading…')
122
+ }
123
+ for (const group of ['', 'cluster.x-k8s.io']) {
124
+ expect(resourceKindListMode({ ...cnpgKind, group }, supported, true)).toBe('table')
125
+ expect(renderResourceKindList({ ...cnpgKind, group }, supported, true, listProps)).toBeNull()
126
+ expect(resourceKindListTitle({ ...cnpgKind, group })).toBeNull()
127
+ }
128
+ expect(resourceKindListTitle(cnpgKind)).toBe('CloudNativePG Clusters')
129
+ })
130
+
131
+ it('decorates Diagnose only for the integration-owned resource context', () => {
132
+ const render = vi.fn(() => <span>diagnose</span>)
133
+ const decorated = decorateResourceDiagnose(render)!
134
+ const context = { ...cluster, compact: false }
135
+ expect(isValidElement(decorated(context))).toBe(true)
136
+ expect(render).not.toHaveBeenCalled()
137
+ decorated({ ...context, group: 'cluster.x-k8s.io' })
138
+ expect(render).toHaveBeenCalledOnce()
139
+ expect(decorateResourceDiagnose(undefined)).toBeUndefined()
140
+ })
141
+ })