@skyhook-io/radar-app 1.17.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/README.md +1 -1
  2. package/package.json +3 -3
  3. package/src/App.tsx +125 -87
  4. package/src/api/actions.errorCode.test.ts +40 -0
  5. package/src/api/actions.test.ts +48 -0
  6. package/src/api/actions.ts +79 -0
  7. package/src/api/apiResources.navigation.test.tsx +232 -0
  8. package/src/api/apiResources.ts +46 -3
  9. package/src/api/client.helmSource.test.ts +39 -0
  10. package/src/api/client.namespaceSwitch.hook.test.tsx +54 -0
  11. package/src/api/client.namespaceSwitch.test.ts +107 -0
  12. package/src/api/client.ts +266 -39
  13. package/src/api/cnpg-ha.live.test.ts +140 -0
  14. package/src/api/cnpg-ha.test.ts +78 -0
  15. package/src/api/cnpg-ha.ts +19 -0
  16. package/src/api/cnpg-history.ts +123 -0
  17. package/src/api/cnpg-inspect.ts +102 -0
  18. package/src/api/cnpg-protection.ts +112 -0
  19. package/src/api/cnpg-recovery.test.tsx +60 -0
  20. package/src/api/cnpg-recovery.ts +262 -0
  21. package/src/api/cnpg-sessions.ts +175 -0
  22. package/src/api/cnpg-storage.ts +138 -0
  23. package/src/api/cnpg.legacy-reads.test.ts +43 -0
  24. package/src/api/cnpg.pooler-runtime.test.ts +15 -0
  25. package/src/api/cnpg.ts +428 -10
  26. package/src/api/radarFeatures.render.test.tsx +47 -3
  27. package/src/api/radarFeatures.ts +4 -1
  28. package/src/api/reviewedActionMutation.test.tsx +164 -0
  29. package/src/api/reviewedActionMutation.ts +66 -0
  30. package/src/api/timelineSource.test.ts +9 -0
  31. package/src/api/timelineSource.ts +8 -0
  32. package/src/components/audit/AuditView.tsx +9 -0
  33. package/src/components/capacity/CapacityActivity.tsx +3 -31
  34. package/src/components/capacity/CapacityDemand.tsx +4 -36
  35. package/src/components/capacity/CapacityOverview.tsx +217 -56
  36. package/src/components/capacity/CapacityPoolDetail.tsx +6 -55
  37. package/src/components/capacity/CapacityView.test.tsx +323 -0
  38. package/src/components/capacity/ClusterSchedulingCard.tsx +2 -10
  39. package/src/components/capacity/certaintyGlyph.test.tsx +2 -9
  40. package/src/components/capacity/routes.ts +42 -0
  41. package/src/components/capacity/shared.tsx +11 -129
  42. package/src/components/capacity/workspaceRoute.ts +18 -0
  43. package/src/components/cnpg/CNPGActionHost.compatibility.test.tsx +30 -0
  44. package/src/components/cnpg/CNPGArchivingRepair.tsx +191 -0
  45. package/src/components/cnpg/CNPGBlockingSessions.test.tsx +50 -0
  46. package/src/components/cnpg/CNPGBlockingSessions.tsx +333 -0
  47. package/src/components/cnpg/CNPGClusterActivity.test.tsx +11 -0
  48. package/src/components/cnpg/CNPGClusterActivity.tsx +18 -9
  49. package/src/components/cnpg/CNPGClusterLogs.compatibility.test.tsx +130 -0
  50. package/src/components/cnpg/CNPGClusterLogs.empty.test.tsx +29 -0
  51. package/src/components/cnpg/CNPGClusterLogs.test.ts +9 -0
  52. package/src/components/cnpg/CNPGClusterLogs.tsx +81 -24
  53. package/src/components/cnpg/CNPGClusterTabs.test.tsx +152 -0
  54. package/src/components/cnpg/CNPGClusterTabs.tsx +187 -0
  55. package/src/components/cnpg/CNPGClustersKindView.tsx +46 -0
  56. package/src/components/cnpg/CNPGConfiguration.test.tsx +223 -0
  57. package/src/components/cnpg/CNPGConfiguration.tsx +190 -0
  58. package/src/components/cnpg/CNPGCreateClusterDialog.test.tsx +113 -0
  59. package/src/components/cnpg/CNPGCreateClusterDialog.tsx +74 -0
  60. package/src/components/cnpg/CNPGDeclarations.test.ts +107 -0
  61. package/src/components/cnpg/CNPGDeclarations.tsx +236 -78
  62. package/src/components/cnpg/CNPGDetailPage.test.tsx +62 -0
  63. package/src/components/cnpg/CNPGDetailPage.tsx +159 -98
  64. package/src/components/cnpg/CNPGDrawerTrail.tsx +2 -2
  65. package/src/components/cnpg/CNPGInvestigationAction.test.tsx +30 -0
  66. package/src/components/cnpg/CNPGOperator.test.tsx +34 -0
  67. package/src/components/cnpg/CNPGOperator.tsx +78 -30
  68. package/src/components/cnpg/CNPGOperatorBanner.tsx +39 -0
  69. package/src/components/cnpg/CNPGOperatorConditions.test.tsx +45 -0
  70. package/src/components/cnpg/CNPGOperatorConditions.tsx +16 -0
  71. package/src/components/cnpg/CNPGOperatorConfig.test.tsx +27 -0
  72. package/src/components/cnpg/CNPGOperatorDiagnosis.tsx +247 -0
  73. package/src/components/cnpg/CNPGOverview.test.tsx +171 -0
  74. package/src/components/cnpg/CNPGOverview.tsx +238 -63
  75. package/src/components/cnpg/CNPGParametersInEffect.test.tsx +51 -0
  76. package/src/components/cnpg/CNPGParametersInEffect.tsx +120 -0
  77. package/src/components/cnpg/CNPGPerformance.prerequisite.test.tsx +48 -0
  78. package/src/components/cnpg/CNPGPerformance.test.tsx +73 -0
  79. package/src/components/cnpg/CNPGPerformance.tsx +477 -0
  80. package/src/components/cnpg/CNPGPooling.test.tsx +89 -0
  81. package/src/components/cnpg/CNPGPooling.tsx +97 -40
  82. package/src/components/cnpg/CNPGProtection.test.tsx +145 -0
  83. package/src/components/cnpg/CNPGProtection.tsx +228 -122
  84. package/src/components/cnpg/CNPGReplicationTab.test.tsx +87 -0
  85. package/src/components/cnpg/CNPGReplicationTab.tsx +212 -0
  86. package/src/components/cnpg/CNPGReplicationView.tsx +264 -0
  87. package/src/components/cnpg/CNPGStorage.test.tsx +140 -0
  88. package/src/components/cnpg/CNPGStorage.tsx +619 -0
  89. package/src/components/cnpg/CNPGSummaryHost.overview.test.tsx +44 -0
  90. package/src/components/cnpg/CNPGSummaryHost.test.tsx +23 -0
  91. package/src/components/cnpg/CNPGSummaryHost.tsx +228 -54
  92. package/src/components/cnpg/CNPGTrends.denied.test.tsx +33 -0
  93. package/src/components/cnpg/CNPGTrends.missing.test.tsx +69 -0
  94. package/src/components/cnpg/CNPGTrends.test.ts +38 -0
  95. package/src/components/cnpg/CNPGTrends.tsx +702 -0
  96. package/src/components/cnpg/CNPGView.tsx +34 -21
  97. package/src/components/cnpg/CNPGWorkspace.compatibility.test.tsx +90 -0
  98. package/src/components/cnpg/actions/CNPGClusterActions.dialog.test.tsx +79 -0
  99. package/src/components/cnpg/actions/CNPGClusterActions.test.tsx +142 -0
  100. package/src/components/cnpg/actions/CNPGClusterActions.tsx +853 -0
  101. package/src/components/cnpg/actions/CNPGConnectButton.read.test.tsx +40 -0
  102. package/src/components/cnpg/actions/CNPGConnectButton.test.ts +25 -0
  103. package/src/components/cnpg/actions/CNPGConnectButton.tsx +131 -0
  104. package/src/components/cnpg/actions/CNPGDestroyInstanceDialog.tsx +185 -0
  105. package/src/components/cnpg/actions/CNPGInstanceActions.test.tsx +38 -0
  106. package/src/components/cnpg/actions/CNPGInstanceActions.tsx +74 -0
  107. package/src/components/cnpg/actions/CNPGMaintenanceBanner.tsx +53 -0
  108. package/src/components/cnpg/actions/CNPGPoolerActions.test.tsx +35 -0
  109. package/src/components/cnpg/actions/CNPGPoolerActions.tsx +79 -0
  110. package/src/components/cnpg/actions/CNPGReportDialog.test.tsx +12 -0
  111. package/src/components/cnpg/actions/CNPGReportDialog.tsx +91 -0
  112. package/src/components/cnpg/actions/CNPGScheduleActions.run.test.tsx +58 -0
  113. package/src/components/cnpg/actions/CNPGScheduleActions.test.tsx +54 -0
  114. package/src/components/cnpg/actions/CNPGScheduleActions.tsx +208 -0
  115. package/src/components/cnpg/actions/actionModel.test.ts +111 -0
  116. package/src/components/cnpg/actions/actionModel.ts +127 -0
  117. package/src/components/cnpg/actions/renderCNPGHeaderActions.tsx +60 -0
  118. package/src/components/cnpg/actions/useCNPGWriteGuard.tsx +58 -0
  119. package/src/components/cnpg/actions/useOpenCNPGPsql.ts +22 -0
  120. package/src/components/cnpg/archivingRepair.test.ts +104 -0
  121. package/src/components/cnpg/archivingRepair.ts +145 -0
  122. package/src/components/cnpg/baseBackup.test.ts +36 -0
  123. package/src/components/cnpg/baseBackup.ts +36 -0
  124. package/src/components/cnpg/blocking.test.ts +59 -0
  125. package/src/components/cnpg/blocking.ts +131 -0
  126. package/src/components/cnpg/coverageText.test.ts +27 -0
  127. package/src/components/cnpg/creation/CNPGTargetFields.tsx +110 -0
  128. package/src/components/cnpg/creation/targetModel.test.ts +137 -0
  129. package/src/components/cnpg/creation/targetModel.ts +149 -0
  130. package/src/components/cnpg/fleetStatus.test.ts +46 -0
  131. package/src/components/cnpg/fleetStatus.ts +43 -0
  132. package/src/components/cnpg/formFields.ts +2 -0
  133. package/src/components/cnpg/grantText.test.tsx +11 -0
  134. package/src/components/cnpg/host.test.tsx +24 -0
  135. package/src/components/cnpg/host.tsx +101 -0
  136. package/src/components/cnpg/inspectModel.test.ts +120 -0
  137. package/src/components/cnpg/inspectModel.ts +140 -0
  138. package/src/components/cnpg/logicalSlots.test.ts +30 -0
  139. package/src/components/cnpg/logicalSlots.ts +29 -0
  140. package/src/components/cnpg/lsn.test.ts +40 -0
  141. package/src/components/cnpg/lsn.ts +43 -0
  142. package/src/components/cnpg/operations/CNPGFleetOperation.tsx +30 -0
  143. package/src/components/cnpg/operations/CNPGOperationTracker.test.tsx +247 -0
  144. package/src/components/cnpg/operations/CNPGOperationTracker.tsx +255 -0
  145. package/src/components/cnpg/operations/model.test.ts +510 -0
  146. package/src/components/cnpg/operations/model.ts +499 -0
  147. package/src/components/cnpg/operations/presentation.test.ts +80 -0
  148. package/src/components/cnpg/operations/presentation.ts +84 -0
  149. package/src/components/cnpg/operations/store.ts +98 -0
  150. package/src/components/cnpg/operatorStatus.test.ts +134 -0
  151. package/src/components/cnpg/operatorStatus.ts +194 -0
  152. package/src/components/cnpg/paths.test.ts +54 -0
  153. package/src/components/cnpg/paths.ts +47 -4
  154. package/src/components/cnpg/protection/CNPGAttachArchiveDialog.tsx +185 -0
  155. package/src/components/cnpg/protection/CNPGCreateScheduleDialog.tsx +196 -0
  156. package/src/components/cnpg/protection/CNPGProtectionSetup.test.tsx +121 -0
  157. package/src/components/cnpg/protection/CNPGProtectionSetup.tsx +452 -0
  158. package/src/components/cnpg/protection/CNPGScheduleInput.tsx +115 -0
  159. package/src/components/cnpg/protection/CNPGScheduleRepairButton.tsx +120 -0
  160. package/src/components/cnpg/protection/scheduleModel.test.ts +19 -0
  161. package/src/components/cnpg/protection/scheduleModel.ts +16 -0
  162. package/src/components/cnpg/recovery/CNPGRecoveryPoint.tsx +220 -0
  163. package/src/components/cnpg/recovery/CNPGRestoreButton.test.tsx +29 -0
  164. package/src/components/cnpg/recovery/CNPGRestoreButton.tsx +26 -0
  165. package/src/components/cnpg/recovery/CNPGRestoreChecks.test.tsx +23 -0
  166. package/src/components/cnpg/recovery/CNPGRestoreChecks.tsx +153 -0
  167. package/src/components/cnpg/recovery/CNPGRestoreDialog.back.test.tsx +149 -0
  168. package/src/components/cnpg/recovery/CNPGRestoreDialog.test.tsx +46 -0
  169. package/src/components/cnpg/recovery/CNPGRestoreDialog.tsx +333 -0
  170. package/src/components/cnpg/recovery/CNPGRestoreProgress.tsx +182 -0
  171. package/src/components/cnpg/recovery/CNPGRestoreValidation.tsx +216 -0
  172. package/src/components/cnpg/recovery/restoreModel.test.ts +709 -0
  173. package/src/components/cnpg/recovery/restoreModel.ts +685 -0
  174. package/src/components/cnpg/recovery/restoreOperation.ts +56 -0
  175. package/src/components/cnpg/refresh.test.ts +38 -0
  176. package/src/components/cnpg/refresh.ts +17 -0
  177. package/src/components/cnpg/refreshNotice.test.tsx +29 -0
  178. package/src/components/cnpg/routes.test.ts +34 -3
  179. package/src/components/cnpg/routes.ts +60 -48
  180. package/src/components/cnpg/runtimeAssessment.ts +198 -0
  181. package/src/components/cnpg/runtimeModel.test.ts +175 -0
  182. package/src/components/cnpg/runtimeModel.ts +196 -0
  183. package/src/components/cnpg/runtimeParts.test.tsx +17 -0
  184. package/src/components/cnpg/runtimeParts.tsx +79 -0
  185. package/src/components/cnpg/shared.test.tsx +59 -0
  186. package/src/components/cnpg/shared.tsx +100 -182
  187. package/src/components/cnpg/storageModel.test.ts +107 -0
  188. package/src/components/cnpg/storageModel.ts +101 -0
  189. package/src/components/cnpg/trendSamples.test.ts +148 -0
  190. package/src/components/cnpg/trendSamples.ts +222 -0
  191. package/src/components/cnpg/useCNPGClusterAssessment.ts +30 -0
  192. package/src/components/cnpg/useCNPGKubectlContext.test.tsx +45 -0
  193. package/src/components/cnpg/useCNPGKubectlContext.ts +13 -0
  194. package/src/components/cnpg/useCNPGNavigate.test.tsx +58 -0
  195. package/src/components/cnpg/useCNPGNavigate.ts +21 -0
  196. package/src/components/cnpg/useCNPGPoolerLive.ts +46 -0
  197. package/src/components/cnpg/useCNPGScreenParams.ts +24 -0
  198. package/src/components/cnpg/useCNPGSidebarWorkspace.ts +66 -16
  199. package/src/components/cnpg/workspaceRoute.ts +23 -0
  200. package/src/components/cost/ApplicationCostTab.test.ts +7 -0
  201. package/src/components/cost/ApplicationCostTab.tsx +7 -7
  202. package/src/components/cost/CostView.test.ts +2 -1
  203. package/src/components/cost/CostView.tsx +0 -2
  204. package/src/components/cost/WorkloadCostTab.tsx +2 -4
  205. package/src/components/cost/errors.ts +3 -1
  206. package/src/components/cost/source.test.ts +10 -0
  207. package/src/components/cost/source.ts +5 -1
  208. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +8 -0
  209. package/src/components/diagnose/investigationEvidence/projection.test.ts +16 -0
  210. package/src/components/dock/BottomDock.tsx +2 -0
  211. package/src/components/dock/TerminalTab.test.tsx +17 -0
  212. package/src/components/dock/TerminalTab.tsx +7 -3
  213. package/src/components/gitops/ArgoResourceDiffLoader.tsx +4 -2
  214. package/src/components/gitops/GitOpsView.tsx +16 -23
  215. package/src/components/helm/HelmCompareRoute.test.tsx +59 -0
  216. package/src/components/helm/HelmCompareRoute.tsx +57 -26
  217. package/src/components/helm/HelmReleaseDrawer.test.ts +55 -3
  218. package/src/components/helm/HelmReleaseDrawer.tsx +50 -12
  219. package/src/components/helm/TrackChartSourceDialog.render.test.tsx +120 -0
  220. package/src/components/helm/TrackChartSourceDialog.test.ts +50 -0
  221. package/src/components/helm/TrackChartSourceDialog.tsx +153 -176
  222. package/src/components/helm/ValuesViewer.test.tsx +301 -0
  223. package/src/components/helm/ValuesViewer.tsx +169 -78
  224. package/src/components/home/ActivitySummary.tsx +2 -1
  225. package/src/components/home/HomeView.tsx +20 -9
  226. package/src/components/home/ProblemsPanel.test.tsx +81 -0
  227. package/src/components/home/TopologyPreview.tsx +2 -2
  228. package/src/components/issues/IssuesPane.render.test.tsx +122 -0
  229. package/src/components/issues/IssuesPane.test.ts +65 -1
  230. package/src/components/issues/IssuesPane.tsx +188 -25
  231. package/src/components/issues/uncheckedSources.ts +21 -0
  232. package/src/components/resource/PVCUsageBar.render.test.tsx +32 -0
  233. package/src/components/resource/PVCUsageBar.test.tsx +24 -0
  234. package/src/components/resource/PVCUsageBar.tsx +15 -6
  235. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +3 -1
  236. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +1 -1
  237. package/src/components/resources/CompositeRenderer.tsx +4 -4
  238. package/src/components/resources/ResourceDetailDrawer.tsx +2 -2
  239. package/src/components/resources/ResourcesView.tsx +26 -12
  240. package/src/components/settings/LocalIntegrationStatus.test.tsx +52 -0
  241. package/src/components/settings/LocalIntegrationStatus.tsx +3 -4
  242. package/src/components/settings/SettingsDialog.tsx +28 -15
  243. package/src/components/timeline/TimelineList.tsx +22 -24
  244. package/src/components/timeline/TimelineLoadError.test.tsx +62 -0
  245. package/src/components/timeline/TimelineLoadError.tsx +96 -0
  246. package/src/components/timeline/TimelineView.tsx +18 -45
  247. package/src/components/timeline/ViewModeToggle.tsx +20 -0
  248. package/src/components/timeline/useStickyError.test.tsx +47 -0
  249. package/src/components/timeline/useStickyError.ts +15 -0
  250. package/src/components/ui/DiagnosticsOverlay.tsx +34 -2
  251. package/src/components/ui/Markdown.test.tsx +65 -0
  252. package/src/components/ui/Markdown.tsx +144 -127
  253. package/src/components/whats-new/releaseNotes.ts +63 -1
  254. package/src/components/workload/WorkloadView.test.ts +10 -1
  255. package/src/components/workload/WorkloadView.tsx +27 -239
  256. package/src/components/workspace/layout.test.tsx +29 -0
  257. package/src/components/workspace/layout.tsx +259 -0
  258. package/src/components/workspace/table.ts +7 -0
  259. package/src/contexts/CapabilitiesContext.tsx +1 -1
  260. package/src/integrations/resourceHost.test.tsx +55 -0
  261. package/src/integrations/resourceHost.ts +99 -0
  262. package/src/integrations/resourceHosts.test.tsx +141 -0
  263. package/src/integrations/resourceHosts.tsx +119 -0
  264. package/src/integrations/workspaceRoute.ts +20 -0
  265. package/src/integrations/workspaceRoutes.test.ts +40 -0
  266. package/src/integrations/workspaceRoutes.ts +22 -0
  267. package/src/integrations/workspaceScreens.tsx +14 -0
  268. package/src/utils/drawer-trail.ts +35 -0
  269. package/src/utils/kindFilterStore.test.ts +147 -0
  270. package/src/utils/kindFilterStore.ts +91 -0
  271. package/src/utils/navigation.test.ts +13 -1
  272. package/src/utils/navigation.ts +39 -2
  273. package/src/utils/page-links.ts +21 -0
  274. package/src/utils/tabStorage.ts +40 -0
  275. package/src/utils/viewMemory.test.ts +78 -0
  276. package/src/utils/viewMemory.ts +54 -0
