@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
@@ -35,7 +35,9 @@ describe('Metrics help', () => {
35
35
  expect(html).toContain('--prometheus-single-cluster')
36
36
  expect(html).toContain('they do not add missing metrics')
37
37
  expect(html).toContain('Leave both unset for automatic matching')
38
- expect(html).toContain('it does not scope rightsizing')
38
+ expect(html).toContain('It does not scope rightsizing')
39
+ expect(html).toContain('Pod comparison and PVC usage')
40
+ expect(html).toContain('labels must exist on the kubelet volume stats')
39
41
  expect(html).toContain('Standalone CLI:')
40
42
  expect(html).toContain('when starting Radar')
41
43
  expect(html).toContain('traffic.prometheusSingleCluster')
@@ -133,7 +133,7 @@ export function WorkloadMetricsHelpContent({ data, pending, error }: Pick<Props,
133
133
  <div><dt className="font-medium">Backend dedicated to this cluster</dt><dd><code className="break-all font-mono text-xs">--prometheus-single-cluster</code><p className="mt-1">Helm: <code className="break-all font-mono text-xs">traffic.prometheusSingleCluster: true</code></p></dd></div>
134
134
  <div><dt className="font-medium">Shared backend with a cluster label</dt><dd><code className="break-all font-mono text-xs">--prometheus-cluster-label cluster=your-cluster</code><p className="mt-1">Helm: set the backend’s actual label and value in <code className="break-all font-mono text-xs">traffic.prometheusClusterLabels</code>.</p></dd></div>
135
135
  </dl>
136
- <p>Leave both unset for automatic matching. An override replaces automatic matching for workload requests, resources, history and Pod comparison only; it does not scope rightsizing or other metrics features.</p>
136
+ <p>Leave both unset for automatic matching. An override replaces automatic matching for workload requests, resources, history, Pod comparison and PVC usage. For PVC usage, its labels must exist on the kubelet volume stats. It does not scope rightsizing, node/HPA or legacy network/storage metrics.</p>
137
137
  <h4 className="font-medium text-theme-text-primary">Operator configuration</h4>
138
138
  <p><strong className="font-medium">Standalone CLI:</strong> supply the flag when starting Radar. Overrides are not saved; after changing the cluster, backend or credentials, verify the new scope before restarting with a fresh override.</p>
139
139
  <p><strong className="font-medium">In-cluster OSS or Radar Cloud:</strong> ask the installation’s operator to update its Helm values or GitOps configuration. These are shared backend settings, not viewer preferences.</p>
@@ -12,7 +12,7 @@ import {
12
12
  type CrossplaneResourceRef,
13
13
  } from '@skyhook-io/k8s-ui/components/resources/resource-utils-crossplane'
14
14
  import { getResourceStatus } from '@skyhook-io/k8s-ui'
15
- import { kindToPlural } from '@skyhook-io/k8s-ui/utils/navigation'
15
+ import { kindToPluralWithGroup } from '@skyhook-io/k8s-ui/utils/navigation'
16
16
  import type { ResourceRef as NavRef } from '../../types'
17
17
  import { fetchJSON, ApiError } from '../../api/client'
18
18
 
@@ -59,8 +59,8 @@ export function CompositeRenderer({ data, onNavigate }: CompositeRendererProps)
59
59
  ],
60
60
  queryFn: async () => {
61
61
  const ns = boundXRRef!.namespace || '_'
62
- const plural = kindToPlural(boundXRRef!.kind)
63
62
  const group = groupFromApiVersion(boundXRRef!.apiVersion)
63
+ const plural = kindToPluralWithGroup(boundXRRef!.kind, group)
64
64
  const query = group ? `?group=${encodeURIComponent(group)}` : ''
65
65
  return fetchJSON<{ resource: any }>(`/resources/${plural}/${ns}/${boundXRRef!.name}${query}`)
66
66
  },
@@ -99,7 +99,7 @@ export function CompositeRenderer({ data, onNavigate }: CompositeRendererProps)
99
99
  queryKey: ['composed-ref', group, ref.kind, ref.namespace ?? '', ref.name],
100
100
  queryFn: async () => {
101
101
  const ns = ref.namespace || '_'
102
- const plural = kindToPlural(ref.kind)
102
+ const plural = kindToPluralWithGroup(ref.kind, group)
103
103
  const query = group ? `?group=${encodeURIComponent(group)}` : ''
104
104
  return fetchJSON<{ resource: any }>(`/resources/${plural}/${ns}/${ref.name}${query}`)
105
105
  },
@@ -137,7 +137,7 @@ export function CompositeRenderer({ data, onNavigate }: CompositeRendererProps)
137
137
  map.set(key, { ref, missing: true })
138
138
  return
139
139
  }
140
- const status = getResourceStatus(kindToPlural(ref.kind), q.data.resource) ?? undefined
140
+ const status = getResourceStatus(kindToPluralWithGroup(ref.kind, groupFromApiVersion(ref.apiVersion)), q.data.resource) ?? undefined
141
141
  map.set(key, {
142
142
  ref,
143
143
  status: status ? { ...status, level: (status as any).level ?? 'unknown' } : undefined,
@@ -1,7 +1,7 @@
1
1
  import { ResourceDetailDrawer as BaseResourceDetailDrawer } from '@skyhook-io/k8s-ui'
2
2
  import type { SelectedResource } from '../../types'
3
3
  import { WorkloadView } from '../workload/WorkloadView'
4
- import { CNPGDrawerTrailBack } from '../cnpg/CNPGDrawerTrail'
4
+ import { ResourceDrawerNavigation } from '../../integrations/resourceHosts'
5
5
 
6
6
  interface ResourceDetailDrawerProps {
7
7
  resource: SelectedResource
@@ -33,7 +33,7 @@ export function ResourceDetailDrawer(props: ResourceDetailDrawerProps) {
33
33
  <BaseResourceDetailDrawer {...props}>
34
34
  {({ resource, expanded, active, initialTab, onClose, onExpand, onExpandIntent, onCancelExpandIntent, onBack, onNavigateToResource, onCollapseToDrawer }) => (
35
35
  <div className="flex h-full min-h-0 flex-col">
36
- {!expanded && <CNPGDrawerTrailBack resource={resource} />}
36
+ {!expanded && <ResourceDrawerNavigation resource={resource} />}
37
37
  <div className="min-h-0 flex-1">
38
38
  <WorkloadView
39
39
  kind={resource.kind}
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useCallback, useEffect } from 'react'
1
+ import { useState, useMemo, useCallback } from 'react'
2
2
  import { useLocation, useNavigate } from 'react-router-dom'
3
3
  import { useQuery } from '@tanstack/react-query'
4
4
  import { ApiError, debugNamespaceLog, fetchJSON, isForbiddenError, isKindSyncFailed, isStillLoadingError, useCapabilities, useNamespaceCapabilities, useResources, useSecretCertExpiry, useTopPodMetrics, useTopNodeMetrics, useBulkDeleteResources, useBulkRestartWorkloads, useBulkScaleWorkloads, useAudit } from '../../api/client'
@@ -8,10 +8,11 @@ import { apiUrl, getAuthHeaders, getCredentialsMode, stripBasename } from '../..
8
8
  import { readErrorBody } from '../../api/httpErrors'
9
9
  import { useAPIResources } from '../../api/apiResources'
10
10
  import { useConnection } from '../../context/ConnectionContext'
11
- import { initNavigationMap, getSecretStoreProviderType } from '@skyhook-io/k8s-ui'
11
+ import { getSecretStoreProviderType } from '@skyhook-io/k8s-ui'
12
12
  import { usePinnedKinds } from '../../hooks/useFavorites'
13
13
  import { useResourceCounts } from '../../hooks/useResourceCounts'
14
14
  import { useCNPGSidebarWorkspace } from '../cnpg/useCNPGSidebarWorkspace'
15
+ import { renderResourceKindList, resourceKindListMode, useResourceHostFeatures } from '../../integrations/resourceHosts'
15
16
  import { useOpenLogs, useOpenWorkloadLogs } from '../dock'
16
17
  import {
17
18
  canBulkRestartKind,
@@ -35,6 +36,9 @@ interface ResourcesViewProps {
35
36
  onResourceClickYaml?: NavigateToResource
36
37
  onKindChange?: () => void
37
38
  onClearNamespaces?: () => void
39
+ rememberedKindSearch?: (kind: { name: string; group: string }) => string | undefined
40
+ onKindFiltersChange?: (kind: { name: string; group: string }, search: string) => void
41
+ onApplyNamespacesToAllSections?: (namespaces: string[]) => Promise<unknown> | void
38
42
  }
39
43
 
40
44
  type SelectedKindInfo = { name: string; kind: string; group: string } | null
@@ -70,12 +74,13 @@ function hasResourceCount(counts: Record<string, number> | undefined, key: strin
70
74
  return Object.prototype.hasOwnProperty.call(counts ?? {}, key)
71
75
  }
72
76
 
73
- export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces }: ResourcesViewProps) {
77
+ export function ResourcesView({ namespaces, selectedResource, onResourceClick, onResourceClickYaml, onKindChange, onClearNamespaces, rememberedKindSearch, onKindFiltersChange, onApplyNamespacesToAllSections }: ResourcesViewProps) {
74
78
  const location = useLocation()
75
79
  const navigate = useNavigate()
76
80
  const { connection } = useConnection()
77
81
 
78
- const { data: capabilities } = useCapabilities()
82
+ const { data: capabilities, isPending: capabilitiesPending } = useCapabilities()
83
+ const hostFeatures = useResourceHostFeatures()
79
84
  const namespaceForCapabilities = namespaces.length === 1 ? namespaces[0] : undefined
80
85
  const { data: namespaceCapabilities } = useNamespaceCapabilities(namespaceForCapabilities, capabilities)
81
86
  const namespaceCapabilityNames = useMemo(() => namespaces.length > 1 ? [...namespaces].sort() : [], [namespaces])
@@ -104,15 +109,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
104
109
  // API resources discovery
105
110
  const { data: apiResources } = useAPIResources()
106
111
 
107
- // Initialize navigation kind↔plural maps from discovered API resources
108
- useEffect(() => {
109
- if (apiResources) initNavigationMap(apiResources)
110
- }, [apiResources])
111
-
112
- const cnpgSidebarWorkspace = useCNPGSidebarWorkspace({ apiResources, namespaces })
113
-
114
112
  // Track the selected kind from the k8s-ui component
115
113
  const [selectedKind, setSelectedKind] = useState<SelectedKindInfo>(null)
114
+ const cnpgSidebarWorkspace = useCNPGSidebarWorkspace({ apiResources, namespaces, withMetrics: selectedKind?.group === 'postgresql.cnpg.io' })
116
115
  const workloadWrites = namespaces.length === 0
117
116
  ? capabilities?.workloadWrites
118
117
  : namespaces.length === 1
@@ -281,7 +280,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
281
280
  printerTable: sanitizePrinterTable(body),
282
281
  }
283
282
  },
284
- enabled: !!selectedKind && !selectedKindQueryBlocked,
283
+ enabled: !!selectedKind && !selectedKindQueryBlocked && resourceKindListMode(selectedKind, hostFeatures, capabilitiesPending) === 'table',
285
284
  staleTime: 30000,
286
285
  refetchInterval: 120000, // Safety net — SSE k8s_event drives near-real-time invalidation
287
286
  retry: (failureCount: number, error: Error) => {
@@ -385,6 +384,17 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
385
384
  setCreateDialogOpen(true)
386
385
  }, [])
387
386
 
387
+ const renderKindView = useCallback(
388
+ (kind: SelectedKindInfo) => renderResourceKindList(kind, hostFeatures, capabilitiesPending, {
389
+ namespaces,
390
+ inspected: selectedResource ?? null,
391
+ onInspect: (resource) => onResourceClick?.(resource),
392
+ onClearNamespaces: onClearNamespaces ?? (() => {}),
393
+ onCreate: handleCreateResource,
394
+ }),
395
+ [hostFeatures, capabilitiesPending, namespaces, selectedResource, onResourceClick, onClearNamespaces, handleCreateResource],
396
+ )
397
+
388
398
  return (
389
399
  <>
390
400
  <BaseResourcesView
@@ -395,6 +405,9 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
395
405
  onResourceClickYaml={onResourceClickYaml}
396
406
  onKindChange={onKindChange}
397
407
  onClearNamespaces={onClearNamespaces}
408
+ rememberedKindSearch={rememberedKindSearch}
409
+ onKindFiltersChange={onKindFiltersChange}
410
+ onApplyNamespacesToAllSections={onApplyNamespacesToAllSections}
398
411
  // Injected data
399
412
  apiResources={apiResources}
400
413
  // Lightweight counts for sidebar (replaces 233 parallel queries)
@@ -434,6 +447,7 @@ export function ResourcesView({ namespaces, selectedResource, onResourceClick, o
434
447
  onOpenWorkloadLogs={openWorkloadLogs}
435
448
  // Create resource
436
449
  onCreateResource={handleCreateResource}
450
+ renderKindView={renderKindView}
437
451
  // Bulk operations
438
452
  onBulkDelete={(items, options) => bulkDeleteMutation.mutate({ items, force: options?.force }, { onSuccess: options?.onSuccess })}
439
453
  isBulkDeleting={bulkDeleteMutation.isPending}
@@ -0,0 +1,52 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import type { useArgoStatus } from '../../api/client'
4
+ import type { IntegrationProfile } from './LocalConnectionSettings'
5
+ import { LocalIntegrationStatus } from './LocalIntegrationStatus'
6
+
7
+ vi.mock('../../api/client', () => ({
8
+ usePrometheusStatus: () => ({}),
9
+ useOpenCostSummary: () => ({}),
10
+ }))
11
+
12
+ const profile: IntegrationProfile = {
13
+ target: {
14
+ binding: 'opaque',
15
+ context: 'development',
16
+ source: '/configs/team',
17
+ inFileName: 'dev',
18
+ fingerprint: 'target',
19
+ clientGeneration: 1,
20
+ operationGeneration: 1,
21
+ identity: { server: 'https://kubernetes', user: 'developer', trust: 'trust', insecureTls: false },
22
+ },
23
+ revision: 'revision',
24
+ state: 'saved',
25
+ mode: 'auto',
26
+ url: '',
27
+ headerKeys: [],
28
+ envHeaderKeys: [],
29
+ headersManaged: false,
30
+ secretSet: true,
31
+ insecureTls: false,
32
+ clusterId: '',
33
+ }
34
+
35
+ const render = (data: Record<string, unknown>) => {
36
+ const argo = { data, isError: false, isPlaceholderData: false, isFetching: false, dataUpdatedAt: 1, errorUpdatedAt: 0 } as unknown as ReturnType<typeof useArgoStatus>
37
+ return renderToStaticMarkup(<LocalIntegrationStatus kind="argocd" profile={profile} argo={argo} busy={false} changedAt={0} />)
38
+ }
39
+
40
+ describe('LocalIntegrationStatus for Argo CD', () => {
41
+ it('says Connecting while the first connection is being made', () => {
42
+ const html = render({ configured: true, connected: false, connecting: true })
43
+ expect(html).toContain('Connecting…')
44
+ expect(html).not.toContain('Not connected')
45
+ })
46
+
47
+ it('shows why the last read failed on a working connection', () => {
48
+ const html = render({ configured: true, connected: true, address: 'https://argocd', reason: "Radar's last read from Argo CD failed: the request timed out." })
49
+ expect(html).toContain('Connected')
50
+ expect(html).toContain('the request timed out')
51
+ })
52
+ })
@@ -26,13 +26,12 @@ export function LocalIntegrationStatus({ kind, profile, argo, busy, changedAt }:
26
26
  status = prom.data.connected ? 'Connected' : prom.data.discovering ? 'Discovering…' : 'Not connected'
27
27
  detail = prom.data.connected ? !profile.url ? `Discovered: ${prom.data.address}` : undefined : prom.data.error
28
28
  } else if (kind === 'argocd' && argo.data) {
29
- status = argo.data.connected ? argo.data.anonymous ? 'Connected · no token needed' : 'Connected' : 'Not connected'
30
- detail = argo.data.connected ? !profile.url ? `Discovered: ${argo.data.address}` : undefined : argo.data.reason
29
+ status = argo.data.connected ? argo.data.anonymous ? 'Connected · no token needed' : 'Connected' : argo.data.connecting ? 'Connecting…' : 'Not connected'
30
+ detail = argo.data.reason ?? (argo.data.connected && !profile.url ? `Discovered: ${argo.data.address}` : undefined)
31
31
  } else if (kind === 'cost' && cost.data) {
32
32
  status = cost.data.available ? `Available · ${costSourceLabel(cost.data.source)}`
33
33
  : ['no_prometheus', 'no_cost_source', 'no_metrics'].includes(cost.data.reason ?? '') ? 'No cost data' : 'Unavailable'
34
- detail = cost.data.reason ? costIntegrationUnavailableMessage(cost.data.reason)
35
- ?? (cost.data.reason === 'query_error' ? 'The active cost source query failed.' : undefined) : undefined
34
+ detail = cost.data.reason ? costIntegrationUnavailableMessage(cost.data.reason) : undefined
36
35
  }
37
36
  }
38
37
  const lastDetail = useRef({ text: detail, visible: !!detail })
@@ -856,11 +856,8 @@ export function SettingsDialog({
856
856
  anonymous={argoSectionStatus?.connected && argoSectionStatus.anonymous}
857
857
  connectedAddress={argoSectionStatus?.connected && !argoSectionStatus.anonymous ? argoSectionStatus.address : undefined}
858
858
  active={section === 'argocd'}
859
- statusReason={
860
- argoSectionStatus?.configured && !argoSectionStatus.connected
861
- ? argoSectionStatus.reason
862
- : undefined
863
- }
859
+ statusReason={argoSectionStatus?.configured ? argoSectionStatus.reason : undefined}
860
+ connecting={argoSectionStatus?.connecting}
864
861
  onChangeUrl={(v) => updateConfigField('argoCdUrl', v || undefined)}
865
862
  onChangeInsecureTls={(v) => updateConfigField('argoCdInsecureTls', v || undefined)}
866
863
  onApplied={({ url, insecureTls, tokenSet }) => {
@@ -1218,7 +1215,7 @@ function OperatorSettingsSummary({ section, config }: { section: SettingsSection
1218
1215
  break
1219
1216
  case 'argocd':
1220
1217
  rows = [
1221
- ['Status', argoError ? 'Status unavailable' : !argo ? 'Checking…' : argo.connected ? 'Connected' : 'Not connected'],
1218
+ ['Status', argoError ? 'Status unavailable' : !argo ? 'Checking…' : argo.connected ? 'Connected' : argo.connecting ? 'Connecting…' : 'Not connected'],
1222
1219
  ['Endpoint', argo?.address || c.argoCdUrl || 'Auto-discovery'],
1223
1220
  ['Token', config.argoCdTokenSet ? 'Configured' : 'None configured'],
1224
1221
  ['TLS verification', c.argoCdInsecureTls ? 'Disabled' : 'Enabled'],
@@ -1317,7 +1314,7 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1317
1314
  : cost?.reason === 'access_denied'
1318
1315
  ? 'Your current permissions do not allow cost data.'
1319
1316
  : cost?.reason
1320
- ? costIntegrationUnavailableMessage(cost.reason) ?? 'The active cost source query failed.'
1317
+ ? costIntegrationUnavailableMessage(cost.reason) ?? 'The active cost source could not provide data.'
1321
1318
  : undefined
1322
1319
 
1323
1320
  const rows: OverviewRow[] = [
@@ -1347,9 +1344,9 @@ function OverviewPanel({ active, onNavigate }: { active: boolean; onNavigate: (s
1347
1344
  },
1348
1345
  {
1349
1346
  id: 'argocd', icon: GitBranch, label: 'Argo CD',
1350
- tone: argo?.connected ? 'ok' : argo?.configured || argo?.reason ? 'warn' : 'off',
1351
- value: argo?.connected ? (argo.anonymous ? 'Connected · no token needed' : 'Connected') : 'Not connected',
1352
- detail: argo?.connected ? argo.address : argo?.reason ? 'Open Argo CD for connection details.' : undefined,
1347
+ tone: argo?.connected ? (argo.reason ? 'warn' : 'ok') : argo?.connecting ? 'unknown' : argo?.configured || argo?.reason ? 'warn' : 'off',
1348
+ value: argo?.connected ? (argo.anonymous ? 'Connected · no token needed' : 'Connected') : argo?.connecting ? 'Connecting…' : 'Not connected',
1349
+ detail: argo?.reason ? 'Open Argo CD for connection details.' : argo?.connected ? argo.address : undefined,
1353
1350
  },
1354
1351
  {
1355
1352
  id: 'advanced', icon: Zap, label: 'MCP',
@@ -2228,6 +2225,7 @@ function ArgoCDConfigField({
2228
2225
  envError,
2229
2226
  cliSession,
2230
2227
  statusReason,
2228
+ connecting,
2231
2229
  anonymous,
2232
2230
  connectedAddress,
2233
2231
  active,
@@ -2242,6 +2240,7 @@ function ArgoCDConfigField({
2242
2240
  envError?: string
2243
2241
  cliSession?: { server: string; user: string; insecure?: boolean }
2244
2242
  statusReason?: string
2243
+ connecting?: boolean
2245
2244
  anonymous?: boolean
2246
2245
  connectedAddress?: string
2247
2246
  active?: boolean
@@ -2250,7 +2249,7 @@ function ArgoCDConfigField({
2250
2249
  onApplied?: (v: { url: string; insecureTls: boolean; tokenSet: boolean }) => void
2251
2250
  }) {
2252
2251
  if (envManaged) {
2253
- return <ArgoCDEnvManagedField url={url} insecureTls={insecureTls} envError={envError} connectedAddress={connectedAddress} statusReason={statusReason} />
2252
+ return <ArgoCDEnvManagedField url={url} insecureTls={insecureTls} envError={envError} connectedAddress={connectedAddress} statusReason={statusReason} connecting={connecting} />
2254
2253
  }
2255
2254
  return (
2256
2255
  <ArgoCDEditableField
@@ -2259,6 +2258,7 @@ function ArgoCDConfigField({
2259
2258
  tokenSet={tokenSet}
2260
2259
  cliSession={cliSession}
2261
2260
  statusReason={statusReason}
2261
+ connecting={connecting}
2262
2262
  anonymous={anonymous}
2263
2263
  connectedAddress={connectedAddress}
2264
2264
  active={active}
@@ -2279,6 +2279,7 @@ function ArgoCDEnvManagedField({
2279
2279
  envError,
2280
2280
  connectedAddress,
2281
2281
  statusReason,
2282
+ connecting,
2282
2283
  }: {
2283
2284
  url: string
2284
2285
  insecureTls: boolean
@@ -2288,6 +2289,7 @@ function ArgoCDEnvManagedField({
2288
2289
  // so this is the one thing on it that can change after deploy.
2289
2290
  connectedAddress?: string
2290
2291
  statusReason?: string
2292
+ connecting?: boolean
2291
2293
  }) {
2292
2294
  if (envError) {
2293
2295
  return (
@@ -2344,10 +2346,15 @@ function ArgoCDEnvManagedField({
2344
2346
  <dt className="w-24 shrink-0 text-theme-text-tertiary">Status</dt>
2345
2347
  <dd className="min-w-0 truncate text-theme-text-secondary">
2346
2348
  {connectedAddress ? (
2347
- <span className="inline-flex items-center gap-1">
2348
- <Check className="w-3 h-3 shrink-0 text-green-600 dark:text-green-400/80" />
2349
- Connected at {connectedAddress}
2350
- </span>
2349
+ <>
2350
+ <span className="inline-flex items-center gap-1">
2351
+ <Check className="w-3 h-3 shrink-0 text-green-600 dark:text-green-400/80" />
2352
+ Connected at {connectedAddress}
2353
+ </span>
2354
+ {statusReason && <span className="block whitespace-normal text-warning-text">{statusReason}</span>}
2355
+ </>
2356
+ ) : connecting ? (
2357
+ 'Connecting…'
2351
2358
  ) : statusReason ? (
2352
2359
  <span className="text-warning-text">Not connected — {statusReason}</span>
2353
2360
  ) : (
@@ -2377,6 +2384,7 @@ function ArgoCDEditableField({
2377
2384
  tokenSet,
2378
2385
  cliSession,
2379
2386
  statusReason,
2387
+ connecting: serverConnecting,
2380
2388
  anonymous,
2381
2389
  connectedAddress,
2382
2390
  active,
@@ -2389,6 +2397,7 @@ function ArgoCDEditableField({
2389
2397
  tokenSet: boolean
2390
2398
  cliSession?: { server: string; user: string; insecure?: boolean }
2391
2399
  statusReason?: string
2400
+ connecting?: boolean
2392
2401
  anonymous?: boolean
2393
2402
  connectedAddress?: string
2394
2403
  active?: boolean
@@ -2517,6 +2526,10 @@ function ArgoCDEditableField({
2517
2526
  </p>
2518
2527
  )}
2519
2528
 
2529
+ {serverConnecting && state.status === 'idle' && !anonymous && !connectedAddress && (
2530
+ <p className="mb-3 text-xs text-theme-text-secondary">Connecting to Argo CD…</p>
2531
+ )}
2532
+
2520
2533
  {statusReason && state.status !== 'connected' && (
2521
2534
  <div className="mb-3 rounded-md border border-theme-border bg-theme-elevated p-3">
2522
2535
  <p className="flex items-center gap-1.5 text-sm font-medium text-warning-text">
@@ -1,6 +1,5 @@
1
1
  import { useState, useCallback, useMemo } from 'react'
2
2
  import {
3
- SEVERITY_TEXT,
4
3
  TimelineList as TimelineListUI,
5
4
  eventsForApplication,
6
5
  isTimelineProblem,
@@ -16,7 +15,9 @@ import { isManagedTimelineEvent } from '../../api/timelineSource'
16
15
  import { TimelineRetentionBanner } from './TimelineRetentionNote'
17
16
  import { useHasLimitedAccess } from '../../contexts/CapabilitiesContext'
18
17
  import type { NavigateToResource } from '../../utils/navigation'
19
- import { AlertTriangle, RefreshCw } from 'lucide-react'
18
+ import { TimelineLoadError, TimelineStaleNote } from './TimelineLoadError'
19
+ import { useStickyError } from './useStickyError'
20
+ import { ViewModeToggle } from './ViewModeToggle'
20
21
 
21
22
  export type { ActivityTypeFilter, ActivityFilterKey }
22
23
 
@@ -93,9 +94,10 @@ interface TimelineListProps {
93
94
  appScoped?: boolean
94
95
  topology?: Topology
95
96
  appScopeLoading?: boolean
97
+ onOpenDiagnostics?: () => void
96
98
  }
97
99
 
98
- export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange, showRoutine, onShowRoutineChange, search, onSearchChange, activityFilter, onActivityFilterChange, kindFilter, onKindFilterChange, selectionWindow, onVisibleWindowChange, scrollToMs, focusedAppIndex, appScoped = false, topology, appScopeLoading = false }: TimelineListProps) {
100
+ export function TimelineList({ namespaces, onViewChange, currentView, onResourceClick, initialFilter, initialTimeRange, showDeleted, onShowDeletedChange, showRoutine, onShowRoutineChange, search, onSearchChange, activityFilter, onActivityFilterChange, kindFilter, onKindFilterChange, selectionWindow, onVisibleWindowChange, scrollToMs, focusedAppIndex, appScoped = false, topology, appScopeLoading = false, onOpenDiagnostics }: TimelineListProps) {
99
101
  const hasLimitedAccess = useHasLimitedAccess()
100
102
  const timelineSource = useTimelineSource()
101
103
  const [queryParams, setQueryParams] = useState<{ timeRange: TimeRange; kinds: string[] }>({
@@ -112,7 +114,7 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
112
114
  const splitRows = !appScoped
113
115
  // Uncapped when splitting: each part gets its own cap below. The rows come
114
116
  // from the loaded ring, so this costs no extra fetch.
115
- const { data: fetchedEvents, isLoading, isError, error, refetch } = timelineSource.useEvents({
117
+ const { data: fetchedEvents, isLoading, isFetching, isError, error, refetch } = timelineSource.useEvents({
116
118
  namespaces,
117
119
  kinds: queryParams.kinds,
118
120
  timeRange: queryParams.timeRange,
@@ -123,6 +125,7 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
123
125
  fromMs: selectionWindow?.fromMs,
124
126
  toMs: selectionWindow?.toMs,
125
127
  })
128
+ const loadError = useStickyError(error, fetchedEvents != null)
126
129
  const split = useMemo(
127
130
  () => (splitRows && fetchedEvents ? splitRoutineActivity(fetchedEvents, fetchLimit) : undefined),
128
131
  [splitRows, fetchedEvents, fetchLimit],
@@ -141,21 +144,22 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
141
144
 
142
145
  // Full-screen error only when nothing is loaded; a failing background poll
143
146
  // with data on screen keeps rendering (data before error).
144
- if (isError && !fetchedEvents) {
147
+ if (loadError && !fetchedEvents) {
145
148
  return (
146
- <div className="flex flex-col items-center justify-center h-full text-theme-text-tertiary gap-3">
147
- <AlertTriangle className="w-10 h-10 text-amber-400/70" />
148
- <p className="text-base">Failed to load timeline data</p>
149
- {error?.message?.trim() && (
150
- <p className="max-w-md px-6 text-center text-sm text-theme-text-tertiary">{error.message.trim()}</p>
149
+ <div className="flex flex-col h-full">
150
+ {currentView && onViewChange && (
151
+ <div className="flex items-center justify-between px-4 py-2 border-b border-theme-border">
152
+ <div />
153
+ <ViewModeToggle viewMode={currentView} onViewModeChange={onViewChange} />
154
+ </div>
151
155
  )}
152
- <button
153
- onClick={() => refetch()}
154
- className="flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg hover:bg-theme-hover transition-colors"
155
- >
156
- <RefreshCw className="w-3.5 h-3.5" />
157
- Try again
158
- </button>
156
+ <TimelineLoadError
157
+ className="flex-1"
158
+ error={loadError}
159
+ retrying={isFetching}
160
+ onRetry={() => refetch()}
161
+ onOpenDiagnostics={onOpenDiagnostics}
162
+ />
159
163
  </div>
160
164
  )
161
165
  }
@@ -165,13 +169,7 @@ export function TimelineList({ namespaces, onViewChange, currentView, onResource
165
169
  // view-level banner already reports the shared failure, and a second note
166
170
  // would double up.
167
171
  const staleNote = isError && fetchedEvents && !selectionWindow ? (
168
- <div className="flex items-center gap-1.5 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-tertiary">
169
- <AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${SEVERITY_TEXT.warning}`} />
170
- Live updates are failing — the list may be stale.
171
- <button type="button" onClick={() => refetch()} className="underline hover:text-theme-text-primary">
172
- Retry now
173
- </button>
174
- </div>
172
+ <TimelineStaleNote error={error} subject="list" onRetry={() => refetch()} />
175
173
  ) : null
176
174
 
177
175
  return (
@@ -0,0 +1,62 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { ApiError } from '../../api/client'
4
+ import { TimelineLoadError, TimelineStaleNote } from './TimelineLoadError'
5
+
6
+ const driverError = 'open PostgreSQL timeline database: dial tcp 10.0.0.5:5432: connect: connection refused'
7
+
8
+ function databaseDown(): ApiError {
9
+ return new ApiError('Timeline history is unavailable because Radar can\'t connect to its PostgreSQL database.', 503, {
10
+ error_code: 'timeline_database_unreachable',
11
+ detail: driverError,
12
+ })
13
+ }
14
+
15
+ function render(error: Error, onOpenDiagnostics?: () => void): string {
16
+ return renderToStaticMarkup(
17
+ <TimelineLoadError error={error} onRetry={() => {}} onOpenDiagnostics={onOpenDiagnostics} />,
18
+ )
19
+ }
20
+
21
+ describe('TimelineLoadError', () => {
22
+ it('names the database and points to the logs when PostgreSQL is unreachable', () => {
23
+ const html = render(databaseDown(), () => {})
24
+ expect(html).toContain('Timeline history is unavailable')
25
+ expect(html).toContain('can&#x27;t connect to its PostgreSQL database')
26
+ expect(html).toContain('[timeline]')
27
+ // The driver's error names the database host; it belongs in the logs and
28
+ // diagnostics, not on the page.
29
+ expect(html).not.toContain(driverError)
30
+ expect(html).toContain('Open diagnostics')
31
+ expect(html).not.toContain('Failed to load timeline data')
32
+ })
33
+
34
+ it('omits the diagnostics link when the host cannot open diagnostics', () => {
35
+ expect(render(databaseDown())).not.toContain('Open diagnostics')
36
+ })
37
+
38
+ it('keeps the generic state, without a diagnostics link, for other failures', () => {
39
+ const html = render(new ApiError('upstream timed out', 502, { error: 'upstream timed out' }), () => {})
40
+ expect(html).toContain('Failed to load timeline data')
41
+ expect(html).toContain('upstream timed out')
42
+ expect(html).not.toContain('Open diagnostics')
43
+ })
44
+ })
45
+
46
+ describe('TimelineStaleNote', () => {
47
+ const note = (error: Error) =>
48
+ renderToStaticMarkup(<TimelineStaleNote error={error} subject="timeline" onRetry={() => {}} />)
49
+
50
+ it('names the database and points to the logs when PostgreSQL is the cause', () => {
51
+ const html = note(databaseDown())
52
+ expect(html).toContain('can&#x27;t connect to its PostgreSQL database, so the timeline may be stale')
53
+ expect(html).toContain('[timeline]')
54
+ expect(html).not.toContain(driverError)
55
+ })
56
+
57
+ it('keeps the generic note for other failures', () => {
58
+ const html = note(new ApiError('upstream timed out', 502, { error: 'upstream timed out' }))
59
+ expect(html).toContain('Live updates are failing, so the timeline may be stale')
60
+ expect(html).not.toContain('PostgreSQL')
61
+ })
62
+ })
@@ -0,0 +1,96 @@
1
+ import { clsx } from 'clsx'
2
+ import { AlertTriangle, Bug, RefreshCw } from 'lucide-react'
3
+ import { timelineDatabaseError } from '../../api/client'
4
+ import { SEVERITY_TEXT } from '@skyhook-io/k8s-ui'
5
+
6
+ const SECONDARY_BUTTON =
7
+ 'flex items-center gap-2 px-3 py-1.5 text-sm bg-theme-elevated border border-theme-border-light rounded-lg hover:bg-theme-hover transition-colors'
8
+
9
+ // TimelineLoadError is the full-screen state when the timeline has nothing to
10
+ // show. An unreachable PostgreSQL database gets its own copy: it says the
11
+ // database is the problem and where to look. The driver's error stays in
12
+ // Radar's logs and the diagnostics overlay.
13
+ export function TimelineLoadError({
14
+ error,
15
+ onRetry,
16
+ retrying = false,
17
+ onOpenDiagnostics,
18
+ className,
19
+ }: {
20
+ error?: Error | null
21
+ onRetry: () => void
22
+ /** A request is in flight: the error stays up, the button shows the attempt. */
23
+ retrying?: boolean
24
+ onOpenDiagnostics?: () => void
25
+ className?: string
26
+ }) {
27
+ const databaseError = timelineDatabaseError(error)
28
+ const detail = error?.message?.trim()
29
+
30
+ return (
31
+ <div className={clsx('flex flex-col items-center justify-center text-theme-text-tertiary gap-3 px-6', className)}>
32
+ <AlertTriangle className="w-10 h-10 text-amber-400/70" />
33
+ {databaseError !== null ? (
34
+ <>
35
+ <p className="text-base text-theme-text-secondary">Timeline history is unavailable</p>
36
+ <p className="max-w-md text-center text-sm">
37
+ Radar can't connect to its PostgreSQL database, so changes aren't being recorded. It keeps
38
+ retrying, and this page recovers on its own once the database is back.
39
+ </p>
40
+ <p className="max-w-md text-center text-xs">
41
+ Radar's logs have the details: search them for <code className="font-mono">[timeline]</code>.
42
+ </p>
43
+ </>
44
+ ) : (
45
+ <>
46
+ <p className="text-base">Failed to load timeline data</p>
47
+ {detail && <p className="max-w-md text-center text-sm">{detail}</p>}
48
+ </>
49
+ )}
50
+ <div className="flex items-center gap-2">
51
+ <button type="button" onClick={onRetry} disabled={retrying} className={clsx(SECONDARY_BUTTON, 'disabled:opacity-60 disabled:cursor-default')}>
52
+ <RefreshCw className={clsx('w-3.5 h-3.5', retrying && 'animate-spin')} />
53
+ Try again
54
+ </button>
55
+ {databaseError !== null && onOpenDiagnostics && (
56
+ <button type="button" onClick={onOpenDiagnostics} className={SECONDARY_BUTTON}>
57
+ <Bug className="w-3.5 h-3.5" />
58
+ Open diagnostics
59
+ </button>
60
+ )}
61
+ </div>
62
+ </div>
63
+ )
64
+ }
65
+
66
+ // TimelineStaleNote is the banner over data that is still on screen while its
67
+ // refresh fails. When the database is the cause it says so and where to look,
68
+ // since the data underneath otherwise reads as current.
69
+ export function TimelineStaleNote({
70
+ error,
71
+ subject,
72
+ onRetry,
73
+ }: {
74
+ error?: Error | null
75
+ /** What may be stale: the swimlane view or the list. */
76
+ subject: 'timeline' | 'list'
77
+ onRetry: () => void
78
+ }) {
79
+ const databaseDown = timelineDatabaseError(error) !== null
80
+ return (
81
+ <div className="flex items-center gap-1.5 border-b border-theme-border px-4 py-1.5 text-xs text-theme-text-tertiary">
82
+ <AlertTriangle className={`h-3.5 w-3.5 shrink-0 ${SEVERITY_TEXT.warning}`} />
83
+ {databaseDown ? (
84
+ <span>
85
+ Radar can't connect to its PostgreSQL database, so the {subject} may be stale. Search Radar's logs
86
+ for <code className="font-mono">[timeline]</code>.
87
+ </span>
88
+ ) : (
89
+ <span>Live updates are failing, so the {subject} may be stale.</span>
90
+ )}
91
+ <button type="button" onClick={onRetry} className="underline hover:text-theme-text-primary">
92
+ Retry now
93
+ </button>
94
+ </div>
95
+ )
96
+ }