@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
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import type { WorkloadMetrics } from './workloadMetrics'
3
+
4
+ vi.mock('@tanstack/react-query', () => ({ useQuery: (options: unknown) => options }))
5
+ vi.mock('./client', () => ({
6
+ useClusterInfo: () => ({ data: { context: 'cluster-a' } }),
7
+ fetchJSON: vi.fn(),
8
+ ApiError: class extends Error {},
9
+ }))
10
+ vi.mock('./config', () => ({ getApiBase: () => '/api' }))
11
+ const { useWorkloadMetrics: workloadQueryOptions } = await import('./workloadMetrics')
12
+
13
+ describe('workload source-switch placeholders', () => {
14
+ const options = () => workloadQueryOptions('Deployment', 'shop', 'api', '1h', 'istio', true) as unknown as {
15
+ queryKey: unknown[]
16
+ placeholderData: (data: WorkloadMetrics, query: { queryKey: unknown[] }) => WorkloadMetrics | undefined
17
+ refetchInterval: (query: { state: { data: WorkloadMetrics } }) => number
18
+ }
19
+ const data: WorkloadMetrics = {
20
+ state: 'available', sources: [], pods: 1, podsTotal: 1,
21
+ history: {}, comparison: {},
22
+ start: 0, end: 60, stepSeconds: 60, rateWindowSeconds: 300, panels: {},
23
+ }
24
+ it('keeps the previous payload when only the observer changes', () => {
25
+ const query = options()
26
+ const key = [...query.queryKey]
27
+ key[key.length - 1] = 'beyla'
28
+ expect(query.placeholderData(data, { queryKey: key })).toBe(data)
29
+ })
30
+ it('polls pending current attribution even while history is available', () => {
31
+ expect(options().refetchInterval({ state: { data } })).toBe(30_000)
32
+ expect(options().refetchInterval({ state: { data: { ...data, comparison: { cpu: { state: 'detecting', unit: 'cores', series: [] } } } } })).toBe(3_000)
33
+ })
34
+ it.each([1, 2, 3, 4, 5, 6])('drops the previous payload when identity field %s changes', (index) => {
35
+ const query = options()
36
+ const key = [...query.queryKey]
37
+ key[index] = 'different'
38
+ expect(query.placeholderData(data, { queryKey: key })).toBeUndefined()
39
+ })
40
+ })
@@ -0,0 +1,68 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { TimeSeries } from "@skyhook-io/k8s-ui/components/charts";
3
+ import { ApiError, fetchJSON, useClusterInfo, type PrometheusTimeRange } from "./client";
4
+ import { getApiBase } from "./config";
5
+
6
+ export type WorkloadMetricState =
7
+ "available" | "partial" | "stale" | "unavailable" | "error" | "detecting";
8
+ export type WorkloadRequestSource = "beyla" | "istio";
9
+
10
+ export interface WorkloadMetricPanel {
11
+ state: WorkloadMetricState;
12
+ reason?: string;
13
+ unit: string;
14
+ series: TimeSeries[];
15
+ }
16
+
17
+ export interface WorkloadMetrics {
18
+ history: Partial<Record<"cpu" | "memory" | "throttling" | "requests", { mode: "workload-history" | "current-pods" | "unavailable"; reason?: string }>>;
19
+ comparison: Partial<Record<"cpu" | "memory" | "throttling", WorkloadMetricPanel>>;
20
+ scopeNotice?: string;
21
+ attribution?: Record<string, string>;
22
+ state: WorkloadMetricState;
23
+ reason?: string;
24
+ source?: WorkloadRequestSource;
25
+ sources: {
26
+ id: WorkloadRequestSource;
27
+ label: string;
28
+ state: WorkloadMetricState;
29
+ }[];
30
+ pods: number;
31
+ podsTotal: number;
32
+ end: number;
33
+ start: number;
34
+ stepSeconds: number;
35
+ rateWindowSeconds: number;
36
+ panels: Partial<
37
+ Record<
38
+ "requests" | "errors" | "p50" | "p95" | "throttling" | "cpu" | "memory" | "observedPods",
39
+ WorkloadMetricPanel
40
+ >
41
+ >;
42
+ }
43
+
44
+ export function useWorkloadMetrics(
45
+ kind: string,
46
+ namespace: string,
47
+ name: string,
48
+ range: PrometheusTimeRange,
49
+ source: WorkloadRequestSource | "",
50
+ enabled: boolean,
51
+ ) {
52
+ const { data: cluster } = useClusterInfo();
53
+ const identity = ["workload-metrics", getApiBase(), cluster?.context, kind, namespace, name, range];
54
+ return useQuery<WorkloadMetrics>({
55
+ queryKey: [...identity, source],
56
+ placeholderData: (previous, previousQuery) =>
57
+ identity.every((value, index) => previousQuery?.queryKey[index] === value) ? previous : undefined,
58
+ queryFn: ({ signal }) =>
59
+ fetchJSON<WorkloadMetrics>(
60
+ `/prometheus/workload/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}?${new URLSearchParams({ range, source })}`,
61
+ signal,
62
+ ),
63
+ enabled,
64
+ staleTime: 30_000,
65
+ refetchInterval: (query) => query.state.data?.state === "detecting" || Object.values(query.state.data?.comparison ?? {}).some((panel) => panel.state === "detecting") ? 3_000 : 30_000,
66
+ retry: (failureCount, error) => failureCount < 1 && error instanceof ApiError && error.status === 409,
67
+ });
68
+ }
@@ -1,6 +1,8 @@
1
- import { useRef, useState } from 'react'
1
+ import { type ReactNode, useEffect, useRef, useState } from 'react'
2
2
  import { useMutation } from '@tanstack/react-query'
