authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-13 22:13:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 22:13:34-07:00
log8984f8103d0836c0b340fe70cd053428d622a8c3
treef61f2689dcf05acc0da64fd9faf11c0d4fb05204
parent1ced38a6647b7f466b535b4b413ca4a74303da8e
signature Signed by PGP key B5690EEEBB952194

fix(web): test device hosts across selected environments (#11699)


6 files changed, 487 insertions(+), 172 deletions(-)

apps/web/src/components/settings/DeviceHostEditor.tsx created+224
...@@ -0,0 +1,224 @@
1import { useState } from "react";
2import * as Option from "effect/Option";
3import type { SshDeviceHostConfig } from "@t3tools/contracts";
4import { CheckIcon, MonitorIcon, XIcon } from "lucide-react";
5import { Button } from "../ui/button";
6import { Input } from "../ui/input";
7import { Spinner } from "../ui/spinner";
8import {
9 Dialog,
10 DialogPopup,
11 DialogHeader,
12 DialogTitle,
13 DialogDescription,
14 DialogPanel,
15 DialogFooter,
16} from "../ui/dialog";
17import { DeviceHostAvailability } from "../device/DeviceHostAvailability";
18import { useHostConnectionChecks } from "./useHostConnectionChecks";
19import {
20 deviceHostConnectionKey,
21 parseDeviceHostDraft,
22 type DeviceHostCheckTarget,
23} from "./deviceHostConnectionChecks";
24
25export function DeviceHostEditor({
26 host,
27 isNew,
28 targets,
29 busy,
30 onSave,
31 onClose,
32}: {
33 host: SshDeviceHostConfig;
34 isNew: boolean;
35 targets: ReadonlyArray<DeviceHostCheckTarget>;
36 busy: boolean;
37 onSave: (host: SshDeviceHostConfig) => void;
38 onClose: () => void;
39}) {
40 const [draft, setDraft] = useState(host);
41 const { checks, testConnection } = useHostConnectionChecks(targets);
42 const results = checks[deviceHostConnectionKey(draft)];
43 const checking = Object.values(results ?? {}).some((check) => check.status === "pending");
44 const input = parseDeviceHostDraft({ ...draft, label: draft.label.trim() || draft.target });
45 const valid = Option.isSome(input);
46 const failed = Object.values(results ?? {}).filter((check) => check.status === "failed").length;
47 return (
48 <Dialog
49 open
50 onOpenChange={(open) => {
51 if (!open && !busy) onClose();
52 }}
53 >
54 <DialogPopup
55 showCloseButton={!busy}
56 render={
57 <form
58 onSubmit={(event) => {
59 event.preventDefault();
60 if (Option.isSome(input) && draft.label.trim() && !busy && !checking)
61 onSave(input.value);
62 }}
63 />
64 }
65 >
66 <DialogHeader>
67 <DialogTitle>{isNew ? "Add device host" : "Edit device host"}</DialogTitle>
68 <DialogDescription>
69 {targets.length === 1
70 ? `Connect from ${targets[0]?.label}.`
71 : `Connect from ${targets.length} selected environments.`}{" "}
72 Hosts on the same machine are skipped.
73 </DialogDescription>
74 </DialogHeader>
75 <DialogPanel className="space-y-4">
76 <label className="block space-y-1.5 text-sm">
77 <span>Name</span>
78 <Input
79 autoFocus
80 required
81 value={draft.label}
82 disabled={busy}
83 onChange={(event) => setDraft({ ...draft, label: event.target.value })}
84 placeholder="Mac mini"
85 />
86 </label>
87 <label className="block space-y-1.5 text-sm">
88 <span>SSH target</span>
89 <Input
90 required
91 value={draft.target}
92 disabled={busy}
93 onChange={(event) => setDraft({ ...draft, target: event.target.value })}
94 placeholder="user@host or SSH alias"
95 />
96 </label>
97 <details
98 open={host.port !== undefined || host.identityFile !== undefined || undefined}
99 className="text-sm"
100 >
101 <summary className="cursor-pointer text-muted-foreground">SSH options</summary>
102 <div className="mt-3 grid grid-cols-[minmax(0,1fr)_7rem] gap-3">
103 <label className="block space-y-1.5">
104 <span>Identity file</span>
105 <Input
106 value={draft.identityFile ?? ""}
107 disabled={busy}
108 onChange={(event) => {
109 const { identityFile: _, ...rest } = draft;
110 setDraft(
111 event.target.value ? { ...rest, identityFile: event.target.value } : rest,
112 );
113 }}
114 placeholder="SSH config default"
115 />
116 </label>
117 <label className="block space-y-1.5">
118 <span>Port</span>
119 <Input
120 type="number"
121 min={1}
122 max={65535}
123 value={draft.port ?? ""}
124 disabled={busy}
125 onChange={(event) => {
126 const { port: _, ...rest } = draft;
127 setDraft(
128 event.target.value ? { ...rest, port: Number(event.target.value) } : rest,
129 );
130 }}
131 placeholder="Default"
132 />
133 </label>
134 </div>
135 <p className="mt-2 text-xs text-muted-foreground">
136 Optional. Resolved separately on each environment.
137 </p>
138 </details>
139 <div className="rounded-lg border border-border/60">
140 <div className="flex items-center justify-between gap-3 px-3 py-2.5">
141 <p role="status" className="text-xs text-muted-foreground">
142 {checking
143 ? "Checking environments…"
144 : results
145 ? failed
146 ? `${failed} of ${targets.length} failed`
147 : "Connection checks passed"
148 : "Check access before saving"}
149 </p>
150 <Button
151 type="button"
152 size="sm"
153 variant="outline"
154 disabled={busy || checking || !valid}
155 onClick={() => {
156 if (Option.isSome(input)) void testConnection(input.value);
157 }}
158 >
159 {checking ? <Spinner className="size-3" /> : null} Test connection
160 </Button>
161 </div>
162 {results ? (
163 <ul className="border-t border-border/60 divide-y divide-border/40">
164 {targets.map((target) => {
165 const result = results[target.environmentId];
166 if (!result) return null;
167 return (
168 <li key={target.environmentId} className="px-3 py-2.5 text-xs">
169 <div className="flex items-center justify-between gap-3">
170 <span className="truncate font-medium">{target.label}</span>
171 <span
172 className={`flex shrink-0 items-center gap-1.5 ${result.status === "failed" ? "text-destructive" : "text-muted-foreground"}`}
173 >
174 {result.status === "pending" ? (
175 <>
176 <Spinner className="size-3" /> Checking…
177 </>
178 ) : result.status === "local" ? (
179 <>
180 <MonitorIcon className="size-3" /> Already available locally
181 </>
182 ) : result.status === "failed" ? (
183 <>
184 <XIcon className="size-3" /> Failed
185 </>
186 ) : (
187 <>
188 <CheckIcon className="size-3" /> Connected
189 </>
190 )}
191 </span>
192 </div>
193 {result.status === "connected" ? (
194 <div className="mt-1.5">
195 <DeviceHostAvailability platforms={result.platforms} />
196 </div>
197 ) : null}
198 {result.status === "failed" ? (
199 <details className="mt-1.5 text-muted-foreground">
200 <summary className="cursor-pointer">Show error</summary>
201 <p className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap break-words">
202 {result.error}
203 </p>
204 </details>
205 ) : null}
206 </li>
207 );
208 })}
209 </ul>
210 ) : null}
211 </div>
212 </DialogPanel>
213 <DialogFooter>
214 <Button type="button" variant="ghost" disabled={busy} onClick={onClose}>
215 Cancel
216 </Button>
217 <Button type="submit" disabled={busy || checking || !valid || !draft.label.trim()}>
218 {busy ? <Spinner className="size-3" /> : null} Save host
219 </Button>
220 </DialogFooter>
221 </DialogPopup>
222 </Dialog>
223 );
224}
apps/web/src/components/settings/DeviceHostsSettings.tsx+66-169
...@@ -1,20 +1,13 @@...@@ -1,20 +1,13 @@
1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";1import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2import { AppleIcon, AndroidIcon } from "../Icons";2import { AppleIcon, AndroidIcon } from "../Icons";
3import { DeviceHostAvailability } from "../device/DeviceHostAvailability";
4import { Spinner } from "../ui/spinner";3import { Spinner } from "../ui/spinner";
5import type {4import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts";
6 DevicePlatformAvailability,
7 EnvironmentId,
8 SshDeviceHostConfig,
9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
11import { randomUUID } from "../../lib/utils";5import { randomUUID } from "../../lib/utils";
12import { useState } from "react";6import { useState } from "react";
13import { deviceEnvironment, useDeviceState } from "../../state/device";7import { useDeviceState } from "../../state/device";
14import { serverEnvironment } from "../../state/server";8import { serverEnvironment } from "../../state/server";
15import { useAtomCommand } from "../../state/use-atom-command";9import { useAtomCommand } from "../../state/use-atom-command";
16import { Button } from "../ui/button";10import { Button } from "../ui/button";
17import { Input } from "../ui/input";
18import { MoreVertical, PlusIcon } from "lucide-react";11import { MoreVertical, PlusIcon } from "lucide-react";
19import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";12import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";
20import { SettingsRow } from "./settingsLayout";13import { SettingsRow } from "./settingsLayout";
...@@ -22,17 +15,23 @@ import { SettingsRow } from "./settingsLayout";...@@ -22,17 +15,23 @@ import { SettingsRow } from "./settingsLayout";
22import { useSettingsScope } from "./SettingsScopeContext";15import { useSettingsScope } from "./SettingsScopeContext";
23import { toastManager } from "../ui/toast";16import { toastManager } from "../ui/toast";
24import { updateDeviceHosts } from "./deviceHostsSettings.logic";17import { updateDeviceHosts } from "./deviceHostsSettings.logic";
18import { DeviceHostEditor } from "./DeviceHostEditor";
19import { useHostConnectionChecks } from "./useHostConnectionChecks";
20import { deviceHostConnectionKey } from "./deviceHostConnectionChecks";
2521
26export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) {22export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) {
27 const { scope, environments, connectedEnvironments, environment: selected } = useSettingsScope();23 const { scope, environments, connectedEnvironments } = useSettingsScope();
28 const projectScope = scope.kind === "project" || scope.kind === "checkout";24 const projectScope = scope.kind === "project" || scope.kind === "checkout";
29 const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });25 const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
30 const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);26 const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);
31 const [originalHost, setOriginalHost] = useState<SshDeviceHostConfig | null>(null);27 const [originalHost, setOriginalHost] = useState<SshDeviceHostConfig | null>(null);
32 const [busy, setBusy] = useState(false);28 const [busy, setBusy] = useState(false);
33 const validPort = (port: number | undefined) =>29 const targets = environments.map((environment) => ({
34 port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535);30 environmentId: environment.environmentId,
35 const { checks, testConnection } = useHostConnectionChecks(props.environmentId);31 label: environment.label,
32 connected: environment.connection.phase === "connected",
33 }));
34 const { checks, testConnection } = useHostConnectionChecks(targets);
36 const save = async (host: SshDeviceHostConfig, remove = false, original = host) => {35 const save = async (host: SshDeviceHostConfig, remove = false, original = host) => {
37 if (!props.environmentId || projectScope) return;36 if (!props.environmentId || projectScope) return;
38 setBusy(true);37 setBusy(true);
...@@ -113,6 +112,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null...@@ -113,6 +112,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
113 environmentId={environment.environmentId}112 environmentId={environment.environmentId}
114 hosts={environment.serverConfig?.settings.deviceHosts ?? []}113 hosts={environment.serverConfig?.settings.deviceHosts ?? []}
115 busy={projectScope || busy}114 busy={projectScope || busy}
115 checks={checks}
116 testConnection={async (host) => {
117 const results = await testConnection(host);
118 if (!results) return;
119 const failed = targets.filter(
120 (target) => results[target.environmentId]?.status === "failed",
121 );
122 toastManager.add({
123 type: failed.length ? "error" : "success",
124 title: failed.length
125 ? `${host.label}: ${failed.length} of ${targets.length} environments failed`
126 : `${host.label}: connection checks passed`,
127 description: failed.length
128 ? `Could not connect from ${failed.map((target) => target.label).join(", ")}.`
129 : "Connected or already available locally on each selected environment.",
130 });
131 return results;
132 }}
116 onEdit={(host) => {133 onEdit={(host) => {
117 setOriginalHost(host);134 setOriginalHost(host);
118 setEditing(host);135 setEditing(host);
...@@ -122,123 +139,15 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null...@@ -122,123 +139,15 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
122 </div>139 </div>
123 ))}140 ))}
124 {editing ? (141 {editing ? (
125 <form142 <DeviceHostEditor
126 className="space-y-3 border-t border-border/50 py-3"143 key={editing.id}
127 onSubmit={(event) => {144 host={editing}
128 event.preventDefault();145 isNew={originalHost === null}
129 void save(editing, false, originalHost ?? editing);146 targets={targets}
130 }}147 busy={busy}
131 >148 onSave={(host) => void save(host, false, originalHost ?? host)}
132 <label className="block space-y-1 text-sm">149 onClose={() => setEditing(null)}
133 <span>Name</span>150 />
134 <Input
135 required
136 value={editing.label}
137 disabled={projectScope || busy}
138 onChange={(event) => setEditing({ ...editing, label: event.target.value })}
139 placeholder="Mac mini"
140 />
141 </label>
142 <label className="block space-y-1 text-sm">
143 <span>SSH target</span>
144 <Input
145 required
146 value={editing.target}
147 disabled={projectScope || busy}
148 onChange={(event) => setEditing({ ...editing, target: event.target.value })}
149 placeholder="user@host or SSH alias"
150 />
151 </label>
152 <label className="block space-y-1 text-sm">
153 <span>Identity file, optional</span>
154 <Input
155 value={editing.identityFile ?? ""}
156 disabled={projectScope || busy}
157 onChange={(event) => {
158 const { identityFile: _, ...rest } = editing;
159 setEditing(
160 event.target.value ? { ...rest, identityFile: event.target.value } : rest,
161 );
162 }}
163 placeholder="~/.ssh/id_ed25519"
164 />
165 </label>
166 <label className="block space-y-1 text-sm">
167 <span>Port, optional</span>
168 <Input
169 type="number"
170 min={1}
171 max={65535}
172 value={editing.port ?? ""}
173 disabled={projectScope || busy}
174 onChange={(event) => {
175 const { port: _, ...rest } = editing;
176 setEditing(
177 event.target.value ? { ...rest, port: Number(event.target.value) } : rest,
178 );
179 }}
180 placeholder="SSH config default"
181 />
182 </label>
183 <div className="flex flex-wrap gap-2">
184 <Button
185 size="sm"
186 type="submit"
187 disabled={
188 busy ||
189 !editing.label.trim() ||
190 !editing.target.trim() ||
191 !validPort(editing.port)
192 }
193 >
194 Save host
195 </Button>
196 <Button
197 size="sm"
198 type="button"
199 variant="outline"
200 disabled={
201 busy ||
202 !editing.label.trim() ||
203 !editing.target.trim() ||
204 !validPort(editing.port)
205 }
206 onClick={() => void testConnection(editing)}
207 >
208 {connectedEnvironments.length > 1
209 ? `Test from ${selected?.label}`
210 : "Test connection"}
211 </Button>
212 <Button
213 size="sm"
214 type="button"
215 variant="ghost"
216 disabled={projectScope || busy}
217 onClick={() => {
218 setEditing(null);
219 }}
220 >
221 Cancel
222 </Button>
223 </div>
224 {checks[editing.id]?.pending ? (
225 <span
226 role="status"
227 className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"
228 >
229 <Spinner className="size-3" />
230 Checking connection…
231 </span>
232 ) : null}
233 {checks[editing.id]?.platforms ? (
234 <DeviceHostAvailability platforms={checks[editing.id]?.platforms ?? []} />
235 ) : null}
236 {checks[editing.id]?.error ? (
237 <p role="alert" className="text-xs text-destructive">
238 {checks[editing.id]?.error}
239 </p>
240 ) : null}
241 </form>
242 ) : null}151 ) : null}
243 </>152 </>
244 )}153 )}
...@@ -247,49 +156,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null...@@ -247,49 +156,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
247 );156 );
248}157}
249158
250function useHostConnectionChecks(environmentId: EnvironmentId | null) {
251 const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false });
252 const [checks, setChecks] = useState<
253 Record<
254 string,
255 { pending?: boolean; platforms?: ReadonlyArray<DevicePlatformAvailability>; error?: string }
256 >
257 >({});
258 const setCheck = (id: string, value: (typeof checks)[string]) =>
259 setChecks((current) => ({ ...current, [id]: value }));
260 const testConnection = async (host: SshDeviceHostConfig) => {
261 if (!environmentId || checks[host.id]?.pending) return;
262 setCheck(host.id, { pending: true });
263 try {
264 const summary = await test({ environmentId: environmentId, input: host });
265 setCheck(
266 host.id,
267 summary._tag === "Failure"
268 ? { error: Cause.pretty(summary.cause) }
269 : { platforms: summary.value.platforms },
270 );
271 } catch (error) {
272 setCheck(host.id, { error: error instanceof Error ? error.message : String(error) });
273 }
274 };
275 return { checks, testConnection };
276}
277
278function DeviceHostList({159function DeviceHostList({
279 environmentId,160 environmentId,
280 hosts,161 hosts,
281 busy,162 busy,
282 onEdit,163 onEdit,
283 onRemove,164 onRemove,
165 checks,
166 testConnection,
284}: {167}: {
285 environmentId: EnvironmentId;168 environmentId: EnvironmentId;
286 hosts: ReadonlyArray<SshDeviceHostConfig>;169 hosts: ReadonlyArray<SshDeviceHostConfig>;
287 busy: boolean;170 busy: boolean;
288 onEdit: (host: SshDeviceHostConfig) => void;171 onEdit: (host: SshDeviceHostConfig) => void;
289 onRemove: (host: SshDeviceHostConfig) => void;172 onRemove: (host: SshDeviceHostConfig) => void;
173 checks: ReturnType<typeof useHostConnectionChecks>["checks"];
174 testConnection: ReturnType<typeof useHostConnectionChecks>["testConnection"];
290}) {175}) {
291 const { state } = useDeviceState(environmentId);176 const { state } = useDeviceState(environmentId);
292 const { checks, testConnection } = useHostConnectionChecks(environmentId);
293 return (177 return (
294 <>178 <>
295 {hosts.length === 0 ? (179 {hosts.length === 0 ? (
...@@ -297,17 +181,27 @@ function DeviceHostList({...@@ -297,17 +181,27 @@ function DeviceHostList({
297 ) : null}181 ) : null}
298 {hosts.map((host) => {182 {hosts.map((host) => {
299 const status = state.hostStatuses[host.id];183 const status = state.hostStatuses[host.id];
300 const check = checks[host.id];184 const check = checks[deviceHostConnectionKey(host)]?.[environmentId];
301 const platforms =185 const platforms =
302 check?.platforms ?? state.hosts.find((value) => value.id === host.id)?.platforms ?? [];186 (check?.status === "connected" ? check.platforms : undefined) ??
303 const progress = check?.pending187 state.hosts.find((value) => value.id === host.id)?.platforms ??
304 ? "Checking connection…"188 [];
305 : status?.status === "installing"189 const progress =
306 ? "Installing device support…"190 check?.status === "pending"
307 : status?.status === "starting"191 ? "Checking connection…"
308 ? "Connecting…"192 : status?.status === "installing"
309 : null;193 ? "Installing device support…"
310 const error = check?.error ?? (status?.status === "failed" ? status.detail : undefined);194 : status?.status === "starting"
195 ? "Connecting…"
196 : null;
197 const error =
198 check?.status === "failed"
199 ? check.error
200 : check?.status === "local"
201 ? undefined
202 : status?.status === "failed"
203 ? status.detail
204 : undefined;
311 return (205 return (
312 <div key={host.id} className="flex items-center gap-2 border-t border-border/50 py-2.5">206 <div key={host.id} className="flex items-center gap-2 border-t border-border/50 py-2.5">
313 <div className="min-w-0 flex-1">207 <div className="min-w-0 flex-1">
...@@ -342,6 +236,9 @@ function DeviceHostList({...@@ -342,6 +236,9 @@ function DeviceHostList({
342 ))}236 ))}
343 </div>237 </div>
344 <p className="truncate text-xs text-muted-foreground">{host.target}</p>238 <p className="truncate text-xs text-muted-foreground">{host.target}</p>
239 {check?.status === "local" ? (
240 <p className="mt-1 text-xs text-muted-foreground">Already available locally</p>
241 ) : null}
345 {error ? (242 {error ? (
346 <div className="mt-1" role="status">243 <div className="mt-1" role="status">
347 <details className="text-xs text-destructive">244 <details className="text-xs text-destructive">
apps/web/src/components/settings/deviceHostConnectionChecks.test.ts created+85
...@@ -0,0 +1,85 @@
1import * as Option from "effect/Option";
2import { describe, expect, it } from "vite-plus/test";
3import { EnvironmentId, type DeviceHostSummary } from "@t3tools/contracts";
4import {
5 checkDeviceHostConnections,
6 parseDeviceHostDraft,
7 deviceHostConnectionKey,
8 type DeviceHostCheck,
9} from "./deviceHostConnectionChecks";
10
11const host = { id: "mac", label: "Mac mini", target: "user@mac" };
12const ids = ["a", "b", "c", "d"].map((id) => EnvironmentId.make(id));
13const targets = ids.map((environmentId, index) => ({
14 environmentId,
15 label: environmentId,
16 connected: index !== 3,
17}));
18const summary: DeviceHostSummary = {
19 id: "mac",
20 label: "Mac mini",
21 kind: "ssh",
22 platforms: [{ platform: "ios", available: true }],
23 hubInstalled: false,
24 agentDeviceInstalled: false,
25};
26
27describe("device host connection checks", () => {
28 it("starts all connected environments and retains success, local, failure, and offline results", async () => {
29 const calls: string[] = [];
30 const pending = new Map<
31 string,
32 { resolve: (value: DeviceHostSummary) => void; reject: (error: Error) => void }
33 >();
34 const results = new Map<string, DeviceHostCheck>();
35 const run = checkDeviceHostConnections(
36 targets,
37 host,
38 (environmentId) => {
39 calls.push(environmentId);
40 return new Promise((resolve, reject) => pending.set(environmentId, { resolve, reject }));
41 },
42 (environmentId, result) => results.set(environmentId, result),
43 );
44 expect(calls).toEqual(ids.slice(0, 3));
45 expect(results.get(ids[0]!)).toEqual({ status: "pending" });
46 pending.get(ids[0]!)!.resolve(summary);
47 pending.get(ids[1]!)!.resolve({ ...summary, id: "local", kind: "local" });
48 pending.get(ids[2]!)!.reject(new Error("SSH key rejected"));
49 await run;
50 expect([...results.values()]).toEqual([
51 { status: "connected", platforms: summary.platforms },
52 { status: "local" },
53 { status: "failed", error: "SSH key rejected" },
54 { status: "failed", error: "Environment disconnected" },
55 ]);
56 });
57
58 it("does not reuse results after editing a destination or SSH options", () => {
59 const key = deviceHostConnectionKey(host);
60 for (const changed of [
61 { ...host, target: "other" },
62 { ...host, port: 2222 },
63 { ...host, identityFile: "~/.ssh/other" },
64 ]) {
65 expect(deviceHostConnectionKey(changed)).not.toBe(key);
66 }
67 expect(
68 deviceHostConnectionKey({ ...host, id: "another-environment-id", label: "Renamed" }),
69 ).toBe(key);
70 });
71 it("validates SSH targets and normalizes optional identity files through the host contract", () => {
72 for (const target of ["-invalid", "user@bad host", " "]) {
73 expect(parseDeviceHostDraft({ ...host, target })._tag).toBe("None");
74 }
75 for (const port of [0, 65536, 1.5]) {
76 expect(parseDeviceHostDraft({ ...host, port })._tag).toBe("None");
77 }
78 expect(parseDeviceHostDraft({ ...host, target: " user@mac ", identityFile: " " })).toEqual(
79 Option.some(host),
80 );
81 expect(parseDeviceHostDraft({ ...host, identityFile: " ~/.ssh/device " })).toEqual(
82 Option.some({ ...host, identityFile: "~/.ssh/device" }),
83 );
84 });
85});
apps/web/src/components/settings/deviceHostConnectionChecks.ts created+61
...@@ -0,0 +1,61 @@
1import {
2 type DeviceHostSummary,
3 type DevicePlatformAvailability,
4 type EnvironmentId,
5 SshDeviceHostConfig,
6} from "@t3tools/contracts";
7import * as Schema from "effect/Schema";
8
9export interface DeviceHostCheckTarget {
10 environmentId: EnvironmentId;
11 label: string;
12 connected: boolean;
13}
14export type DeviceHostCheck =
15 | { status: "pending" }
16 | { status: "local" }
17 | { status: "connected"; platforms: ReadonlyArray<DevicePlatformAvailability> }
18 | { status: "failed"; error: string };
19
20const decodeDeviceHostDraft = Schema.decodeUnknownOption(SshDeviceHostConfig);
21
22export function parseDeviceHostDraft(host: SshDeviceHostConfig) {
23 const { identityFile, ...rest } = host;
24 return decodeDeviceHostDraft({
25 ...rest,
26 ...(identityFile?.trim() ? { identityFile: identityFile.trim() } : {}),
27 });
28}
29
30export function deviceHostConnectionKey(host: SshDeviceHostConfig) {
31 return JSON.stringify([host.target.trim(), host.port, host.identityFile?.trim() || undefined]);
32}
33
34/** Each environment settles independently so one failure cannot hide the other results. */
35export async function checkDeviceHostConnections(
36 targets: ReadonlyArray<DeviceHostCheckTarget>,
37 host: SshDeviceHostConfig,
38 probe: (environmentId: EnvironmentId, host: SshDeviceHostConfig) => Promise<DeviceHostSummary>,
39 report: (environmentId: EnvironmentId, result: DeviceHostCheck) => void,
40) {
41 await Promise.all(
42 targets.map(async (target) => {
43 report(target.environmentId, { status: "pending" });
44 try {
45 if (!target.connected) throw new Error("Environment disconnected");
46 const result = await probe(target.environmentId, host);
47 report(
48 target.environmentId,
49 result.kind === "local"
50 ? { status: "local" }
51 : { status: "connected", platforms: result.platforms },
52 );
53 } catch (error) {
54 report(target.environmentId, {
55 status: "failed",
56 error: error instanceof Error ? error.message : String(error),
57 });
58 }
59 }),
60 );
61}
apps/web/src/components/settings/useHostConnectionChecks.ts created+46
...@@ -0,0 +1,46 @@
1import { useRef, useState } from "react";
2import * as Cause from "effect/Cause";
3import type { SshDeviceHostConfig } from "@t3tools/contracts";
4import { deviceEnvironment } from "../../state/device";
5import { useAtomCommand } from "../../state/use-atom-command";
6import {
7 checkDeviceHostConnections,
8 deviceHostConnectionKey,
9 type DeviceHostCheck,
10 type DeviceHostCheckTarget,
11} from "./deviceHostConnectionChecks";
12
13export function useHostConnectionChecks(targets: ReadonlyArray<DeviceHostCheckTarget>) {
14 const test = useAtomCommand(deviceEnvironment.testHost, { reportFailure: false });
15 const [checks, setChecks] = useState<Record<string, Record<string, DeviceHostCheck>>>({});
16 const running = useRef(new Set<string>());
17 const testConnection = async (host: SshDeviceHostConfig) => {
18 const key = deviceHostConnectionKey(host);
19 if (running.current.has(key)) return;
20 running.current.add(key);
21 setChecks((current) => ({ ...current, [key]: {} }));
22 const results: Record<string, DeviceHostCheck> = {};
23 try {
24 await checkDeviceHostConnections(
25 targets,
26 host,
27 async (environmentId, input) => {
28 const result = await test({ environmentId, input });
29 if (result._tag === "Failure") throw new Error(Cause.pretty(result.cause));
30 return result.value;
31 },
32 (environmentId, result) => {
33 results[environmentId] = result;
34 setChecks((current) => ({
35 ...current,
36 [key]: { ...current[key], [environmentId]: result },
37 }));
38 },
39 );
40 return results;
41 } finally {
42 running.current.delete(key);
43 }
44 };
45 return { checks, testConnection };
46}
docs/user/devices.md+5-3
...@@ -75,14 +75,16 @@ still-image stream and Android cannot show video....@@ -75,14 +75,16 @@ still-image stream and Android cannot show video.
7575
76## SSH device hosts76## SSH device hosts
7777
78In Settings → Integrations → Devices, select one connected environment78In Settings → Integrations → Devices, choose the environments that should use
79and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with79the host and add it under **Device hosts**. Enter an SSH alias or `user@host`, with
80an optional identity file and port. These resolve on the environment server,80an optional identity file and port. These resolve on the environment server,
81so use the SSH configuration and keys available there. Password prompts are81so use the SSH configuration and keys available there. Password prompts are
82not supported.82not supported.
8383
84**Test connection** checks SSH, Node, npm, and platform tools without installing84**Test connection** checks SSH, Node, npm, and platform tools without installing
85anything. The first device listing installs pinned device tools on the host.85anything, with a result for each selected environment. Targets that resolve to
86the environment’s own machine are skipped, since its devices are already local.
87The first device listing installs pinned device tools on the host.
86Node 22 or newer and npm must be available to non-interactive SSH commands.88Node 22 or newer and npm must be available to non-interactive SSH commands.
87T3 checks common Homebrew and Android SDK locations; custom installations need89T3 checks common Homebrew and Android SDK locations; custom installations need
88the appropriate PATH and ANDROID_HOME on the host.90the appropriate PATH and ANDROID_HOME on the host.