import type { StorageLocation } from "../lib/storageLocations"; import type { StorageIdentity, StorageLocationStatus, } from "../lib/storageVolumes"; import type { LocationRollup, MemoizedProbe, } from "../controller/storageLocations"; import type { SavedVideosStoreStatus } from "../controller/relocateSavedVideos"; import type { RegistryReader } from "./inputs"; // THE /storage PAYLOAD — one row per storage location, and for each row the // list of things the operator may do to it and why the rest are withheld. // // PURE, like everything in this directory (one-core phase 3 slice 1): no I/O, // no clock, no singletons. Every live fact arrives as an argument — the probes // from `probeAllLocations`, the channel counts from `channelsOnLocation`, the // jobs from the registry reader, the instant from `now`. The shell // (`editor/app/storage/buildStorage.ts`) is what touches disks and processes. // // EVERY IMPORT HERE IS A TYPE IMPORT, which is not an accident: the row type is // the wire shape the `"use client"` table renders, so this module is reachable // from the client bundle. `controller/storageLocations` and `lib/storageVolumes` // both pull in execa transitively, and a value import of either would fail // `next build` with `node:child_process` in a client component. // // WHY WITHHELD ACTIONS ARE DATA AND NOT ABSENCE. "Delete" missing from a row // is indistinguishable from a bug; "Delete — 3 channels still have their media // under this root" is an instruction. So every action is always present with an // `offered` flag and, when it is false, the sentence saying what to fix. export type StorageActionKind = "refresh" | "repoint" | "mount" | "edit" | "delete"; export type StorageActionView = { kind: StorageActionKind; label: string; offered: boolean; // Set whenever `offered` is false. Never empty. withheld?: string; // "repoint" only: the root the button would move the location to. newRoot?: string; // "mount" only: the volume to mount. uuid?: string; }; export type StorageChannelCounts = { ok: number; unreachable: number; moving: number; total: number; // The share of `unreachable` that is the retired whole-directory layout, // waiting for `archilyzer storage migrate-tier` (release 17). legacy: number; }; export type StorageRow = { id: string; label: string; root: string; // "internal" is the SYNTHETIC row — the corpus volume, where an unrelocated // channel's media is. It is not in settings (see INTERNAL_LOCATION_ID) and it // is the one row with nothing to refresh, re-point, mount, edit or delete: // every action on it is present and withheld, because a greyed button with no // reason is what this page exists not to be. kind: "location" | "internal"; isDefault: boolean; autoRepoint: boolean; status: StorageLocationStatus; // Operator-facing name for the status; the badge's text. statusLabel: string; // One line of identity, or null when the probe could not learn any (no // findmnt, a container, a volume with no UUID). NULL IS NOT A PROBLEM — see // storageVolumes.ts's header — so the page renders it as "unknown", never as // a fault. identity: string | null; channels: StorageChannelCounts; // `n ok / n unreachable / n moving` — the cell's text, built here so the page // and any future poll cannot word it differently. channelsText: string; // `/channels?location=` — the list this row summarises, largest first. // Built here rather than in the page so the param name has ONE spelling // across the two surfaces that use it. channelsHref: string; // What this row's volume holds, summed from each channel's last report, and // how many channels could not contribute a figure. A LOCATION holds the // media tier of the channels on it (release 17: their text never leaves the // corpus volume). The INTERNAL row holds every channel's text and clip // windows, plus the media of the channels in place. bytes: number; unknownBytes: number; // "1.42 TB (2 channels unmeasured)" / "size unknown until Refresh report". // ONE wording, and never a bare "0 B" for a location whose channels have // simply never had a report — that reads as an empty drive. bytesText: string; // The `clips/` share of `bytes` — fetched clip windows, a cache nothing // prunes. On the INTERNAL row only since release 17: `clips/` is never // tiered, so a location holds none (0 there). 0 is a real answer, unlike // `bytes`, which is why it needs no "unknown" companion. clipsBytes: number; // "4.21 GB of it is fetched clip windows", or "" when there are none. Built // here so the row and any future poll cannot word it differently. clipsText: string; // THE INTERNAL ROW'S BREAKDOWN: "text 12.1 GB + clips 4.2 GB on the corpus // volume, plus 310 GB media of in-place channels". "" on a location row, // whose figure is the media tier alone. tiersText: string; // Absent when the location is not available, and ALSO when it is available // but unmeasurable (getFreeBytes fails open to Infinity, which the probe // drops rather than carry). Render "—" for both. freeBytes?: number; // How long ago the probe behind this row was taken, in ms. lastProbeAgeMs: number; // "not answering since 11:35 — …", while the health probe finds this // location's drive not answering (lib/storageHealth.ts). Absent otherwise. notAnswering?: string; warning?: string; candidateRoot?: string; // Why every action on this row is withheld, or null. One re-point runs at a // time (the registry caps the shared `relocate` queue key at 1), so a running // one freezes every row, not just its own. busy: string | null; actions: StorageActionView[]; }; // THE SAVED-VIDEO STORE, AS A ROW OF ITS OWN. // // It is the one large thing in the corpus that is not a channel's `data/`, so // no channel move can ever reach it — and until now nothing could move it at // all. It is NOT a location (nothing lives "on" it) and it is not a channel, so // it gets its own block under the locations rather than being forced into // either table. export type SavedVideosView = { // `paths.savedVideosDir` — the path every reader uses, moved or not. dir: string; // Where the bytes actually are: the link target, or `dir` when in place. at: string; // The location it is on, or "" for the corpus volume. locationId: string; locationLabel: string; status: SavedVideosStoreStatus; statusLabel: string; detail?: string; bytes: number; files: number; // Destinations the Move control offers: every configured location it is not // already on, plus "" (in place) when it is somewhere else. destinations: Array<{ id: string; label: string }>; // Why every control is off, or null. A move of the store shares the relocate // queue key with channel moves and the re-point, so one running anywhere // freezes this too. busy: string | null; // A marker is present AND nothing is running — the state Resume and Clear // exist for. Separate from `busy`, which the marker itself sets: reading the // hatch off `busy === null` would hide it in exactly the state it is for. canResume: boolean; }; export type StorageRowsPayload = { rows: StorageRow[]; // Absent when the caller did not ask for it (a unit test of the rows alone). savedVideos?: SavedVideosView; // Media bytes per row id, INCLUDING "internal". The same numbers the rows // carry, lifted out so a caller that wants the totals (the /channels meter // bridge) does not have to re-fold the rows. bytesOnLocation: Record; // HOW MANY CHANNELS ON EACH ROW COULD NOT CONTRIBUTE A FIGURE, paired with // the map above and for the same reason the ROWS carry `unknownBytes` beside // `bytes`: a total that silently omits an unmeasured 400 GB channel is worse // than no total. A caller lifting `bytesOnLocation` out of the rows to draw // its own meter (the /channels volume bar) was getting the sum without the // caveat, which is exactly how a bar ends up claiming a full drive is empty. unknownBytesOnLocation: Record; // The corpus volume's share — `bytesOnLocation.internal`, named because it is // the number the whole exercise is about: what is still on the disk that is // 96 % full. bytesInPlace: number; // `unknownBytesOnLocation.internal` — the corpus volume's share of the // caveat, named for the same reason `bytesInPlace` is. unknownBytesInPlace: number; defaultLocationId: string; // Whether `udisksctl` resolved in this process. False in a container, and the // reason the Mount button is withheld there. udisksctlAvailable: boolean; }; // What the shell measured about the store. Every fact arrives as an argument, // as everywhere in views/: the walk, the lstat and the marker read are the // shell's. export type SavedVideosInputs = { dir: string; at: string; locationId: string; status: SavedVideosStoreStatus; detail?: string; bytes: number; files: number; hasMarker: boolean; }; export type StorageRowsInputs = { locations: readonly StorageLocation[]; // Omit to leave `savedVideos` off the payload entirely. savedVideos?: SavedVideosInputs; // The corpus volume as a row. Absent → no internal row (a caller that only // wants the configured locations). `freeBytes` is a statfs of the root, taken // by the shell, because nothing in views/ may touch a disk. // // `corpus` (release 17): what EVERY channel keeps on the corpus volume // whatever its media's location — the text tier and the clip windows, // summed off the reports — and how many channels could not contribute (a // report written before release 17 has no text figure, beyond the in-place // channels the internal rollup already counts unmeasured). Absent → only the // in-place channels' media, as before. internal?: { root: string; freeBytes?: number; // `legacy`: channels still on the retired whole-directory layout, whose // text is NOT on this volume — named "(n to migrate)", never unmeasured. corpus?: { textBytes: number; clipsBytes: number; unknown: number; legacy?: number; }; }; defaultLocationId: string; // By location id. A location with no entry has never been probed in this // process — treated as `missing` with an unknown identity rather than // omitted, because a row that vanishes when a probe fails is worse than one // that says it does not know. probes: Record; // By location id: the sentence for a location whose drive is not answering. // Worded by the shell (lib/storageHealth.ts's `notAnsweringText`), because // this module takes types only from lib/. notAnswering?: Record; rollups: Record; registry: RegistryReader; udisksctlAvailable?: boolean; now: number; }; // THE ONE WORDING of each probe status. Exported because the channel page's // Storage panel names the same five states beside its destination select, and // two tables of five strings is how "Not mounted" becomes "Unmounted" on one // page and not the other. export const STORAGE_STATUS_LABEL: Record = { available: "Available", "mounted-elsewhere": "Mounted elsewhere", unmounted: "Not mounted", absent: "Not attached", missing: "Missing", stalled: "Not answering", }; export const REPOINT_JOB_KIND = "repoint-storage-location"; export const SAVED_VIDEOS_JOB_KIND = "relocate-saved-videos"; export const SAVED_VIDEOS_STATUS_LABEL: Record = { "in-place": "In place", ok: "Relocated · reachable", unreachable: "Relocated · unreachable", "in-transition": "Move in flight", inconsistent: "Inconsistent", }; function identityLine(identity: StorageIdentity): string | null { if (!identity.known) return null; const bits: string[] = []; if (identity.label) bits.push(identity.label); if (identity.fstype) bits.push(identity.fstype); bits.push(`UUID ${identity.uuid}`); bits.push(`at ${identity.mountpoint}`); return bits.join(" · "); } function countsOf(rollup: LocationRollup | undefined): StorageChannelCounts { return { ok: rollup?.ok ?? 0, unreachable: rollup?.unreachable ?? 0, moving: rollup?.moving ?? 0, total: rollup?.total ?? 0, legacy: rollup?.legacy ?? 0, }; } // `n ok / n unreachable / n moving` — with "(n to migrate)" after the // unreachable count when any of it is the retired layout. ONE wording, for the // rows and any future poll. export function storageChannelsText(c: StorageChannelCounts): string { const migrate = c.legacy > 0 ? ` (${c.legacy} to migrate)` : ""; return `${c.ok} ok / ${c.unreachable} unreachable${migrate} / ${c.moving} moving`; } // THE INTERNAL ROW'S BREAKDOWN (release 17): what every channel keeps on the // corpus volume, plus the in-place channels' media. export function storageTiersText( textBytes: number, clipsBytes: number, inPlaceMediaBytes: number, legacy = 0, ): string { return ( `text ${bytesLabel(textBytes)} + clips ${bytesLabel(clipsBytes)} on the ` + `corpus volume, plus ${bytesLabel(inPlaceMediaBytes)} media of in-place ` + `channels` + (legacy > 0 ? `; legacy (${legacy} to migrate) — their text is still on their media drive` : "") ); } // Bytes → GB with two decimals, or TB past a terabyte. Local rather than // `lib/format`'s formatBytes because this module is reachable from the client // bundle and is deliberately import-free. function bytesLabel(n: number): string { const GB = 1024 ** 3; if (n >= 1024 * GB) return `${(n / (1024 * GB)).toFixed(2)} TB`; if (n >= GB) return `${(n / GB).toFixed(2)} GB`; if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; return `${n} B`; } // THE ONE WORDING OF A SIZE THAT MAY BE PARTLY UNKNOWN. // // A channel whose snapshot predates `totalMediaBytes` contributes nothing to // the sum, and a total that silently omits it is worse than no total: it ranks // a 400 GB channel as empty. So the count of unmeasured channels travels with // the figure everywhere it goes, and a row where NOTHING could be measured says // so instead of printing "0 B". export function storageBytesText(bytes: number, unknown: number): string { if (unknown > 0 && bytes === 0) { return "size unknown until Refresh report"; } if (unknown > 0) { return `${bytesLabel(bytes)} + ${unknown} unmeasured`; } return bytesLabel(bytes); } // THE CLIP-WINDOW LINE, or nothing at all. // // Zero is not rendered: a row saying "0 B in clip windows" on every drive that // has never been walked by a report is noise on the only page where a byte // figure is supposed to mean something. The phrasing names EVICTION rather than // size, because the number is only actionable if you know there is a control // for it. export function storageClipsText(clipsBytes: number): string { if (clipsBytes <= 0) return ""; return `${bytesLabel(clipsBytes)} of it is fetched clip windows`; } // The one running re-point, or null. Reported for the whole page rather than // per row because the job record carries no location id: it is enqueued on the // shared `relocate` queue key, which the registry caps at concurrency 1, so // "one is running" is a fact about the machine and not about a row. function runningRepoint(registry: RegistryReader): string | null { // TWO KINDS, ONE QUEUE. A re-point and a saved-video store move both rewrite // symlinks under the same roots and both share `relocationQueueKey()`, which // the registry caps at concurrency 1 — so either one running is a fact about // the machine and freezes every row on this page, not just its own. (A // CHANNEL move is on that key too and deliberately does NOT freeze this page: // it touches one channel's `media/`, never a location's root or the store, and // /storage has been usable during one since locations shipped.) const job = registry .list() .find( (j) => (j.kind === REPOINT_JOB_KIND || j.kind === SAVED_VIDEOS_JOB_KIND) && (j.status === "running" || j.status === "queued"), ); if (!job) return null; const what = job.kind === REPOINT_JOB_KIND ? "A storage re-point" : "A saved-video store move"; return ( `${what} is ${job.status} (job ${job.id}). One runs at a ` + `time — wait for it to finish, or cancel it on /jobs.` ); } // The one spelling of the /channels filter param, so the two surfaces that // write it cannot disagree about whether it is `location` or `loc`. export const LOCATION_FILTER_PARAM = "location"; export const INTERNAL_ROW_ID = "internal"; export function channelsHrefForLocation(id: string): string { // `size` descending, because the only reason to open this list is to find the // channels worth moving, and that is the biggest ones. return `/channels?${LOCATION_FILTER_PARAM}=${encodeURIComponent(id)}&sort=size`; } export function buildStorageRows(i: StorageRowsInputs): StorageRowsPayload { const busy = runningRepoint(i.registry); const udisksctlAvailable = i.udisksctlAvailable ?? false; const bytesOnLocation: Record = {}; const unknownBytesOnLocation: Record = {}; const configured = i.locations.map((loc): StorageRow => { const probe = i.probes[loc.id]; const status: StorageLocationStatus = probe?.status ?? "missing"; const counts = countsOf(i.rollups[loc.id]); const candidateRoot = probe?.candidateRoot; const actions: StorageActionView[] = [ { kind: "refresh", label: "Refresh", offered: !busy, ...(busy ? { withheld: busy } : {}), }, repointAction(status, candidateRoot, busy), mountAction(status, probe?.identity, loc, udisksctlAvailable, busy), { kind: "edit", label: "Edit", offered: !busy, ...(busy ? { withheld: busy } : {}), }, deleteAction(loc, counts, busy, i.savedVideos?.locationId === loc.id), ]; const roll = i.rollups[loc.id]; // The media tier only: the channels' text and clip windows are on the // corpus volume, counted on the internal row. const bytes = roll?.bytes ?? 0; const unknownBytes = roll?.unknownBytes ?? 0; bytesOnLocation[loc.id] = bytes; unknownBytesOnLocation[loc.id] = unknownBytes; return { id: loc.id, label: loc.label || loc.id, root: loc.root, kind: "location", isDefault: loc.id === i.defaultLocationId, autoRepoint: loc.autoRepoint, status, statusLabel: STORAGE_STATUS_LABEL[status], identity: probe ? identityLine(probe.identity) : null, channels: counts, channelsText: storageChannelsText(counts), channelsHref: channelsHrefForLocation(loc.id), bytes, unknownBytes, bytesText: storageBytesText(bytes, unknownBytes), clipsBytes: 0, clipsText: "", tiersText: "", ...(probe?.freeBytes !== undefined ? { freeBytes: probe.freeBytes } : {}), lastProbeAgeMs: probe ? Math.max(0, i.now - probe.probedAt) : 0, ...(i.notAnswering?.[loc.id] ? { notAnswering: i.notAnswering[loc.id] } : {}), ...(probe?.warning ? { warning: probe.warning } : {}), ...(candidateRoot ? { candidateRoot } : {}), busy, actions, }; }); const rows = i.internal ? [internalRow(i, bytesOnLocation, unknownBytesOnLocation), ...configured] : configured; return { rows, ...(i.savedVideos ? { savedVideos: savedVideosView(i, i.savedVideos, busy) } : {}), bytesOnLocation, unknownBytesOnLocation, bytesInPlace: bytesOnLocation[INTERNAL_ROW_ID] ?? 0, unknownBytesInPlace: unknownBytesOnLocation[INTERNAL_ROW_ID] ?? 0, defaultLocationId: i.defaultLocationId, udisksctlAvailable, }; } // THE STORE'S OWN ROW. `busy` is the page's — one relocation runs at a time // across all three kinds on the shared queue key, so a channel move in flight // freezes this too, and saying so is better than a button that refuses. function savedVideosView( i: StorageRowsInputs, sv: SavedVideosInputs, pageBusy: string | null, ): SavedVideosView { const on = i.locations.find((l) => l.id === sv.locationId); const busy = pageBusy ?? (sv.status === "in-transition" ? (sv.detail ?? "A move of the store is in flight or was interrupted.") : null); // "In place" is offered only when it is NOT in place, and a location is // offered only when it is not already the one it is on. An option that would // be a no-op is an option that refuses. const destinations: Array<{ id: string; label: string }> = []; if (sv.locationId !== "") { destinations.push({ id: "", label: "Internal (in place)" }); } for (const l of i.locations) { if (l.id !== sv.locationId) { destinations.push({ id: l.id, label: l.label || l.id }); } } return { dir: sv.dir, at: sv.at, locationId: sv.locationId, locationLabel: on ? on.label || on.id : "Internal (in place)", status: sv.status, statusLabel: SAVED_VIDEOS_STATUS_LABEL[sv.status], ...(sv.detail ? { detail: sv.detail } : {}), bytes: sv.bytes, files: sv.files, destinations, busy, canResume: sv.hasMarker && pageBusy === null, }; } // THE CORPUS VOLUME AS A ROW, AND IT IS FIRST. // // It is first because it is the row with the problem: 523 GB on a disk with 67 // left is not a footnote under the locations that were added to fix it. It is // `available` by construction — the process is reading the corpus out of it — // and every action is withheld with the reason, which for all five is the same // fact said five ways: there is nothing here to configure, because this is not // a configured place. Moving media ONTO it is "move back in place", and that // lives on the channel's own Storage panel where the media is. function internalRow( i: StorageRowsInputs, bytesOnLocation: Record, unknownBytesOnLocation: Record, ): StorageRow { const internal = i.internal as NonNullable; const roll = i.rollups[INTERNAL_ROW_ID]; const counts = countsOf(roll); // The in-place channels' media, plus (release 17) every channel's text and // clip windows, which stay on this volume wherever the media is. const inPlaceMedia = roll?.bytes ?? 0; const corpus = internal.corpus; const clipsBytes = corpus ? corpus.clipsBytes : (roll?.clipsBytes ?? 0); const bytes = inPlaceMedia + (corpus ? corpus.textBytes + corpus.clipsBytes : 0); const unknownBytes = (roll?.unknownBytes ?? 0) + (corpus?.unknown ?? 0); bytesOnLocation[INTERNAL_ROW_ID] = bytes; unknownBytesOnLocation[INTERNAL_ROW_ID] = unknownBytes; const withheld = "The corpus volume is where media lives when it has not been moved " + "anywhere. It is not a configured location: there is nothing to re-point, " + "and moving media back onto it is the channel's own Storage panel."; return { id: INTERNAL_ROW_ID, label: "Internal (in place)", root: internal.root, kind: "internal", isDefault: false, autoRepoint: false, status: "available", statusLabel: STORAGE_STATUS_LABEL.available, identity: null, channels: counts, channelsText: storageChannelsText(counts), channelsHref: channelsHrefForLocation(INTERNAL_ROW_ID), bytes, unknownBytes, bytesText: storageBytesText(bytes, unknownBytes), clipsBytes, clipsText: storageClipsText(clipsBytes), tiersText: corpus ? storageTiersText( corpus.textBytes, corpus.clipsBytes, inPlaceMedia, corpus.legacy ?? 0, ) : "", ...(internal.freeBytes !== undefined ? { freeBytes: internal.freeBytes } : {}), lastProbeAgeMs: 0, busy: null, actions: ( ["refresh", "repoint", "mount", "edit", "delete"] as StorageActionKind[] ).map((kind) => ({ kind, label: kind[0].toUpperCase() + kind.slice(1), offered: false, withheld, })), }; } // RE-POINT IS OFFERED FOR EXACTLY ONE STATUS. `mounted-elsewhere` means the // recorded UUID was found mounted at a different mountpoint, and // `candidateRoot` is that mountpoint plus the root's recorded relative path — // the only path the job may be pointed at without the operator typing one. Any // other status is either "nothing is wrong" or "there is nothing to point at", // and both read better as a sentence than as a greyed button with no tooltip. function repointAction( status: StorageLocationStatus, candidateRoot: string | undefined, busy: string | null, ): StorageActionView { if (busy) { return { kind: "repoint", label: "Re-point", offered: false, withheld: busy, }; } if (status !== "mounted-elsewhere") { return { kind: "repoint", label: "Re-point", offered: false, withheld: status === "available" ? "The root is there — nothing to re-point." : `Re-point needs the volume mounted somewhere else; this location reads ${STORAGE_STATUS_LABEL[status].toLowerCase()}.`, }; } if (!candidateRoot) { return { kind: "repoint", label: "Re-point", offered: false, withheld: "The volume is mounted elsewhere but the probe could not work out " + "where the root would be. Edit the location's root by hand.", }; } return { kind: "repoint", label: `Re-point to ${candidateRoot}`, offered: true, newRoot: candidateRoot, }; } // MOUNT NEEDS BOTH A DISK TO MOUNT AND A BINARY TO MOUNT IT WITH. In a // container there is neither — block devices are invisible and udisksctl is not // installed — so the withheld reason names the binary rather than implying the // disk is at fault. function mountAction( status: StorageLocationStatus, identity: StorageIdentity | undefined, loc: StorageLocation, udisksctlAvailable: boolean, busy: string | null, ): StorageActionView { const uuid = loc.volume?.uuid ?? (identity?.known ? identity.uuid : ""); if (busy) { return { kind: "mount", label: "Mount", offered: false, withheld: busy }; } if (status !== "unmounted") { return { kind: "mount", label: "Mount", offered: false, withheld: status === "absent" ? "The volume is not attached to this machine." : `Mount is only offered for an attached, unmounted volume; this location reads ${STORAGE_STATUS_LABEL[status].toLowerCase()}.`, }; } if (!udisksctlAvailable) { return { kind: "mount", label: "Mount", offered: false, withheld: "udisksctl is not available in this process (set UDISKSCTL_BIN) — " + "mount the volume from the host and press Refresh.", }; } return { kind: "mount", label: "Mount", offered: true, uuid }; } // DELETE IS REFUSED WHILE ANYBODY LIVES THERE. Deleting the location would not // touch a byte — but it would erase the only record of which disk those // channels' absolute `mediaDir`s belong to, which is precisely the knowledge // this page exists to keep. Move the channels off it (or re-point it) first. function deleteAction( loc: StorageLocation, counts: StorageChannelCounts, busy: string | null, // The saved-video store lives here. A resident like any other, and orphaned // by exactly the same delete — see the action's own refusal. hasStore: boolean, ): StorageActionView { if (busy) { return { kind: "delete", label: "Delete", offered: false, withheld: busy }; } if (hasStore) { return { kind: "delete", label: "Delete", offered: false, withheld: `The saved-video store is under ${loc.root}. Move it back in place, ` + `or onto another location, first.`, }; } if (counts.total > 0) { return { kind: "delete", label: "Delete", offered: false, withheld: `${counts.total} channel(s) still have their media under ` + `${loc.root}: ${counts.total === 1 ? "move it" : "move them"} back ` + `in place, or onto another location, first.`, }; } return { kind: "delete", label: "Delete", offered: true }; }