3
- import { AlertTriangle, ArrowUpRight, Check, ExternalLink, GitBranch, Info, Loader2, ShieldAlert, X } from 'lucide-react'
3
+ import { AlertTriangle, ArrowUpRight, Check, Copy, ExternalLink, GitBranch, Info, Loader2, ShieldAlert, X } from 'lucide-react'
4
+ import { type AdminNoteContext, type BlockedExit, composeAdminNote, composeFailureNote, needsAdminHandoff } from './cloudConnectHandoff'
5
+ import { copyText } from '@skyhook-io/k8s-ui/utils/clipboard'
4
6
  import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Collapse'
5
7
  import {
6
8
  ApiError,
@@ -19,20 +21,23 @@ import { showApiError } from './ui/Toast'
19
21
  export function CloudConnectFlow({
20
22
  status,
21
23
  blocked,
22
- signupUrl,
24
+ exit,
25
+ where,
23
26
  onStatus,
24
27
  onExit,
25
28
  }: {
26
29
  status: CloudInstallStatus
27
30
  blocked: CloudInstallBlocked | null
28
- signupUrl: string
31
+ exit: BlockedExit
32
+ // Which cluster this is, for the note a blocked person hands to an admin.
33
+ where?: AdminNoteContext
29
34
  // Push a mutation's status response into the shared query state.
30
35
  onStatus: (st: CloudInstallStatus) => void
31
36
  // Leave the flow view (back to the pitch, or close after dismiss).
32
37
  onExit: () => void
33
38
  }) {
34
39
  if (blocked) {
35
- return <BlockedView blocked={blocked} signupUrl={signupUrl} onExit={onExit} />
40
+ return <BlockedView blocked={blocked} exit={exit} where={where} onExit={onExit} />
36
41
  }
37
42
 
38
43
  switch (status.state) {
@@ -58,7 +63,7 @@ export function CloudConnectFlow({
58
63
  case 'connected':
59
64
  return <ConnectedCard status={status} onStatus={onStatus} onExit={onExit} />
60
65
  case 'failed':
61
- return <FailedCard status={status} onStatus={onStatus} onExit={onExit} />
66
+ return <FailedCard status={status} where={where} onStatus={onStatus} onExit={onExit} />
62
67
  default:
63
68
  return null
64
69
  }
@@ -66,13 +71,21 @@ export function CloudConnectFlow({
66
71
 
67
72
  function BlockedView({
68
73
  blocked,
69
- signupUrl,
74
+ exit,
75
+ where,
70
76
  onExit,
71
77
  }: {
72
78
  blocked: CloudInstallBlocked
73
- signupUrl: string
79
+ exit: BlockedExit
80
+ where?: AdminNoteContext
74
81
  onExit: () => void
75
82
  }) {
83
+ const copy = blockedCopy(blocked, exit)
84
+ // The person who can act is usually not the one reading this card. What
85
+ // they need is an ask with the link and the evidence — not the card's
86
+ // second-person explanation — so the note is composed on its own; the
87
+ // CopyForAdmin action previews exactly what will be copied.
88
+ const note = composeAdminNote(blocked, exit, where)
76
89
  const icon =
77
90
  blocked.reason === 'gitops' ? (
78
91
  <GitBranch className="w-4 h-4 shrink-0 mt-0.5 text-emerald-600 dark:text-emerald-400" />
@@ -81,48 +94,34 @@ function BlockedView({
81
94
  ) : (
82
95
  <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5 text-amber-500" />
83
96
  )
84
- const title =
85
- blocked.reason === 'gitops'
86
- ? 'This install is managed by GitOps'
87
- : blocked.reason === 'preflight'
88
- ? 'Your Kubernetes identity can’t install this'
89
- : 'This cluster can’t be connected from here'
90
97
  return (
91
98
  <div className="px-8 pt-6 pb-5">
92
99
  <div className="card-inner-lg flex gap-2.5">
93
100
  {icon}
94
- <div className="min-w-0">
95
- <div className="text-[13px] font-semibold text-theme-text-primary">{title}</div>
96
- <p className="mt-1 text-[12px] leading-relaxed text-theme-text-secondary">{blocked.message}</p>
97
- {blocked.blocking && blocked.blocking.length > 0 && (
98
- <ul className="mt-2 space-y-1 text-[11.5px] text-theme-text-tertiary">
99
- {blocked.blocking.map((line) => (
100
- <li key={line} className="flex items-start gap-1.5">
101
- <span className="mt-[6px] w-1 h-1 rounded-full bg-amber-500 shrink-0" />
102
- {line}
103
- </li>
104
- ))}
105
- </ul>
106
- )}
101
+ <div className="min-w-0 space-y-3">
102
+ <div className="text-[13px] font-semibold text-theme-text-primary">{copy.title}</div>
103
+ <BlockedSection label="What Radar tried">{copy.tried}</BlockedSection>
104
+ <BlockedSection label="Why it stopped">
105
+ {copy.why}
106
+ {blocked.blocking && blocked.blocking.length > 0 && <BlockingLines lines={blocked.blocking} />}
107
+ </BlockedSection>
108
+ <BlockedSection label="What to do">{copy.next}</BlockedSection>
107
109
  </div>
108
110
  </div>
109
- <div className="mt-4 flex items-center gap-4">
110
- {/* Only a preflight denial has a legitimate browser alternative —
111
- someone with more Kubernetes permission can run the wizard. GitOps
112
- and unsupported refusals named a specific reason and target that a
113
- generic signup link cannot carry, so offering it would contradict
114
- the message directly above. */}
115
- {blocked.reason === 'preflight' && (
116
- <a
117
- href={signupUrl}
118
- target="_blank"
119
- rel="noopener noreferrer"
120
- className="text-[12.5px] font-semibold text-emerald-600 dark:text-emerald-400 hover:underline underline-offset-2"
121
- >
122
- Connect through the browser wizard instead →
123
- </a>
124
- )}
125
- <button onClick={onExit} className="text-[12.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors">
111
+ <div className="relative mt-4 flex items-center gap-4">
112
+ <a
113
+ href={exit.href}
114
+ target="_blank"
115
+ rel="noopener noreferrer"
116
+ className="px-5 py-2 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[13.5px] font-bold shadow-[0_0_22px_rgba(16,185,129,0.35)] hover:shadow-[0_0_30px_rgba(16,185,129,0.5)] hover:-translate-y-px transition-all"
117
+ >
118
+ {exit.label}
119
+ </a>
120
+ <CopyForAdmin note={note} />
121
+ <button
122
+ onClick={onExit}
123
+ className="ml-auto text-[12.5px] text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
124
+ >
126
125
  Back
127
126
  </button>
128
127
  </div>
@@ -130,6 +129,291 @@ function BlockedView({
130
129
  )
131
130
  }
132
131
 
132
+ // The person who can act is usually not the one reading a stop card. This
133
+ // hands them the composed note: a preview panel on hover or focus (card-wide,
134
+ // on the elevated surface, sized to the note — a block of text laid over the
135
+ // card, not a hint), and when the clipboard is refused the panel pins open
136
+ // with the note selected so copying is one keystroke.
137
+ function CopyForAdmin({ note }: { note: string }) {
138
+ const [copied, setCopied] = useState<'idle' | 'done' | 'failed'>('idle')
139
+ const [previewing, setPreviewing] = useState(false)
140
+ const [pinned, setPinned] = useState(false)
141
+ const noteRef = useRef<HTMLPreElement | null>(null)
142
+ const panelRef = useRef<HTMLDivElement | null>(null)
143
+ // The panel is anchored above the action row inside the dialog's scroll
144
+ // area. A hover panel taller than the room visible above the row is only
145
+ // partly on screen, and scrolling to see the rest moves it out from under
146
+ // the pointer and closes it. So the ceiling is measured — the row's
147
+ // distance from the visible top of the scroller — rather than guessed. A
148
+ // normal note fits and shows whole; only a longer one scrolls inside.
149
+ const [maxHeight, setMaxHeight] = useState<number>()
150
+ const shown = previewing || pinned
151
+ useEffect(() => {
152
+ if (!shown) return
153
+ const wrapper = panelRef.current
154
+ // The positioned ancestor the panel is anchored to — the action row.
155
+ const row = wrapper?.offsetParent as HTMLElement | null | undefined
156
+ const scroller = row?.closest<HTMLElement>('.overflow-y-auto')
157
+ if (!wrapper || !row || !scroller) return
158
+ const visibleRoom = row.getBoundingClientRect().top - scroller.getBoundingClientRect().top
159
+ const gap = wrapper.getBoundingClientRect().bottom - (wrapper.firstElementChild?.getBoundingClientRect().bottom ?? 0)
160
+ setMaxHeight(Math.max(96, Math.floor(visibleRoom - gap - 8)))
161
+ }, [shown])
162
+ // Leaving closes on a short delay that entering the panel cancels, so the
163
+ // pointer can cross the row's own space on its way up into the panel.
164
+ const closeTimer = useRef<number | undefined>(undefined)
165
+ const enter = () => {
166
+ window.clearTimeout(closeTimer.current)
167
+ setPreviewing(true)
168
+ }
169
+ const leave = () => {
170
+ window.clearTimeout(closeTimer.current)
171
+ closeTimer.current = window.setTimeout(() => setPreviewing(false), 200)
172
+ }
173
+ useEffect(() => () => window.clearTimeout(closeTimer.current), [])
174
+ const copy = () => {
175
+ // copyText also serves Radar on a plain-HTTP non-loopback address, where
176
+ // the async Clipboard API is missing and the legacy command still works.
177
+ void copyText(note).then((ok) => {
178
+ setCopied(ok ? 'done' : 'failed')
179
+ if (ok) setTimeout(() => setCopied('idle'), 2000)
180
+ else setPinned(true)
181
+ })
182
+ }
183
+ // The panel may not be mounted when the clipboard refuses (the pointer has
184
+ // left the button), so the selection waits for the pinned render.
185
+ useEffect(() => {
186
+ if (!pinned) return
187
+ const pre = noteRef.current
188
+ const selection = window.getSelection()
189
+ if (!pre || !selection) return
190
+ selection.removeAllRanges()
191
+ const range = document.createRange()
192
+ range.selectNodeContents(pre)
193
+ selection.addRange(range)
194
+ }, [pinned])
195
+ return (
196
+ // Hover is tracked on this wrapper, which contains the panel, and the
197
+ // gap between button and panel is padding rather than margin, so moving
198
+ // the pointer up into the panel (to scroll a long note) never counts as
199
+ // leaving.
200
+ <span className="contents" onMouseEnter={enter} onMouseLeave={leave}>
201
+ {shown && (
202
+ <div ref={panelRef} className="absolute inset-x-0 bottom-full z-20 pb-3">
203
+ <div
204
+ id="admin-note-preview"
205
+ role="tooltip"
206
+ style={maxHeight ? { maxHeight } : undefined}
207
+ className="flex flex-col rounded-xl border border-theme-border bg-theme-elevated p-4 shadow-theme-lg"
208
+ >
209
+ <div className="mb-1.5 flex items-center justify-between text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary">
210
+ <span>{pinned ? 'Select and copy' : 'What gets copied'}</span>
211
+ {pinned && (
212
+ <button
213
+ type="button"
214
+ onClick={() => {
215
+ setPinned(false)
216
+ setCopied('idle')
217
+ }}
218
+ aria-label="Close"
219
+ className="rounded p-0.5 text-theme-text-tertiary hover:text-theme-text-primary transition-colors"
220
+ >
221
+ <X className="w-3 h-3" />
222
+ </button>
223
+ )}
224
+ </div>
225
+ <pre
226
+ ref={noteRef}
227
+ className="min-h-0 select-text overflow-y-auto whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed text-theme-text-primary"
228
+ >
229
+ {note}
230
+ </pre>
231
+ </div>
232
+ </div>
233
+ )}
234
+ <button
235
+ type="button"
236
+ onClick={copy}
237
+ onFocus={() => setPreviewing(true)}
238
+ onBlur={() => setPreviewing(false)}
239
+ aria-describedby={shown ? 'admin-note-preview' : undefined}
240
+ className="inline-flex items-center gap-1.5 text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary transition-colors"
241
+ >
242
+ {copied === 'done' ? (
243
+ <>
244
+ <Check className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" /> Copied
245
+ </>
246
+ ) : copied === 'failed' ? (
247
+ 'Couldn’t copy — the note is selected above, press ⌘C / Ctrl+C'
248
+ ) : (
249
+ <>
250
+ <Copy className="w-3.5 h-3.5" /> Copy for a cluster admin
251
+ </>
252
+ )}
253
+ </button>
254
+ </span>
255
+ )
256
+ }
257
+
258
+ function BlockedSection({ label, children }: { label: string; children: ReactNode }) {
259
+ return (
260
+ <div>
261
+ <div className="text-[10.5px] font-semibold uppercase tracking-wide text-theme-text-tertiary">{label}</div>
262
+ <div className="mt-0.5 text-[12px] leading-relaxed text-theme-text-secondary">{children}</div>
263
+ </div>
264
+ )
265
+ }
266
+
267
+ function BlockingLines({ lines }: { lines: string[] }) {
268
+ return (
269
+ <ul className="mt-1.5 space-y-1 text-[11.5px] text-theme-text-tertiary">
270
+ {lines.map((line) => (
271
+ <li key={line} className="flex items-start gap-1.5">
272
+ <span className="mt-[6px] w-1 h-1 rounded-full bg-amber-500 shrink-0" />
273
+ {line}
274
+ </li>
275
+ ))}
276
+ </ul>
277
+ )
278
+ }
279
+
280
+ // The blocked card stands in for the plan card the person never saw, so it
281
+ // carries what Radar did first, then why it stopped, then the way forward.
282
+ // Three short labeled parts, not one paragraph. Every card ends the same way
283
+ // — an admin with cluster access, in Radar Cloud — and the exit says whether
284
+ // that is a deep-linked install command or Radar Cloud itself.
285
+ function blockedCopy(blocked: CloudInstallBlocked, exit: BlockedExit): { title: string; tried: ReactNode; why: ReactNode; next: ReactNode } {
286
+ const a = blocked.attempted
287
+ const release = a && (
288
+ <>
289
+ release <code className="font-mono text-[11px] text-theme-text-primary">{a.release}</code> in namespace{' '}
290
+ <code className="font-mono text-[11px] text-theme-text-primary">{a.namespace}</code>
291
+ </>
292
+ )
293
+ const operation = a?.mode === 'adopt' ? 'a Helm upgrade of your existing' : 'a fresh Helm install of'
294
+ // What Radar did is told by the stage it reached, not by the plan it had:
295
+ // a target can be known from discovery before any chart was rendered or
296
+ // dry-run, and the card must not claim work that did not happen.
297
+ const tried =
298
+ !a ? (
299
+ <>Radar was looking for an existing Radar install in this cluster, as your kubeconfig identity. Nothing was changed.</>
300
+ ) : a.mode === 'gitops' ? (
301
+ <>Radar found {release} and traced how it is managed before planning anything. Nothing was changed.</>
302
+ ) : a.stage === 'inspect' ? (
303
+ a.mode === 'adopt' ? (
304
+ <>
305
+ Radar found {release} and was reading its Helm state, as your kubeconfig identity, before planning anything.
306
+ Nothing was changed.
307
+ </>
308
+ ) : (
309
+ <>
310
+ Radar found no Radar running anywhere in this cluster and was checking Helm’s release records in namespace{' '}
311
+ <code className="font-mono text-[11px] text-theme-text-primary">{a.namespace}</code>, as your kubeconfig
312
+ identity, before planning anything. Nothing was changed.
313
+ </>
314
+ )
315
+ ) : a.stage === 'prepare' ? (
316
+ <>
317
+ Radar planned {operation} {release} with the Cloud connection enabled and was preparing the chart, as your
318
+ kubeconfig identity. Nothing was changed.
319
+ </>
320
+ ) : (
321
+ <>
322
+ Radar prepared {operation} {release} with the Cloud connection enabled, and dry-ran it against the cluster as
323
+ your kubeconfig identity. Nothing was changed.
324
+ </>
325
+ )
326
+
327
+ // Where the exit is an install command, Radar Cloud confirms the cluster name (prefilled from the context)
328
+ // first, then shows the instructions — say so, rather than promising a
329
+ // command on the next screen.
330
+ const installNext = (
331
+ <>
332
+ Have an admin with cluster access get the install command from Radar Cloud: it confirms the cluster name, then
333
+ shows the Helm, Argo CD or Flux instructions to review before running.
334
+ </>
335
+ )
336
+ const genericNext = (
337
+ <>
338
+ Have an admin with cluster access resolve the issue above, then connect — or recover — this cluster from Radar
339
+ Cloud.
340
+ </>
341
+ )
342
+ const unknownNext = (
343
+ <>
344
+ Have an admin with cluster access connect it from Radar Cloud.{' '}
345
+ {a?.partialScan ? (
346
+ <>
347
+ Radar could only check namespace{' '}
348
+ <code className="font-mono text-[11px] text-theme-text-primary">{a.namespace}</code> for an existing install,
349
+ so they should check the rest of the cluster first.
350
+ </>
351
+ ) : (
352
+ <>Radar couldn’t tell whether it is already installed here, so they should check first.</>
353
+ )}
354
+ </>
355
+ )
356
+
357
+ if (blocked.reason === 'gitops') {
358
+ return {
359
+ title: 'This install is managed by GitOps',
360
+ tried,
361
+ why: blocked.message,
362
+ next: exit.install ? (
363
+ <>
364
+ Have an admin with repo access get the values patch from Radar Cloud: it confirms the cluster name, then shows
365
+ the patch for your controller and the one command that creates the token Secret.
366
+ </>
367
+ ) : (
368
+ genericNext
369
+ ),
370
+ }
371
+ }
372
+ if (blocked.reason === 'unsupported') {
373
+ return { title: 'Radar can’t connect this cluster from here', tried, why: blocked.message, next: genericNext }
374
+ }
375
+ // Fresh offered on "nothing running" alone: say what was not confirmed.
376
+ const unconfirmedNext = (
377
+ <>
378
+ An admin with cluster access should confirm nothing is installed — Radar couldn’t read Helm’s release records —
379
+ then get the install command from Radar Cloud: it confirms the cluster name, then shows the Helm, Argo CD or Flux
380
+ instructions to review before running.
381
+ </>
382
+ )
383
+ const next = exit.install ? (a?.releaseUnread ? unconfirmedNext : installNext) : unknownNext
384
+ switch (blocked.cause) {
385
+ case 'permissions':
386
+ return {
387
+ title: 'Your Kubernetes identity can’t do this install',
388
+ tried,
389
+ why: 'Your credentials lack permissions this needs (below). Anyone with them can complete this exact install.',
390
+ next,
391
+ }
392
+ case 'verification':
393
+ return {
394
+ title: 'This version of Radar can’t install this chart version from here',
395
+ tried,
396
+ why: 'The chart renders something this Radar build can’t check before applying, so it won’t install it unseen. A limitation of this Radar, not of your cluster or your access.',
397
+ next,
398
+ }
399
+ default:
400
+ return {
401
+ title: 'The cluster blocked part of this install',
402
+ tried,
403
+ why: 'The cluster refused: something already there conflicts with the install, or a policy rejects it. More permission wouldn’t change that.',
404
+ next: exit.install ? (
405
+ <>
406
+ Once the refusals above are resolved, have an admin with cluster access get the install command from Radar
407
+ Cloud: it confirms the cluster name, then shows the Helm, Argo CD or Flux instructions to review before
408
+ running.
409
+ </>
410
+ ) : (
411
+ next
412
+ ),
413
+ }
414
+ }
415
+ }
416
+
133
417
  function PlanCard({
134
418
  status,
135
419
  onStatus,
@@ -348,7 +632,7 @@ function ConsentRow({
348
632
  checked: boolean
349
633
  onChange: (v: boolean) => void
350
634
  tone: 'emerald' | 'amber'
351
- children: React.ReactNode
635
+ children: ReactNode
352
636
  }) {
353
637
  return (
354
638
  <label className="mt-3 flex items-start gap-2 cursor-pointer">
@@ -507,16 +791,22 @@ function ConnectedCard({
507
791
 
508
792
  function FailedCard({
509
793
  status,
794
+ where,
510
795
  onStatus,
511
796
  onExit,
512
797
  }: {
513
798
  status: CloudInstallStatus
799
+ where?: AdminNoteContext
514
800
  onStatus: (st: CloudInstallStatus) => void
515
801
  onExit: () => void
516
802
  }) {
517
803
  const dismiss = useDismiss(status, onStatus, onExit)
518
804
  const failure = status.failure
519
805
  if (!failure) return null
806
+ // After the Hub approved, the guidance is written for an operator — inspect
807
+ // commands, keep this Hub cluster, don't rerun. Hand it over the same way a
808
+ // blocked card does, as a request to finish the connection.
809
+ const handoff = needsAdminHandoff(failure)
520
810
  return (
521
811
  <div className="px-8 pt-6 pb-5">
522
812
  <div className="flex items-start gap-2.5 mb-3">
@@ -529,13 +819,20 @@ function FailedCard({
529
819
  showSummary={failure.guidance.summary !== failure.message}
530
820
  />
531
821
  )}
532
- <div className="mt-4 flex items-center gap-4">
822
+ <div className="relative mt-4 flex items-center gap-4">
533
823
  <button
534
824
  onClick={dismiss}
535
825
  className="px-4 py-1.5 rounded-[10px] bg-theme-elevated hover:bg-theme-hover border border-theme-border text-[12.5px] font-semibold text-theme-text-primary transition-colors"
536
826
  >
537
827
  {failure.retrySafe ? 'Start over' : 'Close'}
538
828
  </button>
829
+ {handoff && (
830
+ <CopyForAdmin
831
+ // The flow's own record of the cluster: a context switch after
832
+ // the install started must not relabel the note for another.
833
+ note={composeFailureNote(failure, { context: status.plan?.contextName ?? where?.context, cluster: where?.cluster })}
834
+ />
835
+ )}
539
836
  </div>
540
837
  </div>
541
838
  )