@skyhook-io/radar-app 1.13.5 → 1.14.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 (242) hide show
  1. package/package.json +9 -9
  2. package/src/App.tsx +66 -28
  3. package/src/api/client.authRedirect.test.ts +12 -1
  4. package/src/api/client.prometheusStatus.test.ts +24 -0
  5. package/src/api/client.ts +324 -16
  6. package/src/api/diagnose.ts +32 -1
  7. package/src/api/drain-plan.test.ts +86 -0
  8. package/src/api/quotas.test.ts +26 -0
  9. package/src/api/quotas.ts +20 -0
  10. package/src/api/workloadMetrics.test.ts +40 -0
  11. package/src/api/workloadMetrics.ts +68 -0
  12. package/src/components/CloudConnectFlow.tsx +343 -46
  13. package/src/components/CloudFunnelButton.tsx +110 -37
  14. package/src/components/ConnectionErrorView.tsx +9 -10
  15. package/src/components/DebugOverlay.tsx +10 -6
  16. package/src/components/SyncProgressPanel.tsx +73 -0
  17. package/src/components/audit/AuditView.tsx +2 -0
  18. package/src/components/cloudConnectHandoff.test.ts +245 -0
  19. package/src/components/cloudConnectHandoff.ts +276 -0
  20. package/src/components/curl/ServiceCurlButton.tsx +19 -26
  21. package/src/components/diagnose/AgentCase.tsx +18 -5
  22. package/src/components/diagnose/AnalysisStory.test.tsx +308 -0
  23. package/src/components/diagnose/AnalysisStory.tsx +564 -0
  24. package/src/components/diagnose/DiagnoseContext.test.ts +10 -0
  25. package/src/components/diagnose/DiagnoseContext.tsx +26 -8
  26. package/src/components/diagnose/DiagnoseSurface.tsx +20 -14
  27. package/src/components/diagnose/InvestigationEvidencePane.story.test.tsx +771 -0
  28. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +8 -33
  29. package/src/components/diagnose/InvestigationEvidencePane.tsx +809 -168
  30. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +30 -0
  31. package/src/components/diagnose/InvestigationResourceEvidence.tsx +20 -0
  32. package/src/components/diagnose/InvestigationView.tsx +493 -529
  33. package/src/components/diagnose/investigationCase.test.tsx +267 -129
  34. package/src/components/diagnose/investigationCase.ts +122 -77
  35. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  41. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  42. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  43. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  44. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  45. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  46. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  47. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  48. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +81 -0
  49. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +285 -0
  50. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  51. package/src/components/diagnose/investigationEvidence/builder.ts +427 -0
  52. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  53. package/src/components/diagnose/investigationEvidence/identity.test.ts +632 -0
  54. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  55. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  56. package/src/components/diagnose/investigationEvidence/observations.ts +978 -0
  57. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  58. package/src/components/diagnose/investigationEvidence/projection.test.ts +2736 -0
  59. package/src/components/diagnose/investigationEvidence/types.ts +514 -0
  60. package/src/components/diagnose/investigationEvidencePresentation.test.ts +2 -0
  61. package/src/components/diagnose/investigationEvidencePresentation.ts +3 -0
  62. package/src/components/diagnose/investigationState.test.ts +316 -58
  63. package/src/components/diagnose/investigationState.ts +257 -44
  64. package/src/components/diagnose/investigationStory.test.ts +161 -0
  65. package/src/components/diagnose/investigationStory.ts +207 -0
  66. package/src/components/diagnose/parts.test.tsx +9 -6
  67. package/src/components/diagnose/parts.tsx +977 -193
  68. package/src/components/diagnose/storyParts.test.tsx +426 -0
  69. package/src/components/diagnose/target.test.ts +19 -0
  70. package/src/components/diagnose/target.ts +3 -2
  71. package/src/components/diagnose/toolCallLabel.test.ts +51 -0
  72. package/src/components/diagnose/toolCallLabel.ts +135 -0
  73. package/src/components/diagnose/useDisclosureReveal.ts +10 -11
  74. package/src/components/gitops/GitOpsView.tsx +7 -0
  75. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  76. package/src/components/helm/HelmCompareRoute.tsx +18 -4
  77. package/src/components/helm/HelmReleaseDrawer.tsx +11 -26
  78. package/src/components/helm/InstallWizard.tsx +8 -3
  79. package/src/components/home/MCPSetupDialog.tsx +15 -9
  80. package/src/components/portforward/PortForwardManager.tsx +6 -13
  81. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  82. package/src/components/resource/PrometheusCharts.tsx +4 -3
  83. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  84. package/src/components/resource/PrometheusChartsGrid.tsx +75 -35
  85. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  86. package/src/components/resource/RightsizingStrip.tsx +15 -3
  87. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  88. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  89. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  90. package/src/components/resource/WorkloadMetricsSection.tsx +480 -0
  91. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  92. package/src/components/resource/workloadMetricValues.ts +30 -0
  93. package/src/components/resources/PodFilePreview.tsx +3 -3
  94. package/src/components/resources/ResourcesView.tsx +44 -6
  95. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  96. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  97. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  98. package/src/components/resources/renderers/ServiceRenderer.tsx +2 -1
  99. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  100. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  101. package/src/components/settings/SettingsDialog.tsx +98 -16
  102. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  103. package/src/components/traffic/TrafficGraph.tsx +6 -6
  104. package/src/components/traffic/TrafficView.tsx +10 -9
  105. package/src/components/traffic/trafficFilters.test.ts +28 -1
  106. package/src/components/traffic/trafficFilters.ts +31 -0
  107. package/src/components/ui/CommandPalette.tsx +10 -6
  108. package/src/components/ui/DiagnosticsOverlay.tsx +10 -6
  109. package/src/components/ui/Disclosure.tsx +47 -0
  110. package/src/components/ui/Markdown.tsx +23 -11
  111. package/src/components/ui/ShortcutHelpOverlay.tsx +3 -1
  112. package/src/components/ui/UpdateNotification.tsx +18 -2
  113. package/src/components/workload/WorkloadView.tsx +19 -8
  114. package/src/context/ConnectionContext.tsx +48 -3
  115. package/src/context/DiagnoseCustomization.tsx +1 -0
  116. package/src/index.css +36 -6
  117. package/src/utils/navigation.ts +1 -1
  118. package/src/utils/topology-namespace.test.ts +64 -0
  119. package/src/utils/topology-namespace.ts +18 -0
  120. package/src/components/diagnose/investigationEvidence.test.ts +0 -6617
  121. package/src/components/diagnose/investigationEvidence.ts +0 -5572
  122. package/src/components/gitops/GitOpsActions.tsx +0 -1
  123. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  124. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  125. package/src/components/gitops/SyncCountdown.tsx +0 -1
  126. package/src/components/gitops/index.ts +0 -4
  127. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  128. package/src/components/logs/JsonLogLine.tsx +0 -1
  129. package/src/components/logs/LogCore.tsx +0 -2
  130. package/src/components/logs/useLogBuffer.ts +0 -2
  131. package/src/components/logs/useLogSearch.ts +0 -1
  132. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  133. package/src/components/resources/drawer-components.tsx +0 -1
  134. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  162. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  169. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  170. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  171. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  172. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  173. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  174. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  175. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  176. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  177. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  178. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  179. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  180. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  181. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  182. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  183. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  184. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  185. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  186. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  187. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  188. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  189. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  190. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  191. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  192. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  193. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  194. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  195. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  196. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  197. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  198. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  199. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  200. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  201. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  202. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  203. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  204. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  205. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  206. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  207. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  208. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  209. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  210. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  211. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  212. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  213. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  214. package/src/components/resources/renderers/index.ts +0 -94
  215. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  216. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  217. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  218. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  219. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  220. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  221. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  222. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  223. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  224. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  225. package/src/components/resources/resource-utils-argo.ts +0 -2
  226. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  227. package/src/components/resources/resource-utils-eso.ts +0 -2
  228. package/src/components/resources/resource-utils-flux.ts +0 -2
  229. package/src/components/resources/resource-utils-istio.ts +0 -2
  230. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  231. package/src/components/resources/resource-utils-knative.ts +0 -2
  232. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  233. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  234. package/src/components/resources/resource-utils-traefik.ts +0 -1
  235. package/src/components/resources/resource-utils-trivy.ts +0 -2
  236. package/src/components/resources/resource-utils-velero.ts +0 -2
  237. package/src/components/shared/EditableYamlView.tsx +0 -24
  238. package/src/components/timeline/DiffViewer.tsx +0 -1
  239. package/src/components/traffic/index.ts +0 -3
  240. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  241. package/src/components/ui/MetricsChart.tsx +0 -1
  242. package/src/components/ui/ResourceBar.tsx +0 -1