@@ -1,10 +1,13 @@
1
1
  import { useMemo } from 'react'
2
- import { useNavigate } from 'react-router-dom'
3
2
  import { Database, FileCheck2, Settings2, ShieldCheck, Waypoints } from 'lucide-react'
4
- import { buildCNPGFleet, type CNPGFleet, type SidebarCategoryWorkspace } from '@skyhook-io/k8s-ui'
3
+ import { applyCNPGDisk, applyCNPGFleetMetrics, buildCNPGFleet, type CNPGDiskReading, type CNPGFleet, type SidebarCategoryWorkspace } from '@skyhook-io/k8s-ui'
5
4
  import type { APIResource } from '../../types'
6
5
  import { useCNPGWorkspace } from '../../api/cnpg'
6
+ import { useRadarFeature } from '../../api/client'
7
+ import { useCNPGFleetDisk } from '../../api/cnpg-storage'
8
+ import { useCNPGFleetMetrics } from '../../api/cnpg-history'
7
9
  import { CNPG_SCREENS, type CNPGScreen } from './routes'
10
+ import { useCNPGNavigate } from './useCNPGNavigate'
8
11
 
9
12
  export const CNPG_SIDEBAR_CATEGORY = 'CloudNativePG'
10
13
 
@@ -20,24 +23,67 @@ export function cnpgDiscovered(apiResources: APIResource[] | undefined): boolean
20
23
  return !!apiResources?.some((r) => r.group === 'postgresql.cnpg.io')
