1import { useSearchParams } from "@solidjs/router";
2import ArrowDownWideNarrow from "lucide-solid/icons/arrow-down-wide-narrow";
3import Disc3 from "lucide-solid/icons/disc-3";
4import FolderTree from "lucide-solid/icons/folder-tree";
5import LayoutGrid from "lucide-solid/icons/layout-grid";
6import { type InferResponseType, parseResponse } from "hono/client";
7import ChevronRight from "lucide-solid/icons/chevron-right";
8import Clapperboard from "lucide-solid/icons/clapperboard";
9import Database from "lucide-solid/icons/database";
10import ExternalLink from "lucide-solid/icons/external-link";
11import FolderOpen from "lucide-solid/icons/folder-open";
12import { createEffect, createMemo, createResource, createSignal, For, type JSX, Match, Show, Switch } from "solid-js";
13import type { Health } from "../types/model.ts";
14import type { VdevState } from "../types/storage.ts";
15import { api, queries, reason } from "../api.ts";
16import { Ago } from "../components/Ago.tsx";
17import { Copy } from "../components/Copy.tsx";
18import { Explorer, REGIONS, squarify } from "../components/Explorer.tsx";
19import { showConfirmDialog } from "../components/Dialog.tsx";
20import { ListPage } from "../components/ListPage.tsx";
21import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx";
22import { StatusLabel } from "../components/Status.tsx";
23import { TabBar } from "../components/TabBar.tsx";
24import { ago, bytes, celsius, count, date, datetime, duration, percent, plural } from "../format.ts";
25import "./Storage.css";
26import { StorageMap } from "./Storage.map.tsx";
27
28type Overview = InferResponseType<typeof api.storage.$get, 200>;
29type Disk = Overview["pool"]["vdevs"][number]["disks"][number];
30
31const HEALTH: Record<VdevState, Health> = {
32 ONLINE: "healthy", DEGRADED: "degraded", FAULTED: "down", UNAVAIL: "down", REMOVED: "down", OFFLINE: "stopped",
33 AVAIL: "stopped", INUSE: "degraded",
34};
35
36export const TABS = [
37 ["datasets", "datasets", Database], ["files", "files", FolderTree], ["map", "map", LayoutGrid],
38 ["largest", "largest files", ArrowDownWideNarrow], ["disks", "disks", Disc3],
39] as const;
40type Tab = (typeof TABS)[number][0];
41
42const href = (tab: Tab, path?: string) => `/storage?${new URLSearchParams(path ? { tab, path } : { tab })}`;
43
44const onActivate = (action: (event: KeyboardEvent) => void) => (event: KeyboardEvent) => {
45 if (event.key !== "Enter" && event.key !== " ") return;
46 event.preventDefault();
47 action(event);
48};
49
50const mediaHref = (path: string) => `/media?${new URLSearchParams(path ? { path } : {})}`;
51
52/** An icon link in a row's action cell; it doesn't also trigger the row. */
53function RowLink(props: { href: string; label: string; external?: boolean; children: JSX.Element }) {
54 return (
55 <a class="row-icon" href={props.href} aria-label={props.label} data-tip={props.label} onClick={(event) => event.stopPropagation()}
56 target={props.external ? "_blank" : undefined} rel={props.external ? "noreferrer" : undefined}>
57 {props.children}
58 </a>
59 );
60}
61
62const exact = (size: number) => (size >= 1024 ? `${count(size)} bytes` : undefined);
63
64/** ATA attributes 5 and 197 together; null for NVMe drives, which report neither. */
65const badSectors = (disk: Disk) => (disk.smart?.reallocated == null ? null : disk.smart.reallocated + (disk.smart.pending ?? 0));
66const errors = (disk: Disk) => disk.read + disk.write + disk.checksum;
67
68/** The pool's health as pills beside the title: space, the last scrub, and disks that need a look. */
69function Stats(props: { data: Overview }) {
70 const space = () => props.data.space;
71 const pool = () => props.data.pool;
72 const disks = () => pool().vdevs.flatMap((vdev) => vdev.disks);
73 const trouble = () => disks().filter((disk) => disk.issue);
74 const used = () => space().live + space().held;
75 const slices = () => {
76 let offset = 0;
77 return ([["used", space().live, "--series-1"], ["snapshots", space().held, "--series-2"], ["free", space().free, "--other"]] as const)
78 .map(([label, value, color]) => {
79 const share = (value / (used() + space().free)) * 100;
80 offset += share;
81 return { label, value, color: `var(${color})`, share, offset: offset - share };
82 });
83 };
84 const hottest = () => Math.max(...disks().map((disk) => disk.smart?.temperature ?? -Infinity));
85 return (
86 <>
87 <span class="stat space" data-tip={`${bytes(used())} / ${bytes(used() + space().free)} `
88 + `(${percent((used() / (used() + space().free)) * 100)}), ${pool().fragmentation}% fragmented`}>
89 <svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
90 <For each={slices()}>
91 {(slice) => (
92 <circle cx="7" cy="7" r="3.5" fill="none" stroke-width="7" transform="rotate(-90 7 7)" style={{ stroke: slice.color }}
93 pathLength="100" stroke-dasharray={`${slice.share} 100`} stroke-dashoffset={-slice.offset} />
94 )}
95 </For>
96 </svg>
97 <For each={slices()}>
98 {(slice, i) => <>{i() ? ", " : ""}<b>{bytes(slice.value)}</b> {slice.label}</>}
99 </For>
100 </span>
101 <Show when={pool().errors !== "No known data errors"}>
102 <span class="stat" data-tip={pool().errors}><StatusLabel health="down">data errors</StatusLabel></span>
103 </Show>
104 <Show when={pool().scan} fallback={<span class="stat">never scrubbed</span>}>
105 {(scan) => (
106 <Show when={scan().state !== "scanning"} fallback={
107 <span class="stat" data-tip={`${bytes(scan().examined)} / ${bytes(scan().total)}, started ${ago(scan().start)}`}>
108 <StatusLabel health="working">{scan().kind === "scrub" ? "scrubbing" : "resilvering"}</StatusLabel>
109 <b>{percent((scan().examined / scan().total) * 100)}</b>
110 </span>
111 }>
112 <span class="stat" data-tip={[
113 datetime(scan().end ?? scan().start),
114 scan().end && `took ${duration(scan().end! - scan().start)}`,
115 scan().repaired || scan().errors
116 ? `repaired ${bytes(scan().repaired)}, ${count(scan().errors)} unrepairable` : `${bytes(scan().examined)} clean`,
117 ].filter(Boolean).join(", ")}>
118 <StatusLabel health={scan().repaired || scan().errors ? "degraded" : "healthy"}>
119 {scan().kind === "scrub" ? "scrubbed" : "resilvered"}
120 {scan().state === "canceled" ? " until canceled" : ""}
121 </StatusLabel>
122 <b>{ago(scan().end ?? scan().start)}</b>
123 </span>
124 </Show>
125 )}
126 </Show>
127 <a class="stat" href={href("disks")} data-tip={trouble().length
128 ? trouble().map((disk) => `${disk.smart?.serial ?? disk.name}: ${disk.issue}`).join("; ")
129 : hottest() > -Infinity ? `hottest ${celsius(hottest())}` : undefined}>
130 <Show when={trouble().length} fallback={<StatusLabel health="healthy">{plural(disks().length, "disk")}</StatusLabel>}>
131 <StatusLabel health="degraded">{trouble().length === 1 ? "1 disk needs" : `${trouble().length} disks need`} a look</StatusLabel>
132 </Show>
133 </a>
134 </>
135 );
136}
137
138function Disks(props: { data: Overview }) {
139 return (
140 <div class="edge">
141 <table class="data disks">
142 <thead>
143 <tr>
144 <th>disk</th><th>state</th><th class="num">temperature</th><th class="num">powered on</th>
145 <th class="num" data-tip="Reallocated and pending sectors">bad sectors</th>
146 <th class="num" data-tip="Read, write and checksum errors since the last scrub">errors</th><th>smart</th>
147 </tr>
148 </thead>
149 <tbody>
150 <For each={props.data.pool.vdevs}>
151 {(vdev) => (
152 <>
153 <tr class="vdev">
154 <td class="mono" data-tip={/^raidz(\d)/.test(vdev.name)
155 ? `${vdev.disks.length} disks, survives ${vdev.name[5]} failing` : vdev.name.startsWith("mirror")
156 ? `${vdev.disks.length}-way mirror` : undefined}>{vdev.name}</td>
157 <td colspan="6"><StatusLabel health={HEALTH[vdev.state]}>{vdev.state.toLowerCase()}</StatusLabel></td>
158 </tr>
159 <For each={vdev.disks}>
160 {(disk) => (
161 <tr classList={{ trouble: !!disk.issue }}>
162 <td class="disk">
163 <Copy value={disk.name} label="device ID"><span class="mono">{disk.smart?.serial ?? disk.name}</span></Copy>
164 <Show when={disk.smart}>
165 {(smart) => <div class="muted">{smart().model} <span class="capacity">{Math.round(smart().capacity / 1e11) / 10} TB</span></div>}
166 </Show>
167 </td>
168 <td><StatusLabel health={HEALTH[disk.state]}>{disk.state.toLowerCase()}</StatusLabel></td>
169 <td class="num"><span data-tip={disk.smart
170 ? `${disk.smart.temperatureRange[0]}–${celsius(disk.smart.temperatureRange[1])} over 24h` : undefined}>
171 {disk.smart ? celsius(disk.smart.temperature) : "–"}
172 </span></td>
173 <td class="num"><span data-tip={disk.smart ? `${count(disk.smart.powerOnHours)} hours` : undefined}>
174 {disk.smart ? `${(disk.smart.powerOnHours / 8766).toFixed(1)} years` : "–"}
175 </span></td>
176 <td class="num"><span data-tip={badSectors(disk)
177 ? `${count(disk.smart!.reallocated!)} reallocated, ${count(disk.smart!.pending ?? 0)} pending` : undefined}>
178 <Show when={badSectors(disk) !== null} fallback="–">
179 <Show when={badSectors(disk)} fallback={0}>
180 {(bad) => <StatusLabel health="degraded">{count(bad())}</StatusLabel>}
181 </Show>
182 </Show>
183 </span></td>
184 <td class="num"><span data-tip={errors(disk) ? `${disk.read} read, ${disk.write} write, ${disk.checksum} checksum` : undefined}>
185 <Show when={errors(disk)} fallback={0}>
186 {(total) => <StatusLabel health="degraded">{count(total())}</StatusLabel>}
187 </Show>
188 </span></td>
189 <td>
190 <Show when={disk.smart} fallback="–">
191 {(smart) => (
192 <StatusLabel health={smart().passed ? "healthy" : "down"}>{smart().passed ? "passed" : "failed"}</StatusLabel>
193 )}
194 </Show>
195 </td>
196 </tr>
197 )}
198 </For>
199 </>
200 )}
201 </For>
202 </tbody>
203 </table>
204 </div>
205 );
206}
207
208function Snapshots(props: { dataset: string; onDestroy: () => void }) {
209 const [list, { refetch }] = createResource(() => props.dataset, (dataset) =>
210 parseResponse(api.storage.snapshots.$get({ query: { dataset } })));
211 const [selection, setSelection] = createSignal<[anchor: number, end: number] | null>(null);
212 const pick = (i: number, extend: boolean) => {
213 const current = selection();
214 if (extend && current) setSelection([current[0], i]);
215 else setSelection(current && current[0] === i && current[1] === i ? null : [i, i]);
216 };
217
218 return (
219 <div class="snapshots">
220 <Loaded data={list} what={`snapshots of ${props.dataset}`} retry={refetch}
221 skeleton={<div class="skeleton" style={{ height: "90px" }} />}>
222 {(snaps) => {
223 const bounds = createMemo(() => {
224 const picked = selection();
225 return picked && ([Math.min(...picked), Math.max(...picked)] as const);
226 });
227 const range = createMemo(() => {
228 const picked = bounds();
229 return picked && { dataset: props.dataset, from: snaps()[picked[0]]!.name, to: snaps()[picked[1]]!.name };
230 });
231 const [reclaim] = createResource(range, (query) => parseResponse(api.storage.reclaim.$get({ query })));
232 const [removed] = createResource(() => range()?.from === range()?.to && range()?.from,
233 (snapshot) => parseResponse(api.storage.removed.$get({ query: { dataset: props.dataset, snapshot } })));
234 const size = () => bounds()![1] - bounds()![0] + 1;
235 const destroy = () => {
236 const [first, last] = bounds()!;
237 const query = range()!;
238 const one = first === last;
239 const target = one ? `${query.dataset}@${query.from}`
240 : `${query.dataset}, ${date(snaps()[first]!.creation)} to ${date(snaps()[last]!.creation)}`;
241 const consequence = `Frees ${bytes(reclaim()!.bytes)}. Files that exist only in ${one ? "this snapshot" : "these snapshots"} `
242 + "are gone for good.";
243 showConfirmDialog({
244 title: one ? "Destroy snapshot?" : `Destroy ${last - first + 1} snapshots?`,
245 description: <><div class="mono target">{target}</div><div>{consequence}</div></>,
246 confirmLabel: "destroy",
247 destructive: true,
248 onConfirm: async () => {
249 await parseResponse(api.storage.destroy.$post({ json: query }));
250 setSelection(null);
251 await refetch();
252 props.onDestroy();
253 },
254 });
255 };
256 return (
257 <Show when={snaps().length} fallback={<div class="empty">No snapshots of this dataset</div>}>
258 <table class="data">
259 <thead>
260 <tr><th>snapshot</th><th>taken</th><th class="num" data-tip="Space only this snapshot holds">held</th></tr>
261 </thead>
262 <tbody>
263 <For each={snaps()}>
264 {(snap, i) => {
265 const selected = () => {
266 const picked = bounds();
267 return !!picked && i() >= picked[0] && i() <= picked[1];
268 };
269 return (
270 <tr class="pickable" classList={{ selected: selected() }} tabindex="0" aria-selected={selected()}
271 onClick={(event) => pick(i(), event.shiftKey)}
272 onKeyDown={(event) => {
273 if (event.key === "Escape") setSelection(null);
274 else onActivate(() => pick(i(), event.shiftKey))(event);
275 }}>
276 <td class="mono">
277 {snap.name}
278 <For each={snap.clones}>
279 {(clone) => <span class="chip" data-tip={clone}>cloned</span>}
280 </For>
281 </td>
282 <td class="nowrap"><Ago t={snap.creation} /></td>
283 <td class="num"><span data-tip={`refers to ${bytes(snap.referenced)}`}>{bytes(snap.used)}</span></td>
284 </tr>
285 );
286 }}
287 </For>
288 </tbody>
289 </table>
290 <div class="selection" aria-live="polite">
291 <Show when={range()} fallback={
292 <span class="muted">select a snapshot to see what destroying it frees, <kbd>shift</kbd> + click for a range</span>
293 }>
294 <Show when={!reclaim.error} fallback={<span class="error">{reason(reclaim.error)}</span>}>
295 <span>
296 destroying {plural(size(), "snapshot")} frees{" "}
297 <strong>{reclaim.state === "ready" ? bytes(reclaim().bytes) : "…"}</strong>
298 </span>
299 </Show>
300 <Show when={removed.state === "ready" && removed()}>
301 {(gone) => (
302 <span class="muted" data-tip={gone().largest.map((file) => `${file.path} ${bytes(file.size)}`).join(", ")
303 + (gone().count > gone().largest.length ? ` and ${count(gone().count - gone().largest.length)} more` : "")}>
304 {gone().count ? `holds ${plural(gone().count, "deleted file")}` : "holds no deleted files"}
305 </span>
306 )}
307 </Show>
308 <span class="spacer" />
309 <button class="button danger small" disabled={!!reclaim.error || reclaim.state !== "ready"} onClick={destroy}>
310 destroy
311 </button>
312 </Show>
313 </div>
314 </Show>
315 );
316 }}
317 </Loaded>
318 </div>
319 );
320}
321
322const W = 1000;
323const H = 300;
324type Dataset = Overview["datasets"][number];
325/** What a dataset takes in the pool itself: its files and snapshots, without child datasets. */
326const within = (path: string, root: string) => path === root || path.startsWith(root + "/");
327const own = (dataset: Dataset) => dataset.usedbydataset + dataset.usedbysnapshots;
328/** Room between regions, and the area under which datasets merge into one block, in treemap units. */
329const GAP = 3;
330const CELL = 400;
331/** The smallest share a region is drawn at, so prod and staging keep room to point at. */
332const FLOOR = 0.1;
333
334function Datasets(props: { data: Overview; onDestroy: () => void }) {
335 const [params, setParams] = useSearchParams<{ dataset?: string }>();
336 const open = (dataset: string | undefined) => setParams({ dataset }, { replace: true });
337 /** A dataset picked on the treemap, whose row scrolls into view once it opens. */
338 const [reveal, setReveal] = createSignal(params.dataset);
339 const [hover, setHover] = createSignal<string | null>(null);
340 const [region, setRegion] = createSignal<(typeof REGIONS)[number] | null>(null);
341 const rel = (name: string) => name.slice(props.data.pool.name.length + 1);
342 const groups = createMemo(() => REGIONS.map((region) => {
343 const members = props.data.datasets
344 .filter((dataset) => own(dataset) > 0 && REGIONS.findLast((r) => within(dataset.mountpoint ?? "", r.root)) === region)
345 .sort((a, b) => own(b) - own(a));
346 const size = members.reduce((sum, dataset) => sum + own(dataset), 0);
347 return { region, members, size, held: members.reduce((sum, dataset) => sum + dataset.usedbysnapshots, 0) };
348 }).filter((group) => group.size > 0));
349 const blocks = createMemo(() => {
350 const total = groups().reduce((sum, group) => sum + group.size, 0);
351 const areas = groups().map((group) => ({ group, size: Math.max(group.size, total * FLOOR) })).sort((a, b) => b.size - a.size);
352 return squarify(areas, W, H).map(({ item: { group }, x, y, w, h }) => {
353 const scale = ((w - 2 * GAP) * (h - 2 * GAP)) / group.size;
354 const shown = group.members.filter((dataset) => own(dataset) * scale >= CELL);
355 const tiny = group.members.slice(shown.length);
356 const rest = tiny.reduce((sum, dataset) => sum + own(dataset), 0);
357 const items = [
358 ...shown.map((dataset) => ({ dataset, size: own(dataset) })),
359 ...(tiny.length ? [{ dataset: null, size: Math.max(rest, CELL / scale) }] : []),
360 ];
361 return { group, x, y, w, h, tiny, rest, cells: squarify(items, w - 2 * GAP, h - 2 * GAP) };
362 });
363 });
364
365 return (
366 <>
367 <div class="files-head">
368 <ul class="regions" aria-label="Regions">
369 <For each={groups()}>
370 {(group) => {
371 return (
372 <li tabindex="0" style={{ "--kind": `var(--series-${group.region.series})` }}
373 onPointerEnter={() => setRegion(group.region)} onPointerLeave={() => setRegion(null)}
374 onFocus={() => setRegion(group.region)} onBlur={() => setRegion(null)}
375 data-tip={`${group.region.root}${group.region.label === "clover" ? " without Media" : ""}: `
376 + `${bytes(group.size - group.held)} used, ${bytes(group.held)} snapshots, `
377 + `${percent((group.size / (props.data.space.live + props.data.space.held + props.data.space.free)) * 100)} of the pool`}>
378 <span class="swatch" />{group.region.label}<b>{bytes(group.size)}</b>
379 </li>
380 );
381 }}
382 </For>
383 </ul>
384 <span class="spacer" />
385 <span class="held-key"><span class="swatch" />snapshots</span>
386 </div>
387 <div class="treemap regions-map" aria-hidden="true" style={{ "aspect-ratio": `${W} / ${H}` }}>
388 <For each={blocks()}>
389 {(block) => (
390 <div class="region" classList={{ dim: !!region() && region() !== block.group.region }}
391 style={{
392 left: `${(block.x / W) * 100}%`, top: `${(block.y / H) * 100}%`,
393 width: `${(block.w / W) * 100}%`, height: `${(block.h / H) * 100}%`,
394 "--kind": `var(--series-${block.group.region.series})`,
395 }}>
396 <For each={block.cells}>
397 {({ item: { dataset }, x, y, w, h }) => (
398 <div class="tm-cell"
399 classList={{ dir: !!dataset, rest: !dataset, hover: !!dataset && hover() === dataset.name,
400 open: !!dataset && params.dataset === dataset.name }}
401 style={{
402 left: `${((GAP + x) / block.w) * 100}%`, top: `${((GAP + y) / block.h) * 100}%`,
403 width: `${(w / block.w) * 100}%`, height: `${(h / block.h) * 100}%`,
404 "--held": dataset ? `${(dataset.usedbysnapshots / own(dataset)) * 100}%` : undefined,
405 }}
406 data-tip={dataset
407 ? [
408 `${dataset.mountpoint ?? dataset.name}: ${bytes(dataset.usedbydataset)} used`,
409 dataset.usedbysnapshots && `${bytes(dataset.usedbysnapshots)} snapshots`,
410 dataset.quota && `${percent((dataset.used / dataset.quota) * 100)} of a ${bytes(dataset.quota)} quota`,
411 ].filter(Boolean).join(", ")
412 : `${block.tiny.slice(0, 4).map((tiny) => rel(tiny.name).split("/").at(-1)).join(", ")}`
413 + `${block.tiny.length > 4 ? ` and ${count(block.tiny.length - 4)} more` : ""}: ${bytes(block.rest)}`}
414 onMouseEnter={() => dataset && setHover(dataset.name)} onMouseLeave={() => setHover(null)}
415 onClick={() => {
416 if (!dataset) return;
417 open(dataset.name);
418 setReveal(dataset.name);
419 }}>
420 <Show when={w > 50 && h > 22}>
421 <span class="name">{dataset ? rel(dataset.name).split("/").at(-1) : `${block.tiny.length} more`}</span>
422 </Show>
423 <Show when={dataset && w > 50 && h > 44}><span class="size">{bytes(own(dataset!))}</span></Show>
424 </div>
425 )}
426 </For>
427 </div>
428 )}
429 </For>
430 </div>
431 <div class="fill">
432 <table class="data datasets">
433 <thead>
434 <tr>
435 <th>dataset</th>
436 <th class="num" data-tip="Including snapshots and child datasets">total</th>
437 <th class="num" data-tip="This dataset's own files, without snapshots or child datasets">live</th>
438 <th class="num">snapshots</th><th class="num">compression</th><th class="num">quota</th><th />
439 </tr>
440 </thead>
441 <tbody>
442 <For each={props.data.datasets}>
443 {(dataset) => {
444 /** Listed ancestors; staging is a plain folder, so a stage sits under srv as staging/<stage>. */
445 const above = props.data.datasets.filter((other) => dataset.name.startsWith(other.name + "/"));
446 const depth = above.length;
447 const expanded = () => params.dataset === dataset.name;
448 let row!: HTMLTableRowElement;
449 createEffect(() => {
450 if (!expanded() || reveal() !== dataset.name) return;
451 row.scrollIntoView({ block: "start", behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" });
452 setReveal(undefined);
453 });
454 return (
455 <>
456 <tr ref={row} classList={{ open: expanded(), hover: hover() === dataset.name }}
457 onMouseEnter={() => setHover(dataset.name)} onMouseLeave={() => setHover(null)}>
458 <td>
459 <div class="name">
460 <button class="expander" style={{ "padding-left": `${depth * 16}px` }} aria-expanded={expanded()}
461 onClick={() => open(expanded() ? undefined : dataset.name)}>
462 <ChevronRight class="chevron" classList={{ open: expanded() }} />
463 <span class="mono">{depth ? dataset.name.slice(above.at(-1)!.name.length + 1) : dataset.mountpoint ?? dataset.name}</span>
464 </button>
465 <Show when={dataset.origin}>
466 {(origin) => <span class="chip" data-tip={origin()}>clone</span>}
467 </Show>
468 </div>
469 </td>
470 <td class="num"><span data-tip={dataset.used - dataset.usedbydataset - dataset.usedbysnapshots > 0
471 ? `${bytes(dataset.usedbydataset + dataset.usedbysnapshots)} own, `
472 + `${bytes(dataset.used - dataset.usedbydataset - dataset.usedbysnapshots)} in child datasets`
473 : undefined}>
474 {bytes(dataset.used)}
475 </span></td>
476 <td class="num">{bytes(dataset.usedbydataset)}</td>
477 <td class="num">{dataset.usedbysnapshots ? bytes(dataset.usedbysnapshots) : "–"}</td>
478 <td class="num"><span data-tip={dataset.compressratio >= 1.01
479 ? `${dataset.compression}, ${bytes(dataset.logicalused)} before` : dataset.compression}>
480 {dataset.compressratio >= 1.01 ? `${dataset.compressratio.toFixed(2)}×` : "–"}
481 </span></td>
482 <td class="num"><span data-tip={dataset.quota
483 ? `${bytes(dataset.used)} / ${bytes(dataset.quota)} (${percent((dataset.used / dataset.quota) * 100)})` : undefined}>
484 {dataset.quota ? bytes(dataset.quota) : "–"}
485 </span></td>
486 <td class="action">
487 <div>
488 <Show when={dataset.media !== null}>
489 <RowLink href={mediaHref(dataset.media!)} label="open in media"><Clapperboard size={14} /></RowLink>
490 </Show>
491 <RowLink href={href("files", rel(dataset.name))} label="browse files">
492 <FolderOpen size={14} />
493 </RowLink>
494 <Show when={dataset.link}>
495 {(link) => <RowLink href={link()} label="open in copyparty" external><ExternalLink size={14} /></RowLink>}
496 </Show>
497 </div>
498 </td>
499 </tr>
500 <Show when={expanded()}>
501 <tr class="expansion">
502 <td colspan="7">
503 <dl class="facts">
504 <div>
505 <dt>mounted at</dt>
506 <dd><Show when={dataset.mountpoint} fallback="nowhere">{(mount) => <Copy value={mount()} />}</Show></dd>
507 </div>
508 <div><dt>records</dt><dd>{bytes(dataset.recordsize)}</dd></div>
509 <div><dt>compression</dt><dd>{dataset.compression}</dd></div>
510 <Show when={dataset.origin}>
511 {(origin) => <div><dt>clone of</dt><dd class="mono">{origin()}</dd></div>}
512 </Show>
513 </dl>
514 <Snapshots dataset={dataset.name} onDestroy={props.onDestroy} />
515 </td>
516 </tr>
517 </Show>
518 </>
519 );
520 }}
521 </For>
522 </tbody>
523 </table>
524 </div>
525 </>
526 );
527}
528
529function Largest() {
530 const [, setParams] = useSearchParams();
531 const [list, { refetch }] = createResource(() => parseResponse(api.storage.files.largest.$get()));
532 return (
533 <Loaded data={list} what="the largest files" retry={refetch} skeleton={<div class="edge"><SkeletonRows count={12} /></div>}>
534 {(list) => (
535 <div class="fill">
536 <Show when={list().length}
537 fallback={<div class="empty">No files indexed yet. The largest show up here after the first scan.</div>}>
538 <table class="data largest">
539 <thead><tr><th>file</th><th class="num">size</th><th class="num">modified</th><th /></tr></thead>
540 <tbody>
541 <For each={list()}>
542 {(file) => {
543 const cut = file.path.lastIndexOf("/");
544 const open = () => setParams({ tab: "files", path: cut === -1 ? undefined : file.path.slice(0, cut) });
545 return (
546 <tr class="pickable" tabindex="0" onClick={open} onKeyDown={onActivate(open)}>
547 <td class="path"><span class="muted">{file.path.slice(0, cut + 1)}</span>{file.path.slice(cut + 1)}</td>
548 <td class="num"><span data-tip={`${exact(file.size)}, ${bytes(file.alloc)} on disk`}>{bytes(file.size)}</span></td>
549 <td class="num"><Ago t={file.mtime} /></td>
550 <td class="action">
551 <div>
552 <Show when={file.link}>
553 {(link) => <RowLink href={link()} label="open in copyparty" external><ExternalLink size={14} /></RowLink>}
554 </Show>
555 </div>
556 </td>
557 </tr>
558 );
559 }}
560 </For>
561 </tbody>
562 </table>
563 </Show>
564 </div>
565 )}
566 </Loaded>
567 );
568}
569
570export function Storage() {
571 const [params] = useSearchParams<{ tab?: Tab; path?: string }>();
572 const tab = () => params.tab ?? "datasets";
573 const [data, { refetch }] = queries.storage.use();
574 const overview = lastGood(data);
575 const apps = lastGood(queries.launcher.use()[0]);
576 /** Copyparty at the folder the map shows. */
577 const files = () => {
578 const root = overview()?.datasets.find((dataset) => dataset.name === overview()!.pool.name)?.link;
579 return root && root + (params.path ?? "").split("/").map(encodeURIComponent).join("/");
580 };
581 return (
582 <ListPage id="storage" class="storage" flush head={
583 <div class="page-head">
584 <h1>storage</h1>
585 <div class="stats" role="status">
586 <Show when={overview()}>{(data) => <Stats data={data()} />}</Show>
587 </div>
588 </div>
589 }>
590 <TabBar label="View">
591 <For each={TABS}>
592 {([id, label, Icon]) => (
593 <a href={href(id, params.path)} aria-current={tab() === id ? "page" : undefined}><Icon size={13} />{label}</a>
594 )}
595 </For>
596 </TabBar>
597 <Switch>
598 <Match when={tab() === "datasets"}>
599 <Loaded data={data} what="datasets" retry={refetch} skeleton={
600 <div class="edge">
601 <div class="skeleton treemap" style={{ height: "160px" }} />
602 <SkeletonRows count={6} />
603 </div>
604 }>
605 {(data) => <Datasets data={data()} onDestroy={refetch} />}
606 </Loaded>
607 </Match>
608 <Match when={tab() === "files"}>
609 <Explorer id="storage" client={api.storage.files} root="/srv" />
610 </Match>
611 <Match when={tab() === "map"}>
612 <StorageMap copyparty={files() ? { href: files()!, app: apps()?.find((app) => app.id === "copyparty") } : null} />
613 </Match>
614 <Match when={tab() === "largest"}><Largest /></Match>
615 <Match when={tab() === "disks"}>
616 <Loaded data={data} what="disks" retry={refetch} skeleton={<div class="edge"><SkeletonRows count={3} /></div>}>
617 {(data) => <Disks data={data()} />}
618 </Loaded>
619 </Match>
620 </Switch>
621 </ListPage>
622 );
623}