// Archive view (US06-05): preview what would leave active storage, confirm it // exactly, watch the transfer, recover an interrupted one, browse what is already // archived, and bring it back. // // Archiving is the only stage that removes originals, so this view never decides // anything itself: the destination's identity, every blocker, the confirmation // token, and what recovery may do all come from the server, and an action the // server would refuse is not offered. Two things follow from that. A medium that // is not mounted produces an instruction naming it rather than a disabled mystery, // and an operation whose evidence is ambiguous offers no button at all. import { api } from "./api.js"; import { el, errorBanner, setActiveNav } from "./dom.js"; import { subscribeJob } from "./events.js"; import { navigate } from "./router.js"; let outcome = null; let activity = []; let render = () => {}; export function setArchiveRender(fn) { render = fn; } // The per-file journal states, split into the three things an operator actually // wants told apart: bytes moving, bytes proven, original removed (concept §9). const PHASES = [ ["planned", "planned", "waiting"], ["transferring", "transfer", "copying to the medium"], ["verified", "verified", "archive copy hashed and manifested"], ["removing", "removing", "removing the active original"], ["complete", "complete", "archived and removed"], ["failed", "failed", "left alone for a decision"], ]; const AVAILABILITY_LABEL = { active: "in the library", archived_online: "archived · medium mounted", archived_offline: "archived · medium away", missing_unexpected: "missing — unexplained", }; export async function renderArchive(root, params = {}) { setActiveNav("archive"); let locations; try { locations = (await api.archiveLocations()).locations; } catch (error) { root.replaceChildren(errorBanner(`Failed to load archive locations: ${error.message}`)); return; } const location = locations.find((l) => l.id === params.location) || locations[0] || null; const nodes = [el("h1", {}, "Archive"), locationsCard(locations, location, params)]; if (!location) { nodes.push( el( "p", { class: "muted", "data-testid": "no-locations" }, "Register the disk, NAS share, or removable medium that will hold archived originals." ), outcomeBanner() ); root.replaceChildren(...nodes.filter(Boolean)); return; } const [preflight, archivePlans, restorePlans, recovery, restoreRecovery, archived, restore] = await Promise.all([ load(() => api.archivePreflight({ location_id: location.id })), load(() => api.listArchivePlans()), load(() => api.listRestorePlans()), load(() => api.archiveRecovery()), load(() => api.restoreRecovery()), load(() => api.listAssets({ limit: 200 })), load(() => api.restorePreflight({ location_id: location.id })), ]); // Both directions share the runs table: one lane moves these originals, so one // history is what an operator has to reason about. const runs = [...plans(archivePlans), ...plans(restorePlans)].sort((a, b) => (a.created_at || "").localeCompare(b.created_at || "") ); const chosen = runs.find((run) => run.id === params.plan) || runs[runs.length - 1] || null; const selectedPlan = chosen ? await load(() => planDetailOf(chosen)) : null; nodes.push( preflight ? previewSection(preflight, location) : null, preflight ? confirmBlock(preflight, location) : null, outcomeBanner(), activityLog(), recoverySection(mergeRecovery(recovery, restoreRecovery)), planList(runs, chosen && chosen.id), selectedPlan ? planDetail(selectedPlan) : null, archived ? archivedSection(archived.items, locations) : null, restore ? restoreSection(restore, location) : null ); root.replaceChildren(...nodes.filter(Boolean)); } function plans(listed) { return listed ? listed.plans : []; } function planDetailOf(run) { return run.direction === "restore" ? api.getRestorePlan(run.id) : api.getArchivePlan(run.id); } // Archive and restore recovery answer the same question about the same lane, so // they are one list; an unresolved item of either kind blocks the other. function mergeRecovery(archive, restore) { if (!archive && !restore) return null; return { operations: [...((archive || {}).operations || []), ...((restore || {}).operations || [])], manual: [...((archive || {}).manual || []), ...((restore || {}).manual || [])], }; } // A section whose data failed to load must not take the rest of the view with it: // the medium being away is exactly when the archived-asset list matters most. async function load(call) { try { return await call(); } catch (_) { return null; } } // ── locations ──────────────────────────────────────────────────────────────── // A location is a medium, not a path: the marker's ``media_id`` is what proves the // right disk is mounted, so it is shown next to the state it produced. function locationsCard(locations, selected, params) { const name = el("input", { type: "text", "data-testid": "location-name", "aria-label": "Archive location name", placeholder: "External disk", }); const root = el("input", { type: "text", "data-testid": "location-root", "aria-label": "Archive location path", placeholder: "/Volumes/archive", }); return el( "div", { class: "card", "data-testid": "archive-locations" }, el("h2", {}, "Destinations"), locations.length ? el( "table", { class: "grid", "data-testid": "locations" }, el( "thead", {}, el( "tr", {}, ...["", "Name", "Root", "Medium", "State", "Last seen"].map((label) => el("th", { scope: "col" }, label) ) ) ), el( "tbody", {}, ...locations.map((location) => el( "tr", { "data-testid": "location-row", "data-name": location.name, "aria-current": selected && location.id === selected.id ? "true" : false, }, el( "td", {}, el("input", { type: "radio", name: "archive-location", "data-testid": "select-location", "aria-label": `Use ${location.name}`, checked: selected && location.id === selected.id ? "checked" : false, onchange: () => navigate("/archive", { ...params, location: location.id }), }) ), el("td", { "data-testid": "location-label" }, location.name), el("td", { class: "path", "data-testid": "location-path" }, location.root), el("td", { class: "path", "data-testid": "location-media" }, location.media_id), el( "td", {}, el( "span", { class: `badge ${location.state === "online" ? "complete" : "attention"}`, "data-testid": "location-state", }, location.state ) ), el("td", { class: "muted" }, location.last_seen_at || "never") ) ) ) ) : null, selected && selected.state !== "online" ? mountInstruction(selected) : null, el( "div", { class: "toolbar" }, name, root, el( "button", { "data-testid": "register-location", onclick: () => run(() => api.registerArchiveLocation({ name: name.value, root: root.value })), }, "Register destination" ) ) ); } // The one thing the app cannot do for the user: name the medium to connect. function mountInstruction(location) { const detail = location.state === "wrong_volume" ? `A different medium is mounted at ${location.root}.` : `Nothing is mounted at ${location.root}.`; return el( "div", { class: "confirm", role: "status", "data-testid": "mount-instruction" }, `${detail} Connect “${location.name}” (medium ${location.media_id}) and mount it there, ` + "then reload this view. Archived photos stay listed and searchable meanwhile." ); } // ── preview ────────────────────────────────────────────────────────────────── function previewSection(preflight, location) { const totals = preflight.totals; const capacity = preflight.capacity; const rows = preflight.albums.map((album) => el( "tr", { "data-testid": "archive-album-row", "data-album": album.album }, el("td", { "data-testid": "album-name" }, album.album), el("td", { class: "path", "data-testid": "album-folder" }, album.folder), el("td", { class: "path", "data-testid": "album-destination" }, album.destination), el( "td", { "data-testid": "album-method" }, album.transfer_method === "move" ? "move (same filesystem)" : "copy · verify · remove" ), el("td", { "data-testid": "album-assets" }, String(album.asset_count)), el("td", { "data-testid": "album-reclaim" }, bytes(album.reclaimable_bytes)), el( "td", {}, el("span", { class: `badge ${album.state}`, "data-testid": "album-state" }, album.state), ...album.blockers.map((blocker) => el( "div", { class: "blocker", "data-testid": "album-blocker", "data-code": blocker.code }, blocker.message ) ) ) ) ); return el( "div", { "data-testid": "archive-preview" }, el("h2", {}, "Preview"), el( "div", { class: "decision-bar" }, el( "span", { class: "badge", "data-testid": "destination-identity" }, `${location.name} · ${location.media_id}` ), el("span", { class: "badge", "data-testid": "total-albums" }, `${totals.albums} album(s)`), el("span", { class: "badge", "data-testid": "total-assets" }, `${totals.assets} photo(s)`), el( "span", { class: "badge", "data-testid": "total-reclaim" }, `${bytes(totals.bytes)} reclaimable` ), el( "span", { class: `badge ${capacity.sufficient ? "complete" : "blocked"}`, "data-testid": "capacity", }, `free ${bytes(capacity.free_bytes)} · reserve ${bytes(capacity.reserve_bytes)}` ) ), blockerList(preflight.blockers, "preflight-blockers", "preflight-blocker", "This scope cannot be archived yet"), rows.length ? el( "table", { class: "grid", "data-testid": "archive-albums" }, el( "thead", {}, el( "tr", {}, ...["Album", "Folder", "Destination", "Transfer", "Photos", "Reclaims", "State"].map( (label) => el("th", { scope: "col" }, label) ) ) ), el("tbody", {}, ...rows) ) : el( "p", { class: "muted", "data-testid": "no-albums" }, "No album has a verified upload whose bytes are still unchanged, so nothing may be archived." ) ); } function blockerList(blockers, containerId, itemId, title) { if (!blockers || !blockers.length) return null; return el( "div", { class: "alert", role: "alert", "data-testid": containerId }, el("strong", {}, title), el( "ul", {}, ...blockers.map((blocker) => el("li", { "data-testid": itemId, "data-code": blocker.code }, `${blocker.code}: ${blocker.message}`) ) ) ); } // ── confirmation ───────────────────────────────────────────────────────────── function confirmBlock(preflight, location) { const ready = preflight.state === "ready"; const totals = preflight.totals; return el( "div", { class: "card", "data-testid": "confirm" }, el("h2", {}, "Confirm"), el( "p", { class: "muted", "data-testid": "confirm-token" }, `Preflight ${preflight.token.slice(0, 20)}… · ${location.name}` ), el( "p", { "data-testid": "archive-note" }, "Archiving removes each original from the library — but only after its copy on " + "the medium has been written, hashed, and recorded in the manifest. The photos " + "stay searchable and deduplicable while the medium is away, and can be restored " + "from this view." ), el( "div", { class: "toolbar" }, el( "button", { class: "primary", "data-testid": "start-archive", disabled: ready ? false : "disabled", title: ready ? false : "resolve the blockers above first", onclick: () => run(async () => { const plan = await api.createArchivePlan({ location_id: location.id, token: preflight.token, }); const started = await api.applyArchivePlan(plan.id); watch(started.job.id, plan.id, "archive"); return { archiving: plan.asset_count }; }), }, `Archive ${totals.ready_albums} album(s) · reclaim ${bytes(totals.bytes)}` ) ) ); } // ── plans and progress ─────────────────────────────────────────────────────── function planList(runs, selectedId) { if (!runs.length) { return el("p", { class: "muted", "data-testid": "no-plans" }, "Nothing has been archived yet."); } return el( "div", { "data-testid": "archive-plans" }, el("h2", {}, "Runs"), el( "table", { class: "grid", "data-testid": "plans" }, el( "thead", {}, el( "tr", {}, ...["Created", "Direction", "State", "Photos", "Bytes"].map((l) => el("th", { scope: "col" }, l) ) ) ), el( "tbody", {}, ...runs.map((plan) => el( "tr", { "data-testid": "plan-row", "data-plan": plan.id, "data-direction": plan.direction, "aria-current": plan.id === selectedId ? "true" : false, }, el( "td", {}, el("a", { class: "link", href: `#/archive?plan=${encodeURIComponent(plan.id)}` }, plan.created_at || plan.id) ), el("td", { "data-testid": "plan-direction" }, plan.direction), el("td", {}, el("span", { class: `badge ${plan.state}`, "data-testid": "plan-state" }, plan.state)), el("td", {}, String(plan.asset_count)), el("td", {}, bytes(plan.byte_size)) ) ) ) ) ); } function planDetail(plan) { const operations = plan.operations || []; const counts = {}; for (const operation of operations) { counts[operation.journal_state] = (counts[operation.journal_state] || 0) + 1; } return el( "div", { class: "card", "data-testid": "plan-detail", "data-plan": plan.id }, el("h2", {}, `${plan.direction === "restore" ? "Restore" : "Archive"} run ${plan.created_at || plan.id}`), // Transfer, verification, and removal are separate answers to separate // questions: what has moved, what is proven, and what is already gone. el( "div", { class: "decision-bar", "data-testid": "plan-progress" }, el("span", { class: `badge ${plan.state}`, "data-testid": "detail-state" }, plan.state), ...PHASES.map(([key, label, title]) => el( "span", { class: `badge ${key}`, "data-testid": `count-${label}`, title }, `${label}: ${counts[key] ?? 0}` ) ) ), el( "table", { class: "grid", "data-testid": "operations" }, el( "thead", {}, el( "tr", {}, ...["Photo", "Destination", "Phase", "Attempts", "Problem"].map((l) => el("th", { scope: "col" }, l) ) ) ), el( "tbody", {}, ...operations.map((operation) => el( "tr", { "data-testid": "operation-row", "data-asset-id": operation.asset_id }, el("td", { class: "path", "data-testid": "operation-source" }, operation.source_path), el("td", { class: "path", "data-testid": "operation-destination" }, operation.destination_path), el( "td", {}, el( "span", { class: `badge ${operation.journal_state}`, "data-testid": "operation-phase" }, phaseLabel(operation.journal_state) ) ), el("td", {}, String(operation.attempt_count)), el( "td", { class: "muted", "data-testid": "operation-error", "data-code": operation.error_code || "" }, operation.error_code ? `${operation.error_code}: ${operation.error_message || ""}` : "—" ) ) ) ) ) ); } function phaseLabel(state) { const found = PHASES.find(([key]) => key === state); return found ? found[1] : state; } // ── recovery ───────────────────────────────────────────────────────────────── // What an interrupted run left behind, straight from the journal plus the files on // disk. Only the operations the server itself classified as resolvable get an // action; ambiguous ones are shown with their evidence and no button. function recoverySection(recovery) { const operations = recovery ? recovery.operations : []; if (!operations.length) return null; const manual = recovery.manual || []; const resolvable = operations.length - manual.length; return el( "div", { class: "card", "data-testid": "archive-recovery" }, el("h2", {}, "Interrupted work"), el( "p", { "data-testid": "recovery-summary" }, `${operations.length} operation(s) did not finish · ${resolvable} resolvable · ` + `${manual.length} need a decision` ), el( "table", { class: "grid", "data-testid": "recovery-operations" }, el( "thead", {}, el( "tr", {}, ...["Photo", "Phase", "Verdict", "Evidence"].map((l) => el("th", { scope: "col" }, l)) ) ), el( "tbody", {}, ...operations.map((verdict) => el( "tr", { "data-testid": "recovery-row", "data-classification": verdict.classification, "data-direction": verdict.direction, }, el("td", { class: "path", "data-testid": "recovery-source" }, verdict.source_path), el("td", {}, el("span", { class: "badge" }, phaseLabel(verdict.journal_state))), el( "td", {}, el( "span", { class: `badge ${verdict.classification === "manual" ? "blocked" : "attention"}`, "data-testid": "recovery-verdict", }, verdict.classification ) ), el( "td", { class: "muted", "data-testid": "recovery-reason" }, `${verdict.reason} (source ${verdict.source_exists ? "present" : "absent"}, ` + `archive copy ${verdict.destination_matches ? "verified" : verdict.destination_exists ? "different bytes" : "absent"})` ) ) ) ) ), manual.length ? el( "div", { class: "alert", role: "alert", "data-testid": "recovery-manual" }, `${manual.length} operation(s) cannot be resolved from the evidence. Nothing will be ` + "removed or retried for them here: inspect the medium and the library, then decide." ) : null, el( "div", { class: "toolbar" }, resolvable ? el( "button", { class: "primary", "data-testid": "resolve-recovery", onclick: () => run(() => api.resolveArchiveRecovery()), }, `Finish ${resolvable} recoverable operation(s)` ) : el( "span", { class: "muted", "data-testid": "no-safe-recovery" }, "No operation can be finished safely from here." ) ) ); } // ── archived assets ────────────────────────────────────────────────────────── // Browsing what is already archived, including while the medium is away: the // retained protected preview and the recorded hashes are the evidence, so the row // stays complete and honest instead of turning into a missing file. function archivedSection(assets, locations) { const archived = assets.filter((asset) => asset.availability_state !== "active"); if (!archived.length) return null; const names = Object.fromEntries(locations.map((location) => [location.id, location.name])); return el( "div", { "data-testid": "archived-assets" }, el("h2", {}, "Archived photos"), el( "table", { class: "grid", "data-testid": "archived" }, el( "thead", {}, el( "tr", {}, ...["Preview", "Archived as", "Medium", "Availability", "Size"].map((l) => el("th", { scope: "col" }, l) ) ) ), el( "tbody", {}, ...archived.map((asset) => el( "tr", { "data-testid": "archived-row", "data-asset-id": asset.id }, el( "td", {}, el("img", { "data-testid": "archived-preview", width: 96, loading: "lazy", src: api.thumbnailUrl(asset.id, 256), alt: `Preview of ${asset.archive_path || asset.id}`, }) ), el("td", { class: "path", "data-testid": "archived-path" }, asset.archive_path || "—"), el( "td", { "data-testid": "archived-medium" }, names[asset.archive_location_id] || asset.archive_location_id || "—" ), el( "td", {}, el( "span", { class: `badge ${asset.availability_state === "archived_online" ? "complete" : "attention"}`, "data-testid": "archived-availability", }, AVAILABILITY_LABEL[asset.availability_state] || asset.availability_state ) ), el("td", {}, bytes(asset.byte_size)) ) ) ) ) ); } // ── restore ────────────────────────────────────────────────────────────────── function restoreSection(restore, location) { const ready = restore.state === "ready"; const items = restore.items || []; if (!items.length && !restore.blockers.length) return null; return el( "div", { class: "card", "data-testid": "restore" }, el("h2", {}, "Restore"), el( "p", { "data-testid": "restore-note" }, "Restoring copies the archived bytes back into the library and leaves the archive " + "copy where it is. A name that is already taken is never overwritten: the photo " + "comes back beside it under a visibly different name." ), blockerList(restore.blockers, "restore-blockers", "restore-blocker", "This restore cannot run yet"), items.length ? el( "table", { class: "grid", "data-testid": "restore-items" }, el( "thead", {}, el( "tr", {}, ...["Archived as", "Comes back as", "Size", "State"].map((l) => el("th", { scope: "col" }, l) ) ) ), el( "tbody", {}, ...items.map((item) => el( "tr", { "data-testid": "restore-row", "data-asset-id": item.asset_id }, el("td", { class: "path", "data-testid": "restore-source" }, item.archive_path), el("td", { class: "path", "data-testid": "restore-destination" }, item.destination_path || "—"), el("td", {}, bytes(item.byte_size)), el( "td", {}, item.blockers.length ? el( "span", { class: "badge blocked", "data-testid": "restore-item-blocker", "data-code": item.blockers[0].code, }, item.blockers[0].code ) : el("span", { class: "badge ready", "data-testid": "restore-item-state" }, "ready") ) ) ) ) ) : null, el( "div", { class: "toolbar" }, el( "button", { class: "primary", "data-testid": "start-restore", disabled: ready ? false : "disabled", title: ready ? false : "the medium and every archived copy must check out first", onclick: () => run(async () => { const plan = await api.createRestorePlan({ location_id: location.id, token: restore.token, }); const started = await api.applyRestorePlan(plan.id); watch(started.job.id, plan.id, "restore"); return { restoring: plan.asset_count }; }), }, `Restore ${items.length} photo(s) from ${location.name}` ) ) ); } // ── running commands ───────────────────────────────────────────────────────── async function run(action) { try { outcome = { kind: "ok", result: await action() }; } catch (error) { outcome = error.status === 409 ? { kind: "conflict", error } : { kind: "error", error }; } render(); } // Live job activity. The plan panel is refreshed on its own tick because the // journal advances per file, not per job event; a full re-render would re-run // preflight (which re-hashes the library), so that happens once when the job ends. const REFRESH_MS = 500; function watch(jobId, planId, kind) { activity = [`Started ${kind} job ${jobId}`]; const tick = setInterval(() => refreshPlan(planId, kind), REFRESH_MS); subscribeJob(jobId, { onEvent: (event) => { activity.push(`${event.type}${event.message ? ": " + event.message : ""}`); const log = document.querySelector('[data-testid="archive-activity"]'); if (log) log.textContent = activity.join("\n"); }, onDone: () => { clearInterval(tick); activity.push("done"); render(); }, }); } async function refreshPlan(planId, kind) { const node = document.querySelector(`[data-testid="plan-detail"][data-plan="${planId}"]`); if (!node) return; // the user navigated away from the running plan try { const plan = await (kind === "restore" ? api.getRestorePlan(planId) : api.getArchivePlan(planId)); node.replaceWith(planDetail(plan)); } catch (_) { // Transient; the next tick tries again and the job's end re-renders anyway. } } function activityLog() { return el( "pre", { class: "activity-log", role: "status", "aria-live": "polite", "data-testid": "archive-activity", }, activity.join("\n") ); } function outcomeBanner() { if (!outcome) return null; if (outcome.kind === "conflict") { return el( "div", { class: "alert", role: "alert", "data-testid": "conflict" }, `The server refused this: ${outcome.error.message}. Nothing was moved or removed; ` + "the state below is the server's current one — review it and decide again." ); } if (outcome.kind === "error") { return el( "div", { class: "alert", role: "alert", "data-testid": "archive-error" }, `Failed: ${outcome.error.message}` ); } const result = outcome.result || {}; const message = result.archiving !== undefined ? `Archiving ${result.archiving} photo(s). Originals are removed only after their copies verify.` : result.restoring !== undefined ? `Restoring ${result.restoring} photo(s) into the library.` : "Done — the state below is the server's."; return el("div", { class: "alert", role: "status", "data-testid": "archive-result" }, message); } // ── formatting ─────────────────────────────────────────────────────────────── function bytes(value) { if (value == null) return "unknown"; const units = ["B", "kB", "MB", "GB", "TB"]; let size = value; let unit = 0; while (size >= 1000 && unit < units.length - 1) { size /= 1000; unit += 1; } return `${unit === 0 ? size : size.toFixed(1)} ${units[unit]}`; }