package/src/api/client.ts CHANGED
@@ -227,6 +227,64 @@ export function shouldRetryCapacityQuery(
227
227
  return failureCount < 3;
228
228
  }
229
229
 
230
+ // isKindSyncPending matches the 503 the resource read handlers return while a
231
+ // kind's informer is still completing its initial sync — the caller should
232
+ // keep polling, not surface an error.
233
+ export function isKindSyncPending(error: unknown): boolean {
234
+ return (
235
+ error instanceof ApiError &&
236
+ error.status === 503 &&
237
+ error.data?.error_code === "kind_sync_pending"
238
+ );
239
+ }
240
+
241
+ // isClusterConnecting matches the 503 the server returns while the whole
242
+ // connection is still being established (no cache handle for this surface
243
+ // yet) — like kind_sync_pending, a keep-polling signal, not an error.
244
+ export function isClusterConnecting(error: unknown): boolean {
245
+ return (
246
+ error instanceof ApiError &&
247
+ error.status === 503 &&
248
+ error.data?.error_code === "cluster_connecting"
249
+ );
250
+ }
251
+
252
+ // isStillLoadingError groups the two retryable "the cluster is warming up"
253
+ // signals every resource surface should sit out in a loading state.
254
+ export function isStillLoadingError(error: unknown): boolean {
255
+ return isKindSyncPending(error) || isClusterConnecting(error);
256
+ }
257
+
258
+ // isKindSyncFailed matches the terminal variant: the kind never synced within
259
+ // the deadline for this connection. Retrying won't help — show the error.
260
+ export function isKindSyncFailed(error: unknown): boolean {
261
+ return (
262
+ error instanceof ApiError &&
263
+ error.status === 503 &&
264
+ error.data?.error_code === "kind_sync_failed"
265
+ );
266
+ }
267
+
268
+ // SyncKindState / SyncStatusSnapshot mirror k8score's SyncSnapshot — attached
269
+ // to the connection-status payload while the initial informer sync is running.
270
+ export interface SyncKindState {
271
+ kind: string;
272
+ key: string;
273
+ synced: boolean;
274
+ deferred: boolean;
275
+ // Terminal: the kind's sync deadline fired without completing.
276
+ failed?: boolean;
277
+ }
278
+
279
+ export interface SyncStatusSnapshot {
280
+ phase: string;
281
+ criticalTotal: number;
282
+ criticalSynced: number;
283
+ deferredTotal: number;
284
+ deferredSynced: number;
285
+ kinds: SyncKindState[];
286
+ }
287
+
230
288
  const METRICS_API_GROUP_TOKENS = ["metrics", "k8s", "io"] as const;
