Skip to content

Offer the browser path when the Cloud dialog has no connected cluster - #1683

Open
roylibman wants to merge 8 commits into
mainfrom
fix/cloud-funnel-unconnected-cta
Open

roylibman wants to merge 8 commits into
mainfrom
fix/cloud-funnel-unconnected-cta

Conversation

@roylibman

@roylibman roylibman commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Why

The Cloud dialog's driver lane ("Connect this cluster…") inspects the live cluster, but the header renders the Cloud button from first paint, before Radar has connected to anything. A first-run user who clicks it immediately hits the prepare endpoint's 503, sees "Could not inspect this cluster for Cloud connect", and the escape link flips to utm_content=driver-escape. The daily funnel report then reads "no cluster yet" as "the in-app install path broke".

This is exactly what happened with the first funnel-attributed org creation this week: six seconds between the dialog opening on a fresh desktop install and the Hub signup landing via driver-escape, with no connect request ever reaching the Hub.

What changes

  • While the cluster is connecting or disconnected, the driver-lane footer replaces the in-app Connect button with "Set up in the browser" as the primary action. No explanation is attached: someone with no cluster connected is not expecting Radar to install into one. The footer keeps the same quiet layout as the wizard lane, and "How it works" describes the Cloud-side path in that state.
  • That link carries a new utm_content=driver-unconnected, so the Hub can tell "prefers the browser" (driver-alt), "in-app path broke" (driver-escape) and "no cluster to connect" apart. The existing two values keep their meaning.
  • A 503 from prepare (connection dropped between render and click) no longer marks the attempt as failed: specific toast, back to the pitch, no "Try again". The server's answer overrides a stale connected feed until the feed moves, the context changes, or the dialog is reopened.
  • The decisions live in pure helpers in cloudFunnelState.ts with unit tests.

No Go changes. The embedded Hub build hides this button entirely, so library consumers are unaffected.

Follow-up outside this repo

Wherever the daily radar-stats report interprets utm_content values, add driver-unconnected so it is not reported as unknown.

Verified

  • tsc clean, vitest run src 914/914.
  • Dev-mode Radar against an unreachable kubeconfig: the modal shows the promoted button, and the link resolves to …&utm_content=driver-unconnected.

🤖 Generated with Claude Code


Note

Low Risk
UI-only Cloud funnel and analytics tagging; no auth, API, or data-path changes beyond clearer prepare error handling.

Overview
Fixes the driver-lane Cloud modal so users without a live cluster are not pushed into Connect this cluster… only to hit prepare 503 and mis-tagged driver-escape analytics.

Connection-aware footer: While Radar is connecting or disconnected, the primary CTA becomes Continue in Radar Cloud; in-app connect returns once connected. Pitch copy temporarily follows the wizard lane when connect is unavailable so it does not promise an on-cluster install.

503 handling: Prepare 503 sets serverReportedNoCluster, shows a connect-first toast, exits the flow without Try again, and treats the cluster as disconnected until the connection feed, kube context, or modal lifecycle clears the flag.

Analytics: Hub signup links use a new utm_content=driver-unconnected via cloudFunnelState helpers (effectiveConnectionState, driverEscapeContent, driverConnectUnavailableNote), with unit tests.

Reviewed by Cursor Bugbot for commit c9153e3. Bugbot is set up for automated code reviews on this repo. Configure here.

The driver lane's "Connect this cluster" inspects the live cluster, but the
header shows the Cloud button from first paint, before Radar has connected
to anything. A first-run click hit the prepare endpoint's 503, surfaced as
"Could not inspect this cluster", and flipped the escape link to
driver-escape, so the funnel report read a missing cluster as a broken
install path.

While the cluster is connecting or disconnected, the footer now explains
why the in-app connect is unavailable and promotes the browser wizard to
the primary action, tagged utm_content=driver-unconnected so the Hub can
tell the three exits apart. A 503 on prepare (connection dropped between
render and click) no longer counts as a failed attempt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Offer browser setup when no cluster is connected

🐞 Bug fix 🧪 Tests 🕐 10-20 Minutes

Grey Divider

AI Description

