| 1 | import { useState } from "react"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | import type { SshDeviceHostConfig } from "@t3tools/contracts"; |
| 4 | import { CheckIcon, MonitorIcon, XIcon } from "lucide-react"; |
| 5 | import { Button } from "../ui/button"; |
| 6 | import { Input } from "../ui/input"; |
| 7 | import { Spinner } from "../ui/spinner"; |
| 8 | import { |
| 9 | Dialog, |
| 10 | DialogPopup, |
| 11 | DialogHeader, |
| 12 | DialogTitle, |
| 13 | DialogDescription, |
| 14 | DialogPanel, |
| 15 | DialogFooter, |
| 16 | } from "../ui/dialog"; |
| 17 | import { DeviceHostAvailability } from "../device/DeviceHostAvailability"; |
| 18 | import { useHostConnectionChecks } from "./useHostConnectionChecks"; |
| 19 | import { |
| 20 | deviceHostConnectionKey, |
| 21 | parseDeviceHostDraft, |
| 22 | type DeviceHostCheckTarget, |
| 23 | } from "./deviceHostConnectionChecks"; |
| 24 | |
| 25 | export 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 | } |