231
289
 
232
290
  function mentionsMetricsAPIGroup(message: string): boolean {
@@ -435,6 +493,7 @@ export type DashboardAudit = AuditCardData;
435
493
  export type { AuditFinding, ResourceGroup, CheckMeta, Check };
436
494
 
437
495
  export interface AuditResponse {
496
+ missingInputs?: string[];
438
497
  summary: DashboardAudit;
439
498
  findings: AuditFinding[];
440
499
  groups: ResourceGroup[];
@@ -700,7 +759,7 @@ export function useResourceIssues(
700
759
  });
701
760
  }
702
761
 
703
- import type { Trace as NetworkTrace, InClusterCapability } from '@skyhook-io/k8s-ui'
762
+ import type { Trace as NetworkTrace, InClusterCapability, DrainPlan, DrainPlanPod } from '@skyhook-io/k8s-ui'
704
763
 
705
764
  // useTrace polls the static path-shaped diagnosis for one network entry
706
765
  // kind. 5s refetch + 15s staleTime keeps the drawer feeling live without
@@ -1834,8 +1893,34 @@ export interface CloudInstallFailure {
1834
1893
  retrySafe: boolean
1835
1894
  }
1836
1895
 
1896
+ // What Radar did before it stopped: the operation it planned, how far it got,
1897
+ // and what it could establish about the cluster. Evidence for the card, and
1898
+ // the input to the exit it offers — the two are decided separately.
1899
+ export interface CloudInstallAttempted {
1900
+ mode: 'fresh' | 'adopt' | 'gitops'
1901
+ namespace: string
1902
+ release: string
1903
+ stage: 'inspect' | 'prepare' | 'preflight'
1904
+ // Discovery saw only the default namespace; a fresh plan is not proof that
1905
+ // no Radar exists elsewhere.
1906
+ partialScan?: boolean
1907
+ // A complete scan found no Radar running, but Helm's release records could
1908
+ // not be read, so a leftover release cannot be ruled out.
1909
+ releaseUnread?: boolean
1910
+ // GitOps only: the Hub install-page tab of the verified owning controller.
1911
+ method?: 'argocd' | 'flux' | ''
1912
+ }
1913
+
1837
1914
  export interface CloudInstallBlocked {
1838
1915
  reason: 'gitops' | 'preflight' | 'unsupported'
1916
+ // The Kubernetes user the attempt acted as; absent when the API server
1917
+ // would not say.
1918
+ identity?: string
1919
+ // Preflight only: what would unblock it, and the Helm operation that was
1920
+ // dry-run — the plan card never renders when preflight blocks, so the
1921
+ // blocked card states what Radar tried itself.
1922
+ cause?: 'permissions' | 'cluster' | 'verification'
1923
+ attempted?: CloudInstallAttempted
1839
1924
  message: string
1840
1925
  blocking?: string[]
1841
1926
  }
@@ -1890,13 +1975,23 @@ export interface CloudConnectInfo {
1890
1975
  freeTier?: string
1891
1976
  }
1892
1977
 
