| 1 | import { useSearchParams } from "@solidjs/router"; |
| 2 | import ArrowDownWideNarrow from "lucide-solid/icons/arrow-down-wide-narrow"; |
| 3 | import Disc3 from "lucide-solid/icons/disc-3"; |
| 4 | import FolderTree from "lucide-solid/icons/folder-tree"; |
| 5 | import LayoutGrid from "lucide-solid/icons/layout-grid"; |
| 6 | import { type InferResponseType, parseResponse } from "hono/client"; |
| 7 | import ChevronRight from "lucide-solid/icons/chevron-right"; |
| 8 | import Clapperboard from "lucide-solid/icons/clapperboard"; |
| 9 | import Database from "lucide-solid/icons/database"; |
| 10 | import ExternalLink from "lucide-solid/icons/external-link"; |
| 11 | import FolderOpen from "lucide-solid/icons/folder-open"; |
| 12 | import { createEffect, createMemo, createResource, createSignal, For, type JSX, Match, Show, Switch } from "solid-js"; |
| 13 | import type { Health } from "../types/model.ts"; |
| 14 | import type { VdevState } from "../types/storage.ts"; |
| 15 | import { api, queries, reason } from "../api.ts"; |
| 16 | import { Ago } from "../components/Ago.tsx"; |
| 17 | import { Copy } from "../components/Copy.tsx"; |
| 18 | import { Explorer, REGIONS, squarify } from "../components/Explorer.tsx"; |
| 19 | import { showConfirmDialog } from "../components/Dialog.tsx"; |
| 20 | import { ListPage } from "../components/ListPage.tsx"; |
| 21 | import { lastGood, Loaded, SkeletonRows } from "../components/Loaded.tsx"; |
| 22 | import { StatusLabel } from "../components/Status.tsx"; |
| 23 | import { TabBar } from "../components/TabBar.tsx"; |
| 24 | import { ago, bytes, celsius, count, date, datetime, duration, percent, plural } from "../format.ts"; |
| 25 | import "./Storage.css"; |
| 26 | import { StorageMap } from "./Storage.map.tsx"; |
| 27 | |
| 28 | type Overview = InferResponseType<typeof api.storage.$get, 200>; |
| 29 | type Disk = Overview["pool"]["vdevs"][number]["disks"][number]; |
| 30 | |
| 31 | const HEALTH: Record<VdevState, Health> = { |
| 32 | ONLINE: "healthy", DEGRADED: "degraded", FAULTED: "down", UNAVAIL: "down", REMOVED: "down", OFFLINE: "stopped", |
| 33 | AVAIL: "stopped", INUSE: "degraded", |
| 34 | }; |
| 35 | |
| 36 | export const TABS = [ |
| 37 | ["datasets", "datasets", Database], ["files", "files", FolderTree], ["map", "map", LayoutGrid], |
| 38 | ["largest", "largest files", ArrowDownWideNarrow], ["disks", "disks", Disc3], |
| 39 | ] as const; |
| 40 | type Tab = (typeof TABS)[number][0]; |
| 41 | |
| 42 | const href = (tab: Tab, path?: string) => `/storage?${new URLSearchParams(path ? { tab, path } : { tab })}`; |
| 43 | |
| 44 | const onActivate = (action: (event: KeyboardEvent) => void) => (event: KeyboardEvent) => { |
| 45 | if (event.key !== "Enter" && event.key !== " ") return; |
| 46 | event.preventDefault(); |
| 47 | action(event); |
| 48 | }; |
| 49 | |
| 50 | const 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. */ |
| 53 | function 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 | |
| 62 | const exact = (size: number) => (size >= 1024 ? `${count(size)} bytes` : undefined); |
| 63 | |
| 64 | /** ATA attributes 5 and 197 together; null for NVMe drives, which report neither. */ |
| 65 | const badSectors = (disk: Disk) => (disk.smart?.reallocated == null ? null : disk.smart.reallocated + (disk.smart.pending ?? 0)); |
| 66 | const 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. */ |
| 69 | function 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 | |
| 138 | function 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 | |
| 208 | function 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 | |
| 322 | const W = 1000; |
| 323 | const H = 300; |
| 324 | type Dataset = Overview["datasets"][number]; |
| 325 | /** What a dataset takes in the pool itself: its files and snapshots, without child datasets. */ |
| 326 | const within = (path: string, root: string) => path === root || path.startsWith(root + "/"); |
| 327 | const own = (dataset: Dataset) => dataset.usedbydataset + dataset.usedbysnapshots; |
| 328 | /** Room between regions, and the area under which datasets merge into one block, in treemap units. */ |
| 329 | const GAP = 3; |
| 330 | const CELL = 400; |
| 331 | /** The smallest share a region is drawn at, so prod and staging keep room to point at. */ |
| 332 | const FLOOR = 0.1; |
| 333 | |
| 334 | function 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 | |
| 529 | function 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 | |
| 570 | export 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 | } |