@skyhook-io/radar-app 1.13.4 → 1.14.0

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 (230) hide show
  1. package/package.json +8 -8
  2. package/src/App.tsx +60 -24
  3. package/src/RadarApp.tsx +5 -0
  4. package/src/api/client.authRedirect.test.ts +12 -1
  5. package/src/api/client.prometheusStatus.test.ts +24 -0
  6. package/src/api/client.ts +325 -16
  7. package/src/api/diagnose.ts +62 -15
  8. package/src/api/drain-plan.test.ts +86 -0
  9. package/src/api/quotas.test.ts +26 -0
  10. package/src/api/quotas.ts +20 -0
  11. package/src/api/workloadMetrics.test.ts +40 -0
  12. package/src/api/workloadMetrics.ts +68 -0
  13. package/src/components/CloudConnectFlow.tsx +179 -42
  14. package/src/components/CloudFunnelButton.tsx +107 -36
  15. package/src/components/ConnectionErrorView.test.tsx +30 -0
  16. package/src/components/ConnectionErrorView.tsx +31 -19
  17. package/src/components/SyncProgressPanel.tsx +73 -0
  18. package/src/components/audit/AuditView.tsx +2 -0
  19. package/src/components/cloudConnectHandoff.test.ts +140 -0
  20. package/src/components/cloudConnectHandoff.ts +124 -0
  21. package/src/components/diagnose/AgentCase.tsx +131 -0
  22. package/src/components/diagnose/DiagnoseContext.tsx +2 -1
  23. package/src/components/diagnose/DiagnoseSurface.test.tsx +0 -20
  24. package/src/components/diagnose/DiagnoseSurface.tsx +32 -195
  25. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +783 -7
  26. package/src/components/diagnose/InvestigationEvidencePane.tsx +774 -133
  27. package/src/components/diagnose/InvestigationView.tsx +222 -5
  28. package/src/components/diagnose/diagnoseEvidenceTypes.ts +22 -1
  29. package/src/components/diagnose/investigationCase.test.tsx +1568 -0
  30. package/src/components/diagnose/investigationCase.ts +439 -0
  31. package/src/components/diagnose/investigationEvidence/adapters/changes.ts +60 -0
  32. package/src/components/diagnose/investigationEvidence/adapters/diagnose.test.ts +573 -0
  33. package/src/components/diagnose/investigationEvidence/adapters/diagnose.ts +935 -0
  34. package/src/components/diagnose/investigationEvidence/adapters/events.ts +96 -0
  35. package/src/components/diagnose/investigationEvidence/adapters/helm.test.ts +242 -0
  36. package/src/components/diagnose/investigationEvidence/adapters/helm.ts +208 -0
  37. package/src/components/diagnose/investigationEvidence/adapters/issues.ts +172 -0
  38. package/src/components/diagnose/investigationEvidence/adapters/logs.test.ts +283 -0
  39. package/src/components/diagnose/investigationEvidence/adapters/logs.ts +183 -0
  40. package/src/components/diagnose/investigationEvidence/adapters/permissions.test.ts +528 -0
  41. package/src/components/diagnose/investigationEvidence/adapters/permissions.ts +292 -0
  42. package/src/components/diagnose/investigationEvidence/adapters/prometheus.test.ts +1641 -0
  43. package/src/components/diagnose/investigationEvidence/adapters/prometheus.ts +769 -0
  44. package/src/components/diagnose/investigationEvidence/adapters/resource.test.ts +54 -0
  45. package/src/components/diagnose/investigationEvidence/adapters/resource.ts +257 -0
  46. package/src/components/diagnose/investigationEvidence/adapters/topology.ts +207 -0
  47. package/src/components/diagnose/investigationEvidence/builder.ts +418 -0
  48. package/src/components/diagnose/investigationEvidence/evidenceFixtures.ts +179 -0
  49. package/src/components/diagnose/investigationEvidence/identity.test.ts +614 -0
  50. package/src/components/diagnose/investigationEvidence/identity.ts +261 -0
  51. package/src/components/diagnose/investigationEvidence/index.ts +55 -0
  52. package/src/components/diagnose/investigationEvidence/observations.ts +954 -0
  53. package/src/components/diagnose/investigationEvidence/parse.ts +467 -0
  54. package/src/components/diagnose/investigationEvidence/projection.test.ts +2703 -0
  55. package/src/components/diagnose/investigationEvidence/types.ts +512 -0
  56. package/src/components/diagnose/investigationEvidenceKinds.ts +218 -0
  57. package/src/components/diagnose/investigationEvidencePresentation.test.ts +31 -0
  58. package/src/components/diagnose/investigationEvidencePresentation.ts +1 -0
  59. package/src/components/diagnose/investigationMetrics.test.ts +712 -0
  60. package/src/components/diagnose/investigationMetrics.ts +393 -0
  61. package/src/components/diagnose/investigationSourceFocus.ts +2 -0
  62. package/src/components/diagnose/investigationState.test.ts +322 -0
  63. package/src/components/diagnose/investigationState.ts +147 -25
  64. package/src/components/diagnose/parts.test.tsx +482 -0
  65. package/src/components/diagnose/parts.tsx +418 -41
  66. package/src/components/diagnose/target.test.ts +19 -0
  67. package/src/components/diagnose/target.ts +3 -2
  68. package/src/components/gitops/GitOpsView.tsx +7 -0
  69. package/src/components/gitops/RemoteDestinationCloudHint.tsx +26 -0
  70. package/src/components/resource/PrometheusCharts.overlays.test.ts +22 -0
  71. package/src/components/resource/PrometheusCharts.tsx +4 -3
  72. package/src/components/resource/PrometheusChartsGrid.render.test.tsx +99 -0
  73. package/src/components/resource/PrometheusChartsGrid.tsx +245 -103
  74. package/src/components/resource/RightsizingStrip.test.ts +38 -0
  75. package/src/components/resource/RightsizingStrip.tsx +15 -3
  76. package/src/components/resource/WorkloadMetricsHelpDialog.test.tsx +79 -0
  77. package/src/components/resource/WorkloadMetricsHelpDialog.tsx +145 -0
  78. package/src/components/resource/WorkloadMetricsSection.render.test.tsx +351 -0
  79. package/src/components/resource/WorkloadMetricsSection.tsx +486 -0
  80. package/src/components/resource/workloadMetricValues.test.ts +65 -0
  81. package/src/components/resource/workloadMetricValues.ts +30 -0
  82. package/src/components/resources/PodFilePreview.test.tsx +131 -0
  83. package/src/components/resources/PodFilePreview.tsx +394 -0
  84. package/src/components/resources/PodFilesystemModal.tsx +157 -67
  85. package/src/components/resources/ResourcesView.tsx +44 -6
  86. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -1
  87. package/src/components/resources/renderers/PodRenderer.tsx +4 -0
  88. package/src/components/resources/renderers/RolloutRenderer.tsx +15 -1
  89. package/src/components/resources/renderers/WorkloadRenderer.tsx +3 -0
  90. package/src/components/rightsizing/RightsizingScanView.tsx +21 -0
  91. package/src/components/settings/SettingsDialog.tsx +94 -14
  92. package/src/components/traffic/TrafficFlowList.tsx +229 -70
  93. package/src/components/traffic/TrafficGraph.tsx +6 -6
  94. package/src/components/traffic/TrafficView.tsx +10 -9
  95. package/src/components/traffic/trafficFilters.test.ts +28 -1
  96. package/src/components/traffic/trafficFilters.ts +31 -0
  97. package/src/components/workload/WorkloadView.tsx +19 -8
  98. package/src/context/ConnectionContext.tsx +48 -3
  99. package/src/context/DiagnoseCustomization.test.tsx +26 -0
  100. package/src/context/DiagnoseCustomization.tsx +15 -2
  101. package/src/index.css +17 -0
  102. package/src/index.ts +1 -0
  103. package/src/utils/navigation.ts +1 -1
  104. package/src/utils/shell-safe.test.ts +25 -1
  105. package/src/utils/shell-safe.ts +16 -0
  106. package/src/utils/topology-namespace.test.ts +64 -0
  107. package/src/utils/topology-namespace.ts +18 -0
  108. package/src/components/diagnose/investigationEvidence.test.ts +0 -5202
  109. package/src/components/diagnose/investigationEvidence.ts +0 -4784
  110. package/src/components/gitops/GitOpsActions.tsx +0 -1
  111. package/src/components/gitops/GitOpsStatusBadge.tsx +0 -1
  112. package/src/components/gitops/ManagedResourcesList.tsx +0 -1
  113. package/src/components/gitops/SyncCountdown.tsx +0 -1
  114. package/src/components/gitops/index.ts +0 -4
  115. package/src/components/helm/ManifestDiffViewer.tsx +0 -69
  116. package/src/components/logs/JsonLogLine.tsx +0 -1
  117. package/src/components/logs/LogCore.tsx +0 -2
  118. package/src/components/logs/useLogBuffer.ts +0 -2
  119. package/src/components/logs/useLogSearch.ts +0 -1
  120. package/src/components/resource-drawer/ResourceDrawer.tsx +0 -216
  121. package/src/components/resources/drawer-components.tsx +0 -1
  122. package/src/components/resources/renderers/AlertRenderer.tsx +0 -1
  123. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +0 -1
  124. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +0 -1
  125. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +0 -1
  126. package/src/components/resources/renderers/CertificateRenderer.tsx +0 -1
  127. package/src/components/resources/renderers/CertificateRequestRenderer.tsx +0 -1
  128. package/src/components/resources/renderers/ChallengeRenderer.tsx +0 -1
  129. package/src/components/resources/renderers/ClusterComplianceReportRenderer.tsx +0 -1
  130. package/src/components/resources/renderers/ClusterExternalSecretRenderer.tsx +0 -1
  131. package/src/components/resources/renderers/ClusterIssuerRenderer.tsx +0 -1
  132. package/src/components/resources/renderers/ConfigAuditReportRenderer.tsx +0 -1
  133. package/src/components/resources/renderers/ConfigMapRenderer.tsx +0 -1
  134. package/src/components/resources/renderers/CronJobRenderer.tsx +0 -1
  135. package/src/components/resources/renderers/CronWorkflowRenderer.tsx +0 -1
  136. package/src/components/resources/renderers/EventRenderer.tsx +0 -1
  137. package/src/components/resources/renderers/ExposedSecretReportRenderer.tsx +0 -1
  138. package/src/components/resources/renderers/ExternalSecretRenderer.tsx +0 -1
  139. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +0 -1
  140. package/src/components/resources/renderers/GRPCRouteRenderer.tsx +0 -1
  141. package/src/components/resources/renderers/GatewayClassRenderer.tsx +0 -1
  142. package/src/components/resources/renderers/GatewayRenderer.tsx +0 -1
  143. package/src/components/resources/renderers/GenericRenderer.tsx +0 -1
  144. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +0 -1
  145. package/src/components/resources/renderers/HTTPRouteRenderer.tsx +0 -1
  146. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +0 -1
  147. package/src/components/resources/renderers/IngressClassRenderer.tsx +0 -1
  148. package/src/components/resources/renderers/IngressRenderer.tsx +0 -1
  149. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +0 -1
  150. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +0 -1
  151. package/src/components/resources/renderers/IstioGatewayRenderer.tsx +0 -1
  152. package/src/components/resources/renderers/IstioPeerAuthenticationRenderer.tsx +0 -1
  153. package/src/components/resources/renderers/IstioServiceEntryRenderer.tsx +0 -1
  154. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +0 -1
  155. package/src/components/resources/renderers/JobRenderer.tsx +0 -1
  156. package/src/components/resources/renderers/KarpenterEC2NodeClassRenderer.tsx +0 -1
  157. package/src/components/resources/renderers/KarpenterNodeClaimRenderer.tsx +0 -1
  158. package/src/components/resources/renderers/KedaScaledJobRenderer.tsx +0 -1
  159. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +0 -1
  160. package/src/components/resources/renderers/KedaTriggerAuthRenderer.tsx +0 -1
  161. package/src/components/resources/renderers/KnativeConfigurationRenderer.tsx +0 -1
  162. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +0 -1
  163. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +0 -1
  164. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +0 -1
  165. package/src/components/resources/renderers/KnativeRevisionRenderer.tsx +0 -1
  166. package/src/components/resources/renderers/KnativeRouteRenderer.tsx +0 -1
  167. package/src/components/resources/renderers/KnativeServiceRenderer.tsx +0 -1
  168. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +0 -1
  169. package/src/components/resources/renderers/KustomizationRenderer.tsx +0 -1
  170. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +0 -1
  171. package/src/components/resources/renderers/LeaseRenderer.tsx +0 -1
  172. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +0 -1
  173. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +0 -1
  174. package/src/components/resources/renderers/OrderRenderer.tsx +0 -1
  175. package/src/components/resources/renderers/PersistentVolumeRenderer.tsx +0 -1
  176. package/src/components/resources/renderers/PodDisruptionBudgetRenderer.tsx +0 -1
  177. package/src/components/resources/renderers/PodMonitorRenderer.tsx +0 -1
  178. package/src/components/resources/renderers/PriorityClassRenderer.tsx +0 -1
  179. package/src/components/resources/renderers/PrometheusRuleRenderer.tsx +0 -1
  180. package/src/components/resources/renderers/ReplicaSetRenderer.tsx +0 -1
  181. package/src/components/resources/renderers/RuntimeClassRenderer.tsx +0 -1
  182. package/src/components/resources/renderers/SbomReportRenderer.tsx +0 -1
  183. package/src/components/resources/renderers/SealedSecretRenderer.tsx +0 -1
  184. package/src/components/resources/renderers/SecretRenderer.tsx +0 -1
  185. package/src/components/resources/renderers/SecretStoreRenderer.tsx +0 -1
  186. package/src/components/resources/renderers/ServiceMonitorRenderer.tsx +0 -1
  187. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +0 -1
  188. package/src/components/resources/renderers/StorageClassRenderer.tsx +0 -1
  189. package/src/components/resources/renderers/TraefikIngressRouteRenderer.tsx +0 -1
  190. package/src/components/resources/renderers/VPARenderer.tsx +0 -1
  191. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +0 -1
  192. package/src/components/resources/renderers/VeleroVSLRenderer.tsx +0 -1
  193. package/src/components/resources/renderers/VulnerabilityReportRenderer.tsx +0 -1
  194. package/src/components/resources/renderers/WebhookConfigRenderer.tsx +0 -1
  195. package/src/components/resources/renderers/WorkflowRenderer.tsx +0 -1
  196. package/src/components/resources/renderers/WorkflowTemplateRenderer.tsx +0 -1
  197. package/src/components/resources/renderers/argo-cells.tsx +0 -1
  198. package/src/components/resources/renderers/certmanager-cells.tsx +0 -1
  199. package/src/components/resources/renderers/cnpg-cells.tsx +0 -1
  200. package/src/components/resources/renderers/eso-cells.tsx +0 -1
  201. package/src/components/resources/renderers/flux-cells.tsx +0 -1
  202. package/src/components/resources/renderers/index.ts +0 -94
  203. package/src/components/resources/renderers/istio-cells.tsx +0 -1
  204. package/src/components/resources/renderers/karpenter-cells.tsx +0 -1
  205. package/src/components/resources/renderers/keda-cells.tsx +0 -1
  206. package/src/components/resources/renderers/knative-cells.tsx +0 -1
  207. package/src/components/resources/renderers/kyverno-cells.tsx +0 -1
  208. package/src/components/resources/renderers/prometheus-cells.tsx +0 -1
  209. package/src/components/resources/renderers/traefik-cells.tsx +0 -1
  210. package/src/components/resources/renderers/trivy-cells.tsx +0 -1
  211. package/src/components/resources/renderers/trivy-shared.tsx +0 -1
  212. package/src/components/resources/renderers/velero-cells.tsx +0 -1
  213. package/src/components/resources/resource-utils-argo.ts +0 -2
  214. package/src/components/resources/resource-utils-certmanager.ts +0 -2
  215. package/src/components/resources/resource-utils-eso.ts +0 -2
  216. package/src/components/resources/resource-utils-flux.ts +0 -2
  217. package/src/components/resources/resource-utils-istio.ts +0 -2
  218. package/src/components/resources/resource-utils-karpenter.ts +0 -2
  219. package/src/components/resources/resource-utils-knative.ts +0 -2
  220. package/src/components/resources/resource-utils-kyverno.ts +0 -2
  221. package/src/components/resources/resource-utils-prometheus.ts +0 -2
  222. package/src/components/resources/resource-utils-traefik.ts +0 -1
  223. package/src/components/resources/resource-utils-trivy.ts +0 -2
  224. package/src/components/resources/resource-utils-velero.ts +0 -2
  225. package/src/components/shared/EditableYamlView.tsx +0 -24
  226. package/src/components/timeline/DiffViewer.tsx +0 -1
  227. package/src/components/traffic/index.ts +0 -3
  228. package/src/components/ui/ForceDeleteConfirmDialog.tsx +0 -1
  229. package/src/components/ui/MetricsChart.tsx +0 -1
  230. 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,31 @@ 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