• Promotes browser setup when the driver lane has no connected cluster.
• Distinguishes unconnected exits with driver-unconnected funnel attribution.
• Treats prepare 503 responses as availability issues using tested state helpers.
Diagram

graph TD
  A["Cloud Dialog"] --> B{"Cluster connected?"}
  B -- No --> C["Browser Setup"]
  B -- Yes --> D["In-App Connect"] --> E{"Prepare result?"}
  E -- 503 --> C
  E -- Success --> F["Connect Flow"]
  E -- Error --> G["Retry Pitch"]
Loading
High-Level Assessment

The PR’s client-side state selection is the appropriate approach because connection availability is already known at render time, while the 503 handler safely covers the click-time race. Merely disabling the in-app action would strand first-run users, and relying only on the prepare error would preserve the misleading failure experience and attribution.

Files changed (3) +104 / -6

Bug fix (2) +68 / -6
CloudFunnelButton.tsxRoute unconnected users to browser-based Cloud setup +41/-6

Route unconnected users to browser-based Cloud setup

• Reads the live connection state to replace the unavailable driver action with a primary browser setup link and explanatory note. Assigns unconnected funnel attribution and handles prepare 503 responses without marking the driver flow as failed.

web/src/components/CloudFunnelButton.tsx

cloudFunnelState.tsCentralize driver availability and attribution decisions +27/-0

Centralize driver availability and attribution decisions

• Adds pure helpers that select driver-lane UTM content and explanatory messaging from connection state. Introduces 'driver-unconnected' while preserving existing alternate and failure semantics.

web/src/components/cloudFunnelState.ts

Tests (1) +36 / -0
cloudFunnelState.test.tsCover driver CTA and attribution state decisions +36/-0

Cover driver CTA and attribution state decisions

• Tests connected, connecting, and disconnected states across browser attribution and availability messaging. Verifies prior failures cannot override unconnected attribution.

web/src/components/cloudFunnelState.test.ts

@qodo-code-review

qodo-code-review Bot commented Sep 8, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Browser exits get wrong drop attribution ✓ Resolved 🐞 Bug ≡ Correctness
Description
prepare.onError calls exitFlow(false) for a 503 but leaves the render-time connectionState as
connected, so the footer immediately uses driver-alt and re-enables Connect. This occurs in the
drop-between-render-and-click case handled here: until the asynchronous connection feed catches up,
a browser exit is counted as a preference rather than a missing cluster and another prepare request
remains available.
Code

web/src/components/CloudFunnelButton.tsx[R144-146]