1893
- // Deliberately a bare cross-origin GET: no credentials, no identifiers, no
1894
- // params. The Hub learns only what any HTTP request reveals.
1895
- export function useCloudConnectInfo(apiUrl: string | undefined, enabled: boolean) {
1978
+ // Deliberately a bare cross-origin GET: no credentials, no identifiers. The
1979
+ // Hub learns what any HTTP request reveals plus `about`, below: the lane the
1980
+ // footer renders and this deployment's mode, both closed enums, so nothing
1981
+ // about the cluster or the person rides along. Part of the query key so a
1982
+ // lane change (a driver install that just became tunneled, say) refetches
1983
+ // instead of reusing the other lane's copy.
1984
+ export function useCloudConnectInfo(
1985
+ apiUrl: string | undefined,
1986
+ enabled: boolean,
1987
+ about: { lane: "driver" | "wizard"; mode?: DeploymentMode },
1988
+ ) {
1989
+ const params = new URLSearchParams({ lane: about.lane });
1990
+ if (about.mode) params.set("mode", about.mode);
1896
1991
  return useQuery<CloudConnectInfo>({
1897
- queryKey: ["cloud-connect-info", apiUrl],
1992
+ queryKey: ["cloud-connect-info", apiUrl, about.lane, about.mode],
1898
1993
  queryFn: async () => {
1899
- const res = await fetch(`${apiUrl}/api/connect/info`, {
1994
+ const res = await fetch(`${apiUrl}/api/connect/info?${params}`, {
1900
1995
  credentials: "omit",
1901
1996
  signal: AbortSignal.timeout(4000),
1902
1997
  });
@@ -2352,6 +2447,15 @@ export function useResource<T>(
2352
2447
  queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
2353
2448
  enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
2354
2449
  refetchInterval: options?.refetchInterval,
2450
+ // Kind still completing its initial sync: stay in loading and poll until
2451
+ // it becomes readable instead of erroring out (deep links during startup).
2452
+ retry: (failureCount, error) => {
2453
+ if (isStillLoadingError(error)) return true;
2454
+ if (isKindSyncFailed(error)) return false;
2455
+ return failureCount < 1; // matches the QueryClient default (retry: 1)
2456
+ },
2457
+ retryDelay: (failureCount, error) =>
2458
+ isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
2355
2459
  });
2356
2460
 
2357
2461
  // Extract resource and relationships from the response
@@ -2375,6 +2479,15 @@ export function useResourceWithRelationships<T>(
2375
2479
  queryKey: ["resource", kind, namespace, name, group],
2376
2480
  queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
2377
2481
  enabled: Boolean(kind && name),
2482
+ // Deep-linked detail views can mount while the kind's informer is still
2483
+ // completing its initial sync: keep polling instead of erroring out.
2484
+ retry: (failureCount, error) => {
2485
+ if (isStillLoadingError(error)) return true;
2486
+ if (isKindSyncFailed(error)) return false;
2487
+ return failureCount < 1; // matches the QueryClient default (retry: 1)
2488
+ },
2489
+ retryDelay: (failureCount, error) =>
2490
+ isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
2378
2491
  });
2379
2492
  }
2380
2493
 
@@ -2397,6 +2510,16 @@ export function useResources<T>(
2397
2510
  enabled: (options?.enabled ?? true) && Boolean(kind),
2398
2511
  staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
2399
2512
  refetchInterval: options?.refetchInterval,
2513
+ // Kind still completing its initial sync (progressive shell, or a
2514
+ // deferred kind shortly after connect): keep polling instead of
2515
+ // surfacing an error.
2516
+ retry: (failureCount, error) => {
2517
+ if (isStillLoadingError(error)) return true;
2518
+ if (isKindSyncFailed(error)) return false;
2519
+ return failureCount < 1; // matches the QueryClient default (retry: 1)
2520
+ },
2521
+ retryDelay: (failureCount, error) =>
2522
+ isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
2400
2523
  });
2401
2524
  }
2402
2525
 
@@ -3067,6 +3190,8 @@ export function useTopNodeMetrics(options?: { enabled?: boolean }) {
3067
3190
  export interface PrometheusStatus {
3068
3191
  available: boolean;
3069
3192
  connected: boolean;
3193
+ /** A discovery run is in flight for the current configuration. Never true alongside connected. */
3194
+ discovering?: boolean;
3070
3195
  address?: string;
3071
3196
  service?: {
3072
3197
  namespace: string;
@@ -3173,6 +3298,8 @@ export interface RightsizingRow {
3173
3298
  currentPodOOM?: boolean;
3174
3299
  windowOomEvidence?: boolean;
3175
3300
  oomEvidenceAvailable: boolean;
3301
+ /** The cluster cache would not list the workload's pods, so currentPodOOM is unknown rather than false. */
3302
+ liveInventoryUnavailable?: boolean;
3176
3303
  limitConflict?: boolean;
3177
3304
  queryError?: string;
3178
3305
  }
@@ -3222,13 +3349,32 @@ export interface RightsizingScanResponse {
3222
3349
  reason?: string;
3223
3350
  }
3224
3351
 
3352
+ // Poll quickly while a discovery run is in flight so the flip to connected
3353
+ // (or to a failure message) lands promptly; otherwise the slow cadence. A
3354
+ // status that is neither connected nor carrying an outcome was read before
3355
+ // the server's run started (boot, context switch) — every run ends in one or
3356
+ // the other, so that state is transient and gets the fast cadence too.
3357
+ export const PROM_STATUS_POLL_DISCOVERING_MS = 2000;
3358
+ export const PROM_STATUS_POLL_IDLE_MS = 60000;
3359
+
3360
+ export function prometheusStatusRefetchInterval(
3361
+ status: PrometheusStatus | undefined,
3362
+ ): number {
3363
+ if (!status) return PROM_STATUS_POLL_IDLE_MS;
3364
+ const settled = status.connected || !!status.error;
3365
+ return status.discovering || !settled
3366
+ ? PROM_STATUS_POLL_DISCOVERING_MS
3367
+ : PROM_STATUS_POLL_IDLE_MS;
3368
+ }
3369
+
3225
3370
  // Check Prometheus availability
3226
3371
  export function usePrometheusStatus() {
3227
3372
  return useQuery<PrometheusStatus>({
3228
3373
  queryKey: ["prometheus-status"],
3229
3374
  queryFn: () => fetchJSON("/prometheus/status"),
3230
3375
  staleTime: 30000,
3231
- refetchInterval: 60000,
3376
+ refetchInterval: (query) =>
3377
+ prometheusStatusRefetchInterval(query.state.data),
3232
3378
  });
3233
3379
  }
3234
3380
 
@@ -3237,6 +3383,9 @@ export interface ArgoStatus {
3237
3383
  // client is live. The two differ right after a restart (configured, reconnecting).
3238
3384
  configured: boolean;
3239
3385
  connected: boolean;
3386
+ // Connected without a token: the install serves reads to everyone, so
3387
+ // there is nothing to add in Settings.
3388
+ anonymous?: boolean;
3240
3389
  address?: string;
3241
3390
  reason?: string;
3242
3391
  }
@@ -3343,6 +3492,12 @@ export function useAutoPromConnect(): void {
3343
3492
  // initial UI render isn't competing with the cluster network call.
3344
3493
  const delay = cached === "1" ? 0 : PROM_FIRSTLAUNCH_PROBE_DELAY_MS;
3345
3494
  const timeout = window.setTimeout(() => {
3495
+ // The server only reports discovering once the run has started; mark it
3496
+ // here first so the view shows progress instead of the manual CTA for the
3497
+ // whole duration of this request.
3498
+ queryClient.setQueryData<PrometheusStatus>(["prometheus-status"], (prev) =>
3499
+ prev ? { ...prev, discovering: true } : prev,
3500
+ );
3346
3501
  // Direct apiFetch (not via the usePrometheusConnect mutation) so the
3347
3502
  // meta-driven toast handler stays silent — the user didn't click anything.
3348
3503
  apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, {
@@ -3357,6 +3512,14 @@ export function useAutoPromConnect(): void {
3357
3512
  queryClient.invalidateQueries({ queryKey: ["prometheus-status"] });
3358
3513
  })
3359
3514
  .catch(() => {
3515
+ // Undo the optimistic flag, then ask the server: if it's reachable
3516
+ // the authoritative status (possibly a still-running discovery)
3517
+ // replaces this within one fetch; if it isn't, the CTA is right.
3518
+ queryClient.setQueryData<PrometheusStatus>(
3519
+ ["prometheus-status"],
3520
+ (prev) => (prev?.discovering ? { ...prev, discovering: false } : prev),
3521
+ );
3522
+ queryClient.invalidateQueries({ queryKey: ["prometheus-status"] });
3360
3523
  try {
3361
3524
  window.localStorage.removeItem(promAutoConnectKey(context));
3362
3525
  } catch {
@@ -4734,6 +4897,31 @@ export function useRolloutCapabilities(
4734
4897
  });
4735
4898
  }
4736
4899
 
4900
+ export interface AnalysisRunSummary {
4901
+ name: string;
4902
+ phase: string;
4903
+ message?: string;
4904
+ trigger?: string;
4905
+ stepIndex?: number;
4906
+ createdAt: string;
4907
+ metricsTotal: number;
4908
+ metricsPassing: number;
4909
+ metricsNotPassing: number;
4910
+ }
4911
+
4912
+ export function useRolloutAnalysisRuns(
4913
+ namespace: string,
4914
+ name: string,
4915
+ enabled = true,
4916
+ ) {
4917
+ return useQuery<{ items: AnalysisRunSummary[] }>({
4918
+ queryKey: ["rollout-analysisruns", namespace, name],
4919
+ queryFn: () => fetchJSON(`/rollouts/${namespace}/${name}/analysisruns`),
4920
+ enabled: Boolean(namespace && name && enabled),
4921
+ staleTime: 10000,
4922
+ });
4923
+ }
4924
+
4737
4925
  // Fallbacks only. The server reports what it actually did — including when it found
4738
4926
  // nothing to do — so its message is preferred over anything asserted here.
4739
4927
  const ROLLOUT_ACTION_MESSAGES: Record<
@@ -4898,6 +5086,75 @@ export interface DrainNodeOptions {
4898
5086
  force?: boolean;
4899
5087
  }
4900
5088
 
5089
+ export interface DrainPlanRequestOptions {
5090
+ deleteEmptyDirData: boolean;
5091
+ force: boolean;
5092
+ }
5093
+
5094
+ /** Path of the read-only plan endpoint; distinct from the drain so a preview can never drain. */
5095
+ export function drainPlanPath(name: string): string {
5096
+ return `/nodes/${encodeURIComponent(name)}/drain-plan`;
5097
+ }
5098
+
5099
+ /** Body for the plan request: both options are always explicit, the server echoes them back. */
5100
+ export function drainPlanBody(options: DrainPlanRequestOptions): string {
5101
+ return JSON.stringify({
5102
+ deleteEmptyDirData: options.deleteEmptyDirData,
5103
+ force: options.force,
5104
+ });
5105
+ }
5106
+
5107
+ /**
5108
+ * The connected radar predates the drain-plan endpoint. Distinguished from a
5109
+ * node-not-found 404 by the body: handlers answer with a JSON error envelope,
5110
+ * while an unknown route gets the router's plain-text 404. Hosts serving a newer
5111
+ * frontend against an older radar (Radar Hub) use this to fall back to the
5112
+ * plan-less drain dialog instead of leaving Drain permanently disabled.
5113
+ */
5114
+ export class DrainPlanUnsupportedError extends Error {
5115
+ constructor() {
5116
+ super("This radar does not support drain plans");
5117
+ this.name = "DrainPlanUnsupportedError";
5118
+ }
5119
+ }
5120
+
5121
+ export function drainPlanFetchError(
5122
+ status: number,
5123
+ body: { error?: string } | null,
5124
+ ): Error {
5125
+ if (status === 404 && body === null) {
5126
+ return new DrainPlanUnsupportedError();
5127
+ }
5128
+ return new Error(body?.error || `HTTP ${status}`);
5129
+ }
5130
+
5131
+ // Read-only drain plan: what a drain with these options would do to each pod on the node.
5132
+ // Modelled as a mutation because it is a POST with a body and is fetched on demand
5133
+ // while the drain dialog is open; it performs no cluster mutation.
5134
+ export function useDrainPlan() {
5135
+ return useMutation<
5136
+ DrainPlan,
5137
+ Error,
5138
+ { name: string; options: DrainPlanRequestOptions }
5139
+ >({
5140
+ mutationFn: async ({ name, options }) => {
5141
+ const response = await apiFetch(apiUrl(drainPlanPath(name)), {
5142
+ method: "POST",
5143
+ headers: { "Content-Type": "application/json" },
5144
+ body: drainPlanBody(options),
5145
+ });
5146
+ if (!response.ok) {
5147
+ const body = await response.json().catch(() => null);
5148
+ throw drainPlanFetchError(response.status, body);
5149
+ }
5150
+ return response.json();
5151
+ },
5152
+ // Deliberately no meta toast keys: the plan is fetched only from the drain dialog, which
5153
+ // renders a failure inline and keeps Drain disabled while it shows; a global toast would
5154
+ // report the same failure twice.
5155
+ });
5156
+ }
5157
+
4901
5158
  export function useDrainNode() {
4902
5159
  const queryClient = useQueryClient();
4903
5160
 
@@ -4927,7 +5184,11 @@ export function useDrainNode() {
4927
5184
  // No static successMessage — handled in onSuccess to distinguish partial failures
4928
5185
  },
4929
5186
  onSuccess: (
4930
- data: { evictedPods?: string[]; errors?: string[] },
5187
+ data: {
5188
+ evictedPods?: string[];
5189
+ skippedPods?: DrainPlanPod[];
5190
+ errors?: string[];
5191
+ },
4931
5192
  variables,
4932
5193
  ) => {
4933
5194
  queryClient.invalidateQueries({ queryKey: ["resources", "nodes"] });
@@ -4936,20 +5197,67 @@ export function useDrainNode() {
4936
5197
  });
4937
5198
  queryClient.invalidateQueries({ queryKey: ["topology"] });
4938
5199
 
4939
- const evicted = data?.evictedPods?.length ?? 0;
4940
- const errors = data?.errors?.length ?? 0;
4941
- if (errors > 0) {
4942
- showApiError(
4943
- `Drain completed with ${errors} error(s)`,
4944
- `${evicted} pods evicted. Errors: ${data.errors!.join("; ")}`,
4945
- );
5200
+ const { title, detail, failed } = describeDrainResult(data);
5201
+ if (failed) {
5202
+ showApiError(title, detail);
4946
5203
  } else {
4947
- showApiSuccess(`Node drained: ${evicted} pods evicted`);
5204
+ showApiSuccess(title, detail);
4948
5205
  }
4949
5206
  },
4950
5207
  });
4951
5208
  }
4952
5209
 
5210
+ const DRAIN_RESULT_MAX_LISTED = 5;
5211
+
5212
+ function listWithOverflow(items: string[]): string {
5213
+ const shown = items.slice(0, DRAIN_RESULT_MAX_LISTED);
5214
+ const rest = items.length - shown.length;
5215
+ return rest > 0 ? `${shown.join("; ")}; +${rest} more` : shown.join("; ");
5216
+ }
5217
+
5218
+ /**
5219
+ * Turns the drain response into a legible summary: evicted, skipped (with reasons),
5220
+ * failed. Lists are capped so the toast stays readable on nodes with many pods.
5221
+ */
5222
+ export function describeDrainResult(data: {
5223
+ evictedPods?: string[];
5224
+ skippedPods?: DrainPlanPod[];
5225
+ errors?: string[];
5226
+ }): { title: string; detail: string; failed: boolean } {
5227
+ const evicted = data?.evictedPods?.length ?? 0;
5228
+ const skipped = data?.skippedPods ?? [];
5229
+ const errors = data?.errors ?? [];
5230
+ const parts: string[] = [];
5231
+ if (skipped.length > 0) {
5232
+ parts.push(
5233
+ `Skipped ${skipped.length}: ${listWithOverflow(
5234
+ skipped.map((p) => `${p.namespace}/${p.name} (${p.reason})`),
5235
+ )}`,
5236
+ );
5237
+ }
5238
+ if (errors.length > 0) {
5239
+ parts.push(`Failed ${errors.length}: ${listWithOverflow(errors)}`);
5240
+ }
5241
+ parts.push(
5242
+ evicted > 0
5243
+ ? "Evictions were accepted; those pods may still be terminating. The node remains cordoned."
5244
+ : "The node remains cordoned.",
5245
+ );
5246
+ const detail = parts.join("\n");
5247
+ if (errors.length > 0) {
5248
+ return {
5249
+ title: `Drain finished with ${errors.length} failed eviction(s): ${evicted} evicted, ${skipped.length} skipped`,
5250
+ detail,
5251
+ failed: true,
5252
+ };
5253
+ }
5254
+ return {
5255
+ title: `Node drained: ${evicted} evicted, ${skipped.length} skipped`,
5256
+ detail,
5257
+ failed: false,
5258
+ };
5259
+ }
5260
+
4953
5261
  // ============================================================================
4954
5262
  // Helm API hooks
4955
5263
  // ============================================================================
@@ -72,7 +72,8 @@ export interface DiagnosisEvidenceSubject {
72
72
  group?: string;
73
73
  kind: string;
74
74
  namespace?: string;
75
- name: string;
75
+ /** Absent when the subject is a listing cited for what it does not contain. */
76
+ name?: string;
76
77
  container?: string;
77
78
  stream?: "current" | "previous";
78
79
  /** Evidence kind (resource, logs, events, changes, metrics, …). */
@@ -85,6 +86,8 @@ export interface DiagnosisEvidenceItem {
85
86
  ref?: string;
86
87
  role?: DiagnosisEvidenceRole;
87
88
  claim?: string;
89
+ /** The agent's own statement of what this result does not cover. */
90
+ gap?: string;
88
91
  subject?: DiagnosisEvidenceSubject;
89
92
  }
90
93
 
@@ -94,10 +97,35 @@ export interface DiagnosisRuledOut {
94
97
  evidenceIndex: number;
95
98
  }
96
99
 
100
+ export type DiagnosisCertainty = "established" | "likely" | "suspected";
101
+ export type DiagnosisStepKind = "mitigate" | "verify" | "investigate";
102
+
103
+ /** One typed next step; `remediation` mirrors `steps[].text` when steps are present. */
104
+ export interface DiagnosisStep {
105
+ text: string;
106
+ kind: DiagnosisStepKind;
107
+ /** The condition under which this step is the right one, when the agent named one. */
108
+ precondition?: string;
109
+ }
110
+
97
111
  export interface Diagnosis {
98
112
  healthy?: boolean;
99
113
  inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
100
114
  rootCause: string;
115
+ /**
116
+ * The plain-language headline; `rootCause` stays the technical one-liner.
117
+ * Present only on turns from a backend that emits the story contract; its
118
+ * absence selects the previous rendering.
119
+ */
120
+ summary?: string;
121
+ /** The agent's own word for how sure it is; rendered in the agent tone, never as a Radar mark. */
122
+ certainty?: DiagnosisCertainty;
123
+ /** What would change the answer or could not be verified. Rendered above the story fold. */
124
+ unresolved?: string[];
125
+ /** A follow-up answer that replaces the assessment on screen (server-validated as a complete verdict). */
126
+ revisesAssessment?: boolean;
127
+ /** Typed next steps; absent on older runs, where `remediation` is the only list. */
128
+ steps?: DiagnosisStep[];
101
129
  rootCauseEvidence?: RootCauseEvidence;
102
130
  /** The agent's case over Radar's evidence; absent from hosted backends and older runs. */
103
131
  evidence?: DiagnosisEvidenceItem[];
@@ -107,6 +135,8 @@ export interface Diagnosis {
107
135
  evidenceMalformed?: boolean;
108
136
  ruledOut?: DiagnosisRuledOut[];
109
137
  report: string;
138
+ /** The agent's evidence ledger, written before its verdict block; shown in Activity, never in Findings. */
139
+ notes?: string;
110
140
  remediation: string[];
111
141
  recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
112
142
  recommendedReason?: string; // why the recommended step is the safe pick
@@ -183,6 +213,7 @@ export interface DiagnoseStreamEvent {
183
213
  // server runs.
184
214
  export interface RunSummary {
185
215
  id: string;
216
+ /** Stored target Kind; existing hosted runs may contain a plural API resource name. */
186
217
  kind: string;
187
218
  /** Kubernetes API group; empty means the core API group. */
188
219
  group: string;
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ describeDrainResult,
4
+ DrainPlanUnsupportedError,
5
+ drainPlanBody,
6
+ drainPlanFetchError,
7
+ drainPlanPath,
8
+ } from "./client";
9
+
10
+ describe("drain plan request", () => {
11
+ it("targets the read-only plan endpoint, never the drain", () => {
12
+ expect(drainPlanPath("worker-1")).toBe("/nodes/worker-1/drain-plan");
13
+ expect(drainPlanPath("worker-1")).not.toContain("/drain\"");
14
+ expect(drainPlanPath("odd node")).toBe("/nodes/odd%20node/drain-plan");
15
+ });
16
+
17
+ it("always sends both options explicitly so no server default applies", () => {
18
+ expect(JSON.parse(drainPlanBody({ deleteEmptyDirData: false, force: false }))).toEqual({
19
+ deleteEmptyDirData: false,
20
+ force: false,
21
+ });
22
+ expect(JSON.parse(drainPlanBody({ deleteEmptyDirData: true, force: true }))).toEqual({
23
+ deleteEmptyDirData: true,
24
+ force: true,
25
+ });
26
+ });
27
+
28
+ it("reads a plain-text 404 as an unsupported endpoint, not as a missing node", () => {
29
+ expect(drainPlanFetchError(404, null)).toBeInstanceOf(DrainPlanUnsupportedError);
30
+ const nodeGone = drainPlanFetchError(404, { error: 'nodes "worker-1" not found' });
31
+ expect(nodeGone).not.toBeInstanceOf(DrainPlanUnsupportedError);
32
+ expect(nodeGone.message).toBe('nodes "worker-1" not found');
33
+ expect(drainPlanFetchError(503, null).message).toBe("HTTP 503");
34
+ });
35
+ });
36
+
37
+ describe("describeDrainResult", () => {
38
+ it("lists evictions, skips with reasons, and individual failures", () => {
39
+ const r = describeDrainResult({
40
+ evictedPods: ["shop/cache"],
41
+ skippedPods: [
42
+ { namespace: "shop", name: "agent", outcome: "skip", reason: "managed by a DaemonSet", emptyDir: false, pdbChecked: false },
43
+ ],
44
+ errors: ["shop/web-1: timed out waiting for PDB to allow eviction"],
45
+ });
46
+ expect(r.failed).toBe(true);
47
+ expect(r.title).toBe("Drain finished with 1 failed eviction(s): 1 evicted, 1 skipped");
48
+ expect(r.detail).toContain("shop/agent (managed by a DaemonSet)");
49
+ expect(r.detail).toContain("shop/web-1: timed out");
50
+ });
51
+
52
+ it("caps long lists so the toast stays readable", () => {
53
+ const skippedPods = Array.from({ length: 8 }, (_, i) => ({
54
+ namespace: "ns", name: `p${i}`, outcome: "skip" as const, reason: "r", emptyDir: false, pdbChecked: false,
55
+ }));
56
+ const r = describeDrainResult({ evictedPods: [], skippedPods });
57
+ expect(r.detail).toContain("Skipped 8:");
58
+ expect(r.detail).toContain("ns/p4 (r); +3 more");
59
+ expect(r.detail).not.toContain("ns/p5");
60
+ });
61
+
62
+ it("reports a clean drain as success and says what success means", () => {
63
+ const r = describeDrainResult({ evictedPods: ["a", "b"] });
64
+ expect(r.failed).toBe(false);
65
+ expect(r.title).toBe("Node drained: 2 evicted, 0 skipped");
66
+ expect(r.detail).toContain("those pods may still be terminating");
67
+ expect(r.detail).toContain("node remains cordoned");
68
+ });
69
+
70
+ it("never claims evictions were accepted when nothing was evicted", () => {
71
+ const allFailed = describeDrainResult({
72
+ evictedPods: [],
73
+ errors: ["shop/web-1: timed out waiting for PDB to allow eviction"],
74
+ });
75
+ expect(allFailed.detail).not.toContain("accepted");
76
+ expect(allFailed.detail).toContain("node remains cordoned");
77
+
78
+ const skipOnly = describeDrainResult({
79
+ evictedPods: [],
80
+ skippedPods: [
81
+ { namespace: "shop", name: "agent", outcome: "skip", reason: "managed by a DaemonSet", emptyDir: false, pdbChecked: false },
82
+ ],
83
+ });
84
+ expect(skipOnly.detail).not.toContain("accepted");
85
+ });
86
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { podLimitRangeNames } from './quotas'
3
+
4
+ describe('Pod-relevant LimitRanges', () => {
5
+ it('excludes PVC-only rules and retains Pod, Container and mixed rules', () => {
6
+ const rules = [
7
+ { metadata: { name: 'storage-only' }, spec: { limits: [{ type: 'PersistentVolumeClaim' }] } },
8
+ { metadata: { name: 'container-defaults' }, spec: { limits: [{ type: 'Container' }] } },
9
+ { metadata: { name: 'pod-total' }, spec: { limits: [{ type: 'Pod' }] } },
10
+ { metadata: { name: 'mixed' }, spec: { limits: [{ type: 'PersistentVolumeClaim' }, { type: 'Container' }] } },
11
+ ]
12
+ expect(podLimitRangeNames(rules)).toEqual(['container-defaults', 'pod-total', 'mixed'])
13
+ })
14
+
15
+ it('ignores LimitRanges with no declared entries', () => {
16
+ expect(podLimitRangeNames([
17
+ { metadata: { name: 'null-entries' }, spec: { limits: null } },
18
+ { metadata: { name: 'empty-entries' }, spec: { limits: [] } },
19
+ ])).toEqual([])
20
+ })
21
+
22
+ it('has no contextual links before data is available or for an empty namespace', () => {
23
+ expect(podLimitRangeNames(undefined)).toEqual([])
24
+ expect(podLimitRangeNames([])).toEqual([])
25
+ })
26
+ })
package/src/api/quotas.ts CHANGED
@@ -14,3 +14,23 @@ export function useNamespaceQuotas(namespace: string, enabled = true) {
14
14
  staleTime: 15000,
15
15
  })
16
16
  }
17
+
18
+ // useNamespaceLimitRanges fetches a namespace's LimitRange objects via
19
+ // /api/resources/limitranges?namespace=<ns> (a bare array). Backs the
20
+ // NamespaceRenderer rules section and the contextual link on Pod/workload
21
+ // details — the defaults and constraints applied at admission are otherwise
22
+ // only visible by reading the object.
23
+ export function useNamespaceLimitRanges(namespace: string, enabled = true) {
24
+ return useQuery<any[]>({
25
+ queryKey: ['limitranges', namespace],
26
+ queryFn: () => fetchJSON<any[]>(`/resources/limitranges?namespace=${encodeURIComponent(namespace)}`),
27
+ enabled: enabled && !!namespace,
28
+ staleTime: 15000,
29
+ })
30
+ }
31
+
32
+ export function podLimitRangeNames(limitRanges: any[] | undefined): string[] {
33
+ return (limitRanges ?? [])
34
+ .filter(lr => lr.spec.limits?.some((entry: { type: string }) => entry.type === 'Container' || entry.type === 'Pod'))
35
+ .map(lr => lr.metadata.name)
36
+ }