diff --git a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx index cf1e121f93..8bb2fe767e 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx @@ -9,6 +9,7 @@ import { waitFor, } from "@testing-library/react"; import type { Host } from "@bb/domain"; +import { MemoryRouter } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; import { BbHttpError, sdk } from "@/lib/sdk"; import { hostsQueryKey } from "@/hooks/queries/query-keys"; @@ -76,11 +77,13 @@ describe("AddMachineDialog", () => { const { queryClient, wrapper } = createQueryClientTestHarness(); render( - , + + + , { wrapper }, ); @@ -155,11 +158,13 @@ describe("AddMachineDialog", () => { const { queryClient, wrapper } = createQueryClientTestHarness(); render( - , + + + , { wrapper }, ); @@ -191,4 +196,89 @@ describe("AddMachineDialog", () => { ).toBeDefined(); expect(screen.queryByText("dev-vm connected")).toBeNull(); }); + + it("explains that a loopback server is unreachable when connect is unpaired", async () => { + vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ + joinCode: "jc_test123", + hostId: "host_new", + expiresAt: Date.now() + 15 * 60 * 1000, + }); + vi.mocked(sdk.plugins.callRpc).mockRejectedValue( + new BbHttpError({ + body: { + ok: false, + error: { code: "handler_error", message: "not_paired" }, + }, + code: "handler_error", + message: "not_paired", + status: 500, + }), + ); + vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); + + const { wrapper } = createQueryClientTestHarness(); + render( + + + , + { wrapper }, + ); + + // The desktop server listens on loopback only. Another machine cannot + // reach it, so a curl command against 127.0.0.1 can never work. + const notice = await screen.findByRole("status"); + expect(notice.textContent).toContain( + "Another machine cannot use this address.", + ); + expect(notice.textContent).toContain("http://127.0.0.1:38886"); + expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); + const link = screen.getByRole("link", { name: "Set up remote access" }); + expect(link.getAttribute("href")).toBe("/settings/plugins/connect"); + expect( + screen.queryByText("Waiting for the machine to connect…"), + ).toBeNull(); + }); + + it("offers a retry when connect is temporarily unavailable on a loopback server", async () => { + vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ + joinCode: "jc_test123", + hostId: "host_new", + expiresAt: Date.now() + 15 * 60 * 1000, + }); + vi.mocked(sdk.plugins.callRpc).mockRejectedValue( + new BbHttpError({ + body: { error: "plugin starting" }, + code: "unavailable", + message: "unavailable", + status: 503, + }), + ); + vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); + + const { wrapper } = createQueryClientTestHarness(); + render( + + + , + { wrapper }, + ); + + // A 503 says nothing about pairing. Do not print a command that dials the + // new machine itself, and do not claim connect is unpaired: let the user + // retry. + expect( + await screen.findByText("Remote access isn't ready yet."), + ).toBeDefined(); + expect(screen.getByRole("button", { name: "Try again" })).toBeDefined(); + expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); + expect(screen.queryByRole("status")).toBeNull(); + }); }); diff --git a/apps/app/src/components/dialogs/AddMachineDialog.tsx b/apps/app/src/components/dialogs/AddMachineDialog.tsx index 2aaed3a8db..eec88ad26e 100644 --- a/apps/app/src/components/dialogs/AddMachineDialog.tsx +++ b/apps/app/src/components/dialogs/AddMachineDialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; import { useMutation } from "@tanstack/react-query"; import type { Host } from "@bb/domain"; import { z } from "zod"; @@ -15,6 +16,8 @@ import { Icon } from "@bb/shared-ui/icon"; import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { useHosts } from "@/hooks/queries/host-queries"; import { useClipboardCopy } from "@/lib/clipboard"; +import { isLocalOnlyUrl } from "@/lib/loopback-hostname"; +import { getPluginConfigurationRoutePath } from "@/lib/route-paths"; import { BbHttpError, sdk } from "@/lib/sdk"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; @@ -41,24 +44,39 @@ function isNotPairedRpcError(error: BbHttpError): boolean { return envelope.success && envelope.data.error.message === "not_paired"; } -async function createConnectMachineCode(): Promise { +/** + * Outcome of asking the connect plugin for a machine code. + * - `issued`: connect is paired; the command routes through getbb.app. + * - `unpaired`: connect is installed but not paired (or not installed at all). + * Only a direct server URL can work. + * - `unavailable`: a temporary failure (for example the plugin is still + * starting). Nothing is known about pairing. + */ +type ConnectMachineCodeResult = + | { kind: "issued"; code: ConnectMachineCode } + | { kind: "unpaired" } + | { kind: "unavailable" }; + +async function createConnectMachineCode(): Promise { try { - return await sdk.plugins.callRpc({ + const code = await sdk.plugins.callRpc({ pluginId: "connect", method: "createMachineCode", input: null, outputSchema: connectMachineCodeSchema, }); + return { kind: "issued", code }; } catch (error) { + if (!(error instanceof BbHttpError)) throw error; if ( - error instanceof BbHttpError && - (error.code === "not_paired" || - isNotPairedRpcError(error) || - error.status === 404 || - error.status === 422 || - error.status === 503) + error.code === "not_paired" || + isNotPairedRpcError(error) || + error.status === 404 ) { - return null; + return { kind: "unpaired" }; + } + if (error.status === 422 || error.status === 503) { + return { kind: "unavailable" }; } throw error; } @@ -120,6 +138,54 @@ function pairingCommand( return `curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 ${serverUrl}/install.sh | sh -s -- --join-code ${joinCode} --host-id ${hostId} --server ${serverUrl}${machineFlag}`; } +const REMOTE_ACCESS_ROUTE = getPluginConfigurationRoutePath({ + pluginId: "connect", +}); + +/** + * Shown instead of the pairing command when connect is unpaired and the only + * server URL we know is loopback or unspecified (issue #1690). bb listens on + * loopback by default, so a command that targets this address dials the new + * machine itself instead of this server. + */ +function UnreachableServerNotice({ serverUrl }: { serverUrl: string }) { + return ( +
+

+ Another machine cannot use this address. +

+

+ The pairing command would target{" "} + {serverUrl}, which points to the + machine that runs it, not to this bb. Set up remote access first, then + come back here to get a pairing command that works from anywhere. +

+
+ + + Other options + +
+
+ ); +} + function AddMachineDialogContent({ onOpenChange, serverUrl, @@ -158,22 +224,38 @@ function AddMachineDialogContent({ ) : undefined) ?? null; - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const interval = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(interval); - }, []); - const joinCode = mintJoinCode.data?.join ?? null; - const machineCode = mintJoinCode.data?.machine ?? null; + const machineCodeResult = mintJoinCode.data?.machine ?? null; + const machineCode = + machineCodeResult?.kind === "issued" ? machineCodeResult.code : null; const expiresAt = joinCode === null ? null : Math.min(joinCode.expiresAt, machineCode?.expiresAt ?? Infinity); - const remainingMs = expiresAt !== null ? expiresAt - now : null; + const localOnlyServerUrl = + serverUrl !== null && isLocalOnlyUrl(serverUrl) ? serverUrl : null; + const unreachableServerUrl = + machineCodeResult?.kind === "unpaired" ? localOnlyServerUrl : null; + // Connect failed for a temporary reason and the fallback URL cannot work: + // offer a retry instead of a command that dials the wrong machine. + const connectUnavailable = + machineCodeResult?.kind === "unavailable" && localOnlyServerUrl !== null; + const showCommand = + joinCode !== null && unreachableServerUrl === null && !connectUnavailable; + + // Tick only while a command with an expiry is on screen. + const [now, setNow] = useState(() => Date.now()); + const hasCountdown = showCommand && expiresAt !== null; + useEffect(() => { + if (!hasCountdown) return; + const interval = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(interval); + }, [hasCountdown]); + const remainingMs = + hasCountdown && expiresAt !== null ? expiresAt - now : null; const expired = remainingMs !== null && remainingMs <= 0; const command = - joinCode !== null + showCommand && joinCode !== null ? pairingCommand( joinCode.joinCode, joinCode.hostId, @@ -188,18 +270,21 @@ function AddMachineDialogContent({ Add a machine - Run this on the machine you want to add. It pairs the machine to this - server and keeps it available for your projects. + {unreachableServerUrl !== null + ? "Pair a machine to run projects and threads on it." + : "Run this on the machine you want to add. It pairs the machine to this server and keeps it available for your projects."}
- {mintJoinCode.isError ? ( + {mintJoinCode.isError || connectUnavailable ? (

- {getMutationErrorMessage({ - error: mintJoinCode.error, - fallbackMessage: "Couldn't create a join code.", - })} + {connectUnavailable + ? "Remote access isn't ready yet." + : getMutationErrorMessage({ + error: mintJoinCode.error, + fallbackMessage: "Couldn't create a join code.", + })}

+ ) : unreachableServerUrl !== null ? ( + ) : command !== null ? (
@@ -259,35 +346,37 @@ function AddMachineDialogContent({
             Creating a join code…
           

)} -
- {connectedNewHost !== null ? ( - <> - - - {connectedNewHost.name} connected - - - - ) : ( - <> - - - Waiting for the machine to connect… - - - )} -
+ {unreachableServerUrl !== null ? null : ( +
+ {connectedNewHost !== null ? ( + <> + + + {connectedNewHost.name} connected + + + + ) : ( + <> + + + Waiting for the machine to connect… + + + )} +
+ )}