From 6b456a9618c35f2fa525f6dfc9ef9b0ff01471b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 5 Oct 2026 19:53:51 -0700 Subject: [PATCH] feat(connect): tell users when an idle tunnel was removed (#13567) Co-authored-by: Claude Opus 5.5 (1M context) --- .../cloudEnvironmentPresentation.test.ts | 21 + .../cloud/cloudEnvironmentPresentation.ts | 4 +- .../CloudEnvironmentConnectList.test.tsx | 36 +- .../cloud/CloudEnvironmentConnectList.tsx | 52 +- docs/user/remote-access.md | 5 + .../migration.sql | 1 + .../snapshot.json | 1594 +++++++++++++++++ .../environments/EnvironmentConnector.test.ts | 33 + .../src/environments/EnvironmentConnector.ts | 12 + .../ManagedEndpointAllocations.test.ts | 36 + .../ManagedEndpointAllocations.ts | 10 +- .../ManagedEndpointProvider.test.ts | 80 + .../environments/ManagedEndpointProvider.ts | 28 + .../ManagedEndpointReaper.test.ts | 5 + .../src/environments/ManagedEndpointReaper.ts | 2 + infra/relay/src/hooks/HeldHooks.test.ts | 1 + infra/relay/src/hooks/HookForwarder.test.ts | 1 + infra/relay/src/http/Api.test.ts | 4 + infra/relay/src/persistence/schema.ts | 2 + .../src/relay/errorPresentation.ts | 20 +- packages/contracts/src/relay.ts | 9 + 21 files changed, 1932 insertions(+), 24 deletions(-) create mode 100644 infra/relay/migrations/postgres/20261005232034_managed_endpoint_tunnel_released_at/migration.sql create mode 100644 infra/relay/migrations/postgres/20261005232034_managed_endpoint_tunnel_released_at/snapshot.json diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts index ebd418da7b48658f76acc130b786cc02f42f53b8..f78cecc0671824cca3a99d787fa657dcb625443e 100644 --- a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.test.ts @@ -2,6 +2,8 @@ import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contract import type { RelayEnvironmentStatusResponse } from "@t3tools/contracts/relay"; import { describe, expect, it } from "vite-plus/test"; +import { RELAY_TUNNEL_RELEASED_MESSAGE } from "@t3tools/client-runtime/relay"; + import { availableCloudEnvironmentPresentation } from "./cloudEnvironmentPresentation"; function relayStatus( @@ -111,4 +113,23 @@ describe("available cloud environment presentation", () => { connectionErrorTraceId: "trace-status", }); }); + + it("tells the user to update a host whose idle tunnel was removed", () => { + const status = { + ...relayStatus("offline", "Managed endpoint health request failed.", "trace-released"), + offlineReason: "tunnel_released", + } satisfies RelayEnvironmentStatusResponse; + expect( + availableCloudEnvironmentPresentation({ + isStatusPending: false, + status, + statusError: null, + statusErrorTraceId: null, + }), + ).toMatchObject({ + connectionState: "error", + statusText: RELAY_TUNNEL_RELEASED_MESSAGE, + connectionErrorTraceId: "trace-released", + }); + }); }); diff --git a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts index 3e958b6453d182a6ba55d660edd83e1d0ba68036..49e1e790c50ed511a57911dd5681f52239ed6919 100644 --- a/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts +++ b/apps/mobile/src/features/cloud/cloudEnvironmentPresentation.ts @@ -3,6 +3,7 @@ import { orchestrationProtocolCompatibilityError, type EnvironmentConnectionPhase, } from "@t3tools/client-runtime/connection"; +import { relayOfflineReasonMessage } from "@t3tools/client-runtime/relay"; export interface AvailableCloudEnvironmentPresentation { readonly connectionError: string | null; @@ -39,7 +40,8 @@ export function availableCloudEnvironmentPresentation(input: { } if (input.status?.status === "offline") { - const connectionError = input.status.error ?? "Relay is offline."; + const connectionError = + relayOfflineReasonMessage(input.status) ?? input.status.error ?? "Relay is offline."; return { connectionError, connectionErrorTraceId: input.status.traceId ?? null, diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx index d596866efc53f668034913f8c2e282e8e3702a12..f6ea5be62291482f1bc8668bff4980a30529bfa7 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -1,4 +1,4 @@ -import type { Discovery } from "@t3tools/client-runtime/relay"; +import { RELAY_TUNNEL_RELEASED_MESSAGE, type Discovery } from "@t3tools/client-runtime/relay"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { EnvironmentId, ORCHESTRATION_PROTOCOL_VERSION } from "@t3tools/contracts"; import * as Option from "effect/Option"; @@ -152,6 +152,40 @@ afterEach(async () => { vi.unstubAllGlobals(); }); +describe("cloud environment offline reasons", () => { + it("tells the user to update a computer whose idle tunnel was removed", async () => { + const base = linkedMachines.get(newMachineId)!; + const offlineEntry = (offlineReason?: "tunnel_released") => ({ + ...base, + availability: "offline" as const, + status: Option.some({ + environmentId: newMachineId, + endpoint: base.environment.endpoint, + status: "offline" as const, + checkedAt: "2026-09-15T00:00:00Z", + error: "Managed endpoint health request failed.", + ...(offlineReason ? { offlineReason } : {}), + }), + }); + const rowText = () => + renderer!.root.findAllByType("p").flatMap((paragraph) => paragraph.children); + + discovery.listEnvironments.mockResolvedValue(new Map([[newMachineId, offlineEntry()]])); + await mount(); + expect(rowText()).toContain("T3 Connect · Not added · Relay offline"); + + await act(async () => { + publish({ + environments: new Map([[newMachineId, offlineEntry("tunnel_released")]]), + refreshing: false, + offline: false, + error: Option.none(), + }); + }); + expect(rowText()).toContain(RELAY_TUNNEL_RELEASED_MESSAGE); + }); +}); + describe("cloud onboarding discovery", () => { it("signals that the section can expand after initial discovery settles", async () => { let finishDiscovery!: (environments: DiscoveredEnvironments) => void; diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index fe5eb7836239e9dba028163140cccbf342bdadc8..ddff0de446b6cfff259703c153b0cdb748c03f8a 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -5,6 +5,7 @@ import { RelayConnectionTarget, orchestrationProtocolCompatibilityError, } from "@t3tools/client-runtime/connection"; +import { relayOfflineReasonMessage } from "@t3tools/client-runtime/relay"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -335,7 +336,14 @@ export function CloudEnvironmentConnectRows({ // A connected machine's own config (with the user's icon pick) wins. Before // that, the relay's health probe already carries the server's descriptor, so // a machine can wear its detected glyph before this device ever connects. - const descriptor = status === undefined ? undefined : Option.getOrNull(status)?.descriptor; + const relayStatus = status === undefined ? null : Option.getOrNull(status); + const descriptor = relayStatus?.descriptor; + // Why the relay reports this environment offline, when it knows more than + // "no answer". Shown for saved and unsaved rows alike. + const offlineReason = + availability === "offline" && relayStatus !== null + ? relayOfflineReasonMessage(relayStatus) + : null; const machineKind = resolveEnvironmentMachineKind( savedEnvironment?.serverConfig ?? (descriptor === undefined ? null : { environment: descriptor }), @@ -361,16 +369,18 @@ export function CloudEnvironmentConnectRows({ const statusText = unsupported && !savedEnvironment ? `T3 Connect · ${notAdded} · Client not supported` - : savedConnection - ? savedConnection.statusText - : availability === "online" - ? `T3 Connect · ${notAdded} · Relay online` - : availability === "offline" - ? `T3 Connect · ${notAdded} · Relay offline` - : availability === "checking" - ? `T3 Connect · ${notAdded} · Checking relay status…` - : (Option.getOrNull(error)?.message ?? - `T3 Connect · ${notAdded} · Relay status unavailable`); + : offlineReason !== null + ? offlineReason + : savedConnection + ? savedConnection.statusText + : availability === "online" + ? `T3 Connect · ${notAdded} · Relay online` + : availability === "offline" + ? `T3 Connect · ${notAdded} · Relay offline` + : availability === "checking" + ? `T3 Connect · ${notAdded} · Checking relay status…` + : (Option.getOrNull(error)?.message ?? + `T3 Connect · ${notAdded} · Relay status unavailable`); if (selection) { return (