@skyhook-io/radar-app 1.16.3 → 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 (321) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +138 -93
  4. package/src/RadarApp.tsx +27 -17
  5. package/src/api/actions.errorCode.test.ts +40 -0
  6. package/src/api/actions.test.ts +48 -0
  7. package/src/api/actions.ts +79 -0
  8. package/src/api/apiResources.navigation.test.tsx +232 -0
  9. package/src/api/apiResources.ts +46 -3
  10. package/src/api/client.goneResource.test.ts +49 -0
  11. package/src/api/client.helmSource.test.ts +39 -0
  12. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  13. package/src/api/client.namespaceSwitch.test.ts +107 -0
  14. package/src/api/client.ts +381 -47
  15. package/src/api/cnpg-ha.live.test.ts +140 -0
  16. package/src/api/cnpg-ha.test.ts +78 -0
  17. package/src/api/cnpg-ha.ts +19 -0
  18. package/src/api/cnpg-history.ts +123 -0
  19. package/src/api/cnpg-inspect.ts +102 -0
  20. package/src/api/cnpg-protection.ts +112 -0
  21. package/src/api/cnpg-recovery.test.tsx +60 -0
  22. package/src/api/cnpg-recovery.ts +262 -0
  23. package/src/api/cnpg-sessions.ts +175 -0
  24. package/src/api/cnpg-storage.ts +138 -0
  25. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  26. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  27. package/src/api/cnpg.ts +428 -10
  28. package/src/api/diagnose.test.ts +40 -0
  29. package/src/api/diagnose.ts +12 -1
  30. package/src/api/radarFeatures.render.test.tsx +65 -3
  31. package/src/api/radarFeatures.ts +6 -0
  32. package/src/api/reviewedActionMutation.test.tsx +164 -0
  33. package/src/api/reviewedActionMutation.ts +66 -0
  34. package/src/api/timelineSource.test.ts +9 -0
  35. package/src/api/timelineSource.ts +8 -0
  36. package/src/api/traffic.sources.test.ts +55 -0
  37. package/src/api/traffic.ts +173 -14
  38. package/src/components/applications/ApplicationsView.tsx +3 -0
  39. package/src/components/audit/AuditView.tsx +9 -0
  40. package/src/components/capacity/CapacityActivity.tsx +3 -31
  41. package/src/components/capacity/CapacityDemand.tsx +4 -36
  42. package/src/components/capacity/CapacityOverview.tsx +217 -56
  43. package/src/components/capacity/CapacityPoolDetail.tsx +24 -62
  44. package/src/components/capacity/CapacityView.test.tsx +354 -1
  45. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  46. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  47. package/src/components/capacity/routes.ts +42 -0
  48. package/src/components/capacity/shared.tsx +23 -150
  49. package/src/components/capacity/workspaceRoute.ts +18 -0
  50. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  51. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  52. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  53. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  54. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  55. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  56. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  57. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  58. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  59. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  60. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  61. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  62. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  63. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  64. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  65. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  66. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  67. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  68. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  69. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  70. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  71. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  72. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  73. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  74. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  75. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  76. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  77. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  78. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  79. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  80. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  81. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  82. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  83. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  84. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  85. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  86. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  87. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  88. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  89. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  90. package/src/components/cnpg/CNPGProtection.tsx +230 -124
  91. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  92. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  93. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  94. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  95. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  96. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  97. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  98. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  99. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  100. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  101. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  102. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  103. package/src/components/cnpg/CNPGView.tsx +34 -21
  104. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  105. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  106. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  107. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  108. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  109. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  110. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  111. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  112. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  113. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  114. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  115. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  116. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  117. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  118. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  119. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  120. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  121. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  122. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  123. package/src/components/cnpg/actions/actionModel.ts +127 -0
  124. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  125. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  126. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  127. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  128. package/src/components/cnpg/archivingRepair.ts +145 -0
  129. package/src/components/cnpg/baseBackup.test.ts +36 -0
  130. package/src/components/cnpg/baseBackup.ts +36 -0
  131. package/src/components/cnpg/blocking.test.ts +59 -0
  132. package/src/components/cnpg/blocking.ts +131 -0
  133. package/src/components/cnpg/coverageText.test.ts +27 -0
  134. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  135. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  136. package/src/components/cnpg/creation/targetModel.ts +149 -0
  137. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  138. package/src/components/cnpg/fleetStatus.ts +43 -0
  139. package/src/components/cnpg/formFields.ts +2 -0
  140. package/src/components/cnpg/grantText.test.tsx +11 -0
  141. package/src/components/cnpg/host.test.tsx +24 -0
  142. package/src/components/cnpg/host.tsx +101 -0
  143. package/src/components/cnpg/inspectModel.test.ts +120 -0
  144. package/src/components/cnpg/inspectModel.ts +140 -0
  145. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  146. package/src/components/cnpg/logicalSlots.ts +29 -0
  147. package/src/components/cnpg/lsn.test.ts +40 -0
  148. package/src/components/cnpg/lsn.ts +43 -0
  149. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  150. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  151. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  152. package/src/components/cnpg/operations/model.test.ts +510 -0
  153. package/src/components/cnpg/operations/model.ts +499 -0
  154. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  155. package/src/components/cnpg/operations/presentation.ts +84 -0
  156. package/src/components/cnpg/operations/store.ts +98 -0
  157. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  158. package/src/components/cnpg/operatorStatus.ts +194 -0
  159. package/src/components/cnpg/paths.test.ts +54 -0
  160. package/src/components/cnpg/paths.ts +47 -4
  161. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  162. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  163. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  164. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  165. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  166. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  167. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  168. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  169. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  172. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  173. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  174. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  175. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  176. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  177. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  178. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  179. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  180. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  181. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  182. package/src/components/cnpg/refresh.test.ts +38 -0
  183. package/src/components/cnpg/refresh.ts +17 -0
  184. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  185. package/src/components/cnpg/routes.test.ts +34 -3
  186. package/src/components/cnpg/routes.ts +60 -48
  187. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  188. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  189. package/src/components/cnpg/runtimeModel.ts +196 -0
  190. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  191. package/src/components/cnpg/runtimeParts.tsx +79 -0
  192. package/src/components/cnpg/shared.test.tsx +59 -0
  193. package/src/components/cnpg/shared.tsx +100 -182
  194. package/src/components/cnpg/storageModel.test.ts +107 -0
  195. package/src/components/cnpg/storageModel.ts +101 -0
  196. package/src/components/cnpg/trendSamples.test.ts +148 -0
  197. package/src/components/cnpg/trendSamples.ts +222 -0
  198. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  199. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  200. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  201. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  202. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  203. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  204. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  205. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  206. package/src/components/cnpg/workspaceRoute.ts +23 -0
  207. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  208. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  209. package/src/components/cost/CostView.test.ts +2 -1
  210. package/src/components/cost/CostView.tsx +0 -2
  211. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  212. package/src/components/cost/errors.ts +3 -1
  213. package/src/components/cost/source.test.ts +10 -0
  214. package/src/components/cost/source.ts +5 -1
  215. package/src/components/diagnose/AgentSetupNotice.test.tsx +58 -0
  216. package/src/components/diagnose/AgentSetupNotice.tsx +142 -51
  217. package/src/components/diagnose/ApplyDialog.ack.test.tsx +47 -0
  218. package/src/components/diagnose/ApplyDialog.test.tsx +59 -6
  219. package/src/components/diagnose/ApplyDialog.tsx +26 -28
  220. package/src/components/diagnose/DiagnoseContext.test.ts +43 -0
  221. package/src/components/diagnose/DiagnoseContext.tsx +136 -54
  222. package/src/components/diagnose/DiagnoseSurface.tsx +16 -3
  223. package/src/components/diagnose/InvestigationView.tsx +39 -2
  224. package/src/components/diagnose/LocalDiagnoseAction.tsx +40 -20
  225. package/src/components/diagnose/agentCatalog.ts +2 -2
  226. package/src/components/diagnose/diagnoseEvidenceTypes.ts +2 -0
  227. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  228. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  229. package/src/components/dock/BottomDock.tsx +2 -0
  230. package/src/components/dock/TerminalTab.test.tsx +17 -0
  231. package/src/components/dock/TerminalTab.tsx +7 -3
  232. package/src/components/dock/TrafficFlowListTab.tsx +27 -6
  233. package/src/components/execution/BatchExecutionView.tsx +11 -11
  234. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  235. package/src/components/gitops/GitOpsView.tsx +16 -23
  236. package/src/components/helm/ChartBrowser.tsx +3 -3
  237. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  238. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  239. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  240. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  241. package/src/components/helm/RevisionHistory.tsx +3 -2
  242. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  243. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  244. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  245. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  246. package/src/components/helm/ValuesViewer.tsx +169 -78
  247. package/src/components/home/ActivitySummary.tsx +2 -1
  248. package/src/components/home/ClusterHealthCard.tsx +7 -0
  249. package/src/components/home/HomeView.tsx +20 -9
  250. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  251. package/src/components/home/TopologyPreview.tsx +2 -2
  252. package/src/components/home/mcpToolCatalog.ts +1 -1
  253. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  254. package/src/components/issues/IssuesPane.test.ts +65 -1
  255. package/src/components/issues/IssuesPane.tsx +188 -25
  256. package/src/components/issues/uncheckedSources.ts +21 -0
  257. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  258. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  259. package/src/components/resource/PVCUsageBar.tsx +15 -6
  260. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  261. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  262. package/src/components/resources/CompositeRenderer.tsx +4 -4
  263. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  264. package/src/components/resources/ResourcesView.tsx +26 -12
  265. package/src/components/resources/renderers/KyvernoPolicyQueued.tsx +2 -1
  266. package/src/components/resources/renderers/NodeRenderer.tsx +7 -1
  267. package/src/components/resources/renderers/ServiceRenderer.test.tsx +81 -0
  268. package/src/components/resources/renderers/ServiceRenderer.tsx +19 -14
  269. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  270. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  271. package/src/components/settings/SettingsDialog.tsx +150 -34
  272. package/src/components/shared/LargeClusterNamespacePicker.tsx +11 -5
  273. package/src/components/timeline/TimelineList.tsx +25 -24
  274. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  275. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  276. package/src/components/timeline/TimelineRetentionNote.test.tsx +49 -0
  277. package/src/components/timeline/TimelineRetentionNote.tsx +20 -0
  278. package/src/components/timeline/TimelineView.tsx +25 -45
  279. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  280. package/src/components/timeline/useStickyError.test.tsx +47 -0
  281. package/src/components/timeline/useStickyError.ts +15 -0
  282. package/src/components/traffic/TrafficConnectionsTable.tsx +144 -0
  283. package/src/components/traffic/TrafficFilterSidebar.tsx +22 -1
  284. package/src/components/traffic/TrafficFlowListContext.tsx +12 -4
  285. package/src/components/traffic/TrafficFocusSearch.tsx +172 -0
  286. package/src/components/traffic/TrafficGraph.tsx +109 -26
  287. package/src/components/traffic/TrafficGraphTooLarge.tsx +150 -0
  288. package/src/components/traffic/TrafficView.tsx +582 -149
  289. package/src/components/traffic/trafficFilters.test.ts +398 -1
  290. package/src/components/traffic/trafficFilters.ts +408 -0
  291. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  292. package/src/components/ui/Markdown.test.tsx +65 -0
  293. package/src/components/ui/Markdown.tsx +144 -127
  294. package/src/components/whats-new/releaseNotes.ts +63 -1
  295. package/src/components/workload/WorkloadView.test.ts +11 -1
  296. package/src/components/workload/WorkloadView.tsx +162 -529
  297. package/src/components/workspace/layout.test.tsx +29 -0
  298. package/src/components/workspace/layout.tsx +259 -0
  299. package/src/components/workspace/table.ts +7 -0
  300. package/src/context/TimelineCustomization.tsx +15 -0
  301. package/src/contexts/CapabilitiesContext.tsx +1 -1
  302. package/src/hooks/useGitOpsWriteGuard.test.tsx +457 -0
  303. package/src/hooks/useGitOpsWriteGuard.ts +193 -0
  304. package/src/hooks/useResolvedGitOpsOwner.ts +256 -0
  305. package/src/integrations/resourceHost.test.tsx +55 -0
  306. package/src/integrations/resourceHost.ts +99 -0
  307. package/src/integrations/resourceHosts.test.tsx +141 -0
  308. package/src/integrations/resourceHosts.tsx +119 -0
  309. package/src/integrations/workspaceRoute.ts +20 -0
  310. package/src/integrations/workspaceRoutes.test.ts +40 -0
  311. package/src/integrations/workspaceRoutes.ts +22 -0
  312. package/src/integrations/workspaceScreens.tsx +14 -0
  313. package/src/utils/drawer-trail.ts +35 -0
  314. package/src/utils/kindFilterStore.test.ts +147 -0
  315. package/src/utils/kindFilterStore.ts +91 -0
  316. package/src/utils/navigation.test.ts +13 -1
  317. package/src/utils/navigation.ts +39 -2
  318. package/src/utils/page-links.ts +21 -0
  319. package/src/utils/tabStorage.ts +40 -0
  320. package/src/utils/viewMemory.test.ts +78 -0
  321. package/src/utils/viewMemory.ts +54 -0
