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>
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 size="xs" /> : 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 size="xs" /> 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 size="xs" /> : null} Save host
219 </Button>
220 </DialogFooter>
221 </DialogPopup>
222 </Dialog>
223 );
224}