diff --git a/frontend/index.html b/frontend/index.html index f32e6dd..f1897aa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,6 +19,7 @@ Albums Renames Upload + Archive Stats diff --git a/frontend/js/api.js b/frontend/js/api.js index d75d0ce..f870d2b 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -138,4 +138,31 @@ export const api = { }), uploadVerifications: (id, opts = {}) => request(`/upload-batches/${encodeURIComponent(id)}/verifications`, opts), + + // ── Archive and restore: destinations, preflight, plans, recovery ──────── + archiveLocations: (opts = {}) => request("/archive-locations", opts), + registerArchiveLocation: (payload, opts = {}) => + request("/archive-locations", { method: "POST", body: JSON.stringify(payload), ...opts }), + archivePreflight: (payload, opts = {}) => + request("/archive-preflight", { method: "POST", body: JSON.stringify(payload), ...opts }), + createArchivePlan: (payload, opts = {}) => + request("/archive-plans", { method: "POST", body: JSON.stringify(payload), ...opts }), + listArchivePlans: (opts = {}) => request("/archive-plans", opts), + getArchivePlan: (id, opts = {}) => request(`/archive-plans/${encodeURIComponent(id)}`, opts), + applyArchivePlan: (id, opts = {}) => + request(`/archive-plans/${encodeURIComponent(id)}/apply`, { method: "POST", ...opts }), + archiveRecovery: (opts = {}) => request("/archive-recovery", opts), + resolveArchiveRecovery: (opts = {}) => + request("/archive-recovery/resolve", { method: "POST", ...opts }), + restorePreflight: (payload, opts = {}) => + request("/restore-preflight", { method: "POST", body: JSON.stringify(payload), ...opts }), + createRestorePlan: (payload, opts = {}) => + request("/restore-plans", { method: "POST", body: JSON.stringify(payload), ...opts }), + listRestorePlans: (opts = {}) => request("/restore-plans", opts), + getRestorePlan: (id, opts = {}) => request(`/restore-plans/${encodeURIComponent(id)}`, opts), + applyRestorePlan: (id, opts = {}) => + request(`/restore-plans/${encodeURIComponent(id)}/apply`, { method: "POST", ...opts }), + restoreRecovery: (opts = {}) => request("/restore-recovery", opts), + resolveRestoreRecovery: (opts = {}) => + request("/restore-recovery/resolve", { method: "POST", ...opts }), }; diff --git a/frontend/js/app.js b/frontend/js/app.js index a163fc2..4b0c725 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,4 +1,5 @@ import { api } from "./api.js"; +import { renderArchive, setArchiveRender } from "./archive.js"; import { navigate, onRouteChange, parseHash } from "./router.js"; import { renderRenames, setRenamesRender } from "./renames.js"; import { renderUploads, setUploadsRender } from "./uploads.js"; @@ -366,6 +367,7 @@ function render() { else if (path === "/albums") renderAlbums(root, params); else if (path === "/renames") renderRenames(root, params); else if (path === "/uploads") renderUploads(root, params); + else if (path === "/archive") renderArchive(root, params); else if (path === "/stats") renderStats(root, params); else show(errorBanner("Unknown view")); } @@ -374,5 +376,6 @@ function render() { setRender(render); setRenamesRender(render); setUploadsRender(render); +setArchiveRender(render); onRouteChange(render); render(); diff --git a/frontend/js/archive.js b/frontend/js/archive.js new file mode 100644 index 0000000..6589460 --- /dev/null +++ b/frontend/js/archive.js @@ -0,0 +1,870 @@ +// 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]}`; +} diff --git a/tests/e2e/_pipeline_harness.py b/tests/e2e/_pipeline_harness.py index f0b2967..2845247 100644 --- a/tests/e2e/_pipeline_harness.py +++ b/tests/e2e/_pipeline_harness.py @@ -393,6 +393,115 @@ def mark_upload_ready(seeded: Seeded, *, unverified: tuple[str, ...] = ()) -> No session.commit() +# ── Phase F: an archivable album and a mountable fake medium ───────────────── + + +def mark_uploaded(seeded: Seeded, *, album: str = "rome") -> None: + """Give every seeded photo the verified upload evidence archiving requires. + + Archiving refuses anything Immich is not proven to hold, and that proof is an + upload batch — recorded here as fixture state so the archive journeys do not + have to re-run an upload they are not testing. + """ + from sqlalchemy import select + + from photo_pipeline.models import Asset, UploadBatch, UploadItem + from photo_pipeline.services.hashing import sha256_file + + with session_factory(seeded) as sf: + with sf() as session: + batch_id = str(uuid.uuid4()) + session.add( + UploadBatch( + id=batch_id, + album=album, + folder=str(seeded.lib / album), + album_name=album, + state="succeeded", + preflight_token="v1:e2e", + outcome_state="verified", + created_at=NOW, + ) + ) + for asset in session.scalars(select(Asset)): + if not asset.current_path: + continue + session.add( + UploadItem( + batch_id=batch_id, + asset_id=asset.id, + path=asset.current_path, + sha256=sha256_file(asset.current_path), + sha1="0" * 40, + state="sent", + outcome="uploaded", + ) + ) + session.commit() + + +class ArchiveStack: + """A seeded, archivable library plus the server, the worker, and a fake medium. + + The medium is an ordinary directory whose marker file makes it identifiable; + ``unmount()`` takes that marker away, which is exactly what the application sees + when an external disk is unplugged. + """ + + MARKER = ".photo-pipeline-archive.json" + + def __init__(self, tmp_path: Path, seeded: Seeded) -> None: + self.tmp_path = tmp_path + self.seeded = seeded + self.archive = tmp_path / "archive" + self.archive.mkdir(exist_ok=True) + self.server: Server | None = None + self.worker: subprocess.Popen | None = None + self.base = "" + + def start(self, *, worker: bool = True, extra_env: dict[str, str] | None = None) -> "ArchiveStack": + env = {"PHOTO_PIPELINE_ARCHIVE_FREE_SPACE_RESERVE_BYTES": "0", **(extra_env or {})} + self.server = Server(self.seeded, extra_env=env).start() + self.base = self.server.base + if worker: + self.worker = start_worker(self.seeded, extra_env=env) + return self + + def register(self, name: str = "external") -> dict: + response = httpx.post( + f"{self.base}/api/v1/archive-locations", + json={"name": name, "root": str(self.archive)}, + timeout=20, + ) + response.raise_for_status() + return response.json() + + def unmount(self) -> None: + (self.archive / self.MARKER).rename(self.archive / f"{self.MARKER}.away") + + def remount(self) -> None: + (self.archive / f"{self.MARKER}.away").rename(self.archive / self.MARKER) + + def plans(self) -> list[dict]: + return httpx.get(f"{self.base}/api/v1/archive-plans", timeout=20).json()["plans"] + + def assets(self) -> list[dict]: + return httpx.get( + f"{self.base}/api/v1/inventory/assets", params={"limit": 200}, timeout=20 + ).json()["items"] + + def restart_server(self) -> None: + self.server.stop() + self.server.start() + + def stop(self) -> None: + if self.worker is not None: + self.worker.kill() + self.worker.wait(timeout=10) + if self.server is not None: + self.server.stop() + + class UploadStack: """A seeded, upload-ready library plus the server, worker, and fake Immich.""" diff --git a/tests/e2e/test_archive_ui.py b/tests/e2e/test_archive_ui.py new file mode 100644 index 0000000..e4af903 --- /dev/null +++ b/tests/e2e/test_archive_ui.py @@ -0,0 +1,302 @@ +"""Browser journeys for the archive view (US06-05). + +Archiving is the only stage that removes originals, so these journeys check the +two things a browser must never get wrong about it: that the preview names exactly +what would leave and where it would go, and that nothing offers an action the +server would refuse. The medium is a real directory whose marker makes it +identifiable; unmounting it is what an unplugged disk looks like from here. + +Nothing is mocked inside the browser: the transfer runs in the real worker process +and the assertions read the filesystem afterwards. +""" + +from __future__ import annotations + +import pytest +from playwright.sync_api import expect + +from tests.e2e._pipeline_harness import ( + ArchiveStack, + mark_uploaded, + seed_album, + session_factory, + wait_until, +) + +pytestmark = pytest.mark.phase_f # part of the Phase F acceptance gate (US06-06) + +TIMEOUT = 10 +RUN_TIMEOUT = 30_000 + + +@pytest.fixture +def stack(tmp_path): + seeded = seed_album(tmp_path) + mark_uploaded(seeded) + running = ArchiveStack(tmp_path, seeded) + try: + yield running + finally: + running.stop() + + +def _open(page, stack) -> None: + page.goto(f"{stack.base}/app/#/archive") + page.get_by_test_id("archive-locations").wait_for() + + +def _archive(page, stack) -> None: + """Confirm the archive and wait for the worker to finish the run.""" + _open(page, stack) + page.get_by_test_id("start-archive").click() + expect(page.get_by_test_id("detail-state")).to_have_text("complete", timeout=RUN_TIMEOUT) + + +def _archived_paths(stack) -> list[str]: + return sorted(p.name for p in (stack.archive / "rome").glob("*.jpg")) + + +# ── preview and confirmation ───────────────────────────────────────────────── + + +def test_the_preview_names_the_scope_destination_and_reclaimable_bytes(page, stack): + errors = [] + page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None) + stack.start(worker=False) + location = stack.register() + _open(page, stack) + + row = page.get_by_test_id("location-row").first + expect(row.get_by_test_id("location-label")).to_have_text("external") + expect(row.get_by_test_id("location-media")).to_have_text(location["media_id"]) + expect(row.get_by_test_id("location-state")).to_have_text("online") + + album = page.get_by_test_id("archive-album-row").first + expect(album.get_by_test_id("album-name")).to_have_text("rome") + expect(album.get_by_test_id("album-destination")).to_have_text(str(stack.archive / "rome")) + expect(album.get_by_test_id("album-assets")).to_have_text("2") + expect(album.get_by_test_id("album-state")).to_have_text("ready") + # Same filesystem here, so the transfer method is the move path — and it is + # named, because copy-verify-remove and move fail differently. + expect(album.get_by_test_id("album-method")).to_contain_text("move") + + expect(page.get_by_test_id("destination-identity")).to_contain_text(location["media_id"]) + expect(page.get_by_test_id("capacity")).to_contain_text("reserve") + expect(page.get_by_test_id("start-archive")).to_contain_text("Archive 1 album(s) · reclaim") + expect(page.get_by_test_id("start-archive")).to_be_enabled() + assert stack.plans() == [], "previewing may not create anything" + assert errors == [], f"console errors: {errors}" + + +def test_an_offline_medium_blocks_the_confirmation_and_says_what_to_mount(page, stack): + stack.start(worker=False) + location = stack.register() + stack.unmount() + _open(page, stack) + + expect(page.get_by_test_id("location-state")).to_have_text("offline") + expect(page.get_by_test_id("preflight-blocker").first).to_have_attribute( + "data-code", "location_offline" + ) + instruction = page.get_by_test_id("mount-instruction") + expect(instruction).to_contain_text("external") + expect(instruction).to_contain_text(location["media_id"]) + expect(page.get_by_test_id("start-archive")).to_be_disabled() + + +def test_an_unuploaded_album_is_blocked_with_its_reason(page, stack): + # No upload evidence at all: archiving would remove the only copy. + seeded = stack.seeded + with session_factory(seeded) as sf: + from sqlalchemy import delete + + from photo_pipeline.models import UploadItem + + with sf() as session: + session.execute(delete(UploadItem)) + session.commit() + stack.start(worker=False) + stack.register() + _open(page, stack) + + expect(page.get_by_test_id("album-state")).to_have_text("blocked") + expect(page.get_by_test_id("album-blocker").first).to_have_attribute( + "data-code", "partial_scope" + ) + expect(page.get_by_test_id("start-archive")).to_be_disabled() + + +# ── running, progress, reload ──────────────────────────────────────────────── + + +def test_a_confirmed_archive_runs_and_separates_transfer_verify_and_removal(page, stack): + stack.start() + stack.register() + _archive(page, stack) + + expect(page.get_by_test_id("count-complete")).to_have_text("complete: 2") + expect(page.get_by_test_id("count-failed")).to_have_text("failed: 0") + expect(page.get_by_test_id("count-transfer")).to_have_text("transfer: 0") + expect(page.get_by_test_id("count-verified")).to_have_text("verified: 0") + expect(page.get_by_test_id("count-removing")).to_have_text("removing: 0") + expect(page.get_by_test_id("operation-phase").first).to_have_text("complete") + + # The library really lost the originals and the medium really holds them. + assert _archived_paths(stack) == ["a.jpg", "b.jpg"] + assert not (stack.seeded.lib / "rome" / "a.jpg").exists() + assert {a["availability_state"] for a in stack.assets()} == {"archived_online"} + + +def test_the_run_survives_a_reload_because_the_state_is_the_servers(page, stack): + stack.start() + stack.register() + _archive(page, stack) + + page.reload() + page.get_by_test_id("plan-detail").wait_for() + expect(page.get_by_test_id("detail-state")).to_have_text("complete") + expect(page.get_by_test_id("count-complete")).to_have_text("complete: 2") + # And the run stays addressable by its own URL. + plan_id = stack.plans()[0]["id"] + page.goto(f"{stack.base}/app/#/archive?plan={plan_id}") + expect(page.get_by_test_id("plan-detail")).to_have_attribute("data-plan", plan_id) + + +# ── recovery ───────────────────────────────────────────────────────────────── + + +def test_an_interrupted_transfer_is_shown_with_evidence_and_a_safe_action(page, stack): + stack.start(worker=False) + stack.register() + _open(page, stack) + page.get_by_test_id("start-archive").click() + expect(page.get_by_test_id("archive-result")).to_be_visible() + # No worker ran, so every item is still planned; leave one mid-transfer as a + # crash would: intent recorded, nothing published, source intact. + _interrupt(stack) + + page.reload() + page.get_by_test_id("archive-recovery").wait_for() + row = page.get_by_test_id("recovery-row").first + expect(row).to_have_attribute("data-classification", "resumable") + expect(row.get_by_test_id("recovery-reason")).to_contain_text("source present") + page.get_by_test_id("resolve-recovery").click() + expect(page.get_by_test_id("archive-recovery")).to_have_count(0) + assert (stack.seeded.lib / "rome" / "a.jpg").exists(), "recovery may not move anything" + + +def test_ambiguous_evidence_offers_no_action_at_all(page, stack): + stack.start(worker=False) + stack.register() + _open(page, stack) + page.get_by_test_id("start-archive").click() + expect(page.get_by_test_id("archive-result")).to_be_visible() + # Journal says the copy is verified, but the medium holds nothing: no evidence + # supports either finishing or retrying this item. + _interrupt(stack, state="verified") + + page.reload() + page.get_by_test_id("archive-recovery").wait_for() + expect(page.get_by_test_id("recovery-row").first).to_have_attribute( + "data-classification", "manual" + ) + expect(page.get_by_test_id("recovery-manual")).to_be_visible() + expect(page.get_by_test_id("resolve-recovery")).to_have_count(0) + expect(page.get_by_test_id("no-safe-recovery")).to_be_visible() + + +def _interrupt(stack, *, state: str = "transferring") -> None: + """Leave the plan's first operation in a non-terminal journal state.""" + with session_factory(stack.seeded) as sf: + from photo_pipeline.services.archive_journal import ArchiveJournal + + journal = ArchiveJournal(sf) + plan_id = stack.plans()[0]["id"] + operation = journal.operations(plan_id)[0] + journal.begin(operation["id"], worker_id="crashed", fencing_token=1) + if state != "transferring": + journal.transition(operation["id"], state, fencing_token=1) + + +# ── offline browsing and restore ───────────────────────────────────────────── + + +def test_archived_photos_stay_browsable_while_the_medium_is_away(page, stack): + stack.start() + stack.register() + _archive(page, stack) + stack.unmount() + + page.reload() + page.get_by_test_id("archived-assets").wait_for() + row = page.get_by_test_id("archived-row").first + expect(row.get_by_test_id("archived-availability")).to_have_text("archived · medium away") + expect(row.get_by_test_id("archived-path")).to_contain_text("rome/") + expect(row.get_by_test_id("archived-medium")).to_have_text("external") + expect(page.get_by_test_id("mount-instruction")).to_contain_text("external") + # The retained protected preview is served even though the original is gone. + preview = row.get_by_test_id("archived-preview") + assert preview.evaluate("img => img.complete && img.naturalWidth > 0") + # Restoring is impossible right now, and says so instead of failing later. + expect(page.get_by_test_id("start-restore")).to_be_disabled() + + +def test_restoring_brings_the_photos_back_without_losing_identity(page, stack): + stack.start() + stack.register() + _archive(page, stack) + before = {asset["id"] for asset in stack.assets()} + + page.reload() + page.get_by_test_id("restore").wait_for() + expect(page.get_by_test_id("restore-row").first.get_by_test_id("restore-destination")).to_contain_text( + str(stack.seeded.lib / "rome") + ) + page.get_by_test_id("start-restore").click() + wait_until( + lambda: all(a["availability_state"] == "active" for a in stack.assets()), + timeout=30, + ) + + assert {asset["id"] for asset in stack.assets()} == before # same identities + assert (stack.seeded.lib / "rome" / "a.jpg").exists() + assert _archived_paths(stack) == ["a.jpg", "b.jpg"] # the archive copy stays + + +def test_a_taken_name_is_restored_beside_it_never_over_it(page, stack): + stack.start() + stack.register() + _archive(page, stack) + squatter = stack.seeded.lib / "rome" / "a.jpg" + squatter.parent.mkdir(parents=True, exist_ok=True) + squatter.write_bytes(b"a different photo now lives here") + + page.reload() + page.get_by_test_id("restore").wait_for() + destinations = page.get_by_test_id("restore-destination").all_inner_texts() + assert any("(restored)" in text for text in destinations), destinations + + page.get_by_test_id("start-restore").click() + wait_until( + lambda: all(a["availability_state"] == "active" for a in stack.assets()), + timeout=30, + ) + assert squatter.read_bytes() == b"a different photo now lives here" + assert (stack.seeded.lib / "rome" / "a (restored).jpg").exists() + + +# ── keyboard ───────────────────────────────────────────────────────────────── + + +def test_the_whole_archive_can_be_confirmed_from_the_keyboard(page, stack): + stack.start() + stack.register() + _open(page, stack) + + button = page.get_by_test_id("start-archive") + button.focus() + expect(button).to_be_focused() + page.keyboard.press("Enter") + + expect(page.get_by_test_id("detail-state")).to_have_text("complete", timeout=RUN_TIMEOUT) + assert _archived_paths(stack) == ["a.jpg", "b.jpg"] diff --git a/tests/story_traceability.json b/tests/story_traceability.json index fec5eb9..17bf1aa 100644 --- a/tests/story_traceability.json +++ b/tests/story_traceability.json @@ -137,6 +137,9 @@ ], "US06-04": [ "tests/integration/test_restore.py" + ], + "US06-05": [ + "tests/e2e/test_archive_ui.py" ] } }