1import { useSearchParams } from "@solidjs/router";
2import CircuitBoard from "lucide-solid/icons/circuit-board";
3import HardDrive from "lucide-solid/icons/hard-drive";
4import Network from "lucide-solid/icons/network";
5import Plus from "lucide-solid/icons/plus";
6import Power from "lucide-solid/icons/power";
7import Usb from "lucide-solid/icons/usb";
8import { createEffect, createResource, createSignal, For, onCleanup, Show } from "solid-js";
9import { LIVE_INTERVAL } from "../types/model.ts";
10import type { Domain, DOMAIN_ACTIONS, Hostdev, Image } from "../types/vms.ts";
11import { parseResponse } from "hono/client";
12import { api, queries, reason, unconnected } from "../api.ts";
13import { Checkbox } from "../components/Checkbox.tsx";
14import { Copy } from "../components/Copy.tsx";
15import { showConfirmDialog, showTextDialog } from "../components/Dialog.tsx";
16import { ListPage } from "../components/ListPage.tsx";
17import { lastGood, Loaded } from "../components/Loaded.tsx";
18import { Meter } from "../components/Meter.tsx";
19import { type StatusKind, StatusLabel } from "../components/Status.tsx";
20import { TimeChart } from "../components/TimeChart.tsx";
21import { toast } from "../components/Toast.tsx";
22import { bytes, datetime, duration, percent, plural } from "../format.ts";
23import "./VMs.css";
24
25const GiB = 2 ** 30;
26/** A guest whose agent hasn't answered this long after power-on counts as up anyway; many never install one. */
27const STARTING = 180;
28
29const REASONS: Record<string, string> = {
30 user: "paused manually",
31 ioerror: "paused after a disk error, check the vms pool's free space",
32 watchdog: "paused by its watchdog",
33 panicked: "the guest kernel panicked",
34};
35
36const isOff = (vm: Domain) => vm.state === "shutoff" || vm.state === "crashed";
37const running = (vm: Domain) => vm.state === "running" || vm.state === "blocked";
38
39function status(vm: Domain): [StatusKind, string, tip?: string] {
40 if (running(vm)) {
41 if (vm.startedAt === null) return ["healthy", "up"];
42 const up = Date.now() / 1000 - vm.startedAt!;
43 const started = `started ${datetime(vm.startedAt!)}`;
44 if (vm.agent !== "disconnected") return ["healthy", `up ${duration(up)}`, started];
45 if (up < STARTING) return ["starting", "starting", started];
46 return ["healthy", `up ${duration(up)}`, `${started}, guest agent not answering`];
47 }
48 const tip = vm.reason ? REASONS[vm.reason] ?? vm.reason : undefined;
49 switch (vm.state) {
50 case "paused": return ["stopped", "paused", tip];
51 case "pmsuspended": return ["stopped", "asleep", tip];
52 case "shutdown": return ["restarting", "stopping"];
53 case "crashed": return ["down", "crashed", tip];
54 default: return ["stopped", "off"];
55 }
56}
57
58/** "/dev/disk/by-id/nvme-Samsung_SSD_980_PRO_1TB_S5GX…" reads as "Samsung SSD 980 PRO 1TB": no bus, no serial. */
59const drive = (path: string) => path.split("/").at(-1)!.replace(/^[a-z]+-/, "").replace(/_[^_]+$/, "").replaceAll("_", " ");
60
61/** [8, 9, 10, 12] reads "8–10, 12". */
62const threads = (list: number[]) => list
63 .reduce<[number, number][]>((runs, n) => {
64 const last = runs.at(-1);
65 if (last && n === last[1] + 1) last[1] = n;
66 else runs.push([n, n]);
67 return runs;
68 }, [])
69 .map(([from, to]) => (from === to ? `${from}` : `${from}–${to}`))
70 .join(", ");
71
72/** Current and max memory, like "16 GiB", or "8–12 GiB" while the balloon holds some back. */
73const allocated = (vm: Domain) => (vm.balloon < vm.memory ? `${+(vm.balloon / GiB).toFixed(1)}–${bytes(vm.memory)}` : bytes(vm.memory));
74
75/** Remote desktop for Windows, the web UI for Home Assistant, ssh for the rest. */
76function remote(os: string, address: string): [href: string, label: string] {
77 const host = address.includes(":") ? `[${address}]` : address;
78 if (os.startsWith("Windows")) return [`rdp://full%20address=s:${host}:3389`, "rdp"];
79 if (os.startsWith("Home Assistant")) return [`http://${host}:8123`, "open"];
80 return [`ssh://${host}`, "ssh"];
81}
82
83/** Devices sharing an IOMMU group pass through together, so a GPU and its audio function read as one. */
84function groups(devices: Hostdev[]) {
85 const byGroup = new Map<number | Hostdev, Hostdev[]>();
86 for (const dev of devices) byGroup.set(dev.iommuGroup ?? dev, [...(byGroup.get(dev.iommuGroup ?? dev) ?? []), dev]);
87 return [...byGroup.values()];
88}
89
90async function act(vm: Domain, action: (typeof DOMAIN_ACTIONS)[number], refetch: () => unknown) {
91 await parseResponse(api.vms[":name"][":action"].$post({ param: { name: vm.name, action } }));
92 await refetch();
93}
94
95const stop = (vm: Domain, refetch: () => unknown) => showConfirmDialog({
96 title: `Stop ${vm.name}?`,
97 description: `${vm.name} shuts down as if its power button were pressed. If it hangs, force it off.`,
98 confirmLabel: "stop",
99 destructive: true,
100 onConfirm: () => act(vm, "shutdown", refetch),
101});
102
103const restart = (vm: Domain, refetch: () => unknown) => showConfirmDialog({
104 title: `Restart ${vm.name}?`,
105 description: `${vm.name} shuts down and boots again. Anyone using it is interrupted.`,
106 confirmLabel: "restart",
107 onConfirm: () => act(vm, "reboot", refetch),
108});
109
110const forceOff = (vm: Domain, refetch: () => unknown) => showConfirmDialog({
111 title: `Force off ${vm.name}?`,
112 description: `${vm.name} loses power without shutting down. Unsaved work in it is lost.`,
113 confirmLabel: "force off",
114 destructive: true,
115 onConfirm: () => act(vm, "destroy", refetch),
116});
117
118const describe = (vm: Domain, refetch: () => unknown) => showTextDialog({
119 title: `Describe ${vm.name}`,
120 label: "what it's for",
121 placeholder: "game streaming to the TV…",
122 initialValue: vm.description,
123 validateInput: () => true,
124 confirmLabel: "save",
125 onConfirm: async (description) => {
126 await parseResponse(api.vms[":name"].$patch({ param: { name: vm.name }, json: { description } }));
127 await refetch();
128 },
129});
130
131function remove(vm: Domain, refetch: () => unknown) {
132 const volumes = vm.disks.filter((disk) => disk.pool);
133 const size = bytes(volumes.reduce((sum, disk) => sum + disk.capacity, 0));
134 showTextDialog({
135 title: `Delete ${vm.name}?`,
136 description: () => (
137 <>
138 <p>{isOff(vm) ? "" : `${vm.name} is forced off first. `}This can't be undone.</p>
139 <Show when={vm.disks.length > volumes.length}><p>Passed-through drives are left as they are.</p></Show>
140 </>
141 ),
142 label: `type ${vm.name} to confirm`,
143 body: volumes.length ? () => (
144 <Checkbox name="disks" checked>
145 {volumes.length === 1 ? `delete its disk too, ${size}` : `delete its ${volumes.length} disks too, ${size}`}
146 </Checkbox>
147 ) : undefined,
148 validateInput: (value) => value === vm.name,
149 confirmLabel: "delete",
150 destructive: true,
151 onConfirm: async (_, form) => {
152 await parseResponse(api.vms[":name"].$delete({ param: { name: vm.name }, query: { disks: form.has("disks") ? "1" : "0" } }));
153 await refetch();
154 },
155 });
156}
157
158function create(node: { cpus: number; memory: number }, images: Image[], domains: Domain[], refetch: () => unknown) {
159 const free = node.memory - domains.filter((vm) => !isOff(vm)).reduce((sum, vm) => sum + vm.balloon, 0);
160 showConfirmDialog({
161 title: "New virtual machine",
162 confirmLabel: "create",
163 body: () => {
164 const [name, setName] = createSignal("");
165 const [image, setImage] = createSignal(images[0]);
166 const problem = () => (domains.some((vm) => vm.name === name()) ? `${name()} already exists. Pick another name` : "");
167 return (
168 <>
169 <label class="field">
170 name
171 <input name="name" class="search" autofocus required pattern="[a-z0-9][a-z0-9\-]{0,62}" placeholder="nixos-dev…"
172 autocomplete="off" spellcheck={false} aria-invalid={!!problem()} aria-describedby="vm-name-problem"
173 ref={(input) => createEffect(() => input.setCustomValidity(problem()))}
174 onInput={(event) => setName(event.currentTarget.value)} />
175 <span id="vm-name-problem" class={problem() ? "error" : "hint"}>{problem() || "lowercase letters, digits and dashes"}</span>
176 </label>
177 <label class="field">
178 what it's for
179 <input name="description" class="search" maxlength="200" placeholder="trying out configs…" autocomplete="off" />
180 </label>
181 <label class="field">
182 os
183 <select name="image" class="search"
184 onChange={(event) => setImage(images.find((item) => item.volume === event.currentTarget.value))}>
185 <For each={images}>{(item) => <option value={item.volume}>{item.volume === "blank" ? item.os : item.kind === "installer" ? `${item.os} installer` : item.os}</option>}</For>
186 </select>
187 </label>
188 <Show when={image()} keyed>
189 {(image) => (
190 <div class="row vm-sizes">
191 <label class="field">
192 cpus
193 <input name="vcpus" class="search" type="number" required min="1" max={node.cpus} value={image.recommended.vcpus} />
194 </label>
195 <label class="field">
196 memory, GiB
197 <input name="memory" class="search" type="number" required min="1" max={node.memory / GiB}
198 value={image.recommended.memory / GiB} aria-describedby="vm-memory-free" />
199 <span id="vm-memory-free" class="hint">{bytes(Math.max(0, free))} unallocated</span>
200 </label>
201 <label class="field">
202 disk, GiB
203 <input name="disk" class="search" type="number" required value={image.recommended.disk / GiB}
204 min={image.kind === "disk" ? Math.ceil(image.capacity / GiB) : 1} />
205 </label>
206 </div>
207 )}
208 </Show>
209 <div class="vm-checks">
210 <Checkbox name="start" checked>start now</Checkbox>
211 <Checkbox name="autostart">start with the host</Checkbox>
212 </div>
213 </>
214 );
215 },
216 onConfirm: async (form) => {
217 await parseResponse(api.vms.$post({
218 json: {
219 name: String(form.get("name")),
220 description: String(form.get("description")),
221 image: String(form.get("image")),
222 vcpus: Number(form.get("vcpus")),
223 memory: Math.round(Number(form.get("memory")) * GiB),
224 disk: Math.round(Number(form.get("disk")) * GiB),
225 autostart: form.has("autostart"),
226 start: form.has("start"),
227 },
228 }));
229 await refetch();
230 },
231 });
232}
233
234function Detail(props: { vm: Domain; refetch: () => unknown }) {
235 const [history, { refetch }] = createResource(() => props.vm.name, (name) =>
236 parseResponse(api.vms.history.$get({ query: { range: "3600", name } })));
237 const timer = setInterval(refetch, 30_000);
238 onCleanup(() => clearInterval(timer));
239 const [saving, setSaving] = createSignal(false);
240 const setAutostart = async (autostart: boolean) => {
241 setSaving(true);
242 try {
243 await parseResponse(api.vms[":name"].$patch({ param: { name: props.vm.name }, json: { autostart } }));
244 await props.refetch();
245 } catch (failure) {
246 toast(`Couldn't change autostart. ${reason(failure)}`);
247 } finally {
248 setSaving(false);
249 }
250 };
251 return (
252 <div class="vm-detail">
253 <Loaded data={history} what="usage history" retry={refetch} skeleton={
254 <div class="vm-charts"><div class="skeleton" /><div class="skeleton" /></div>
255 }>
256 {(latest) => {
257 const samples = () => latest().domains[props.vm.name];
258 return (
259 <Show when={samples()?.cpu.some((v) => v !== null)} fallback={<p class="off-hour">Off for the last hour</p>}>
260 <div class="vm-charts">
261 <TimeChart series={[{ name: "cpu", t: latest().t, v: samples()!.cpu }]} format={percent} max={100}
262 colors={["var(--series-1)"]} height={104} inline sync={`vm-${props.vm.name}`} />
263 <TimeChart series={[{ name: "memory", t: latest().t, v: samples()!.memory }]} format={bytes} max={props.vm.memory}
264 colors={["var(--series-2)"]} height={104} inline sync={`vm-${props.vm.name}`} />
265 </div>
266 </Show>
267 );
268 }}
269 </Loaded>
270 <div class="facts">
271 <div class="settings">
272 <dl class="kv">
273 <dt>for</dt>
274 <dd>
275 <button class="describe" classList={{ empty: !props.vm.description }} data-tip="edit"
276 onClick={() => describe(props.vm, props.refetch)}>
277 {props.vm.description || "add a description"}
278 </button>
279 </dd>
280 <dt>autostart</dt>
281 <dd>
282 <Checkbox checked={props.vm.autostart} disabled={saving()} onChange={setAutostart}>start with the host</Checkbox>
283 </dd>
284 <dt>cpu</dt>
285 <dd>{props.vm.pinned ? `pinned to threads ${threads(props.vm.pinned)}` : "floats over all threads"}</dd>
286 </dl>
287 <div class="vm-actions">
288 <Show when={running(props.vm)}>
289 <button class="button small" onClick={() => restart(props.vm, props.refetch)}>restart</button>
290 </Show>
291 <Show when={!isOff(props.vm)}>
292 <button class="button small danger" onClick={() => forceOff(props.vm, props.refetch)}>force off</button>
293 </Show>
294 <button class="button small danger" onClick={() => remove(props.vm, props.refetch)}>delete</button>
295 </div>
296 </div>
297 <dl class="kv">
298 <dt>disks</dt>
299 <dd class="lines disks">
300 <For each={props.vm.disks}>
301 {(disk) => (
302 <div>
303 <span class="muted mono">{disk.target}</span>
304 <span class="source" classList={{ mono: !!disk.pool }}>
305 <Copy value={disk.source} label={disk.pool ? "volume name" : "device path"}>
306 {disk.pool ? disk.source : drive(disk.source)}
307 </Copy>
308 </span>
309 <Show when={disk.pool} fallback={<span class="muted used">whole drive</span>}>
310 <span class="used" data-tip={`${bytes(disk.allocation)} / ${bytes(disk.capacity)} (${percent((disk.allocation / disk.capacity) * 100)})`}>
311 <Meter value={disk.allocation} max={disk.capacity} />
312 </span>
313 </Show>
314 <span class="num">{bytes(disk.capacity)}</span>
315 </div>
316 )}
317 </For>
318 </dd>
319 <dt>network</dt>
320 <dd class="lines nics">
321 <For each={props.vm.interfaces}>
322 {(nic) => (
323 <div>
324 <span class="muted mono">{nic.source}</span>
325 <span class="mono">
326 <Show when={nic.addresses[0]} fallback={<span class="muted">{running(props.vm) ? "no address" : ""}</span>}>
327 {(address) => <Copy value={address()} />}
328 </Show>
329 </span>
330 <span class="mono muted"><Copy value={nic.mac} label="MAC address" /></span>
331 </div>
332 )}
333 </For>
334 </dd>
335 <Show when={props.vm.hostdevs.length}>
336 <dt>passthrough</dt>
337 <dd class="lines devices">
338 <For each={props.vm.hostdevs}>
339 {(dev) => (
340 <div>
341 <span class="mono muted"><Copy value={dev.address}>{dev.address.replace(/^0000:/, "")}</Copy></span>
342 <span>{dev.name}</span>
343 </div>
344 )}
345 </For>
346 </dd>
347 </Show>
348 </dl>
349 </div>
350 </div>
351 );
352}
353
354function Row(props: {
355 vm: Domain;
356 hostCpus: number;
357 open: boolean;
358 onToggle: () => void;
359 refetch: () => unknown;
360}) {
361 const [pending, setPending] = createSignal(false);
362 const state = () => status(props.vm);
363 const run = async (action: "start" | "resume") => {
364 setPending(true);
365 try {
366 await act(props.vm, action, props.refetch);
367 } catch (failure) {
368 toast(`Couldn't ${action} ${props.vm.name}. ${reason(failure)}`);
369 } finally {
370 setPending(false);
371 }
372 };
373 const pinned = () => props.vm.pinned && `pinned to threads ${threads(props.vm.pinned)}`;
374 const cpuTip = () => {
375 if (!props.vm.usage) return pinned() || undefined;
376 const busy = (props.vm.usage.cpu / 100) * props.vm.vcpus;
377 return [`${busy.toFixed(1)} of ${props.vm.vcpus} vcpus busy, ${percent((busy / props.hostCpus) * 100)} of the host`, pinned()]
378 .filter(Boolean).join(", ");
379 };
380 const memoryTip = () => {
381 const used = props.vm.usage?.memory;
382 const grows = props.vm.balloon < props.vm.memory && `can grow to ${bytes(props.vm.memory)}`;
383 if (used === undefined) return grows ? `boots with ${bytes(props.vm.balloon)}, ${grows}` : undefined;
384 return [`${bytes(used)} / ${bytes(props.vm.balloon)} (${percent((used / props.vm.balloon) * 100)})`, grows]
385 .filter(Boolean).join(", ");
386 };
387 return (
388 <tbody class="vm" classList={{ open: props.open, off: isOff(props.vm) }} onClick={props.onToggle}>
389 <tr class="top">
390 <td class="title">
391 <button aria-expanded={props.open}>
392 <span class="vm-name">{props.vm.name}</span>
393 <Show when={props.vm.description}><span class="for">{props.vm.description}</span></Show>
394 </button>
395 </td>
396 <td class="state" tabindex={state()[2] ? 0 : undefined} data-tip={state()[2]}>
397 <StatusLabel health={state()[0]}>{state()[1]}</StatusLabel>
398 </td>
399 <td class="num" data-tip={cpuTip()}>{props.vm.usage && percent(props.vm.usage.cpu)}</td>
400 <td class="num" data-tip={memoryTip()}>{props.vm.usage && bytes(props.vm.usage.memory)}</td>
401 <td class="actions" rowspan="2" onClick={(event) => event.stopPropagation()}>
402 <div>
403 <Show when={running(props.vm) && props.vm.interfaces.flatMap((nic) => nic.addresses)[0]}>
404 {(value) => {
405 const target = () => remote(props.vm.os, value());
406 return <a href={target()[0]} target="_blank" rel="noreferrer">{target()[1]}</a>;
407 }}
408 </Show>
409 <Show when={isOff(props.vm)}>
410 <button disabled={pending()} aria-busy={pending()} onClick={() => run("start")}>start</button>
411 </Show>
412 <Show when={props.vm.state === "paused" || props.vm.state === "pmsuspended"}>
413 <button disabled={pending()} aria-busy={pending()} onClick={() => run("resume")}>resume</button>
414 </Show>
415 <Show when={props.vm.state === "shutdown"}>
416 <button class="danger" onClick={() => forceOff(props.vm, props.refetch)}>force off</button>
417 </Show>
418 <Show when={running(props.vm)}>
419 <button onClick={() => stop(props.vm, props.refetch)}>stop</button>
420 </Show>
421 </div>
422 </td>
423 </tr>
424 <tr class="bottom">
425 <td class="specs" colspan="2">
426 <div onClick={(event) => event.stopPropagation()}>
427 <span>{props.vm.os}</span>
428 <For each={props.vm.interfaces}>
429 {(nic) => (
430 <span>
431 <Network size={12} aria-label={nic.source} />
432 <Show when={nic.addresses[0]} fallback={nic.source}>{(value) => <span class="mono"><Copy value={value()} /></span>}</Show>
433 </span>
434 )}
435 </For>
436 <For each={groups(props.vm.hostdevs)}>
437 {(group) => (
438 <span tabindex="0" data-tip={[
439 group[0]!.address.replace(/^0000:/, ""),
440 group[0]!.iommuGroup !== null && `iommu group ${group[0]!.iommuGroup}`,
441 ...group.slice(1).map((dev) => `with ${dev.address.replace(/^0000:/, "")} ${dev.name}`),
442 ].filter(Boolean).join(", ")}>
443 {group[0]!.iommuGroup === null ? <Usb size={12} aria-label="usb" /> : <CircuitBoard size={12} aria-label="pci" />}
444 {group[0]!.name.match(/\[(.+)\]/)?.[1] ?? group[0]!.name}
445 </span>
446 )}
447 </For>
448 <For each={props.vm.disks}>
449 {(disk) => (
450 <span tabindex="0" data-tip={disk.pool
451 ? `${bytes(disk.allocation)} / ${bytes(disk.capacity)} (${percent((disk.allocation / disk.capacity) * 100)})`
452 : `${drive(disk.source)}, whole drive`}>
453 <HardDrive size={12} aria-label="disk" />{bytes(disk.capacity)}
454 </span>
455 )}
456 </For>
457 <Show when={props.vm.autostart}><span><Power size={12} aria-hidden="true" />autostart</span></Show>
458 </div>
459 </td>
460 <td class="num" tabindex={cpuTip() ? 0 : undefined} data-tip={cpuTip()}>{plural(props.vm.vcpus, "vcpu")}</td>
461 <td class="num" tabindex={memoryTip() ? 0 : undefined} data-tip={memoryTip()}>{allocated(props.vm)}</td>
462 </tr>
463 <Show when={props.open}>
464 <tr class="expanded" onClick={(event) => event.stopPropagation()}>
465 <td colspan="5"><Detail vm={props.vm} refetch={props.refetch} /></td>
466 </tr>
467 </Show>
468 </tbody>
469 );
470}
471
472export function VMs() {
473 const [data, { refetch }] = queries.vms.use();
474 const timer = setInterval(() => data.loading || unconnected(data.error) || refetch(), LIVE_INTERVAL * 1000);
475 onCleanup(() => clearInterval(timer));
476 const [params, setParams] = useSearchParams<{ vm?: string }>();
477 const loaded = lastGood(data);
478 const newVm = () => {
479 const latest = loaded();
480 if (latest) create(latest.node, latest.images, latest.domains, refetch);
481 };
482 const newButton = (label: string) => (
483 <button class="button primary" disabled={!loaded()} onClick={newVm}><Plus size={14} />{label}</button>
484 );
485
486 return (
487 <ListPage id="vms" class="vms" flush head={
488 <div class="page-head">
489 <h1>virtual machines</h1>
490 <span class="spacer" />
491 <Show when={loaded()?.domains.length !== 0}>{newButton("new vm")}</Show>
492 </div>
493 }>
494 <Loaded data={data} what="virtual machines" retry={refetch} skeleton={
495 <div class="edge vms-list">
496 <For each={Array(4)}>{() => <div class="skeleton row-skeleton" />}</For>
497 </div>
498 }>
499 {(latest) => (
500 <Show when={latest().domains.length} fallback={
501 <div class="empty vms-empty">
502 <p>No virtual machines yet.</p>
503 {newButton("create vm")}
504 </div>
505 }>
506 <div class="edge vms-list">
507 <table class="data">
508 <thead>
509 <tr>
510 <th>name</th>
511 <th>state</th>
512 <th class="num">cpu</th>
513 <th class="num">memory</th>
514 <th><span class="sr-only">actions</span></th>
515 </tr>
516 </thead>
517 <For each={latest().domains.map((vm) => vm.name)}>
518 {(name) => (
519 <Show when={latest().domains.find((vm) => vm.name === name)}>
520 {(vm) => (
521 <Row vm={vm()} hostCpus={latest().node.cpus} open={params.vm === name}
522 onToggle={() => setParams({ vm: params.vm === name ? undefined : name }, { replace: true })}
523 refetch={refetch} />
524 )}
525 </Show>
526 )}
527 </For>
528 </table>
529 </div>
530 </Show>
531 )}
532 </Loaded>
533 </ListPage>
534 );
535}