+ // Preflight only: what would unblock it, and the Helm operation that was
1917
+ // dry-run — the plan card never renders when preflight blocks, so the
1918
+ // blocked card states what Radar tried itself.
1919
+ cause?: 'permissions' | 'cluster' | 'verification'
1920
+ attempted?: CloudInstallAttempted
1839
1921
  message: string
1840
1922
  blocking?: string[]
1841
1923
  }
@@ -1890,13 +1972,23 @@ export interface CloudConnectInfo {
1890
1972
  freeTier?: string
1891
1973
  }
1892
1974
 
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) {
1975
+ // Deliberately a bare cross-origin GET: no credentials, no identifiers. The
1976
+ // Hub learns what any HTTP request reveals plus `about`, below: the lane the
1977
+ // footer renders and this deployment's mode, both closed enums, so nothing
1978
+ // about the cluster or the person rides along. Part of the query key so a
1979
+ // lane change (a driver install that just became tunneled, say) refetches
1980
+ // instead of reusing the other lane's copy.
1981
+ export function useCloudConnectInfo(
1982
+ apiUrl: string | undefined,
1983
+ enabled: boolean,
1984
+ about: { lane: "driver" | "wizard"; mode?: DeploymentMode },
1985
+ ) {
1986
+ const params = new URLSearchParams({ lane: about.lane });
1987
+ if (about.mode) params.set("mode", about.mode);
1896
1988
  return useQuery<CloudConnectInfo>({
1897
- queryKey: ["cloud-connect-info", apiUrl],
1989
+ queryKey: ["cloud-connect-info", apiUrl, about.lane, about.mode],
1898
1990
  queryFn: async () => {
1899
- const res = await fetch(`${apiUrl}/api/connect/info`, {
1991
+ const res = await fetch(`${apiUrl}/api/connect/info?${params}`, {
1900
1992
  credentials: "omit",
1901
1993
  signal: AbortSignal.timeout(4000),
1902
1994
  });
@@ -2352,6 +2444,15 @@ export function useResource<T>(
2352
2444
  queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
2353
2445
  enabled: (options?.enabled ?? true) && Boolean(kind && name), // namespace can be empty for cluster-scoped resources
2354
2446
  refetchInterval: options?.refetchInterval,
2447
+ // Kind still completing its initial sync: stay in loading and poll until
2448
+ // it becomes readable instead of erroring out (deep links during startup).
2449
+ retry: (failureCount, error) => {
2450
+ if (isStillLoadingError(error)) return true;
2451
+ if (isKindSyncFailed(error)) return false;
2452
+ return failureCount < 1; // matches the QueryClient default (retry: 1)
2453
+ },
2454
+ retryDelay: (failureCount, error) =>
2455
+ isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
2355
2456
  });
2356
2457
 
2357
2458
  // Extract resource and relationships from the response
@@ -2375,6 +2476,15 @@ export function useResourceWithRelationships<T>(
2375
2476
  queryKey: ["resource", kind, namespace, name, group],
2376
2477
  queryFn: () => fetchResourceWithRelationships<T>(kind, namespace, name, group),
2377
2478
  enabled: Boolean(kind && name),
2479
+ // Deep-linked detail views can mount while the kind's informer is still
2480
+ // completing its initial sync: keep polling instead of erroring out.
2481
+ retry: (failureCount, error) => {
2482
+ if (isStillLoadingError(error)) return true;
2483
+ if (isKindSyncFailed(error)) return false;
2484
+ return failureCount < 1; // matches the QueryClient default (retry: 1)
2485
+ },
2486
+ retryDelay: (failureCount, error) =>
2487
+ isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
2378
2488
  });
2379
2489
  }
2380
2490
 
@@ -2397,6 +2507,16 @@ export function useResources<T>(
2397
2507
  enabled: (options?.enabled ?? true) && Boolean(kind),
2398
2508
  staleTime: 30000, // 30 seconds - matches refetchInterval in ResourcesView
2399
2509
  refetchInterval: options?.refetchInterval,
2510
+ // Kind still completing its initial sync (progressive shell, or a
2511
+ // deferred kind shortly after connect): keep polling instead of
2512
+ // surfacing an error.
2513
+ retry: (failureCount, error) => {
2514
+ if (isStillLoadingError(error)) return true;
2515
+ if (isKindSyncFailed(error)) return false;
2516
+ return failureCount < 1; // matches the QueryClient default (retry: 1)
2517
+ },
2518
+ retryDelay: (failureCount, error) =>
2519
+ isStillLoadingError(error) ? 2000 : Math.min(1000 * 2 ** failureCount, 30000),
2400
2520
  });
2401
2521
  }
2402
2522
 
@@ -3067,6 +3187,8 @@ export function useTopNodeMetrics(options?: { enabled?: boolean }) {
3067
3187
  export interface PrometheusStatus {
3068
3188
  available: boolean;
3069
3189
  connected: boolean;
3190
+ /** A discovery run is in flight for the current configuration. Never true alongside connected. */
3191
+ discovering?: boolean;
3070
3192
  address?: string;
3071
3193
  service?: {
3072
3194
  namespace: string;
@@ -3105,6 +3227,10 @@ export interface PrometheusResourceMetrics {
3105
3227
  result: PrometheusQueryResult;
3106
3228
  query?: string; // PromQL query (included when result is empty, for diagnostics)
3107
3229
  hint?: string; // Contextual hint when results are empty (e.g. cri-docker label issues)
3230
+ // Workload kinds only: the pods the chart covers, established by
3231
+ // controller ownership. podsTotal is the full set when the cap cut the list.
3232
+ pods?: number;
3233
+ podsTotal?: number;
3108
3234
  }
3109
3235
 
3110
3236
  export type PrometheusMetricCategory =
@@ -3169,6 +3295,8 @@ export interface RightsizingRow {
3169
3295
  currentPodOOM?: boolean;
3170
3296
  windowOomEvidence?: boolean;
3171
3297
  oomEvidenceAvailable: boolean;
3298
+ /** The cluster cache would not list the workload's pods, so currentPodOOM is unknown rather than false. */
3299
+ liveInventoryUnavailable?: boolean;
3172
3300
  limitConflict?: boolean;
3173
3301
  queryError?: string;
3174
3302
  }
@@ -3218,13 +3346,32 @@ export interface RightsizingScanResponse {
3218
3346
  reason?: string;
3219
3347
  }
3220
3348
 
3349
+ // Poll quickly while a discovery run is in flight so the flip to connected
3350
+ // (or to a failure message) lands promptly; otherwise the slow cadence. A
3351
+ // status that is neither connected nor carrying an outcome was read before
3352
+ // the server's run started (boot, context switch) — every run ends in one or
3353
+ // the other, so that state is transient and gets the fast cadence too.
3354
+ export const PROM_STATUS_POLL_DISCOVERING_MS = 2000;
3355
+ export const PROM_STATUS_POLL_IDLE_MS = 60000;
3356
+
3357
+ export function prometheusStatusRefetchInterval(
3358
+ status: PrometheusStatus | undefined,
3359
+ ): number {
3360
+ if (!status) return PROM_STATUS_POLL_IDLE_MS;
3361
+ const settled = status.connected || !!status.error;
3362
+ return status.discovering || !settled
3363
+ ? PROM_STATUS_POLL_DISCOVERING_MS
3364
+ : PROM_STATUS_POLL_IDLE_MS;
3365
+ }
3366
+
3221
3367
  // Check Prometheus availability
3222
3368
  export function usePrometheusStatus() {
3223
3369
  return useQuery<PrometheusStatus>({
3224
3370
  queryKey: ["prometheus-status"],
3225
3371
  queryFn: () => fetchJSON("/prometheus/status"),
3226
3372
  staleTime: 30000,
3227
- refetchInterval: 60000,
3373
+ refetchInterval: (query) =>
3374
+ prometheusStatusRefetchInterval(query.state.data),
3228
3375
  });
3229
3376
  }
3230
3377
 
@@ -3233,6 +3380,9 @@ export interface ArgoStatus {
3233
3380
  // client is live. The two differ right after a restart (configured, reconnecting).
3234
3381
  configured: boolean;
3235
3382
  connected: boolean;
3383
+ // Connected without a token: the install serves reads to everyone, so
3384
+ // there is nothing to add in Settings.
3385
+ anonymous?: boolean;
3236
3386
  address?: string;
3237
3387
  reason?: string;
3238
3388
  }
@@ -3339,6 +3489,12 @@ export function useAutoPromConnect(): void {
3339
3489
  // initial UI render isn't competing with the cluster network call.
3340
3490
  const delay = cached === "1" ? 0 : PROM_FIRSTLAUNCH_PROBE_DELAY_MS;
3341
3491
  const timeout = window.setTimeout(() => {
3492
+ // The server only reports discovering once the run has started; mark it
3493
+ // here first so the view shows progress instead of the manual CTA for the
3494
+ // whole duration of this request.
3495
+ queryClient.setQueryData<PrometheusStatus>(["prometheus-status"], (prev) =>
3496
+ prev ? { ...prev, discovering: true } : prev,
3497
+ );
3342
3498
  // Direct apiFetch (not via the usePrometheusConnect mutation) so the
3343
3499
  // meta-driven toast handler stays silent — the user didn't click anything.
3344
3500
  apiFetch(`${getApiBase()}/prometheus/connect?optional=true`, {
@@ -3353,6 +3509,14 @@ export function useAutoPromConnect(): void {
3353
3509
  queryClient.invalidateQueries({ queryKey: ["prometheus-status"] });
3354
3510
  })
3355
3511
  .catch(() => {
3512
+ // Undo the optimistic flag, then ask the server: if it's reachable
3513
+ // the authoritative status (possibly a still-running discovery)
3514
+ // replaces this within one fetch; if it isn't, the CTA is right.
3515
+ queryClient.setQueryData<PrometheusStatus>(
3516
+ ["prometheus-status"],
3517
+ (prev) => (prev?.discovering ? { ...prev, discovering: false } : prev),
3518
+ );
3519
+ queryClient.invalidateQueries({ queryKey: ["prometheus-status"] });
3356
3520
  try {
3357
3521
  window.localStorage.removeItem(promAutoConnectKey(context));
3358
3522
  } catch {
@@ -4730,6 +4894,31 @@ export function useRolloutCapabilities(
4730
4894
  });
4731
4895
  }
4732
4896
 
4897
+ export interface AnalysisRunSummary {
4898
+ name: string;
4899
+ phase: string;
4900
+ message?: string;
4901
+ trigger?: string;
4902
+ stepIndex?: number;
4903
+ createdAt: string;
4904
+ metricsTotal: number;
4905
+ metricsPassing: number;
4906
+ metricsNotPassing: number;
4907
+ }
4908
+
4909
+ export function useRolloutAnalysisRuns(
4910
+ namespace: string,
4911
+ name: string,
4912
+ enabled = true,
4913
+ ) {
4914
+ return useQuery<{ items: AnalysisRunSummary[] }>({
4915
+ queryKey: ["rollout-analysisruns", namespace, name],
4916
+ queryFn: () => fetchJSON(`/rollouts/${namespace}/${name}/analysisruns`),
4917
+ enabled: Boolean(namespace && name && enabled),
4918
+ staleTime: 10000,
4919
+ });
4920
+ }
4921
+
4733
4922
  // Fallbacks only. The server reports what it actually did — including when it found
4734
4923
  // nothing to do — so its message is preferred over anything asserted here.
4735
4924
  const ROLLOUT_ACTION_MESSAGES: Record<
@@ -4894,6 +5083,75 @@ export interface DrainNodeOptions {
4894
5083
  force?: boolean;
4895
5084
  }
4896
5085
 
5086
+ export interface DrainPlanRequestOptions {
5087
+ deleteEmptyDirData: boolean;
5088
+ force: boolean;
5089
+ }
5090
+
5091
+ /** Path of the read-only plan endpoint; distinct from the drain so a preview can never drain. */
5092
+ export function drainPlanPath(name: string): string {
5093
+ return `/nodes/${encodeURIComponent(name)}/drain-plan`;
5094
+ }
5095
+
5096
+ /** Body for the plan request: both options are always explicit, the server echoes them back. */
5097
+ export function drainPlanBody(options: DrainPlanRequestOptions): string {
5098
+ return JSON.stringify({
5099
+ deleteEmptyDirData: options.deleteEmptyDirData,
5100
+ force: options.force,
5101
+ });
5102
+ }
5103
+
5104
+ /**
5105
+ * The connected radar predates the drain-plan endpoint. Distinguished from a
5106
+ * node-not-found 404 by the body: handlers answer with a JSON error envelope,
5107
+ * while an unknown route gets the router's plain-text 404. Hosts serving a newer
5108
+ * frontend against an older radar (Radar Hub) use this to fall back to the
5109
+ * plan-less drain dialog instead of leaving Drain permanently disabled.
5110
+ */
5111
+ export class DrainPlanUnsupportedError extends Error {
5112
+ constructor() {
5113
+ super("This radar does not support drain plans");
5114
+ this.name = "DrainPlanUnsupportedError";
5115
+ }
5116
+ }
5117
+
5118
+ export function drainPlanFetchError(
5119
+ status: number,
5120
+ body: { error?: string } | null,
5121
+ ): Error {
5122
+ if (status === 404 && body === null) {
5123
+ return new DrainPlanUnsupportedError();
5124
+ }
5125
+ return new Error(body?.error || `HTTP ${status}`);
5126
+ }
5127
+
5128
+ // Read-only drain plan: what a drain with these options would do to each pod on the node.
5129
+ // Modelled as a mutation because it is a POST with a body and is fetched on demand
5130
+ // while the drain dialog is open; it performs no cluster mutation.
5131
+ export function useDrainPlan() {
5132
+ return useMutation<
5133
+ DrainPlan,
5134
+ Error,
5135
+ { name: string; options: DrainPlanRequestOptions }
5136
+ >({
5137
+ mutationFn: async ({ name, options }) => {
5138
+ const response = await apiFetch(apiUrl(drainPlanPath(name)), {
5139
+ method: "POST",
5140
+ headers: { "Content-Type": "application/json" },
5141
+ body: drainPlanBody(options),
5142
+ });
5143
+ if (!response.ok) {
5144
+ const body = await response.json().catch(() => null);
5145
+ throw drainPlanFetchError(response.status, body);
5146
+ }
5147
+ return response.json();
5148
+ },
5149
+ // Deliberately no meta toast keys: the plan is fetched only from the drain dialog, which
5150
+ // renders a failure inline and keeps Drain disabled while it shows; a global toast would
5151
+ // report the same failure twice.
5152
+ });
5153
+ }
5154
+
4897
5155
  export function useDrainNode() {
4898
5156
  const queryClient = useQueryClient();
4899
5157
 
@@ -4923,7 +5181,11 @@ export function useDrainNode() {
4923
5181
  // No static successMessage — handled in onSuccess to distinguish partial failures
4924
5182
  },
4925
5183
  onSuccess: (
4926
- data: { evictedPods?: string[]; errors?: string[] },
5184
+ data: {
5185
+ evictedPods?: string[];
5186
+ skippedPods?: DrainPlanPod[];
5187
+ errors?: string[];
5188
+ },
4927
5189
  variables,
4928
5190
  ) => {
4929
5191
  queryClient.invalidateQueries({ queryKey: ["resources", "nodes"] });
@@ -4932,20 +5194,67 @@ export function useDrainNode() {
4932
5194
  });
4933
5195
  queryClient.invalidateQueries({ queryKey: ["topology"] });
4934
5196
 
4935
- const evicted = data?.evictedPods?.length ?? 0;
4936
- const errors = data?.errors?.length ?? 0;
4937
- if (errors > 0) {
4938
- showApiError(
4939
- `Drain completed with ${errors} error(s)`,
4940
- `${evicted} pods evicted. Errors: ${data.errors!.join("; ")}`,
4941
- );
5197
+ const { title, detail, failed } = describeDrainResult(data);
5198
+ if (failed) {
5199
+ showApiError(title, detail);
4942
5200
  } else {
4943
- showApiSuccess(`Node drained: ${evicted} pods evicted`);
5201
+ showApiSuccess(title, detail);
4944
5202
  }
4945
5203
  },
4946
5204
  });
4947
5205
  }
4948
5206
 
5207
+ const DRAIN_RESULT_MAX_LISTED = 5;
5208
+
5209
+ function listWithOverflow(items: string[]): string {
5210
+ const shown = items.slice(0, DRAIN_RESULT_MAX_LISTED);
5211
+ const rest = items.length - shown.length;
5212
+ return rest > 0 ? `${shown.join("; ")}; +${rest} more` : shown.join("; ");
5213
+ }
5214
+
5215
+ /**
5216
+ * Turns the drain response into a legible summary: evicted, skipped (with reasons),
5217
+ * failed. Lists are capped so the toast stays readable on nodes with many pods.
5218
+ */
5219
+ export function describeDrainResult(data: {
5220
+ evictedPods?: string[];
5221
+ skippedPods?: DrainPlanPod[];
5222
+ errors?: string[];
5223
+ }): { title: string; detail: string; failed: boolean } {
5224
+ const evicted = data?.evictedPods?.length ?? 0;
5225
+ const skipped = data?.skippedPods ?? [];
5226
+ const errors = data?.errors ?? [];
5227
+ const parts: string[] = [];
5228
+ if (skipped.length > 0) {
5229
+ parts.push(
5230
+ `Skipped ${skipped.length}: ${listWithOverflow(
5231
+ skipped.map((p) => `${p.namespace}/${p.name} (${p.reason})`),
5232
+ )}`,
5233
+ );
5234
+ }
5235
+ if (errors.length > 0) {
5236
+ parts.push(`Failed ${errors.length}: ${listWithOverflow(errors)}`);
5237
+ }
5238
+ parts.push(
5239
+ evicted > 0
5240
+ ? "Evictions were accepted; those pods may still be terminating. The node remains cordoned."
5241
+ : "The node remains cordoned.",
5242
+ );
5243
+ const detail = parts.join("\n");
5244
+ if (errors.length > 0) {
5245
+ return {
5246
+ title: `Drain finished with ${errors.length} failed eviction(s): ${evicted} evicted, ${skipped.length} skipped`,
5247
+ detail,
5248
+ failed: true,
5249
+ };
5250
+ }
5251
+ return {
5252
+ title: `Node drained: ${evicted} evicted, ${skipped.length} skipped`,
5253
+ detail,
5254
+ failed: false,
5255
+ };
5256
+ }
5257
+
4949
5258
  // ============================================================================
4950
5259
  // Helm API hooks
4951
5260
  // ============================================================================
@@ -52,11 +52,60 @@ export interface RootCauseEvidence {
52
52
  refs?: string[];
53
53
  }
54
54
 
55
+ /**
56
+ * How the agent frames one cited Radar result. Roles order cards; they never
57
+ * hide one.
58
+ *
59
+ * One half of a Go↔TS contract: `evidenceRoles` in internal/ai/parse.go and
60
+ * EVIDENCE_ROLES in components/diagnose/investigationCase.ts must list exactly
61
+ * these roles. Change all three together.
62
+ */
63
+ export type DiagnosisEvidenceRole =
64
+ "cause" | "symptom" | "context" | "benign" | "demoted" | "rules_out";
65
+
66
+ /**
67
+ * Which observation inside one tool result a claim is about. Agent text
68
+ * copied verbatim by the server; the UI resolves it against captured evidence
69
+ * and places the claim only on an exact, unique match.
70
+ */
71
+ export interface DiagnosisEvidenceSubject {
72
+ group?: string;
73
+ kind: string;
74
+ namespace?: string;
75
+ name: string;
76
+ container?: string;
77
+ stream?: "current" | "previous";
78
+ /** Evidence kind (resource, logs, events, changes, metrics, …). */
79
+ observation?: string;
80
+ }
81
+
82
+ /** One server-bound item of the agent's case. Unlinked items are never rendered. */
83
+ export interface DiagnosisEvidenceItem {
84
+ status: "linked" | "unlinked";
85
+ ref?: string;
86
+ role?: DiagnosisEvidenceRole;
87
+ claim?: string;
88
+ subject?: DiagnosisEvidenceSubject;
89
+ }
90
+
91
+ export interface DiagnosisRuledOut {
92
+ hypothesis: string;
93
+ /** Index into Diagnosis.evidence of the item whose result contradicted it. */
94
+ evidenceIndex: number;
95
+ }
96
+
55
97
  export interface Diagnosis {
56
98
  healthy?: boolean;
57
99
  inconclusive?: boolean; // investigated but couldn't determine — distinct from healthy
58
100
  rootCause: string;
59
101
  rootCauseEvidence?: RootCauseEvidence;
102
+ /** The agent's case over Radar's evidence; absent from hosted backends and older runs. */
103
+ evidence?: DiagnosisEvidenceItem[];
104
+ /** How many of the agent's evidence entries never reached the UI, including ones cut before they got a slot in `evidence`. */
105
+ unlinkedEvidence?: number;
106
+ /** The agent's evidence field was not a list, so none of it could be read. */
107
+ evidenceMalformed?: boolean;
108
+ ruledOut?: DiagnosisRuledOut[];
60
109
  report: string;
61
110
  remediation: string[];
62
111
  recommendedIndex?: number; // 1-based index into remediation of the step Apply performs
@@ -87,6 +136,13 @@ export interface ResourceHealthSignal {
87
136
 
88
137
  export type ApplyMutationOutcome = "confirmed" | "failed" | "unknown";
89
138
 
139
+ // One MCP server's connection state as the agent CLI reported it at startup.
140
+ // Only "connected" means its tools are usable.
141
+ export interface MCPServerStatus {
142
+ name: string;
143
+ status: string;
144
+ }
145
+
90
146
  export interface DiagnoseStreamEvent {
91
147
  type:
92
148
  | "turn"
@@ -98,7 +154,11 @@ export interface DiagnoseStreamEvent {
98
154
  | "closed"
99
155
  | "history_unavailable"
100
156
  | "replay_complete";
101
- phase?: string;
157
+ phase?: string; // "investigating" | "connected" | "ready"
158
+ // Startup facts the agent CLI reported on the "ready" phase.
159
+ model?: string;
160
+ toolCount?: number; // Radar tools the agent registered
161
+ mcpServers?: MCPServerStatus[];
102
162
  step?: DiagnoseStep;
103
163
  token?: string;
104
164
  diagnosis?: Diagnosis;
@@ -123,6 +183,7 @@ export interface DiagnoseStreamEvent {
123
183
  // server runs.
124
184
  export interface RunSummary {
125
185
  id: string;
186
+ /** Stored target Kind; existing hosted runs may contain a plural API resource name. */
126
187
  kind: string;
127
188
  /** Kubernetes API group; empty means the core API group. */
128
189
  group: string;
@@ -255,20 +316,6 @@ export async function getRun(
255
316
  return res.json();
256
317
  }
257
318
 
258
- export async function updateRunVisibility(
259
- id: string,
260
- visibility: "private" | "organization",
261
- ): Promise<RunSummary> {
262
- const res = await fetch(`${RUNS()}/${encodeURIComponent(id)}`, {
263
- method: "PATCH",
264
- credentials: getCredentialsMode(),
265
- headers: { "Content-Type": "application/json", ...getAuthHeaders() },
266
- body: JSON.stringify({ visibility }),
267
- });
268
- if (!res.ok) throw new DiagnoseError(res.status, await errorText(res));
269
- return res.json();
270
- }
271
-
272
319
  // recordConsent acknowledges the current disclosure for an execution profile, server-side.
273
320
  export async function recordConsent(surface: string): Promise<void> {
274
321
  const res = await fetch(`${getApiBase()}/diagnose/consent`, {
@@ -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
+ });