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"
]
}
}