21
24
  }
22
25
 
23
- export function useCNPGFleet(namespaces: string[], enabled = true) {
26
+ // Disk use joins the fleet here, so low-disk clusters count toward Needs
27
+ // attention on every screen and sidebar badge that reads the enriched fleet;
28
+ // measured replication lag and disk growth join the same way. Both are
29
+ // Prometheus fan-outs per namespace, so callers outside the CloudNativePG views
30
+ // pass withMetrics=false.
31
+ export function useCNPGFleet(namespaces: string[], enabled = true, withMetrics = true) {
24
32
  const query = useCNPGWorkspace(namespaces, { enabled })
25
- const fleet = useMemo<CNPGFleet | null>(() => (query.data?.installed ? buildCNPGFleet(query.data) : null), [query.data])
33
+ const { support } = useRadarFeature('cnpgWorkspace')
34
+ const enrich = enabled && withMetrics && support === 'supported' && !!query.data?.installed
35
+ const disk = useCNPGFleetDisk(namespaces, enrich)
36
+ const metrics = useCNPGFleetMetrics(namespaces, enrich)
37
+ const fleet = useMemo<CNPGFleet | null>(
38
+ () =>
39
+ query.data?.installed
40
+ ? applyCNPGFleetMetrics(
41
+ applyCNPGDisk(buildCNPGFleet(query.data), enrich ? disk.data?.clusters ?? (disk.error ? diskFailed(query.data.objects.clusters ?? [], disk.error) : undefined) : undefined),
42
+ enrich ? metrics.data?.clusters : undefined,
43
+ enrich ? metrics.data : undefined,
44
+ )
45
+ : null,
46
+ [query.data, disk.data, disk.error, metrics.data, enrich],
47
+ )
26
48
  return { query, fleet }
27
49
  }
28
50
 
29
- function destinationCount(screen: CNPGScreen, fleet: CNPGFleet | null): { count?: number | null; title?: string } {
51
+ // A failed disk read is stated per cluster; left undefined it would render as
52
+ // still loading.
53
+ function diskFailed(clusters: any[], err: unknown): CNPGDiskReading[] {
54
+ const reason = `Disk usage could not be read: ${err instanceof Error ? err.message : 'request failed'}`
55
+ return clusters.map((c) => ({ namespace: c?.metadata?.namespace ?? '', name: c?.metadata?.name ?? '', state: 'error', reason, claims: 0, measured: 0 }))
56
+ }
57
+
58
+ const UNMEASURED_NOTE = 'Disk use and replication lag are counted on the CloudNativePG views.'
59
+
60
+ function destinationCount(screen: CNPGScreen, fleet: CNPGFleet | null, withMetrics: boolean): { count?: number | null; lowerBound?: boolean; title?: string } {
30
61
  if (!fleet) return {}
31
- const lowerBound = fleet.incompleteKinds.length > 0 ? ' Some CloudNativePG data is not readable, so this is a lower bound.' : ''
62
+ const partial = fleet.incompleteKinds.length > 0
63
+ const note = partial ? ' Some CloudNativePG data is not readable, so this is a lower bound.' : ''
64
+ // Disk use and measured lag only ever add problems, so a count without them
65
+ // is a lower bound, and its zero is not "none".
66
+ const at = (count: number, what: string, unmeasured?: string) => {
67
+ const lowerBound = partial || !!unmeasured
68
+ const extra = unmeasured ? ` ${unmeasured}` : ''
69
+ if (lowerBound && count === 0) {
70
+ return {
71
+ count: null,
72
+ lowerBound: true,
73
+ title: partial ? `Unknown: some CloudNativePG data could not be read.${extra}` : `None found in the workspace read.${extra}`,
74
+ }
75
+ }
76
+ return { count, lowerBound, title: `${lowerBound ? 'At least ' : ''}${count} ${what}.${note}${extra}` }
77
+ }
32
78
  switch (screen) {
33
79
  case 'overview':
34
- return { count: fleet.attentionCount, title: `${fleet.attentionCount} clusters need attention.${lowerBound}` }
80
+ return at(fleet.attentionCount, 'clusters need attention', withMetrics ? undefined : UNMEASURED_NOTE)
35
81
  case 'protection':
36
- return { count: fleet.categoryCounts.protection, title: `${fleet.categoryCounts.protection} clusters with failing backups or WAL archiving.${lowerBound}` }
82
+ return at(fleet.categoryCounts.protection, 'clusters with failing backups or WAL archiving')
37
83
  case 'declarations':
38
- return { count: fleet.categoryCounts.declarations, title: `${fleet.categoryCounts.declarations} clusters with declarations that are not reconciled.${lowerBound}` }
84
+ return at(fleet.categoryCounts.declarations, 'clusters with declarations that are not reconciled')
39
85
  case 'pooling':
40
- return { count: fleet.categoryCounts.pooling, title: `${fleet.categoryCounts.pooling} clusters with Pooler problems.${lowerBound}` }
86
+ return at(fleet.categoryCounts.pooling, 'clusters with Pooler problems')
41
87
  default:
42
88
  return {}
43
89
  }
@@ -52,25 +98,29 @@ export function useCNPGSidebarWorkspace({
52
98
  apiResources,
53
99
  namespaces,
54
100
  active,
101
+ withMetrics = !!active,
55
102
  }: {
56
103
  apiResources: APIResource[] | undefined
57
104
  namespaces: string[]
58
105
  active?: { screen: CNPGScreen; child?: { label: string; title?: string } }
106
+ /** Count disk use and replication lag too. Off by default outside the CloudNativePG views: every Resources page renders this sidebar. */
107
+ withMetrics?: boolean
59
108
  }): Record<string, SidebarCategoryWorkspace> | undefined {
60
- const navigate = useNavigate()
61
- const discovered = cnpgDiscovered(apiResources)
62
- const { fleet } = useCNPGFleet(namespaces, discovered)
109
+ const navigate = useCNPGNavigate()
110
+ const available = cnpgDiscovered(apiResources)
111
+ const { fleet } = useCNPGFleet(namespaces, available, withMetrics)
63
112
  const nsKey = namespaces.join(',')
64
113
 
65
114
  return useMemo(() => {
66
- if (!discovered) return undefined
115
+ if (!available) return undefined
67
116
  const destinations = CNPG_SCREENS.map((s) => {
68
- const { count, title } = destinationCount(s.id, fleet)
117
+ const { count, lowerBound, title } = destinationCount(s.id, fleet, withMetrics)
69
118
  return {
70
119
  id: s.id,
71
120
  label: s.label,
72
121
  icon: ICONS[s.id],
73
122
  count,
123
+ countLowerBound: lowerBound,
74
124
  countTitle: title,
75
125
  active: active?.screen === s.id,
76
126
  child: active?.screen === s.id ? active.child : undefined,
@@ -84,5 +134,5 @@ export function useCNPGSidebarWorkspace({
84
134
  scopeNote: nsKey ? `Counts for namespace ${nsKey.split(',').join(', ')}` : undefined,
85
135
  },
86
136
  }
87
- }, [discovered, fleet, active?.screen, active?.child?.label, active?.child?.title, navigate, nsKey]) // eslint-disable-line react-hooks/exhaustive-deps
137
+ }, [available, fleet, withMetrics, active?.screen, active?.child?.label, active?.child?.title, navigate, nsKey]) // eslint-disable-line react-hooks/exhaustive-deps
88
138
  }
@@ -0,0 +1,23 @@
1
+ import type { WorkspaceRoute } from '../../integrations/workspaceRoute'
2
+ import { CNPG_SCREENS, parseCNPGRoute } from './routes'
3
+
4
+ export const cnpgWorkspaceRoute: WorkspaceRoute = {
5
+ id: 'cnpg',
6
+ path: '/cnpg',
7
+ label: 'CloudNativePG',
8
+ navView: 'resources',
9
+ namespaceScope: 'view',
10
+ pageTitle(pathname) {
11
+ const route = parseCNPGRoute(pathname)
12
+ if (route.detail) return route.detail.name
13
+ const screen = CNPG_SCREENS.find(screen => screen.id === route.screen)
14
+ return `CloudNativePG ${screen?.label ?? 'Clusters'}`
15
+ },
16
+ contextSwitchSearch(pathname, search) {
17
+ // A detail's context pin prevents loading a same-named object after a switch.
18
+ const next = new URLSearchParams()
19
+ const context = search.get('ctx')
20
+ if (context && pathname.startsWith('/cnpg/')) next.set('ctx', context)
21
+ return next
22
+ },
23
+ }
@@ -195,6 +195,13 @@ describe('getApplicationCostState', () => {
195
195
  ).toBe('not_found')
196
196
  })
197
197
 
198
+ it('does not report a cross-origin refusal as missing access', () => {
199
+ const refused = new ApiError('Radar refused this request because it came from http://radar.internal', 403, {
200
+ error_code: 'cross_origin_refused',
201
+ })
202
+ expect(getApplicationCostState(undefined, undefined, { currentError: refused })).toBe('load_error')
203
+ })
204
+
198
205
  it('queries only steady-state workload kinds', () => {
199
206
  const workloads = [
200
207
  { kind: 'Deployment', namespace: 'prod', name: 'api' },
@@ -3,6 +3,7 @@ import { AlertCircle, Coins, HelpCircle, Loader2, TrendingUp } from 'lucide-reac
3
3
  import type { AppRow, AppWorkload } from '@skyhook-io/k8s-ui'
4
4
  import {
5
5
  COST_DISCOVERY_GRACE_MS,
6
+ crossOriginRefusalMessage,
6
7
  useOpenCostApplicationCost,
7
8
  useOpenCostApplicationCostTrend,
8
9
  type CostTimeRange,
@@ -149,7 +150,8 @@ export function ApplicationCostTab({
149
150
  />
150
151
  )
151
152
  }
152
- return <ApplicationCostUnavailable state={state} settingsAvailable={settingsAvailable} />
153
+ const refusal = crossOriginRefusalMessage(currentQuery.error) ?? crossOriginRefusalMessage(trendQuery.error)
154
+ return <ApplicationCostUnavailable state={state} message={refusal} settingsAvailable={settingsAvailable} />
153
155
  }
154
156
 
155
157
  const current = currentQuery.data
@@ -512,11 +514,11 @@ function applicationCostKey(ref: { kind: string; namespace: string; name: string
512
514
  function reasonLabel(reason?: CostUnavailableReason) {
513
515
  if (reason === 'no_prometheus') return 'Metrics backend not found'
514
516
  if (reason === 'no_cost_source') return 'Cost source not found'
515
- if (reason === 'query_error') return 'Cost query failed'
517
+ if (reason === 'query_error') return 'Cost data unavailable'
516
518
  if (reason === 'access_denied') return 'No access to this workload'
517
519
  if (reason === 'not_found') return 'Workload not found'
518
520
  if (reason === 'configuration_mismatch') return 'Kubecost settings are not valid for this cluster'
519
- if (reason === 'deployment_configuration_error') return 'Radar cost deployment is misconfigured'
521
+ if (reason === 'deployment_configuration_error') return 'Radar configuration is invalid'
520
522
  if (reason === 'metrics_settings_error') return 'Saved metrics settings need review'
521
523
  if (reason === 'cost_settings_error') return 'Saved cost settings need review'
522
524
  return 'No workload cost metrics'
@@ -568,10 +570,8 @@ function ApplicationCostUnavailable({
568
570
  costIntegrationUnavailableMessage(state, settingsAvailable) ??
569
571
  (state === 'no_prometheus'
570
572
  ? 'No compatible metrics backend was found. OpenCost application cost requires OpenCost metrics in a PromQL-compatible backend.'
571
- : state === 'query_error'
572
- ? 'Cost data is temporarily unavailable. A metrics backend was found, but application cost queries failed.'
573
- : state === 'history_unsupported'
574
- ? 'Historical application cost is not available for Kubecost yet.'
573
+ : state === 'history_unsupported'
574
+ ? 'Historical application cost is not available for Kubecost yet.'
575
575
  : state === 'access_denied'
576
576
  ? 'Cost data is unavailable because these workloads are not accessible with your current permissions.'
577
577
  : state === 'not_found'
@@ -17,7 +17,8 @@ describe('costUnavailableMessage', () => {
17
17
  expect(costUnavailableMessage('source_unavailable')).toContain('Settings → Cost')
18
18
  expect(costUnavailableMessage('authentication_error')).toContain('API key')
19
19
  expect(costUnavailableMessage('configuration_mismatch')).toContain('not valid for this cluster')
20
- expect(costUnavailableMessage('deployment_configuration_error')).toContain('Helm cost values')
20
+ expect(costUnavailableMessage('deployment_configuration_error')).toContain('launch flags')
21
+ expect(costUnavailableMessage('deployment_configuration_error')).toContain('Helm values')
21
22
  })
22
23
 
23
24
  it('distinguishes a scoped empty result from missing source data', () => {
@@ -402,8 +402,6 @@ export function costUnavailableMessage(
402
402
  return 'No compatible cost source found — connect OpenCost metrics through a PromQL-compatible backend or configure Kubecost'
403
403
  case 'no_metrics':
404
404
  return 'Cost data is not ready — the selected source returned no allocation data'
405
- case 'query_error':
406
- return 'Cost data is temporarily unavailable — the selected source query failed'
407
405
  case 'access_denied':
408
406
  return 'You do not have access to view cluster cost data'
409
407
  default:
@@ -363,10 +363,8 @@ function WorkloadCostUnavailable({
363
363
  costIntegrationUnavailableMessage(state, settingsAvailable) ??
364
364
  (state === 'no_prometheus'
365
365
  ? 'No compatible metrics backend was found. OpenCost workload cost requires OpenCost metrics in a PromQL-compatible backend.'
366
- : state === 'query_error'
367
- ? 'Cost data is temporarily unavailable. A metrics backend was found, but workload cost queries failed.'
368
- : state === 'history_unsupported'
369
- ? 'Historical workload cost is not available for Kubecost yet.'
366
+ : state === 'history_unsupported'
367
+ ? 'Historical workload cost is not available for Kubecost yet.'
370
368
  : state === 'access_denied'
371
369
  ? 'You do not have access to view cost for this workload.'
372
370
  : state === 'not_found'
@@ -1,7 +1,9 @@
1
- import { ApiError, type CostUnavailableReason } from '../../api/client'
1
+ import { ApiError, crossOriginRefusalMessage, type CostUnavailableReason } from '../../api/client'
2
2
 
3
3
  export function costUnavailableReasonFromError(error: unknown): CostUnavailableReason | undefined {
4
4
  if (!(error instanceof ApiError)) return undefined
5
+ // Not a permission problem: the server's message says what to fix.
6
+ if (crossOriginRefusalMessage(error)) return undefined
5
7
  if (error.status === 403) return 'access_denied'
6
8
  if (error.status === 404) return 'not_found'
7
9
  return undefined
@@ -58,3 +58,13 @@ describe('cost source presentation', () => {
58
58
  expect(costIntegrationUnavailableMessage('authentication_error', false)).not.toContain('Settings')
59
59
  })
60
60
  })
61
+
62
+ it('gives connection guidance without claiming an unreachable source ran a query', () => {
63
+ const standalone = costIntegrationUnavailableMessage('query_error', true)
64
+ expect(standalone).toContain('Settings → Metrics')
65
+ expect(standalone).toContain('Settings → Cost')
66
+ expect(standalone).not.toContain('query failed')
67
+ const embedded = costIntegrationUnavailableMessage('query_error', false)
68
+ expect(embedded).toContain('Ask your operator')
69
+ expect(embedded).not.toContain('Settings')
70
+ })
@@ -107,6 +107,10 @@ export function costIntegrationUnavailableMessage(
107
107
  settingsAvailable = true,
108
108
  ): string | undefined {
109
109
  switch (reason) {
110
+ case 'query_error':
111
+ return settingsAvailable
112
+ ? 'The selected cost source could not provide data. Check its connection in Settings → Metrics (OpenCost) or Settings → Cost (Kubecost).'
113
+ : 'The selected cost source could not provide data. Ask your operator to check its connection and query health.'
110
114
  case 'no_cost_source':
111
115
  return settingsAvailable
112
116
  ? 'No compatible cost source was found. Connect OpenCost metrics in Settings → Metrics or configure Kubecost in Settings → Cost.'
@@ -120,7 +124,7 @@ export function costIntegrationUnavailableMessage(
120
124
  case 'cost_settings_error':
121
125
  return 'This cluster’s saved cost settings need review before cost data can load. Review them in Settings → Cost.'
122
126
  case 'deployment_configuration_error':
123
- return 'Cost collection is misconfigured by this Radar deployment. Update its environment variables or Helm cost values, then restart Radar.'
127
+ return "Radar's operator-supplied cost or metrics configuration is invalid. Check local launch flags or the deployment environment variables and Helm values."
124
128
  case 'authentication_error':
125
129
  return settingsAvailable
126
130
  ? 'Kubecost rejected the configured API key. Update it in Settings → Cost.'
@@ -71,6 +71,14 @@ export function adaptIssues(
71
71
  "truncated",
72
72
  );
73
73
  }
74
+ if (nonEmptyString(value.recent_changes_error)) {
75
+ builder.limit(
76
+ source,
77
+ "Issue-related changes",
78
+ value.recent_changes_error,
79
+ "error",
80
+ );
81
+ }
74
82
  if (value.correlation_truncated === true) {
75
83
  builder.limit(
76
84
  source,
@@ -247,6 +247,22 @@ describe("strict evidence adapters", () => {
247
247
  }),
248
248
  ]);
249
249
 
250
+ const unreadIssueFeed = project([
251
+ tool("issues-down", "issues", {
252
+ issues: [],
253
+ total: 0,
254
+ total_matched: 0,
255
+ recent_changes_error: "timeline history is unavailable",
256
+ }),
257
+ ]);
258
+ expect(unreadIssueFeed.limitations).toEqual([
259
+ expect.objectContaining({
260
+ source: "Issue-related changes",
261
+ kind: "error",
262
+ message: "timeline history is unavailable",
263
+ }),
264
+ ]);
265
+
250
266
  const filtered = project([
251
267
  tool("filtered", "get_resource", {
252
268
  resource: deployment,
@@ -17,6 +17,8 @@ function renderTabContent(tab: DockTab, isActive: boolean) {
17
17
  containerName={tab.containerName!}
18
18
  containers={tab.containers!}
19
19
  isActive={isActive}
20
+ shell={tab.shell}
21
+ note={tab.sessionNote}
20
22
  />
21
23
  )
22
24
  }
@@ -0,0 +1,17 @@
1
+ import { renderToString } from 'react-dom/server'
2
+ import { expect, it, vi } from 'vitest'
3
+ import type { TerminalTabProps } from '@skyhook-io/k8s-ui'
4
+ import { TerminalTab } from './TerminalTab'
5
+ const captured = vi.hoisted(() => ({ props: null as TerminalTabProps | null }))
6
+ vi.mock('@skyhook-io/k8s-ui', () => ({ TerminalTab: (props: TerminalTabProps) => { captured.props = props; return null } }))
7
+ vi.mock('../../api/config', () => ({ apiUrl: (path: string) => `/api${path}`, getWsUrl: (path: string) => `ws://localhost/api${path}` }))
8
+ it.each([undefined, '/bin/custom+command'])('forwards the executable and context through the shared terminal (%s)', async shell => {
9
+ renderToString(<TerminalTab namespace="app" podName="worker" containerName="app" containers={['app', 'sidecar']} shell={shell} note="Primary" />)
10
+ const props = captured.props!
11
+ expect(props.command).toBe(shell); expect(props.note).toBe('Primary')
12
+ expect(Boolean(props.createDebugContainer)).toBe(!shell)
13
+ const { wsUrl } = await props.createSession('app')
14
+ const url = new URL(wsUrl)
15
+ expect(url.pathname).toBe('/api/pods/app/worker/exec'); expect(url.searchParams.get('container')).toBe('app')
16
+ expect(url.searchParams.get('shell')).toBe(shell ?? null)
17
+ })
@@ -8,12 +8,14 @@ interface TerminalTabProps {
8
8
  containerName: string
9
9
  containers: string[]
10
10
  isActive?: boolean
11
+ shell?: string
12
+ note?: string
11
13
  }
12
14
 
13
- export function TerminalTab({ namespace, podName, containerName, containers, isActive }: TerminalTabProps) {
15
+ export function TerminalTab({ namespace, podName, containerName, containers, isActive, shell, note }: TerminalTabProps) {
14
16
  const createSession = (container: string) =>
15
17
  Promise.resolve({
16
- wsUrl: getWsUrl(`/pods/${namespace}/${podName}/exec?container=${container}`),
18
+ wsUrl: getWsUrl(`/pods/${namespace}/${podName}/exec?container=${container}${shell ? `&shell=${encodeURIComponent(shell)}` : ''}`),
17
19
  })
18
20
 
19
21
  const createDebugContainer = async (targetContainer: string) => {
@@ -37,7 +39,9 @@ export function TerminalTab({ namespace, podName, containerName, containers, isA
37
39
  containers={containers}
38
40
  isActive={isActive}
39
41
  createSession={createSession}
40
- createDebugContainer={createDebugContainer}
42
+ createDebugContainer={shell ? undefined : createDebugContainer}
43
+ note={note}
44
+ command={shell}
41
45
  />
42
46
  )
43
47
  }
@@ -1,23 +1,25 @@
1
1
  import { ArgoResourceDiff } from '@skyhook-io/k8s-ui'
2
- import type { GitOpsInsightRef } from '@skyhook-io/k8s-ui'
2
+ import type { GitOpsDrift, GitOpsInsightRef } from '@skyhook-io/k8s-ui'
3
3
  import { useArgoResourceDiff } from '../../api/client'
4
4
 
5
5
  interface ArgoResourceDiffLoaderProps {
6
6
  appNamespace: string
7
7
  appName: string
8
8
  resourceRef: GitOpsInsightRef
9
+ fallbackDrift?: GitOpsDrift
9
10
  }
10
11
 
11
12
  // Host wrapper: fetches the Argo CD resource diff and hands the data to the
12
13
  // pure k8s-ui presentation component. Mirrors the data-in-web / render-in-
13
14
  // k8s-ui split the resource renderers use for their host-wired data.
14
- export function ArgoResourceDiffLoader({ appNamespace, appName, resourceRef }: ArgoResourceDiffLoaderProps) {
15
+ export function ArgoResourceDiffLoader({ appNamespace, appName, resourceRef, fallbackDrift }: ArgoResourceDiffLoaderProps) {
15
16
  const { data, isLoading, error } = useArgoResourceDiff(appNamespace, appName, resourceRef)
16
17
  return (
17
18
  <ArgoResourceDiff
18
19
  diff={data ?? null}
19
20
  loading={isLoading}
20
21
  error={(error as Error | null)?.message ?? null}
22
+ fallbackDrift={fallbackDrift}
21
23
  />
22
24
  )
23
25
  }
@@ -25,7 +25,6 @@ import {
25
25
  isArgoOperationInProgress,
26
26
  isArgoSuspendedByRadar,
27
27
  gitOpsInsightChangeKey,
28
- initNavigationMap,
29
28
  kindToPluralWithGroup,
30
29
  normalizeArgoApplication,
31
30
  normalizeArgoApplicationSet,
@@ -72,7 +71,7 @@ import {
72
71
  useGitOpsTree,
73
72
  useResource,
74
73
  } from '../../api/client'
75
- import { useAPIResources } from '../../api/apiResources'
74
+ import { useAPIResources, GITOPS_KINDS } from '../../api/apiResources'
76
75
  import { useConnection } from '../../context/ConnectionContext'
77
76
  import { apiUrl, getAuthHeaders, getCredentialsMode } from '../../api/config'
78
77
  import { useRegisterShortcut } from '../../hooks/useKeyboardShortcuts'
@@ -84,17 +83,6 @@ import { RemoteDestinationCloudHint } from './RemoteDestinationCloudHint'
84
83
  const GITOPS_HEALTH_DOCS_URL = 'https://radarhq.io/docs/features/gitops#per-resource-health'
85
84
  import type { GitOpsHistoryItem } from '@skyhook-io/k8s-ui'
86
85
 
87
- const GITOPS_KINDS: APIResource[] = [
88
- { name: 'applications', kind: 'Application', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
89
- { name: 'applicationsets', kind: 'ApplicationSet', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
90
- { name: 'appprojects', kind: 'AppProject', group: 'argoproj.io', version: 'v1alpha1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
91
- { name: 'kustomizations', kind: 'Kustomization', group: 'kustomize.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
92
- { name: 'helmreleases', kind: 'HelmRelease', group: 'helm.toolkit.fluxcd.io', version: 'v2', namespaced: true, verbs: ['list', 'get'], isCrd: true },
93
- { name: 'gitrepositories', kind: 'GitRepository', group: 'source.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
94
- { name: 'ocirepositories', kind: 'OCIRepository', group: 'source.toolkit.fluxcd.io', version: 'v1beta2', namespaced: true, verbs: ['list', 'get'], isCrd: true },
95
- { name: 'helmrepositories', kind: 'HelmRepository', group: 'source.toolkit.fluxcd.io', version: 'v1', namespaced: true, verbs: ['list', 'get'], isCrd: true },
96
- { name: 'alerts', kind: 'Alert', group: 'notification.toolkit.fluxcd.io', version: 'v1beta3', namespaced: true, verbs: ['list', 'get'], isCrd: true },
97
- ]
98
86
 
99
87
  type ArgoSyncDialogTarget =
100
88
  | { scope: 'application' }
@@ -164,9 +152,6 @@ function GitOpsTableView({ namespaces, onClearNamespaces }: { namespaces: string
164
152
  })
165
153
  }
166
154
 
167
- useEffect(() => {
168
- initNavigationMap([...(apiResources ?? []), ...GITOPS_KINDS])
169
- }, [apiResources])
170
155
 
171
156
  const hasGitOpsRowResource = useMemo(() => (
172
157
  hasAPIResource(apiResources, 'applications', 'argoproj.io') ||
@@ -816,8 +801,8 @@ function GitOpsDetailView({ namespaces, onOpenResource, onOpenSettings }: GitOps
816
801
  ) : undefined}
817
802
  focusKey={changesFocusKey}
818
803
  tree={tree}
819
- renderResourceDiff={isArgoApp ? (ref) => (
820
- <ArgoResourceDiffLoader appNamespace={namespace} appName={name} resourceRef={ref} />
804
+ renderResourceDiff={isArgoApp ? (ref, fallbackDrift) => (
805
+ <ArgoResourceDiffLoader appNamespace={namespace} appName={name} resourceRef={ref} fallbackDrift={fallbackDrift} />
821
806
  ) : undefined}
822
807
  onOpenSettings={onOpenSettings}
823
808
  settingsNote={settingsNote}
@@ -1097,18 +1082,26 @@ function newestConditionTime(resource: any): string {
1097
1082
  // many of them are healthy / drifted" at a glance, without making the user
1098
1083
  // count facets in the filter rail.
1099
1084
  function TopologyCounts({ tree }: { tree: GitOpsResourceTree }) {
1100
- const nodes = (tree.nodes ?? []).filter((n) => n.role !== 'group' && n.role !== 'root')
1085
+ const counted = (tree.nodes ?? []).filter((n) => n.role !== 'group' && n.role !== 'root')
1086
+ // Dependencies (Flux sources, charts, dependsOn targets) are consumed, not
1087
+ // managed: they get their own count and never inflate the managed totals.
1088
+ const nodes = counted.filter((n) => !n.dependency)
1089
+ const dependencies = counted.length - nodes.length
1101
1090
  const total = nodes.length
1102
- if (total === 0) return null
1103
- const healthy = nodes.filter((n) => (n.health || '').toLowerCase() === 'healthy').length
1104
- const degraded = nodes.filter((n) => {
1091
+ if (counted.length === 0) return null
1092
+ const isDegraded = (n: GitOpsTreeNode) => {
1105
1093
  const h = (n.health || '').toLowerCase()
1106
1094
  return h === 'degraded' || h === 'missing' || h === 'unhealthy'
1107
- }).length
1095
+ }
1096
+ const healthy = nodes.filter((n) => (n.health || '').toLowerCase() === 'healthy').length
1097
+ const degraded = nodes.filter(isDegraded).length
1098
+ const failingDependencies = counted.filter((n) => n.dependency && isDegraded(n)).length
1108
1099
  const outOfSync = nodes.filter((n) => (n.sync || '').toLowerCase() === 'outofsync').length
1109
1100
  return (
1110
1101
  <div className="hidden min-w-0 flex-1 items-center gap-3 truncate text-[11px] text-theme-text-tertiary sm:flex">
1111
1102
  <span><span className="text-theme-text-primary">{total}</span> resources</span>
1103
+ {dependencies > 0 && <span><span className="text-theme-text-primary">{dependencies}</span> {dependencies === 1 ? 'dependency' : 'dependencies'}</span>}
1104
+ {failingDependencies > 0 && <span className="flex items-center gap-1 font-medium text-red-600 dark:text-red-400"><span className="h-1.5 w-1.5 rounded-full bg-red-500" /> {failingDependencies} {failingDependencies === 1 ? 'dependency' : 'dependencies'} failing</span>}
1112
1105
  {healthy > 0 && <span className="flex items-center gap-1"><span className="h-1.5 w-1.5 rounded-full bg-emerald-500" /> {healthy} healthy</span>}
1113
1106
  {/* Bad-news counts use status colors on the number itself so the worst
1114
1107
  fact in the row visually pops, not just the dot next to it. */}
@@ -0,0 +1,59 @@
1
+ // @vitest-environment jsdom
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { CompareSummary } from './HelmCompareRoute'
5
+
6
+ const sameDiff = '--- revision-1\n+++ revision-2\n'
7
+ const changedDiff = '--- revision-1\n+++ revision-2\n@@ -1 +1 @@\n-old\n+new\n'
8
+
9
+ function renderSummary(overridesDiff: string, effectiveValuesDiff: string) {
10
+ document.body.innerHTML = renderToStaticMarkup(
11
+ <CompareSummary
12
+ revision1={1}
13
+ revision2={2}
14
+ manifestDiff={sameDiff}
15
+ manifestLoading={false}
16
+ manifestError={null}
17
+ overridesDiff={overridesDiff}
18
+ overridesLoading={false}
19
+ overridesError={null}
20
+ effectiveValuesDiff={effectiveValuesDiff}
21
+ effectiveValuesLoading={false}
22
+ effectiveValuesError={null}
23
+ notesDiff={sameDiff}
24
+ notesLoading={false}
25
+ notesError={null}
26
+ hooksLoading={false}
27
+ hooksError={null}
28
+ resourceLoading={false}
29
+ resourceError={null}
30
+ />,
31
+ )
32
+ }
33
+
34
+ function signalText(section: string) {
35
+ return document.querySelector(`a[href="#${section}"]`)?.textContent
36
+ }
37
+
38
+ describe('CompareSummary value signals', () => {
39
+ it('shows unchanged overrides and changed effective values when chart defaults change', () => {
40
+ renderSummary(sameDiff, changedDiff)
41
+
42
+ expect(signalText('overrides')).toBe('Overridessame')
43
+ expect(signalText('effective-values')).toBe('Effective values1 add / 1 remove')
44
+ })
45
+
46
+ it('shows both signals changed when an override changes effective values', () => {
47
+ renderSummary(changedDiff, changedDiff)
48
+
49
+ expect(signalText('overrides')).toBe('Overrides1 add / 1 remove')
50
+ expect(signalText('effective-values')).toBe('Effective values1 add / 1 remove')
51
+ })
52
+
53
+ it('shows both signals unchanged when both value sets are identical', () => {
54
+ renderSummary(sameDiff, sameDiff)
55
+
56
+ expect(signalText('overrides')).toBe('Overridessame')
57
+ expect(signalText('effective-values')).toBe('Effective valuessame')
58
+ })
59
+ })