@@ -0,0 +1,122 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { MemoryRouter } from 'react-router-dom'
4
+ import type { IssuesResponse, SubjectIssuesResponse } from '../../api/client'
5
+ import { IssuesPane } from './IssuesPane'
6
+
7
+ const state = vi.hoisted(() => ({
8
+ fleet: { data: undefined as IssuesResponse | undefined, isLoading: false, error: null as Error | null, dataUpdatedAt: 0, refetch: vi.fn() },
9
+ subject: { data: undefined as SubjectIssuesResponse | undefined, isLoading: false, error: null as Error | null, dataUpdatedAt: 0, refetch: vi.fn() },
10
+ }))
11
+ vi.mock('../../api/client', () => ({ useIssues: () => state.fleet, useSubjectIssues: () => state.subject }))
12
+ vi.mock('../../api/apiResources', () => ({ useAPIResources: () => ({ data: undefined }), karpenterCapacityAvailable: () => false }))
13
+ vi.mock('../../contexts/CapabilitiesContext', () => ({ useCapabilitiesContext: () => ({}) }))
14
+ vi.mock('../../context/ConnectionContext', () => ({ useConnection: () => ({ connection: { state: 'connected' } }) }))
15
+ vi.mock('../diagnose/LocalDiagnoseAction', () => ({ IssueDiagnoseButton: () => null }))
16
+
17
+ const subjectUrl = '/issues?kind=Pod&resource=db%2Fpg-1'
18
+ const issue = {
19
+ id: 'pod-failed', severity: 'critical' as const, source: 'problem', category: 'workload', category_group: 'workload', grouping_scope: 'resource',
20
+ kind: 'Pod', group: '', namespace: 'db', name: 'pg-1', reason: 'CrashLoopBackOff', message: 'The selected Pod keeps crashing',
21
+ }
22
+
23
+ function render(url = subjectUrl, namespaces: string[] = []) {
24
+ return renderToString(<MemoryRouter initialEntries={[url]}><IssuesPane namespaces={namespaces} onNavigateToResource={() => {}} /></MemoryRouter>).replaceAll('<!-- -->', '')
25
+ }
26
+
27
+ beforeEach(() => {
28
+ state.fleet = { data: undefined, isLoading: false, error: null, dataUpdatedAt: 0, refetch: vi.fn() }
29
+ state.subject = { data: { issues: [issue], coverage: 'ok' }, isLoading: false, error: null, dataUpdatedAt: 0, refetch: vi.fn() }
30
+ })
31
+
32
+ describe('Issues page query ownership', () => {
33
+ it.each(['loading', 'failed'] as const)('renders subject results while the fleet query is %s', (fleetState) => {
34
+ state.fleet.isLoading = fleetState === 'loading'
35
+ state.fleet.error = fleetState === 'failed' ? new Error('Fleet unavailable') : null
36
+ const html = render()
37
+ expect(html).toContain('The selected Pod keeps crashing')
38
+ expect(html).toContain('Showing issues about Pod')
39
+ expect(html).not.toContain('Failed to load issues')
40
+ expect(html).not.toContain('Loading issues…')
41
+ })
42
+
43
+ it('uses the subject visibility rather than stale fleet visibility', () => {
44
+ state.fleet.data = { issues: [], visibility: { impact: 'Fleet-only limitation' } }
45
+ state.subject.data!.visibility = { state: 'limited', impact: 'Subject evidence is incomplete.' }
46
+ const html = render()
47
+ expect(html).toContain('Subject evidence is incomplete.')
48
+ expect(html).not.toContain('Fleet-only limitation')
49
+ })
50
+
51
+ it.each(['loading', 'failed'] as const)('reports a %s subject lookup independently of fleet failure', (subjectState) => {
52
+ state.fleet.error = new Error('Fleet unavailable')
53
+ state.subject.data = undefined
54
+ state.subject.isLoading = subjectState === 'loading'
55
+ state.subject.error = subjectState === 'failed' ? new Error('Subject unavailable') : null
56
+ const html = render()
57
+ expect(html).toContain(subjectState === 'loading' ? 'Checking issues about Pod' : 'Subject unavailable')
58
+ expect(html).not.toContain('Failed to load issues')
59
+ expect(html).not.toContain('none now')
60
+ })
61
+
62
+ it('explains a hidden subject even when the fleet query fails', () => {
63
+ state.fleet.error = new Error('Fleet unavailable')
64
+ const html = render(subjectUrl, ['other'])
65
+ expect(html).toContain('namespace filter hides')
66
+ expect(html).not.toContain('The selected Pod keeps crashing')
67
+ expect(html).not.toContain('Failed to load issues')
68
+ expect(html).not.toContain('Auto-updating')
69
+ })
70
+
71
+ it.each(['loading', 'failed'] as const)('retains the fleet %s state on the unfiltered page', (fleetState) => {
72
+ state.fleet.isLoading = fleetState === 'loading'
73
+ state.fleet.error = fleetState === 'failed' ? new Error('Fleet unavailable') : null
74
+ expect(render('/issues')).toContain(fleetState === 'loading' ? 'Loading issues…' : 'Failed to load issues')
75
+ })
76
+ })
77
+
78
+ describe('Issues page when Radar could not read Helm releases', () => {
79
+ const line = "Helm releases couldn't be checked, so failed or stuck releases may be missing."
80
+ const text = (html: string) => html.replace(/&#x27;|&#39;/g, "'")
81
+
82
+ it('shows no all-clear when nothing else was found', () => {
83
+ state.fleet.data = { issues: [], total: 0, total_matched: 0, unchecked_sources: [{ source: 'helm', reason: 'read_failed' }] }
84
+ const html = text(render('/issues'))
85
+ expect(html).toContain('No issues found')
86
+ expect(html).toContain(line)
87
+ expect(html).not.toContain('Nothing broken right now')
88
+ expect(html.split(line).length - 1).toBe(1)
89
+ })
90
+
91
+ it('says so above the rows when there are other issues', () => {
92
+ state.fleet.data = { issues: [issue], total: 1, total_matched: 1, unchecked_sources: [{ source: 'helm', reason: 'read_failed' }] }
93
+ const html = text(render('/issues'))
94
+ expect(html).toContain(line)
95
+ expect(html).toContain('The selected Pod keeps crashing')
96
+ })
97
+
98
+ it('shows no all-clear for a source this version does not know, with a generic line', () => {
99
+ state.fleet.data = { issues: [], total: 0, total_matched: 0, unchecked_sources: [{ source: 'some-new-source', reason: 'read_failed' }] }
100
+ const html = text(render('/issues'))
101
+ expect(html).toContain('No issues found')
102
+ expect(html).toContain("Some issue checks couldn't run, so issues may be missing.")
103
+ expect(html).not.toContain('some-new-source')
104
+ expect(html).not.toContain('Nothing broken right now')
105
+ })
106
+
107
+ it('keeps the all-clear for a Radar that read Helm or predates the field', () => {
108
+ state.fleet.data = { issues: [], total: 0, total_matched: 0 }
109
+ const html = text(render('/issues'))
110
+ expect(html).toContain('Nothing broken right now')
111
+ expect(html).not.toContain('couldn')
112
+ })
113
+ })
114
+
115
+ describe('Helm notice in subject mode', () => {
116
+ it('is left out: the per-resource lookup never reads Helm storage', () => {
117
+ state.fleet.data = { issues: [], unchecked_sources: [{ source: 'helm', reason: 'read_failed' }] }
118
+ const html = render()
119
+ expect(html).toContain('The selected Pod keeps crashing')
120
+ expect(html).not.toContain('Helm releases couldn')
121
+ })
122
+ })
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { Issue } from "@skyhook-io/k8s-ui";
3
- import { capacityHrefForIssue } from "./IssuesPane";
3
+ import type { SubjectIssuesResponse } from "../../api/client";
4
+ import { writeViewParams } from "../../utils/navigation";
5
+ import { capacityHrefForIssue, issueSubjectFromParams, issueSubjectKindLabel, issueSubjectState } from "./IssuesPane";
4
6
 
5
7
  function issue(partial: Partial<Issue>): Issue {
6
8
  return {
@@ -140,3 +142,65 @@ describe("capacityHrefForIssue subject carry", () => {
140
142
  ).toBe("/capacity/demand");
141
143
  });
142
144
  });
