diff --git a/apps/web/src/components/settings/DeviceHostsSettings.tsx b/apps/web/src/components/settings/DeviceHostsSettings.tsx index c55cbe93f4..3dacd3f24a 100644 --- a/apps/web/src/components/settings/DeviceHostsSettings.tsx +++ b/apps/web/src/components/settings/DeviceHostsSettings.tsx @@ -22,6 +22,8 @@ import { MoreVertical, PlusIcon } from "lucide-react"; import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu"; import { SettingsRow } from "./settingsLayout"; import { useSettingsScope } from "./SettingsScopeContext"; +import { planDeviceHostUpdates } from "./deviceHostsSettings.logic"; +import { toastManager } from "../ui/toast"; import { useHostConnectionChecks } from "./useHostConnectionChecks"; import { deviceHostChecksKey, parseDeviceHostDraft } from "./deviceHostConnectionChecks"; @@ -30,11 +32,12 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null; hosts: ReadonlyArray; }) { - const update = useAtomCommand(serverEnvironment.updateSettings); + const { scope, environments, connectedEnvironments } = useSettingsScope(); + const projectScope = scope.kind === "project" || scope.kind === "checkout"; + const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false }); const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false }); const retry = useAtomCommand(deviceEnvironment.list); const { state } = useDeviceState(props.environmentId); - const { environments } = useSettingsScope(); const targets = environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, @@ -43,6 +46,8 @@ export function DeviceHostsSettings(props: { const { checks: environmentChecks, testConnection: testAcrossEnvironments } = useHostConnectionChecks(targets); const [editing, setEditing] = useState(null); + const [originalHost, setOriginalHost] = useState(null); + const [identityFileEdited, setIdentityFileEdited] = useState(false); const parsedEditing = editing ? parseDeviceHostDraft(editing) : Option.none(); const validEditing = Option.isSome(parsedEditing) && editing?.label.trim() !== ""; const [busy, setBusy] = useState(false); @@ -60,47 +65,91 @@ export function DeviceHostsSettings(props: { >({}); const setCheck = (id: string, value: (typeof checks)[string]) => setChecks((current) => ({ ...current, [id]: value })); - const save = async (hosts: ReadonlyArray) => { - if (!props.environmentId) return; + const checkKey = (environmentId: EnvironmentId, hostId: string) => + JSON.stringify([environmentId, hostId]); + const save = async ( + host: SshDeviceHostConfig, + original: SshDeviceHostConfig | null, + remove = false, + ) => { + if (!props.environmentId || projectScope) return; setBusy(true); try { - const saved = await update({ - environmentId: props.environmentId, - input: { patch: { deviceHosts: hosts } }, - }); - if (saved._tag === "Success") { + const plan = planDeviceHostUpdates( + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + connected: environment.connection.phase === "connected", + hosts: environment.serverConfig?.settings.deviceHosts ?? null, + })), + host, + original, + remove, + identityFileEdited, + ); + const results = await Promise.allSettled( + plan.writes.map((target) => + update({ + environmentId: target.environmentId, + input: { patch: { deviceHosts: target.hosts } }, + }), + ), + ); + const failed = [ + ...plan.failed, + ...plan.writes.flatMap((target, index) => { + const result = results[index]; + return result?.status !== "fulfilled" || result.value._tag === "Failure" + ? [target.label] + : []; + }), + ]; + if (failed.length === 0) { setEditing(null); + setOriginalHost(null); + setIdentityFileEdited(false); + } else { + toastManager.add({ + type: "error", + title: "Device hosts not saved on all environments", + description: `Could not update ${failed.join(", ")}.`, + }); } } finally { setBusy(false); } }; - const testConnection = async (host: SshDeviceHostConfig) => { - if (!props.environmentId || checks[host.id]?.pending) return; - setCheck(host.id, { pending: true }); + const testConnection = async (host: SshDeviceHostConfig, environmentId: EnvironmentId) => { + const key = checkKey(environmentId, host.id); + if (checks[key]?.pending) return; + setCheck(key, { pending: true }); try { - const summary = await test({ environmentId: props.environmentId, input: host }); + const summary = await test({ environmentId, input: host }); setCheck( - host.id, + key, summary._tag === "Failure" ? { error: Cause.pretty(summary.cause) } : { platforms: summary.value.platforms }, ); } catch (error) { - setCheck(host.id, { error: error instanceof Error ? error.message : String(error) }); + setCheck(key, { error: error instanceof Error ? error.message : String(error) }); } }; return ( { + setOriginalHost(null); + setIdentityFileEdited(false); setEditing({ id: randomUUID(), label: "", target: "" }); }} > @@ -111,13 +160,23 @@ export function DeviceHostsSettings(props: {
{!props.environmentId ? (

- Select one connected environment to manage its device hosts. + Connect a selected environment to manage device hosts.

) : ( <> + {connectedEnvironments.length > 1 ? ( +

+ { + connectedEnvironments.find( + (environment) => environment.environmentId === props.environmentId, + )?.label + }{" "} + · local device status +

+ ) : null} {props.hosts.map((host) => { const status = state.hostStatuses[host.id]; - const check = checks[host.id]; + const check = checks[checkKey(props.environmentId!, host.id)]; const platforms = check?.platforms ?? state.hosts.find((value) => value.id === host.id)?.platforms ?? @@ -173,8 +232,7 @@ export function DeviceHostsSettings(props: { value.id === host.id)?.toolInspectionError} tools={ - state.hosts.find((value) => value.id === host.id)?.tools ?? - checks[host.id]?.tools + state.hosts.find((value) => value.id === host.id)?.tools ?? check?.tools } /> {error ? ( @@ -201,7 +259,7 @@ export function DeviceHostsSettings(props: { @@ -257,16 +312,71 @@ export function DeviceHostsSettings(props: {
); })} + {connectedEnvironments + .filter((environment) => environment.environmentId !== props.environmentId) + .map((environment) => ( +
+

+ {environment.label} +

+ {(environment.serverConfig?.settings.deviceHosts ?? []).map((host) => { + const check = checks[checkKey(environment.environmentId, host.id)]; + return ( +
+
+
+

{host.label}

+

{host.target}

+
+ + + +
+ {check?.platforms ? ( + + ) : null} + {check?.error ? ( +

+ {check.error} +

+ ) : null} +
+ ); + })} +
+ ))} {editing ? (
{ event.preventDefault(); if (validEditing && Option.isSome(parsedEditing)) { - void save([ - ...props.hosts.filter((host) => host.id !== editing.id), - parsedEditing.value, - ]); + void save(parsedEditing.value, originalHost); } }} > @@ -275,7 +385,7 @@ export function DeviceHostsSettings(props: { setEditing({ ...editing, label: event.target.value })} placeholder="Mac mini" /> @@ -285,17 +395,21 @@ export function DeviceHostsSettings(props: { setEditing({ ...editing, target: event.target.value })} placeholder="user@host or SSH alias" />
-