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 @@
11import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
22import { AppleIcon, AndroidIcon } from "../Icons";
3import { DeviceHostAvailability } from "../device/DeviceHostAvailability";
43import { Spinner } from "../ui/spinner";
5import type {
6 DevicePlatformAvailability,
7 EnvironmentId,
8 SshDeviceHostConfig,
9} from "@t3tools/contracts";
10import * as Cause from "effect/Cause";
4import type { EnvironmentId, SshDeviceHostConfig } from "@t3tools/contracts";
115import { randomUUID } from "../../lib/utils";
126import { useState } from "react";
13import { deviceEnvironment, useDeviceState } from "../../state/device";
7import { useDeviceState } from "../../state/device";
148import { serverEnvironment } from "../../state/server";
159import { useAtomCommand } from "../../state/use-atom-command";
1610import { Button } from "../ui/button";
17import { Input } from "../ui/input";
1811import { MoreVertical, PlusIcon } from "lucide-react";
1912import { Menu, MenuTrigger, MenuPopup, MenuItem } from "../ui/menu";
2013import { SettingsRow } from "./settingsLayout";
......@@ -22,17 +15,23 @@ import { SettingsRow } from "./settingsLayout";
2215import { useSettingsScope } from "./SettingsScopeContext";
2316import { toastManager } from "../ui/toast";
2417import { updateDeviceHosts } from "./deviceHostsSettings.logic";
18import { DeviceHostEditor } from "./DeviceHostEditor";
19import { useHostConnectionChecks } from "./useHostConnectionChecks";
20import { deviceHostConnectionKey } from "./deviceHostConnectionChecks";
2521
2622export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null }) {
27 const { scope, environments, connectedEnvironments, environment: selected } = useSettingsScope();
23 const { scope, environments, connectedEnvironments } = useSettingsScope();
2824 const projectScope = scope.kind === "project" || scope.kind === "checkout";
2925 const update = useAtomCommand(serverEnvironment.updateSettings, { reportFailure: false });
3026 const [editing, setEditing] = useState<SshDeviceHostConfig | null>(null);
3127 const [originalHost, setOriginalHost] = useState<SshDeviceHostConfig | null>(null);
3228 const [busy, setBusy] = useState(false);
33 const validPort = (port: number | undefined) =>
34 port === undefined || (Number.isInteger(port) && port >= 1 && port <= 65535);
35 const { checks, testConnection } = useHostConnectionChecks(props.environmentId);
29 const targets = environments.map((environment) => ({
30 environmentId: environment.environmentId,
31 label: environment.label,
32 connected: environment.connection.phase === "connected",
33 }));
34 const { checks, testConnection } = useHostConnectionChecks(targets);
3635 const save = async (host: SshDeviceHostConfig, remove = false, original = host) => {
3736 if (!props.environmentId || projectScope) return;
3837 setBusy(true);
......@@ -113,6 +112,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
113112 environmentId={environment.environmentId}
114113 hosts={environment.serverConfig?.settings.deviceHosts ?? []}
115114 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 }}
116133 onEdit={(host) => {
117134 setOriginalHost(host);
118135 setEditing(host);
......@@ -122,123 +139,15 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
122139 </div>
123140 ))}
124141 {editing ? (
125 <form
126 className="space-y-3 border-t border-border/50 py-3"
127 onSubmit={(event) => {
128 event.preventDefault();
129 void save(editing, false, originalHost ?? editing);
130 }}
131 >
132 <label className="block space-y-1 text-sm">
133 <span>Name</span>
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>
142 <DeviceHostEditor
143 key={editing.id}
144 host={editing}
145 isNew={originalHost === null}
146 targets={targets}
147 busy={busy}
148 onSave={(host) => void save(host, false, originalHost ?? host)}
149 onClose={() => setEditing(null)}
150 />
242151 ) : null}
243152 </>
244153 )}
......@@ -247,49 +156,24 @@ export function DeviceHostsSettings(props: { environmentId: EnvironmentId | null
247156 );
248157}
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
278159function DeviceHostList({
279160 environmentId,
280161 hosts,
281162 busy,
282163 onEdit,
283164 onRemove,
165 checks,
166 testConnection,
284167}: {
285168 environmentId: EnvironmentId;
286169 hosts: ReadonlyArray<SshDeviceHostConfig>;
287170 busy: boolean;
288171 onEdit: (host: SshDeviceHostConfig) => void;
289172 onRemove: (host: SshDeviceHostConfig) => void;
173 checks: ReturnType<typeof useHostConnectionChecks>["checks"];
174 testConnection: ReturnType<typeof useHostConnectionChecks>["testConnection"];
290175}) {
291176 const { state } = useDeviceState(environmentId);
292 const { checks, testConnection } = useHostConnectionChecks(environmentId);
293177 return (
294178 <>
295179 {hosts.length === 0 ? (
......@@ -297,17 +181,27 @@ function DeviceHostList({
297181 ) : null}
298182 {hosts.map((host) => {
299183 const status = state.hostStatuses[host.id];
300 const check = checks[host.id];
184 const check = checks[deviceHostConnectionKey(host)]?.[environmentId];
301185 const platforms =
302 check?.platforms ?? state.hosts.find((value) => value.id === host.id)?.platforms ?? [];
303 const progress = check?.pending
304 ? "Checking connection…"
305 : status?.status === "installing"
306 ? "Installing device support…"
307 : status?.status === "starting"
308 ? "Connecting…"
309 : null;
310 const error = check?.error ?? (status?.status === "failed" ? status.detail : undefined);
186 (check?.status === "connected" ? check.platforms : undefined) ??
187 state.hosts.find((value) => value.id === host.id)?.platforms ??
188 [];
189 const progress =
190 check?.status === "pending"
191 ? "Checking connection…"
192 : status?.status === "installing"
193 ? "Installing device support…"
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;
311205 return (
312206 <div key={host.id} className="flex items-center gap-2 border-t border-border/50 py-2.5">
313207 <div className="min-w-0 flex-1">
......@@ -342,6 +236,9 @@ function DeviceHostList({
342236 ))}
343237 </div>
344238 <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}
345242 {error ? (
346243 <div className="mt-1" role="status">
347244 <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.
7575
7676## SSH device hosts
7777
78In Settings → Integrations → Devices, select one connected environment
79and add a host under **Device hosts**. Enter an SSH alias or `user@host`, with
78In Settings → Integrations → Devices, choose the environments that should use
79the host and add it under **Device hosts**. Enter an SSH alias or `user@host`, with
8080an optional identity file and port. These resolve on the environment server,
8181so use the SSH configuration and keys available there. Password prompts are
8282not supported.
8383
8484**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.
8688Node 22 or newer and npm must be available to non-interactive SSH commands.
8789T3 checks common Homebrew and Android SDK locations; custom installations need
8890the appropriate PATH and ANDROID_HOME on the host.