145
+
146
+ describe('issue subject links', () => {
147
+ it('reads the subject a link narrows to, with its API group', () => {
148
+ expect(issueSubjectFromParams(new URLSearchParams('kind=Cluster&apiGroup=postgresql.cnpg.io&resource=pg%2Fpg-main'))).toEqual({ kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'pg-main' })
149
+ expect(issueSubjectFromParams(new URLSearchParams('kind=Pod&resource=pg%2Fpg-main-1'))).toEqual({ kind: 'Pod', group: '', namespace: 'pg', name: 'pg-main-1' })
150
+ expect(issueSubjectFromParams(new URLSearchParams('kind=ClusterImageCatalog&apiGroup=postgresql.cnpg.io&resource=pg'))).toEqual({ kind: 'ClusterImageCatalog', group: 'postgresql.cnpg.io', namespace: '', name: 'pg' })
151
+ expect(issueSubjectFromParams(new URLSearchParams('kind=Cluster'))).toBeNull()
152
+ expect(issueSubjectFromParams(new URLSearchParams('kind=Cluster&resource=pg%2F'))).toBeNull()
153
+ })
154
+ it('keeps the API group through App’s URL sync on the Issues view', () => {
155
+ for (const [search, subject] of [
156
+ ['kind=Cluster&apiGroup=postgresql.cnpg.io&resource=pg%2Fpg-main', { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'pg-main' }],
157
+ ['kind=Service&apiGroup=serving.knative.dev&resource=web%2Fapi', { kind: 'Service', group: 'serving.knative.dev', namespace: 'web', name: 'api' }],
158
+ ] as const) {
159
+ const params = new URLSearchParams(search)
160
+ writeViewParams(params, ['web'], null)
161
+ expect(issueSubjectFromParams(params)).toEqual(subject)
162
+ }
163
+ })
164
+ it('names the API group when the kind could be another resource', () => {
165
+ expect(issueSubjectKindLabel({ kind: 'Service', group: 'serving.knative.dev', namespace: 'web', name: 'api' })).toBe('Service (serving.knative.dev)')
166
+ expect(issueSubjectKindLabel({ kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main' })).toBe('Cluster (postgresql.cnpg.io)')
167
+ expect(issueSubjectKindLabel({ kind: 'Service', group: '', namespace: 'web', name: 'api' })).toBe('Service')
168
+ expect(issueSubjectKindLabel({ kind: 'Deployment', group: 'apps', namespace: 'web', name: 'api' })).toBe('Deployment')
169
+ })
170
+ it('never reads the view filter as the subject namespace', () => {
171
+ expect(issueSubjectFromParams(new URLSearchParams('kind=Cluster&namespace=pg&name=pg-main'))).toBeNull()
172
+ })
173
+ })
174
+
175
+ describe('issueSubjectState', () => {
176
+ const subject = { kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main' }
177
+ const loaded = (data: Partial<SubjectIssuesResponse>) => ({ isLoading: false, error: null, data: { issues: [], coverage: 'ok' as const, ...data } })
178
+ it('says the view filter hides the subject rather than none', () => {
179
+ expect(issueSubjectState(subject, ['app'], loaded({}))).toEqual({ state: 'hidden' })
180
+ expect(issueSubjectState(subject, ['app', 'pg'], loaded({}))).toEqual({ state: 'none' })
181
+ expect(issueSubjectState(subject, [], loaded({}))).toEqual({ state: 'none' })
182
+ expect(issueSubjectState({ ...subject, kind: 'ClusterImageCatalog', namespace: '' }, ['app'], loaded({}))).toEqual({ state: 'none' })
183
+ })
184
+ it('reads an empty answer as unconfirmed when Radar does not read the kind', () => {
185
+ expect(issueSubjectState(subject, [], loaded({ coverage: 'notWatched' }))).toEqual({ state: 'unconfirmed', why: "Radar isn't watching Cluster" })
186
+ expect(issueSubjectState(subject, [], loaded({ coverage: 'syncing' }))).toEqual({ state: 'unconfirmed', why: 'Radar is still loading Cluster' })
187
+ })
188
+ it('says when the cluster has no such kind, as for a link made on another cluster', () => {
189
+ expect(issueSubjectState(subject, [], loaded({ coverage: 'unknownKind' }))).toEqual({ state: 'unconfirmed', why: 'this cluster has no such kind' })
190
+ })
191
+ it('reads an empty answer as unconfirmed when RBAC withheld issues about it', () => {
192
+ expect(issueSubjectState(subject, [], loaded({ withheld: { issues: 2 } }))).toEqual({ state: 'unconfirmed', why: 'your permissions withhold 2 issues about it' })
193
+ })
194
+ it('reads an empty answer under limited visibility as unconfirmed, never none', () => {
195
+ expect(issueSubjectState(subject, [], loaded({ visibility: { state: 'degraded', impact: 'Pods are not readable.' } }))).toEqual({ state: 'unconfirmed', why: "some evidence isn't readable (Pods are not readable)" })
196
+ expect(issueSubjectState(subject, [], loaded({ visibility: { state: 'limited' } }))).toEqual({ state: 'unconfirmed', why: "some evidence isn't readable" })
197
+ })
198
+ it('reports a failed lookup as unconfirmed and a pending one as checking', () => {
199
+ expect(issueSubjectState(subject, [], { isLoading: false, error: new Error('no access to Cluster main'), data: undefined })).toEqual({ state: 'unconfirmed', why: 'no access to Cluster main' })
200
+ expect(issueSubjectState(subject, [], { isLoading: true, error: null, data: undefined })).toEqual({ state: 'checking' })
201
+ })
202
+ it('takes the per-resource answer, which covers members past the inline cap', () => {
203
+ const grouped = issue({ kind: 'Cluster', group: 'postgresql.cnpg.io', namespace: 'pg', name: 'main', members: [], members_truncated: true })
204
+ expect(issueSubjectState(subject, [], loaded({ issues: [grouped], withheld: { issues: 1 } }))).toEqual({ state: 'found', issues: [grouped], withheld: 1 })
205
+ })
206
+ })
@@ -1,6 +1,6 @@
1
1
  import { useMemo, useState } from "react";
2
- import { useNavigate } from "react-router-dom";
3
- import { useIssues } from "../../api/client";
2
+ import { useNavigate, useSearchParams } from "react-router-dom";
3
+ import { useIssues, useSubjectIssues, type SubjectIssuesResponse } from "../../api/client";
4
4
  import {
5
5
  useAPIResources,
6
6
  karpenterCapacityAvailable,
@@ -9,6 +9,7 @@ import { useCapabilitiesContext } from "../../contexts/CapabilitiesContext";
9
9
  import { useConnection } from "../../context/ConnectionContext";
10
10
  import type { SelectedResource } from "../../types";
11
11
  import {
12
+ EmptyState,
12
13
  IssuesView,
13
14
  PaneLoader,
14
15
  PageHeader,
@@ -21,8 +22,10 @@ import {
21
22
  type IssueSeverity,
22
23
  type SummaryTone,
23
24
  } from "@skyhook-io/k8s-ui";
25
+ import { builtinGroupForKind } from "@skyhook-io/k8s-ui/utils/api-resources";
24
26
  import { AlertTriangle } from "lucide-react";
25
27
  import { IssueDiagnoseButton } from "../diagnose/LocalDiagnoseAction";
28
+ import { uncheckedSourceLines } from "./uncheckedSources";
26
29
 
27
30
  // A capacity-relevant issue links to its Karpenter diagnosis. Karpenter is
28
31
  // always single-cluster (unlike Argo hub-and-spoke), so the issue and the
@@ -75,6 +78,82 @@ export function capacityHrefForIssue(
75
78
  return null;
76
79
  }
77
80
 
81
+ /**
82
+ * The subject a link into Issues narrows to (?kind=&apiGroup=&resource=ns/name),
83
+ * or null. The group is `apiGroup`, as in resource links: App's URL sync owns
84
+ * `group` (topology grouping) and drops it on every other view.
85
+ */
86
+ export function issueSubjectFromParams(params: URLSearchParams): IssueSubject | null {
87
+ const kind = params.get("kind");
88
+ const resource = params.get("resource");
89
+ if (!kind || !resource) return null;
90
+ const group = params.get("apiGroup") ?? "";
91
+ const slash = resource.indexOf("/");
92
+ if (slash < 0) return { kind, group, namespace: "", name: resource };
93
+ const name = resource.slice(slash + 1);
94
+ return name ? { kind, group, namespace: resource.slice(0, slash), name } : null;
95
+ }
96
+
97
+ export interface IssueSubject {
98
+ kind: string;
99
+ /** The API group; "" for the core group. Tells a CNPG Cluster from a CAPI one. */
100
+ group: string;
101
+ namespace: string;
102
+ name: string;
103
+ }
104
+
105
+ /** The subject's kind, with its API group when that isn't the built-in kind's (a Knative Service). */
106
+ export function issueSubjectKindLabel(subject: IssueSubject): string {
107
+ return subject.group && subject.group !== builtinGroupForKind(subject.kind)
108
+ ? `${subject.kind} (${subject.group})`
109
+ : subject.kind;
110
+ }
111
+
112
+ export type IssueSubjectState =
113
+ | { state: "hidden" }
114
+ | { state: "checking" }
115
+ | { state: "unconfirmed"; why: string }
116
+ | { state: "found"; issues: Issue[]; withheld: number }
117
+ | { state: "none" };
118
+
119
+ /**
120
+ * What can be said about the subject's issues. They come from the per-resource
121
+ * lookup, which matches every grouped member (the list's inline members are
122
+ * capped) by exact API group; an empty answer is "none" only when Radar reads
123
+ * the subject's kind, nothing kept it from the evidence, and RBAC withheld no
124
+ * issue about it.
125
+ */
126
+ export function issueSubjectState(
127
+ subject: IssueSubject,
128
+ viewNamespaces: string[],
129
+ related: { isLoading: boolean; error: unknown; data: SubjectIssuesResponse | undefined },
130
+ ): IssueSubjectState {
131
+ if (subject.namespace && viewNamespaces.length > 0 && !viewNamespaces.includes(subject.namespace)) {
132
+ return { state: "hidden" };
133
+ }
134
+ if (related.error) {
135
+ return { state: "unconfirmed", why: related.error instanceof Error ? related.error.message : String(related.error) };
136
+ }
137
+ const data = related.data;
138
+ if (related.isLoading || !data) return { state: "checking" };
139
+ if (data.issues.length > 0) return { state: "found", issues: data.issues, withheld: data.withheld?.issues ?? 0 };
140
+ if (data.coverage === "unknownKind") return { state: "unconfirmed", why: "this cluster has no such kind" };
141
+ if (data.coverage === "notWatched") return { state: "unconfirmed", why: `Radar isn't watching ${subject.kind}` };
142
+ if (data.coverage === "syncing") return { state: "unconfirmed", why: `Radar is still loading ${subject.kind}` };
143
+ const withheld = data.withheld?.issues ?? 0;
144
+ if (withheld > 0) {
145
+ return { state: "unconfirmed", why: `your permissions withhold ${withheld} ${withheld === 1 ? "issue" : "issues"} about it` };
146
+ }
147
+ const visibility = data.visibility;
148
+ if (visibility?.state === "degraded" || visibility?.state === "limited") {
149
+ return {
150
+ state: "unconfirmed",
151
+ why: `some evidence isn't readable${visibility.impact ? ` (${visibility.impact.replace(/\.$/, "")})` : ""}`,
152
+ };
153
+ }
154
+ return { state: "none" };
155
+ }
156
+
78
157
  const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = {
79
158
  critical: "error",
80
159
  warning: "warning",
@@ -83,6 +162,8 @@ const SEVERITY_TONE: Record<IssueSeverity, SummaryTone> = {
83
162
  interface IssuesPaneProps {
84
163
  namespaces: string[];
85
164
  onNavigateToResource: (resource: SelectedResource) => void;
165
+ /** Brings a namespace into the view filter: added to it, or (where Radar's scope allows only one) switched to it. */
166
+ showNamespace?: { mode: "add" | "switch"; show: (namespace: string) => void };
86
167
  }
87
168
 
88
169
  // The per-cluster Issues surface. Renders the same shared triage queue
@@ -96,9 +177,12 @@ interface IssuesPaneProps {
96
177
  export function IssuesPane({
97
178
  namespaces,
98
179
  onNavigateToResource,
180
+ showNamespace,
99
181
  }: IssuesPaneProps) {
182
+ const [searchParams, setSearchParams] = useSearchParams();
183
+ const subject = useMemo(() => issueSubjectFromParams(searchParams), [searchParams]);
100
184
  const { data, isLoading, error, dataUpdatedAt, refetch } =
101
- useIssues(namespaces);
185
+ useIssues(namespaces, !subject);
102
186
  const { connection } = useConnection();
103
187
  const navigate = useNavigate();
104
188
  const apiResources = useAPIResources();
@@ -110,15 +194,37 @@ export function IssuesPane({
110
194
  new Set(),
111
195
  );
112
196
 
113
- const allIssues = useMemo(() => data?.issues ?? [], [data]);
114
- const totals = useMemo(() => {
115
- const t: Record<IssueSeverity, number> = { critical: 0, warning: 0 };
116
- for (const i of allIssues) t[i.severity] = (t[i.severity] ?? 0) + 1;
117
- return t;
118
- }, [allIssues]);
197
+ const subjectHidden =
198
+ !!subject?.namespace && namespaces.length > 0 && !namespaces.includes(subject.namespace);
199
+ const related = useSubjectIssues(subject, !subjectHidden);
200
+ const subjectState = subject
201
+ ? issueSubjectState(subject, namespaces, related)
202
+ : null;
203
+ const visibility = subject
204
+ ? subjectHidden ? undefined : related.data?.visibility
205
+ : data?.visibility;
206
+ const pageIssues = useMemo(() => data?.issues ?? [], [data]);
207
+ const uncheckedLines = subject ? [] : uncheckedSourceLines(data?.unchecked_sources);
208
+ // With a subject set, the tiles and the list both describe the subject.
209
+ const scopeIssues = subjectState
210
+ ? subjectState.state === "found" ? subjectState.issues : []
211
+ : pageIssues;
212
+ const totals: Record<IssueSeverity, number> = { critical: 0, warning: 0 };
213
+ for (const i of scopeIssues) totals[i.severity] = (totals[i.severity] ?? 0) + 1;
119
214
  const shown = severityFilter.size
120
- ? allIssues.filter((i) => severityFilter.has(i.severity))
121
- : allIssues;
215
+ ? scopeIssues.filter((i) => severityFilter.has(i.severity))
216
+ : scopeIssues;
217
+ const clearSubject = () =>
218
+ setSearchParams(
219
+ (prev) => {
220
+ const next = new URLSearchParams(prev);
221
+ next.delete("kind");
222
+ next.delete("apiGroup");
223
+ next.delete("resource");
224
+ return next;
225
+ },
226
+ { replace: true },
227
+ );
122
228
 
123
229
  const toggleSeverity = (s: IssueSeverity) =>
124
230
  setSeverityFilter((prev) => {
@@ -136,11 +242,11 @@ export function IssuesPane({
136
242
  group: ref.group ?? "",
137
243
  });
138
244
 
139
- if (isLoading) {
245
+ if (!subject && isLoading) {
140
246
  return <PaneLoader label="Loading issues…" className="flex-1" />;
141
247
  }
142
248
 
143
- if (error) {
249
+ if (!subject && error) {
144
250
  return (
145
251
  <div className="flex-1 flex items-center justify-center text-theme-text-secondary">
146
252
  <p>Failed to load issues</p>
@@ -156,17 +262,20 @@ export function IssuesPane({
156
262
  description="Live cluster problems — crashes, scheduling failures, bad references — grouped by the resource they affect."
157
263
  actions={
158
264
  <>
159
- <FreshnessControl
265
+ {subjectHidden ? null : <FreshnessControl
160
266
  mode="auto"
161
- dataUpdatedAt={dataUpdatedAt}
162
- onRefresh={() => refetch()}
267
+ dataUpdatedAt={subject ? related.dataUpdatedAt : dataUpdatedAt}
268
+ onRefresh={() => {
269
+ if (subject) related.refetch();
270
+ else refetch();
271
+ }}
163
272
  connectionState={connection.state}
164
- />
165
- {allIssues.length > 0 && (
273
+ />}
274
+ {scopeIssues.length > 0 && (
166
275
  <>
167
276
  <SummaryTile
168
- label={allIssues.length === 1 ? "issue" : "issues"}
169
- value={allIssues.length}
277
+ label={scopeIssues.length === 1 ? "issue" : "issues"}
278
+ value={scopeIssues.length}
170
279
  />
171
280
  {ISSUE_SEVERITIES.map((s) =>
172
281
  totals[s] > 0 || severityFilter.has(s) ? (
@@ -189,19 +298,29 @@ export function IssuesPane({
189
298
  {/* Visibility honesty: when RBAC reads are incomplete, an empty queue may
190
299
  mean "can't see" rather than "nothing broken" — say so up front so the
191
300
  empty state isn't mistaken for a clean bill of health. */}
192
- {data?.visibility?.impact && (
301
+ {visibility?.impact && (
193
302
  <div className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
194
303
  <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
195
304
  <span>
196
- Limited visibility — {data.visibility.impact} Results may be
305
+ Limited visibility — {visibility.impact} Results may be
197
306
  incomplete.
198
307
  </span>
199
308
  </div>
200
309
  )}
201
310
 
311
+ {/* Unchecked-source honesty: the rows can't include issues from a
312
+ source Radar couldn't check. With no rows the empty state below says
313
+ it instead. */}
314
+ {scopeIssues.length > 0 && uncheckedLines.map((line) => (
315
+ <div key={line} className="flex items-start gap-2 rounded-lg border border-theme-border bg-theme-elevated px-3 py-2 text-xs text-theme-text-secondary">
316
+ <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
317
+ <span>{line}</span>
318
+ </div>
319
+ ))}
320
+
202
321
  {/* Truncation honesty: when more issues matched than were returned, say
203
322
  so — don't present a capped list as the complete picture. */}
204
- {data?.total_matched != null &&
323
+ {!subject && data?.total_matched != null &&
205
324
  data.total_matched > (data.issues?.length ?? 0) && (
206
325
  <p className="text-xs text-theme-text-tertiary">
207
326
  Showing {data.issues?.length ?? 0} of {data.total_matched} issues
@@ -209,10 +328,52 @@ export function IssuesPane({
209
328
  </p>
210
329
  )}
211
330
 
331
+ {subject && subjectState && (
332
+ <div className="flex flex-wrap items-center gap-2 text-xs text-theme-text-secondary">
333
+ {subjectState.state === "hidden" ? (
334
+ <>
335
+ <span>
336
+ {issueSubjectKindLabel(subject)}{" "}
337
+ <span className="font-mono">{subject.namespace}/{subject.name}</span>{" "}
338
+ is in namespace <span className="font-mono">{subject.namespace}</span>, which your
339
+ namespace filter hides.
340
+ </span>
341
+ {showNamespace && (
342
+ <button
343
+ type="button"
344
+ onClick={() => showNamespace.show(subject.namespace)}
345
+ className="text-accent-text hover:underline"
346
+ >
347
+ {showNamespace.mode === "add" ? "Add" : "Switch to"}{" "}
348
+ <span className="font-mono">{subject.namespace}</span>
349
+ {showNamespace.mode === "add" ? " to the view" : ""}
350
+ </button>
351
+ )}
352
+ </>
353
+ ) : (
354
+ <span>
355
+ {subjectState.state === "checking" ? "Checking issues about" : "Showing issues about"}{" "}
356
+ {issueSubjectKindLabel(subject)}{" "}
357
+ <span className="font-mono">
358
+ {subject.namespace ? `${subject.namespace}/` : ""}
359
+ {subject.name}
360
+ </span>
361
+ {subjectState.state === "none" && " — none now"}
362
+ {subjectState.state === "unconfirmed" && ` — can't confirm: ${subjectState.why}`}
363
+ {subjectState.state === "found" && subjectState.withheld > 0 &&
364
+ ` — your permissions withhold ${subjectState.withheld} more`}
365
+ </span>
366
+ )}
367
+ <button type="button" onClick={clearSubject} className="text-accent-text hover:underline">
368
+ Show all issues
369
+ </button>
370
+ </div>
371
+ )}
372
+
212
373
  {/* Filtered-empty is NOT the healthy empty state: when a severity filter
213
374
  hides every row but issues still exist, say "no matches" rather than
214
375
  letting IssuesView render its "nothing broken" terminal state. */}
215
- {severityFilter.size > 0 && allIssues.length > 0 && shown.length === 0 ? (
376
+ {subject && scopeIssues.length === 0 ? null : severityFilter.size > 0 && scopeIssues.length > 0 && shown.length === 0 ? (
216
377
  <div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-theme-text-secondary">
217
378
  <p>No issues match the selected severity.</p>
218
379
  <button
@@ -223,12 +384,14 @@ export function IssuesPane({
223
384
  Clear filter
224
385
  </button>
225
386
  </div>
387
+ ) : uncheckedLines.length > 0 && scopeIssues.length === 0 ? (
388
+ <EmptyState tone="neutral" variant="card" headline="No issues found" body={uncheckedLines.join(" ")} />
226
389
  ) : (
227
390
  /* anyData = the query resolved, i.e. the cluster is reachable; an empty
228
391
  list then means "nothing broken" rather than "not connected". */
229
392
  <IssuesView
230
393
  issues={shown}
231
- anyData={!!data}
394
+ anyData={subject ? !!related.data : !!data}
232
395
  onResourceClick={onResourceClick}
233
396
  renderActions={({ issue }) => {
234
397
  const capacityHref = capacityHrefForIssue(issue, hasKarpenter);
@@ -0,0 +1,21 @@
1
+ import type { IssuesResponse } from "../../api/client";
2
+
3
+ // The issue sources /api/issues can list in unchecked_sources, and the line
4
+ // each one gets wherever Radar's issues would otherwise read as complete.
5
+ const SOURCE_LINES: Record<string, string> = {
6
+ helm: "Helm releases couldn't be checked, so failed or stuck releases may be missing.",
7
+ };
8
+
9
+ // For a source this version doesn't know, sent by a newer Radar.
10
+ export const UNKNOWN_SOURCE_LINE = "Some issue checks couldn't run, so issues may be missing.";
11
+
12
+ /** One line per unchecked source, known sources first, without repeats. */
13
+ export function uncheckedSourceLines(sources: IssuesResponse["unchecked_sources"]): string[] {
14
+ const known = new Set<string>();
15
+ let unknown = false;
16
+ for (const { source } of sources ?? []) {
17
+ if (Object.hasOwn(SOURCE_LINES, source)) known.add(SOURCE_LINES[source]);
18
+ else unknown = true;
19
+ }
20
+ return unknown ? [...known, UNKNOWN_SOURCE_LINE] : [...known];
21
+ }
@@ -182,3 +182,35 @@ describe('PVC metrics next step', () => {
182
182
  expect(render()).not.toContain('Ask your operator')
183
183
  })
184
184
  })
185
+
186
+ describe('PVC scope remedies', () => {
187
+ it.each(['ambiguous_scope', 'scope_mismatch'] as const)('shows operator scope controls for %s to owners and viewers', scope => {
188
+ statusResult = { data: { connected: true } }
189
+ usageResult = { data: { ...measured, hasData: false, status: scope } }
190
+ roleLoading = false
191
+ for (const owner of [true, false]) {
192
+ canConfigure = owner
193
+ const html = render()
194
+ expect(html).toContain('--prometheus-cluster-label')
195
+ expect(html).toContain('traffic.prometheusClusterLabels')
196
+ expect(html).toContain('not available in Settings or Desktop')
197
+ expect(html).not.toContain('Configure metrics')
198
+ expect(html).not.toContain('verified labels')
199
+ if (scope === 'scope_mismatch') {
200
+ expect(html).toContain('align cluster labels')
201
+ expect(html).toContain('affects all these charts')
202
+ expect(html).toContain('dedicated to this cluster')
203
+ }
204
+ }
205
+ })
206
+
207
+ it('keeps connection settings when a cached scope failure outlives the connection', () => {
208
+ canConfigure = true
209
+ roleLoading = false
210
+ statusResult = { data: { connected: false } }
211
+ usageResult = { data: { ...measured, hasData: false, status: 'ambiguous_scope' } }
212
+ const html = render()
213
+ expect(html).toContain('Configure metrics')
214
+ expect(html).not.toContain('--prometheus-cluster-label')
215
+ })
216
+ })
@@ -0,0 +1,24 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { expect, it, vi } from 'vitest'
3
+ import { PVCUsageBar } from './PVCUsageBar'
4
+
5
+ const state = vi.hoisted(() => ({ status: 'ambiguous_scope' }))
6
+ vi.mock('../../api/client', () => ({
7
+ isForbiddenError: () => false,
8
+ useAutoPromConnect: () => {},
9
+ useCloudRole: () => ({ canAtLeast: () => false, isLoading: false }),
10
+ usePrometheusStatus: () => ({ data: { connected: true } }),
11
+ usePrometheusPVCUsage: () => ({ data: { status: state.status, hasData: false } }),
12
+ }))
13
+ vi.mock('../../context/NavCustomization', () => ({ useNavCustomization: () => ({ embedded: true }) }))
14
+ vi.mock('../../hooks/usePreviousIntegrationSettings', () => ({ usePreviousIntegrationSettings: () => ({}), previousSettingsNote: () => undefined }))
15
+
16
+ it('distinguishes mismatched volume-stat labels from multiple cluster identities', () => {
17
+ state.status = 'ambiguous_scope'
18
+ expect(renderToStaticMarkup(<PVCUsageBar namespace="demo" name="disk" />)).toContain('more than one cluster')
19
+ state.status = 'scope_mismatch'
20
+ const html = renderToStaticMarkup(<PVCUsageBar namespace="demo" name="disk" />)
21
+ expect(html).toContain('could not match this volume’s stats to this cluster')
22
+ expect(html).toContain('Used space is unknown')
23
+ expect(html).not.toContain('more than one cluster')
24
+ })
@@ -33,23 +33,32 @@ export function PVCUsageBar({ namespace, name }: { namespace: string; name: stri
33
33
  unavailable = 'Loading usage measurements…'
34
34
  } else if (usage.status === 'no_series') {
35
35
  unavailable = 'No usage measurements reported for this volume.'
36
+ } else if (usage.status === 'ambiguous_scope') {
37
+ unavailable = 'Prometheus has volume stats with this name from more than one cluster. Radar cannot tell which belong to this cluster. Used space is unknown.'
38
+ } else if (usage.status === 'scope_mismatch') {
39
+ unavailable = 'Radar could not match this volume’s stats to this cluster. Used space is unknown.'
36
40
  } else if (usage.status === 'invalid_data') {
37
41
  unavailable = 'Volume usage measurements are invalid. Used space is unknown.'
38
42
  } else if (!usage.hasData) {
39
43
  unavailable = 'Usage measurements are unavailable.'
40
44
  }
41
45
 
46
+ const scopeFailure = isConnected && !error && !statusError && (usage?.status === 'ambiguous_scope' || usage?.status === 'scope_mismatch')
42
47
  const denied = isForbiddenError(error) || isForbiddenError(statusError)
43
48
  const waiting = !denied && !statusError && (!status || status.discovering || (isConnected && !usage && !error))
44
49
  const offers = usePreviousIntegrationSettings(canConfigure && !waiting && !denied && !statusError && !isConnected)
45
50
  const previousNote = offers.metrics ? previousSettingsNote('metrics', offers.metrics) : undefined
46
51
  const guidance = denied
47
52
  ? 'Ask your operator to review your metrics access.'
48
- : !canConfigure && !roleLoading
49
- ? !isConnected || statusError
50
- ? 'Ask your operator to check the metrics connection for this cluster.'
51
- : 'Ask your operator to check metrics availability for this volume.'
52
- : undefined
53
+ : scopeFailure
54
+ ? usage.status === 'ambiguous_scope'
55
+ ? 'An operator can set this cluster’s exact metrics labels with --prometheus-cluster-label, or traffic.prometheusClusterLabels in Helm. These scope controls are not available in Settings or Desktop.'
56
+ : 'Ask your operator to align cluster labels across kube-state-metrics, workload metrics and volume stats. --prometheus-cluster-label (Helm: traffic.prometheusClusterLabels) affects all these charts; its labels must exist on every affected source. For a backend dedicated to this cluster, an operator can use --prometheus-single-cluster instead. These scope controls are not available in Settings or Desktop.'
57
+ : !canConfigure && !roleLoading
58
+ ? !isConnected || statusError
59
+ ? 'Ask your operator to check the metrics connection for this cluster.'
60
+ : 'Ask your operator to check metrics availability for this volume.'
61
+ : undefined
53
62
 
54
63
  if (unavailable || !usage) {
55
64
  return (
@@ -58,7 +67,7 @@ export function PVCUsageBar({ namespace, name }: { namespace: string; name: stri
58
67
  <p className="text-sm text-theme-text-tertiary">{unavailable}</p>
59
68
  {previousNote && <p className="mt-2 text-xs text-theme-text-secondary">{previousNote}</p>}
60
69
  {!waiting && (
61
- canConfigure && !denied ? (
70
+ canConfigure && !denied && !scopeFailure ? (
62
71
  <button
63
72
  type="button"
64
73
  className="mt-2 text-xs text-accent hover:underline"