@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
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { ApiError, type CloudInstallAttempted } from '../api/client'
3
+ import { exitFor, handoffForBlocked, handoffForPrepareError, isHandoffOutcome, signupUrlFor } from './cloudConnectHandoff'
4
+
5
+ const APP = 'https://app.test.example'
6
+ const UTM = 'utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
7
+ const CARD = 'driver-blocked-card-browser-link'
8
+
9
+ const attempted = (over: Partial<CloudInstallAttempted>): CloudInstallAttempted => ({
10
+ mode: 'fresh',
11
+ namespace: 'radar',
12
+ release: 'radar',
13
+ stage: 'preflight',
14
+ ...over,
15
+ })
16
+
17
+ describe('signupUrlFor', () => {
18
+ it('tags the link and omits the outcome when there was no attempt', () => {
19
+ expect(signupUrlFor(APP, 'driver-footer-browser-link')).toBe(`${APP}/signup?${UTM}&utm_content=driver-footer-browser-link`)
20
+ expect(signupUrlFor(APP, 'driver-footer-browser-link', null)).not.toContain('radar_outcome')
21
+ })
22
+
23
+ it('appends a well-formed outcome after an attempt', () => {
24
+ expect(signupUrlFor(APP, 'driver-footer-browser-link', { outcome: 'hub_connect_request_failed', retryable: true })).toMatch(
25
+ /utm_content=driver-footer-browser-link&radar_outcome=hub_connect_request_failed$/,
26
+ )
27
+ })
28
+
29
+ it('never forwards an outcome outside the vocabulary shape', () => {
30
+ // A server message or a cluster name must not ride along in the link.
31
+ for (const bad of ['connect failed: dial tcp 10.0.0.1', 'Prod-East', '', 'a'.repeat(41), 'x&y=1']) {
32
+ expect(signupUrlFor(APP, 'driver-footer-browser-link', { outcome: bad, retryable: true })).not.toContain('radar_outcome')
33
+ }
34
+ })
35
+ })
36
+
37
+ describe('exitFor — every blocked card has one exit, chosen by what Radar established', () => {
38
+ it('adopts a release Radar found, pinned to the Helm tab', () => {
39
+ const h = handoffForBlocked('preflight', attempted({ mode: 'adopt', namespace: 'monitoring', release: 'radar-prod', stage: 'inspect' }))
40
+ const exit = exitFor(APP, CARD, h)
41
+ expect(exit).toMatchObject({ install: true, label: 'Get the install command' })
42
+ expect(exit.href).toBe(
43
+ `${APP}/install?existing=1&ns=monitoring&release=radar-prod&method=helm&${UTM}&utm_content=${CARD}&radar_outcome=blocked_preflight_checks_failed`,
44
+ )
45
+ })
46
+
47
+ it('offers a fresh install only from a complete plan whose discovery saw the whole cluster', () => {
48
+ const fresh = exitFor(APP, CARD, handoffForBlocked('preflight', attempted({})))
49
+ expect(fresh).toMatchObject({ install: true, label: 'Get the install command' })
50
+ expect(fresh.href).toContain('/install?method=helm&')
51
+ expect(fresh.href).not.toContain('existing')
52
+
53
+ // A partial scan may have missed a Radar elsewhere; guessing fresh would
54
+ // reset its values, so the exit is Radar Cloud itself.
55
+ const partial = exitFor(APP, CARD, handoffForBlocked('preflight', attempted({ partialScan: true })))
56
+ expect(partial).toMatchObject({ install: false, label: 'Open Radar Cloud' })
57
+ expect(partial.href).toContain('/signup?')
58
+ expect(partial.href).toContain('radar_outcome=blocked_preflight_checks_failed')
59
+ })
60
+
61
+ it('offers a fresh install when nothing is running and only the release records were unread', () => {
62
+ const exit = exitFor(APP, CARD, handoffForBlocked('preflight', attempted({ stage: 'inspect', releaseUnread: true })))
63
+ expect(exit).toMatchObject({ install: true, label: 'Get the install command' })
64
+ expect(exit.href).toContain('/install?method=helm&')
65
+ })
66
+
67
+ it('sends an unknown target to Radar Cloud, never to a guessed install', () => {
68
+ const exit = exitFor(APP, CARD, handoffForBlocked('preflight'))
69
+ expect(exit).toMatchObject({ install: false, label: 'Open Radar Cloud' })
70
+ expect(exit.href).toContain('/signup?')
71
+ })
72
+
73
+ it('hands a GitOps-managed release to its verified controller’s tab', () => {
74
+ for (const [method, label] of [
75
+ ['argocd', 'Get the Argo CD values patch'],
76
+ ['flux', 'Get the Flux values patch'],
77
+ ] as const) {
78
+ const exit = exitFor(APP, CARD, handoffForBlocked('gitops', attempted({ mode: 'gitops', namespace: 'radar', release: 'radar', stage: 'inspect', method })))
79
+ expect(exit).toMatchObject({ install: true, label })
80
+ expect(exit.href).toContain(`/install?existing=1&ns=radar&release=radar&method=${method}&`)
81
+ }
82
+ // An unverified or unrecognized owner gets no patch for the wrong tool.
83
+ const unknownOwner = exitFor(APP, CARD, handoffForBlocked('gitops', attempted({ mode: 'gitops', stage: 'inspect', method: '' })))
84
+ expect(unknownOwner).toMatchObject({ install: false, label: 'Open Radar Cloud' })
85
+ })
86
+
87
+ it('never turns an unsupported refusal into an install, even with a known release', () => {
88
+ // Preparation can refuse after inspection found a release (incompatible
89
+ // settings, a token Secret already present); the card still keeps that
90
+ // as evidence, but the exit is Radar Cloud, not an install over it.
91
+ const exit = exitFor(APP, CARD, handoffForBlocked('unsupported', attempted({ mode: 'adopt', stage: 'prepare' })))
92
+ expect(exit).toMatchObject({ install: false, label: 'Open Radar Cloud' })
93
+ expect(exit.href).toContain('radar_outcome=blocked_unsupported_install')
94
+ expect(exitFor(APP, CARD, handoffForBlocked('unsupported'))).toMatchObject({ install: false, label: 'Open Radar Cloud' })
95
+ })
96
+ })
97
+
98
+ describe('handoffForPrepareError', () => {
99
+ it('separates no cluster, inspection failed, Radar unreachable, and an unusable response', () => {
100
+ expect(handoffForPrepareError(new ApiError('down', 503)).outcome).toBe('radar_not_connected_to_cluster')
101
+ expect(handoffForPrepareError(new ApiError('nope', 500)).outcome).toBe('cluster_inspect_failed')
102
+ expect(handoffForPrepareError(new TypeError('Failed to fetch')).outcome).toBe('radar_server_unreachable')
103
+ expect(handoffForPrepareError(new SyntaxError('Unexpected token <')).outcome).toBe('cluster_inspect_request_error')
104
+ })
105
+
106
+ it('is worth trying again and keeps the message for the pitch', () => {
107
+ const h = handoffForPrepareError(new ApiError('Not connected to cluster', 503))
108
+ expect(h.retryable).toBe(true)
109
+ expect(h.detail).toBe('Not connected to cluster')
110
+ })
111
+ })
112
+
113
+ describe('handoffForBlocked', () => {
114
+ it('names the refusal, does not offer a retry, and keeps what Radar attempted', () => {
115
+ expect(handoffForBlocked('gitops')).toEqual({ outcome: 'blocked_gitops_managed_install', retryable: false, target: null })
116
+ expect(handoffForBlocked('preflight')).toEqual({ outcome: 'blocked_preflight_checks_failed', retryable: false, target: null })
117
+ expect(handoffForBlocked('unsupported')).toEqual({ outcome: 'blocked_unsupported_install', retryable: false, target: null })
118
+ const a = attempted({ mode: 'adopt', partialScan: false })
119
+ expect(handoffForBlocked('preflight', a).target).toBe(a)
120
+ })
121
+ })
122
+
123
+ describe('isHandoffOutcome', () => {
124
+ it('accepts the server failure kinds verbatim', () => {
125
+ for (const kind of [
126
+ 'hub_connect_request_failed',
127
+ 'hub_approval_poll_failed',
128
+ 'approval_rejected_in_browser',
129
+ 'approval_window_expired',
130
+ 'approved_but_credential_pickup_expired',
131
+ 'approval_outcome_unknown',
132
+ 'canceled_before_approval_page',
133
+ 'canceled_after_approval',
134
+ 'helm_provision_failed',
135
+ 'installed_but_tunnel_not_confirmed',
136
+ ]) {
137
+ expect(isHandoffOutcome(kind)).toBe(true)
138
+ }
139
+ })
140
+ })
@@ -0,0 +1,124 @@
1
+ import { ApiError, type CloudInstallAttempted, type CloudInstallBlocked } from '../api/client'
2
+
3
+ // The Cloud dialog's links into the Hub. utm_content names the link that was
4
+ // clicked. After an in-app connect attempt that did not end connected, the
5
+ // link also carries radar_outcome, a short phrase for what happened, so the
6
+ // wizard opens knowing what the person is coming from. The person sees both
7
+ // in the address bar, so every value reads as plain words about the flow —
8
+ // never an error message, never a cluster name. Anything that is not a short
9
+ // snake_case phrase is dropped here and again on the Hub.
10
+ export const SIGNUP_QUERY = '?utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
11
+
12
+ // What the person is coming from, whether the pitch should offer "Try
13
+ // again" (a failure can be retried; a refusal or the person's own cancel
14
+ // cannot be "tried again" without misreading it as something going wrong),
15
+ // and, for a blocked plan, the release Radar found so the card's install
16
+ // link adopts it instead of opening a fresh install over it.
17
+ export interface Handoff {
18
+ outcome: string
19
+ retryable: boolean
20
+ // What Radar attempted, when it got that far — the blocked card's evidence
21
+ // and the input to the exit it offers.
22
+ target?: CloudInstallAttempted | null
23
+ // A prepare error's message, shown on the pitch so the reason outlives the
24
+ // toast that first carried it.
25
+ detail?: string
26
+ }
27
+
28
+ // Closed shape, not a closed list: flow failure kinds come from the server
29
+ // (hub_connect_request_failed, approval_window_expired, helm_provision_failed,
30
+ // …) and this frontend must not need a release to forward a new one.
31
+ const OUTCOME_SHAPE = /^[a-z][a-z_]{0,39}$/
32
+
33
+ export function isHandoffOutcome(value: unknown): value is string {
34
+ return typeof value === 'string' && OUTCOME_SHAPE.test(value)
35
+ }
36
+
37
+ // The prepare POST failed before any flow existed, so there is no failure
38
+ // kind to forward; classify by HTTP class. Prepare never contacts the Hub: a
39
+ // 503 is Radar without a cluster (disconnected or still connecting), any other
40
+ // status is Radar itself declining or failing the inspection, a TypeError is
41
+ // fetch failing to reach Radar's own server, and anything else is a response
42
+ // that could not be used. All of them are worth trying again.
43
+ export function handoffForPrepareError(err: unknown): Handoff {
44
+ let outcome = 'cluster_inspect_request_error'
45
+ if (err instanceof ApiError) {
46
+ outcome = err.status === 503 ? 'radar_not_connected_to_cluster' : 'cluster_inspect_failed'
47
+ } else if (err instanceof TypeError) {
48
+ outcome = 'radar_server_unreachable'
49
+ }
50
+ return { outcome, retryable: true, detail: err instanceof Error ? err.message : undefined }
51
+ }
52
+
53
+ // A blocked plan was refused for a stated reason and trying again reproduces
54
+ // it; the wizard is the next step, not a retry.
55
+ const BLOCKED_OUTCOMES: Record<CloudInstallBlocked['reason'], string> = {
56
+ gitops: 'blocked_gitops_managed_install',
57
+ preflight: 'blocked_preflight_checks_failed',
58
+ unsupported: 'blocked_unsupported_install',
59
+ }
60
+
61
+ export function handoffForBlocked(reason: CloudInstallBlocked['reason'], target?: CloudInstallAttempted | null): Handoff {
62
+ return { outcome: BLOCKED_OUTCOMES[reason], retryable: false, target: target ?? null }
63
+ }
64
+
65
+ export function signupUrlFor(appUrl: string, content: string, handoff?: Handoff | null): string {
66
+ const url = `${appUrl}/signup${SIGNUP_QUERY}&utm_content=${content}`
67
+ return handoff && isHandoffOutcome(handoff.outcome) ? `${url}&radar_outcome=${handoff.outcome}` : url
68
+ }
69
+
70
+ // The one exit every blocked card offers. An install-page deep link is
71
+ // justified only where Radar established both the target and the operation:
72
+ // a release it found (adopt, or its GitOps owner's values patch), or a fresh
73
+ // install from a complete plan whose discovery saw the whole cluster. The
74
+ // Hub's fresh command would reset an existing release's values and its script
75
+ // writes the token Secret before Helm runs, so a guessed target is never
76
+ // linked. Everywhere else the exit is Radar Cloud itself — the same
77
+ // signup/login entry as the pitch — and the copy hands the work to an admin.
78
+ // Signed out, the Hub stashes an /install link across sign-in only when it
79
+ // carries a target or a method, so every install link carries one.
80
+ export interface BlockedExit {
81
+ href: string
82
+ label: string
83
+ // True when href is the install page for an established target.
84
+ install: boolean
85
+ }
86
+
87
+ export function exitFor(appUrl: string, content: string, handoff: Handoff | null | undefined): BlockedExit {
88
+ const generic = { href: signupUrlFor(appUrl, content, handoff), label: 'Open Radar Cloud', install: false }
89
+ const t = handoff?.target
90
+ if (!t) return generic
91
+ // An unsupported refusal names its own remedy (several Radars to pick from,
92
+ // a pairing to recover, ownership Radar will not guess at); a release it
93
+ // happens to know is not an invitation to install over it.
94
+ if (handoff?.outcome === BLOCKED_OUTCOMES.unsupported) return generic
95
+ const params = new URLSearchParams()
96
+ switch (t.mode) {
97
+ case 'adopt':
98
+ params.set('existing', '1')
99
+ params.set('ns', t.namespace)
100
+ params.set('release', t.release)
101
+ params.set('method', 'helm')
102
+ return { href: installHref(appUrl, content, handoff, params), label: 'Get the install command', install: true }
103
+ case 'gitops':
104
+ if (t.method !== 'argocd' && t.method !== 'flux') return generic
105
+ params.set('existing', '1')
106
+ params.set('ns', t.namespace)
107
+ params.set('release', t.release)
108
+ params.set('method', t.method)
109
+ return {
110
+ href: installHref(appUrl, content, handoff, params),
111
+ label: `Get the ${t.method === 'argocd' ? 'Argo CD' : 'Flux'} values patch`,
112
+ install: true,
113
+ }
114
+ default:
115
+ if (t.partialScan) return generic
116
+ params.set('method', 'helm')
117
+ return { href: installHref(appUrl, content, handoff, params), label: 'Get the install command', install: true }
118
+ }
119
+ }
120
+
121
+ function installHref(appUrl: string, content: string, handoff: Handoff | null | undefined, params: URLSearchParams): string {
122
+ const url = `${appUrl}/install?${params.toString()}&${SIGNUP_QUERY.slice(1)}&utm_content=${content}`
123
+ return handoff && isHandoffOutcome(handoff.outcome) ? `${url}&radar_outcome=${handoff.outcome}` : url
124
+ }
@@ -0,0 +1,131 @@
1
+ import { clsx } from "clsx";
2
+ import { Sparkles } from "lucide-react";
3
+ import { Badge } from "@skyhook-io/k8s-ui";
4
+
5
+ import type { DiagnosisEvidenceRole } from "../../api/diagnose";
6
+ import { Tooltip } from "../ui/Tooltip";
7
+
8
+ export const AGENT_ROLE_LABELS: Readonly<
9
+ Record<DiagnosisEvidenceRole, string>
10
+ > = {
11
+ cause: "Cause",
12
+ symptom: "Symptom",
13
+ context: "Context",
14
+ benign: "Not a problem",
15
+ demoted: "Less relevant",
16
+ rules_out: "Rules out",
17
+ };
18
+
19
+ /**
20
+ * The agent's framing of a Radar fact. Always the agent tone, never a
21
+ * severity tone: a chip must not read as a Radar finding.
22
+ */
23
+ export function AgentRoleChip({ role }: { role: DiagnosisEvidenceRole }) {
24
+ return (
25
+ <Tooltip
26
+ content={`The agent labelled this evidence "${AGENT_ROLE_LABELS[role]}". Radar recorded the fact; the label is the agent's reading of it.`}
27
+ // The label has to share the sentence's baseline. Centring the badge box
28
+ // drops it ~1px (padding and border make the box taller than the line),
29
+ // and the badge's own baseline comes from the sparkle, landing 1px high.
30
+ wrapperClassName="shrink-0 align-[-1px]"
31
+ >
32
+ <Badge tone="agent" size="sm">
33
+ <Sparkles className="h-2.5 w-2.5 shrink-0" aria-hidden />
34
+ {AGENT_ROLE_LABELS[role]}
35
+ </Badge>
36
+ </Tooltip>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * One sentence from the agent about a Radar fact. Everything the agent
42
+ * contributed to a card lives in this one row — the sparkle, the role, and the
43
+ * sentence — so the register is unmistakable: the fact above is Radar's, this
44
+ * line is the agent's reading of it. The role travelled with the card title
45
+ * once, where it sat in the same slot as Radar's own badges and could be read
46
+ * as Radar having established it.
47
+ *
48
+ * A `rules_out` note carries the hypothesis it excludes, as text beside the
49
+ * chip rather than inside it: "Rules out" alone forces the reader to find the
50
+ * hypothesis in a block further down and match it back, and a hypothesis long
51
+ * enough to be useful makes a badge that swallows the row.
52
+ */
53
+ export function AgentClaimNote({
54
+ claim,
55
+ role,
56
+ excludes,
57
+ subject,
58
+ className,
59
+ }: {
60
+ claim: string;
61
+ role?: DiagnosisEvidenceRole;
62
+ /**
63
+ * The hypothesis this item excludes. Rendered only for `rules_out`, where
64
+ * the chip frames it as rejected: a hypothesis is a claim the agent
65
+ * DISPROVED, so on any other card it would read as a statement of fact.
66
+ */
67
+ excludes?: string;
68
+ /** The observation the note was bound to, when it is not on that card. */
69
+ subject?: string;
70
+ className?: string;
71
+ }) {
72
+ if (!claim && !role) return null;
73
+ return (
74
+ <p
75
+ data-agent-claim
76
+ className={clsx(
77
+ "flex min-w-0 items-start gap-2 border-t border-dashed border-theme-border/70 text-xs leading-relaxed text-theme-text-secondary",
78
+ className,
79
+ )}
80
+ >
81
+ <span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
82
+ {/* The chip and its sparkle carry this visually; the word is what
83
+ survives when colour and iconography do not. "Agent" rather than
84
+ "Agent's note" because a role can arrive with no sentence. */}
85
+ <span className="sr-only">Agent: </span>
86
+ {role ? (
87
+ <>
88
+ <AgentRoleChip role={role} />{" "}
89
+ </>
90
+ ) : null}
91
+ {excludes && role === "rules_out" ? (
92
+ <>
93
+ <span className="italic text-theme-text-tertiary">{excludes}</span>
94
+ {" — "}
95
+ </>
96
+ ) : null}
97
+ {claim ? (
98
+ <>
99
+ {subject ? (
100
+ <span className="text-theme-text-tertiary">{subject} · </span>
101
+ ) : null}
102
+ {renderClaim(claim)}
103
+ </>
104
+ ) : null}
105
+ </span>
106
+ </p>
107
+ );
108
+ }
109
+
110
+ /**
111
+ * Claims are one sentence of prose in which the agent marks identifiers with
112
+ * backticks. Only that inline-code span is honoured: a full Markdown render
113
+ * would wrap the sentence in a block and break the shared baseline with the
114
+ * role chip and the lead-in.
115
+ */
116
+ function renderClaim(claim: string) {
117
+ const parts = claim.split("`");
118
+ if (parts.length < 3) return claim;
119
+ return parts.map((part, i) =>
120
+ i % 2 === 1 && i < parts.length - 1 ? (
121
+ <code
122
+ key={i}
123
+ className="inline-code rounded border border-theme-border/60 bg-theme-base px-1 py-px font-mono text-[11px] font-normal text-theme-text-primary"
124
+ >
125
+ {part}
126
+ </code>
127
+ ) : (
128
+ part
129
+ ),
130
+ );
131
+ }
@@ -36,6 +36,7 @@ import {
36
36
  type ExecutionProfile,
37
37
  } from "../../api/diagnose";
38
38
  import { runTargetKey } from "./target";
39
+ import { knownKindForPluralWithGroup } from "../../utils/navigation";
39
40
 
40
41
  export interface Target {
41
42
  kind: string;
@@ -660,7 +661,7 @@ function RoutedDiagnoseProvider({
660
661
  const startRunRef = useRef<(t: Target) => void>(() => {});
661
662
  startRunRef.current = (t: Target) => {
662
663
  const seq = ++startSeqRef.current;
663
- createRun(t, {
664
+ createRun({ ...t, kind: knownKindForPluralWithGroup(t.kind, t.group) ?? t.kind }, {
664
665
  agent: selectedAgent || undefined,
665
666
  profile: hosted ? undefined : effectiveProfile,
666
667
  model: model || undefined,
@@ -4,7 +4,6 @@ import {
4
4
  INVESTIGATION_HISTORY_MIN_WIDTH,
5
5
  MAXIMIZED_RUN_META_VISIBILITY_CLASS,
6
6
  canRerunInvestigation,
7
- canCopyRunLink,
8
7
  investigationHistoryIsPersistent,
9
8
  investigationHeaderPresentation,
10
9
  openInvestigationEvidenceResource,
@@ -337,25 +336,6 @@ describe("canContinueInvestigation", () => {
337
336
  });
338
337
  });
339
338
 
340
- describe("canCopyRunLink", () => {
341
- it("does not expose collaboration UI for an OSS run", () => {
342
- expect(canCopyRunLink(run("done"))).toBe(false);
343
- });
344
-
345
- it("exposes the copy action only for a canonical hosted URL", () => {
346
- expect(
347
- canCopyRunLink({
348
- ...run("done"),
349
- radarUrl: "/c/cluster-1?org=org-1&ai-run=r1",
350
- }),
351
- ).toBe(true);
352
- });
353
-
354
- it("treats an empty hosted URL as unavailable", () => {
355
- expect(canCopyRunLink({ ...run("done"), radarUrl: "" })).toBe(false);
356
- });
357
- });
358
-
359
339
  describe("canInvestigateFurther", () => {
360
340
  it("offers a context-preserving next step on a completed automatic investigation", () => {
361
341
  expect(