diff --git a/apps/web/src/components/settings/RenameEnvironmentDialog.tsx b/apps/web/src/components/settings/RenameEnvironmentDialog.tsx new file mode 100644 index 0000000000000..78e33725f15d6 --- /dev/null +++ b/apps/web/src/components/settings/RenameEnvironmentDialog.tsx @@ -0,0 +1,111 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { useState } from "react"; + +import { environmentCatalog } from "~/connection/catalog"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { stackedThreadToast, toastManager } from "../ui/toast"; + +// The popup unmounts when closed, so each open starts from the current label. +function RenameEnvironmentForm({ + environmentId, + label: currentLabel, + onDone, +}: { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly onDone: () => void; +}) { + const [label, setLabel] = useState(currentLabel); + const setEnvironmentLabel = useAtomCommand(environmentCatalog.setLabel, "Rename environment"); + const canSave = label.trim() !== ""; + + const save = async () => { + if (!canSave) return; + const result = await setEnvironmentLabel({ environmentId, label }); + if (result._tag === "Success") { + onDone(); + return; + } + if (isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not rename environment", + description: error instanceof Error ? error.message : "The label was not saved.", + }), + ); + }; + + return ( + <> + + Rename environment + + +
{ + event.preventDefault(); + void save(); + }} + > + + setLabel(event.target.value)} + autoFocus + /> +
+
+ + }>Cancel + + + + ); +} + +export function RenameEnvironmentDialog({ + open, + onOpenChange, + environmentId, + label, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly environmentId: EnvironmentId; + readonly label: string; +}) { + return ( + + + onOpenChange(false)} + /> + + + ); +} diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 53b745412d983..8029d8e988486 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -3,6 +3,7 @@ import { ChevronRightIcon, ChevronsLeftRightEllipsisIcon, EllipsisIcon, + PencilIcon, RouteIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; @@ -29,6 +30,7 @@ import { SettingsRow } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconMenu } from "./EnvironmentIconPicker"; import { EnvironmentRoutesList } from "./EnvironmentRoutesList"; +import { RenameEnvironmentDialog } from "./RenameEnvironmentDialog"; import { ConnectionDiagnosisPanel, ConnectionDiagnosisSummary, @@ -117,6 +119,7 @@ export function SavedBackendListRow({ const [routesOpen, setRoutesOpen] = useState(false); const [permissionsOpen, setPermissionsOpen] = useState(false); const [diagnosisOpen, setDiagnosisOpen] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); const environmentId = environment.environmentId; const unsupported = environment.connection.phase === "unsupported"; const blocked = unsupported && !hasSavedBearerRoute(environment.entry); @@ -413,6 +416,12 @@ export function SavedBackendListRow({ environmentId={environmentId} serverConfig={environment.serverConfig} /> + {environment.relayManaged ? null : ( + setRenameOpen(true)}> + + Rename… + + )} setRoutesOpen((open) => !open)}> {routesOpen ? "Hide routes" : "Routes"} @@ -429,6 +438,12 @@ export function SavedBackendListRow({ + ); } diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index f6c305ab6bb90..55c1663b2d312 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -2196,6 +2196,107 @@ describe("EnvironmentRegistry routes", () => { }), ); + it.effect("renames every route and keeps credentials and learned state", () => + Effect.gen(function* () { + const environmentId = LAN_TARGET.environmentId; + const learnedId = `learned:${environmentId}:http://100.64.0.9:3773@${LAN_TARGET.connectionId}`; + const connectedProfileLabels: Array = []; + const harness = yield* makeHarness( + [ + new BearerConnectionTarget({ ...LAN_TARGET, connectionId: learnedId }), + LAN_TARGET, + RELAY_TARGET, + ], + [ + new BearerConnectionProfile({ ...LAN_PROFILE, connectionId: learnedId, learned: true }), + LAN_PROFILE, + ], + [[LAN_TARGET.connectionId, BEARER_CREDENTIAL]], + { + checkRoute: (route) => { + if (Option.isSome(route.profile)) + connectedProfileLabels.push(route.profile.value.label); + return "unchecked"; + }, + }, + ); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* awaitConnectionState( + registry, + environmentId, + (state) => state.phase === "connected", + ); + yield* registry.setLabel(environmentId, " Studio "); + + expect((yield* registry.state(environmentId)).phase).toBe("connected"); + expect(yield* Ref.get(harness.sessions)).toHaveLength(1); + expect(yield* Ref.get(harness.releasedSessions)).toBe(0); + expect( + routesOf(yield* Ref.get(harness.storedTargets), environmentId).map((target) => [ + target._tag, + target.label, + ]), + ).toEqual([ + ["BearerConnectionTarget", "Studio"], + ["BearerConnectionTarget", "Studio"], + ["RelayConnectionTarget", "Studio"], + ]); + const profiles = yield* Ref.get(harness.storedProfiles); + expect(profiles.get(learnedId)).toMatchObject({ label: "Studio", learned: true }); + expect(profiles.get(LAN_TARGET.connectionId)?.label).toBe("Studio"); + expect((yield* Ref.get(harness.storedCredentials)).get(LAN_TARGET.connectionId)).toEqual( + BEARER_CREDENTIAL, + ); + const entry = (yield* SubscriptionRef.get(registry.entries)).get(environmentId); + expect(entry?.target.label).toBe("Studio"); + + // A retained supervisor must reconnect with the new label: the SSH broker saves the + // profile it is handed, so a stale one would write the old label back. + connectedProfileLabels.length = 0; + yield* registry.setEnabled(environmentId, false); + yield* awaitConnectionState( + registry, + environmentId, + (state) => state.phase !== "connected", + ); + yield* registry.setEnabled(environmentId, true); + yield* awaitConnectionState( + registry, + environmentId, + (state) => state.phase === "connected", + ); + expect(connectedProfileLabels).not.toHaveLength(0); + expect(new Set(connectedProfileLabels)).toEqual(new Set(["Studio"])); + + const error = yield* registry.setLabel(environmentId, " ").pipe(Effect.flip); + expect(error._tag).toBe("ConnectionBlockedError"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + + it.effect("a rename keeps the SSH target the broker last saved", () => + Effect.gen(function* () { + const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]); + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + const moved = { ...SSH_TARGET, hostname: "moved.example.test" }; + yield* Ref.update(harness.storedProfiles, (current) => + new Map(current).set( + SSH_CONNECTION.connectionId, + new SshConnectionProfile({ ...SSH_PROFILE, target: moved }), + ), + ); + + yield* registry.setLabel(SSH_CONNECTION.environmentId, "Studio"); + + expect((yield* Ref.get(harness.storedProfiles)).get(SSH_CONNECTION.connectionId)).toEqual( + new SshConnectionProfile({ ...SSH_PROFILE, label: "Studio", target: moved }), + ); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("removing the last route forgets the environment", () => Effect.gen(function* () { const harness = yield* makeHarness( @@ -2246,6 +2347,9 @@ describe("EnvironmentRegistry routes", () => { LAN_TARGET.connectionId, ]); expect(yield* permissions.get(yield* current())).toBe("read"); + + yield* registry.setLabel(LAN_TARGET.environmentId, "Studio"); + expect(yield* permissions.get(yield* current())).toBe("read"); }).pipe( Effect.provide(harness.layer), Effect.provideService(PullRequestRoutingPermissions, permissions), diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index 12f8aa5ec505f..35d44beed339f 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -51,6 +51,8 @@ import { findRouteToSameAddress, hasSavedBearerRoute, mergeLearnedRoutes, + profileWithLabel, + routeWithLabel, routesAfterRemoving, upsertRoute, type ReportedEndpoint, @@ -151,6 +153,16 @@ export class EnvironmentRegistry extends Context.Service< | PlatformEnvironmentRemovalError | ConnectionBlockedError >; + readonly setLabel: ( + environmentId: EnvironmentId, + label: string, + ) => Effect.Effect< + void, + | Persistence.ConnectionPersistenceError + | ConnectionAttemptError + | EnvironmentNotRegisteredError + | PlatformEnvironmentRemovalError + >; readonly removeRelayEnvironments: () => Effect.Effect< void, | Persistence.ConnectionPersistenceError @@ -202,7 +214,7 @@ export class EnvironmentRegistry extends Context.Service< interface EnvironmentServiceScope { readonly entry: ConnectionCatalogEntry; - readonly supervisor: EnvironmentSupervisor.EnvironmentSupervisor["Service"]; + readonly supervisor: EnvironmentSupervisor.ManagedEnvironmentSupervisor; readonly scope: Scope.Closeable; } @@ -1025,6 +1037,54 @@ export const make = Effect.gen(function* () { ); }); + const setLabel = Effect.fn("EnvironmentRegistry.setLabel")(function* ( + environmentId: EnvironmentId, + label: string, + ) { + const trimmed = label.trim(); + if (trimmed === "") { + return yield* new ConnectionBlockedError({ + reason: "configuration", + detail: "Environment label cannot be empty.", + }); + } + yield* withLeaseLock( + environmentId, + Effect.gen(function* () { + const entry = yield* userEntry(environmentId); + const routes = connectionRoutes(entry); + if (routes.every((route) => route.target.label === trimmed)) return; + // Every route carries the label: the first one is displayed, and reordering can change it. + const renamed = routes.map((route) => routeWithLabel(route, trimmed)); + for (const { profile } of routes) { + if (Option.isNone(profile)) continue; + // The SSH broker saves a freshly resolved target only to the store, so relabel that copy. + const saved = yield* profiles.get(profile.value.connectionId); + yield* profiles.put( + profileWithLabel( + Option.getOrElse(saved, () => profile.value), + trimmed, + ), + ); + } + const next = entryWithRoutes(entry, renamed); + yield* registrations.setRoutes(environmentId, persistedRoutes(next)); + // A label changes no address, so the supervisor keeps its session. Its next connect uses + // the new label; its own messages keep the old one until it is next recreated. + const lease = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + if (lease !== undefined) { + yield* lease.supervisor.setLabel(trimmed); + yield* SubscriptionRef.update(serviceScopes, (current) => + new Map(current).set(environmentId, { ...lease, entry: next }), + ); + } + yield* SubscriptionRef.update(entries, (current) => + new Map(current).set(environmentId, next), + ); + }), + ); + }); + const removeRelayEnvironments = Effect.fn("EnvironmentRegistry.removeRelayEnvironments")( function* () { const relayEnvironmentIds = [...(yield* SubscriptionRef.get(entries)).values()] @@ -1193,6 +1253,7 @@ export const make = Effect.gen(function* () { remove, removeRoute, reorderRoutes, + setLabel, removeRelayEnvironments, retryNow, setEnabled, diff --git a/packages/client-runtime/src/connection/routes.ts b/packages/client-runtime/src/connection/routes.ts index 9d83550a8c4a2..db0d0fbacd98d 100644 --- a/packages/client-runtime/src/connection/routes.ts +++ b/packages/client-runtime/src/connection/routes.ts @@ -10,9 +10,17 @@ import * as Option from "effect/Option"; import { BearerConnectionProfile, type ConnectionCatalogEntry, + type ConnectionProfile, type ConnectionRoute, + SshConnectionProfile, } from "./catalog.ts"; -import { BearerConnectionTarget, type ConnectionTarget } from "./model.ts"; +import { + BearerConnectionTarget, + type ConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "./model.ts"; export type ConnectionRouteKind = | "relay" @@ -65,6 +73,32 @@ export function routeEntry( return entryWithRoutes(entry, [route]); } +function targetWithLabel(target: ConnectionTarget, label: string): ConnectionTarget { + switch (target._tag) { + case "PrimaryConnectionTarget": + return new PrimaryConnectionTarget({ ...target, label }); + case "BearerConnectionTarget": + return new BearerConnectionTarget({ ...target, label }); + case "RelayConnectionTarget": + return new RelayConnectionTarget({ ...target, label }); + case "SshConnectionTarget": + return new SshConnectionTarget({ ...target, label }); + } +} + +export function routeWithLabel(route: ConnectionRoute, label: string): ConnectionRoute { + return { + target: targetWithLabel(route.target, label), + profile: Option.map(route.profile, (profile) => profileWithLabel(profile, label)), + }; +} + +export function profileWithLabel(profile: ConnectionProfile, label: string): ConnectionProfile { + return profile._tag === "SshConnectionProfile" + ? new SshConnectionProfile({ ...profile, label }) + : new BearerConnectionProfile({ ...profile, label }); +} + export function routeHttpBaseUrl(route: ConnectionRoute): string | null { if (route.target._tag === "PrimaryConnectionTarget") return route.target.httpBaseUrl; const profile = Option.getOrNull(route.profile); diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index b4fc6955366a0..b2a696a95f7c4 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -2002,4 +2002,40 @@ describe("EnvironmentSupervisor routes", () => { ]); }), ); + + it.effect("keeps a rename that lands while route learning finishes", () => + Effect.gen(function* () { + const learned = yield* Deferred.make(); + const preparedLabels = yield* Ref.make>([]); + const harness = yield* makeHarness({ + configEvents: Stream.make({ + version: 1 as const, + type: "snapshot" as const, + config: { directEndpoints: [] } as unknown as ServerConfig, + }).pipe(Stream.ensuring(Deferred.succeed(learned, undefined))), + prepare: (_attempt, target) => + Ref.update(preparedLabels, (labels) => [...labels, target.label]).pipe( + Effect.as(preparedFor(target)), + ), + }); + const entry: ConnectionCatalogEntry = { + target: RELAY_TARGET, + profile: Option.none(), + enabled: true, + }; + const supervisor: EnvironmentSupervisor.ManagedEnvironmentSupervisor = + yield* EnvironmentSupervisor.make(entry, { + initiallyDesired: true, + learnRoutes: () => supervisor.setLabel("Studio").pipe(Effect.as(Option.some(entry))), + }).pipe(Effect.provide(harness.dependencies)); + + yield* Deferred.await(learned); + yield* supervisor.disconnect; + yield* awaitState(supervisor.state, (state) => state.phase !== "connected"); + yield* supervisor.connect; + yield* awaitState(supervisor.state, (state) => state.phase === "connected"); + + expect((yield* Ref.get(preparedLabels)).at(-1)).toBe("Studio"); + }), + ); }); diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 5f279edf568c4..2c83821eaec44 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -38,6 +38,7 @@ import { entryWithRoutes, insertRoute, type ReportedEndpoint, + routeWithLabel, } from "./routes.ts"; const RETRY_BASE_DELAY_MS = 1_000; @@ -265,11 +266,22 @@ export class EnvironmentSupervisor extends Context.Service< } >()("@t3tools/client-runtime/connection/supervisor/EnvironmentSupervisor") {} +const entryWithLabel = (entry: ConnectionCatalogEntry, label: string) => + entryWithRoutes( + entry, + connectionRoutes(entry).map((route) => routeWithLabel(route, label)), + ); + +/** What the registry holds. Sessions only ever see the service. */ +export type ManagedEnvironmentSupervisor = EnvironmentSupervisor["Service"] & { + readonly setLabel: (label: string) => Effect.Effect; +}; + export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( entry: ConnectionCatalogEntry, options?: EnvironmentSupervisorOptions, ): Effect.fn.Return< - EnvironmentSupervisor["Service"], + ManagedEnvironmentSupervisor, never, | Connectivity.Connectivity | ConnectionDriver.ConnectionDriver @@ -355,7 +367,10 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( Effect.gen(function* () { const updated = yield* learn({ activeRoute, reported }); if (Option.isSome(updated)) { - yield* Ref.set(currentEntry, updated.value); + // A rename can land after the registry built this entry. + yield* Ref.update(currentEntry, (current) => + entryWithLabel(updated.value, current.target.label), + ); yield* requestBetterRouteCheck(lease); } }), @@ -1240,13 +1255,19 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( yield* Effect.addFinalizer(() => Queue.shutdown(signals).pipe(Effect.andThen(clearLease))); - return EnvironmentSupervisor.of({ - target, - state, - session, - prepared, - connect, - disconnect, - retryNow, - }); + const setLabel = (label: string) => + Ref.update(currentEntry, (current) => entryWithLabel(current, label)); + + return { + ...EnvironmentSupervisor.of({ + target, + state, + session, + prepared, + connect, + disconnect, + retryNow, + }), + setLabel, + }; }); diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 206f75a600416..40123896b62c8 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -182,6 +182,15 @@ export function createEnvironmentCatalogAtoms( Effect.flatMap((registry) => registry.reorderRoutes(input.environmentId, input.routeIds)), ), }); + const setLabel = createRuntimeCommand(runtime, { + label: "environment-catalog:set-label", + scheduler: commandScheduler, + concurrency: serial, + execute: (input: { readonly environmentId: EnvironmentIdType; readonly label: string }) => + EnvironmentRegistry.EnvironmentRegistry.pipe( + Effect.flatMap((registry) => registry.setLabel(input.environmentId, input.label)), + ), + }); const retryNow = createRuntimeCommand(runtime, { label: "environment-catalog:retry-now", scheduler: commandScheduler, @@ -208,5 +217,6 @@ export function createEnvironmentCatalogAtoms( removeRelayEnvironments, retryNow, setEnabled, + setLabel, }; } diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index af2ab6d0575a6..4bc3bbbda986c 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -133,6 +133,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? remove: () => Effect.die("Unexpected environment removal"), removeRoute: () => Effect.die("Unexpected route removal"), reorderRoutes: () => Effect.die("Unexpected route reorder"), + setLabel: () => Effect.die("Unexpected label update"), removeRelayEnvironments: () => Effect.die("Unexpected environment removal"), retryNow: () => Effect.void, setEnabled: () => Effect.die("Unexpected environment toggle"),