+      if (err instanceof ApiError && err.status === 503) {
+        exitFlow(false)
+        showApiError("Radar isn't connected to a cluster yet", 'Connect a cluster first, or set up Radar Cloud in the browser.')
Relevance

●●● Strong

Accepted correctness findings consistently address stale connection-derived UI state and misleading
failure attribution.

PR-#821
PR-#972

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The prepare endpoint returns 503 specifically when its server-side connectivity check fails. The new
handler only exits the flow, while footer state and attribution continue to be derived exclusively
from the asynchronous connection context; when that value is still connected, the helpers return
no unavailable note and driver-alt, and the footer consequently renders another Connect button.

internal/server/cloud_install.go[820-829]
internal/server/server.go[4797-4804]
web/src/components/CloudFunnelButton.tsx[141-146]
web/src/components/CloudFunnelButton.tsx[275-280]
web/src/components/cloudFunnelState.ts[9-25]
web/src/components/CloudFunnelButton.tsx[422-441]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A prepare 503 proves the server no longer has a cluster connection, but the dialog continues deriving its actions and attribution from a potentially stale `connected` context value. Record the no-cluster result locally so the browser action is promoted and tagged `driver-unconnected` immediately.

## Issue Context
The local condition should remain active until an observed reconnection or cluster-context change makes another in-app attempt valid. Add coverage for a 503 received while the render-time connection state is still `connected`.

## Fix Focus Areas
- web/src/components/CloudFunnelButton.tsx[141-146]
- web/src/components/CloudFunnelButton.tsx[275-280]
- web/src/components/cloudFunnelState.test.ts[4-35]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Browser action background stays emerald 📘 Rule violation ⚙ Maintainability
Description
The new browser <a> uses palette-specific bg-emerald-500, hover:bg-emerald-400, and
text-emerald-950 utilities plus arbitrary normal and hover shadow-[...] values instead of
approved theme tokens. It renders on the promoted browser path for connecting or disconnected users
when the driver lane lacks a connected cluster, tying its label, surfaces, and shadows in both
interaction states to component-local styling rather than the active centralized theme.
Code

web/src/components/CloudFunnelButton.tsx[429]

+            className="whitespace-nowrap px-6 py-2.5 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[14px] 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"
Relevance

●●● Strong

Recent frontend reviews accept replacing custom styling with shared components or centralized theme
conventions.

PR-#1585
PR-#1363

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rules 3036653 and 3036659 permit only shared theme utilities for backgrounds and regular text in
modified frontend components, but the new link uses hardcoded emerald background and text classes.
Rule 3036687 similarly requires shadow-theme-sm, shadow-theme-md, or shadow-theme-lg, while
the link introduces two arbitrary Tailwind shadow values for its normal and hover states.

Rule 3036653: Use theme background tokens instead of hardcoded utility color classes
Rule 3036687: Use theme shadow utility tokens instead of raw Tailwind shadow classes
Rule 3036659: Use theme text color utility classes instead of hardcoded Tailwind gray classes
web/src/components/CloudFunnelButton.tsx[429-429]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Replace the browser action's hardcoded emerald background and text utilities and its arbitrary normal and hover shadow utilities with approved theme tokens.

## Issue Context
The new primary browser action appears on the promoted browser path for connecting or disconnected users when the driver lane lacks a connected cluster. Its label, normal and hover surfaces, and normal and hover shadows should derive from the active centralized theme rather than palette-specific colors or component-local raw shadow values; use approved background and text utilities and one of `shadow-theme-sm`, `shadow-theme-md`, or `shadow-theme-lg` as appropriate.

## Fix Focus Areas
- web/src/components/CloudFunnelButton.tsx[429-429]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 41 rules
✅ Cross-repo context — repo relationships
  Explored: repo: skyhook-dev/radar-hub (sha: 34e22e7a)
Review mode: ⚖️ Balanced: This changes runtime Cloud connection-state handling, CTA behavior, analytics attribution, and error recovery across multiple paths, creating enough behavioral risk for a full review.

Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

href={driverEscapeUrl}
target="_blank"
rel="noopener noreferrer"
className="whitespace-nowrap px-6 py-2.5 rounded-[10px] bg-emerald-500 hover:bg-emerald-400 text-emerald-950 text-[14px] 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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Browser action background stays emerald 📘 Rule violation ⚙ Maintainability

The new browser <a> uses palette-specific bg-emerald-500, hover:bg-emerald-400, and
text-emerald-950 utilities plus arbitrary normal and hover shadow-[...] values instead of
approved theme tokens. It renders on the promoted browser path for connecting or disconnected users
when the driver lane lacks a connected cluster, tying its label, surfaces, and shadows in both
interaction states to component-local styling rather than the active centralized theme.
Agent Prompt
## Issue description
Replace the browser action's hardcoded emerald background and text utilities and its arbitrary normal and hover shadow utilities with approved theme tokens.

## Issue Context
The new primary browser action appears on the promoted browser path for connecting or disconnected users when the driver lane lacks a connected cluster. Its label, normal and hover surfaces, and normal and hover shadows should derive from the active centralized theme rather than palette-specific colors or component-local raw shadow values; use approved background and text utilities and one of `shadow-theme-sm`, `shadow-theme-md`, or `shadow-theme-lg` as appropriate.

## Fix Focus Areas
- web/src/components/CloudFunnelButton.tsx[429-429]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread web/src/components/CloudFunnelButton.tsx Outdated
Until the connection feed observes the drop, the footer would re-offer the
in-app connect and tag the browser link driver-alt. The server's answer
now wins until that feed moves or the context changes. Also fold the
duplicated primary-action class string into one constant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 93da5f1. Configure here.

Comment thread web/src/components/CloudFunnelButton.tsx
roylibman and others added 6 commits September 7, 2026 23:30
The connection feed holds 'connected' across short reconnects, so a 503
caught in that window would otherwise hide the in-app connect until the
context changed. Clearing the override whenever the dialog opens or
closes bounds it to the session that saw the error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A visible note above the footer competed with the pitch for attention.
The promoted browser action now carries the reason on hover, so the
footer keeps the same quiet layout as the wizard lane.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
For anyone running Radar as a binary or in-cluster, Radar itself is
already in a browser, so "set up in the browser" read as "set up here".
The link hands off to Radar Cloud's wizard, so say that, on the promoted
button, the secondary escape link, the tooltip, and the 503 toast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
With the in-app connect unavailable, "How it works" still promised that
setup runs here in the app. Hand the pitch the wizard lane in that state
so it describes the path the footer actually offers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Nobody with no cluster connected expects Radar to install into one, so
the "can't install the connection for you" clause answered a question
that was never asked. One sentence on the situation, one on the way
forward.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Someone with no cluster connected is not expecting Radar to install into
one, so explaining why the in-app connect is missing adds nothing. The
helper collapses to a plain availability check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nadaverell added a commit that referenced this pull request Sep 15, 2026
…ery outcome in plain words (#1770)

## Summary

When someone tries the in-app Cloud connect and it doesn't end
connected, the "or set up in the browser" link now tells the wizard what
they're coming from, and everything a person might read along the way —
the query param in their address bar, the failure kinds behind it, the
failed card's headline — is written in plain words rather than internal
tokens or raw errors. The dialog's copy request also names which lane
rendered and where Radar runs. Radar still sends nothing on its own;
every value travels only in a request or link the person triggers.

## What changed

**The browser link carries the outcome.** After an in-app attempt that
did not connect, the driver links append `radar_outcome=<phrase>`. The
value is one of a fixed vocabulary of short snake_case phrases,
shape-checked in `web/src/components/cloudConnectHandoff.ts` before it
is appended and again on the Hub; never an error message, never a
cluster name. Its presence is what says an attempt preceded the click.

**One vocabulary, grouped by stage, each value readable without knowing
the UI:**

| Stage | `radar_outcome` |
|---|---|
| Radar inspecting the cluster (prepare HTTP class) |
`radar_not_connected_to_cluster` (503), `cluster_inspect_failed` (any
other status), `radar_server_unreachable` (fetch failed),
`cluster_inspect_request_error` (unusable response) |
| Refused (blocked plan) | `blocked_gitops_managed_install`,
`blocked_preflight_checks_failed`, `blocked_unsupported_install` |
| Plan shown, not started | `install_plan_canceled` |
| Hub / approval (server failure kinds) | `hub_connect_request_failed`,
`hub_approval_poll_failed`, `approval_rejected_in_browser`,
`approval_window_expired`, `approved_but_credential_pickup_expired`,
`approval_outcome_unknown`, `canceled_before_approval_page`,
`canceled_after_approval` |
| Installing | `helm_provision_failed`,
`installed_but_tunnel_not_confirmed` |
| Fallback | `failure_kind_unknown` |

The server's failure kinds (`internal/server/cloud_install.go`) are
these same strings. The Hub connect-request failure and the
approval-poll failure are separate kinds because they differ in whether
starting over is safe.

**`utm_content` names the link, prefixed by lane** (`driver-*`: the
in-app flow was on offer; `wizard-*`: it was not):

| Link | `utm_content` |
|---|---|
| browser-only dialog's main button | `wizard-signup-button` |
| in-cluster install link, install not identified |
`wizard-install-link-unknown-install` |
| in-cluster install link with namespace/release/method |
`wizard-install-link-known-install` |
| driver dialog footer, "or set up in the browser" |
`driver-footer-browser-link` |
| same destination from inside the blocked card |
`driver-blocked-card-browser-link` |

Previous values (`funnel-cta`, `wizard-generic`, `wizard-deeplink`,
`driver-alt`/`driver-escape`, `flow-escape`) remain on rows recorded
before this release.

**"Try again" follows the server's `retrySafe`.** The pitch CTA reads
"Try again" only when the failure the person just saw is one the server
marked safe to retry — the same verdict the failed card's "Start
over"/"Close" already renders. A refused plan, or a plan the person
canceled, keeps "Connect this cluster…". The component carries `{
outcome, retryable }` through `exitFlow` rather than inferring
retryability from the kind.

**Failed cards say what happened.** The Hub connect-request failure
reads "Radar couldn't reach Radar Hub, so no connection was requested."
(transport, typed as `cloud.HubUnreachableError`), "Radar Hub declined
the connection request (HTTP n)." (the Hub answered with a refusal), or
a generic sentence; the approval-poll failure reads "Radar lost track of
the connection request while waiting for approval." In every case the
raw error is in the guidance's inspect block, not the headline —
matching how the other failures already present.

**The copy request names the dialog.** `useCloudConnectInfo` sends
`?lane=driver|wizard&mode=local|in-cluster`, both closed enums, both in
the query key so a lane change refetches instead of reusing the other
lane's copy. It still fires only when a person opens the dialog.

**Per-cluster state resets on context switch.** A blocked plan is
cleared alongside the outcome when the kubeconfig context changes, so
one cluster's refusal never reopens against the next.

Pairs with skyhook-dev/radar-hub#248 (docs) and
skyhook-dev/radar-hub-web#302 (`radar_outcome` handling). Safe to ship
in any order: the Hub ignores unknown query params and older Radars send
neither.

## Testing

- `make tsc`; `web` vitest (`cloudConnectHandoff.test.ts`: URL shape,
outcome shape guard, prepare-error classes and retryability, blocked
outcomes, server kinds); `go test ./internal/server/ ./internal/cloud/`
(`TestConnectRequestFailureHeadlines` pins that the raw error never
appears in a headline; `cloud_connect_self_test.go` pins the link names)
- Driven against a throwaway kind cluster with `RADAR_HUB_URL` pointed
at a dead port and `RADAR_CLOUD_FUNNEL=on`: fresh dialog →
`utm_content=driver-footer-browser-link`, no outcome, copy fetch
`/api/connect/info?lane=driver&mode=local`; Connect → plan → Cancel →
CTA stays "Connect this cluster…", link carries
`radar_outcome=install_plan_canceled`; Connect → Continue in browser →
server reports `hub_connect_request_failed` with `retrySafe: true`, card
shows the written headline with the dial error in the inspect block →
Start over → CTA "Try again", link carries
`radar_outcome=hub_connect_request_failed`
- Not exercised by hand: the blocked-plan branches and the `retrySafe:
false` kinds (need a GitOps-managed Radar / an approving Hub);
unit-level coverage only

## Notes

- `blocked_unsupported_install` still absorbs untyped errors from
`cloudinstall/plan.go` (a chart download failure lands there as a
permanent-sounding refusal). Separating refusals from errors needs typed
errors in the plan package — follow-up.
- The blocked card's "Your Kubernetes identity can't install this"
headline is imprecise for preflight failures that are ownership
conflicts rather than permissions — follow-up copy fix.

Follow-up to #1683.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> UX, analytics query params, and error presentation for Cloud connect;
no auth or cluster mutation changes beyond clearer failure mapping.
> 
> **Overview**
> When in-app Cloud connect does not finish connected, **browser links
into Radar Hub** now append a shape-checked `radar_outcome` query param
(via new `cloudConnectHandoff.ts`) so the wizard knows what happened;
**`utm_content` values are renamed** to name each specific link (driver
footer, blocked card, wizard install deep links, signup button). The
dialog tracks a **`Handoff` `{ outcome, retryable }`** instead of a bare
“prepare failed” flag so **“Try again”** follows server `retrySafe` and
outcomes survive modal close until kubecontext changes (blocked state
clears on context switch too).
> 
> **Server-side**, flow **failure `kind` strings** are rewritten as
plain, address-bar-readable phrases (e.g. `hub_connect_request_failed`,
`hub_approval_poll_failed`). **Hub connect-request errors** use
`HubUnreachableError` / `HubDeclinedStatus` and
`connectRequestFailure()` so cards show human headlines while raw errors
stay in guidance inspect text; approval-poll transport failures are a
separate kind with a clearer message.
> 
> **Copy fetch** (`useCloudConnectInfo`) now sends closed-enums `lane`
and `mode` on `/api/connect/info` and includes them in the React Query
key so lane changes refetch.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
65624f8. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Nadav Erell <nadaverell@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant