// Upload view (US05-05): preflight a scope, confirm exactly what will be sent, // watch the batch run, and resolve whatever the uploader left uncertain. // // Two rules shape this file. First, nothing here decides what is safe: blockers, // the preflight token, and the retry policy all come from the server, and an action // the server would refuse is not offered at all. Second, the API key never reaches // the browser — preflight reports only that one is configured, and every command // preview arrives redacted — so nothing in this view may reconstruct, store, or // route a secret. import { api } from "./api.js"; import { el, errorBanner, setActiveNav } from "./dom.js"; import { subscribeJob } from "./events.js"; import { navigate } from "./router.js"; // Result of the last command issued from this tab, and the activity of the last // upload job. Deliberately not persisted: after a reload the page must show what // the server says happened, not what this page remembers. let outcome = null; let activity = []; let render = () => {}; export function setUploadsRender(fn) { render = fn; } // Report outcomes (services/upload_reports.py) in the words the operator uses. const OUTCOME_LABEL = [ ["uploaded", "new"], ["upgraded", "upgraded"], ["duplicate", "duplicate"], ["skipped", "skipped"], ["failed", "failed"], ["unknown", "uncertain"], ]; export async function renderUploads(root, params = {}) { setActiveNav("uploads"); const albums = parseAlbums(params.albums); const allowPartial = params.partial === "1"; let preflight, listed; try { [preflight, listed] = await Promise.all([ api.uploadPreflight({ albums, allow_partial: allowPartial }), api.listUploadBatches(), ]); } catch (error) { root.replaceChildren(errorBanner(`Failed to load uploads: ${error.message}`)); return; } const batches = listed.batches; const selectedId = params.batch || (batches.length ? batches[batches.length - 1].id : null); let batch = null; let history = []; if (selectedId) { try { const [detail, verifications] = await Promise.all([ api.getUploadBatch(selectedId), api.uploadVerifications(selectedId), ]); batch = detail; history = verifications.verifications; } catch (error) { root.replaceChildren(errorBanner(`Failed to load upload batch: ${error.message}`)); return; } } root.replaceChildren( ...[ el("h1", {}, "Upload"), configurationCard(preflight), preflightBlockers(preflight), scopeSection(preflight, params, albums, allowPartial), confirmBlock(preflight, params, allowPartial), outcomeBanner(), activityLog(), batchList(batches, selectedId), batch ? batchDetail(batch, history) : null, ].filter(Boolean) ); } // Album names can contain commas, so each one is escaped before the URL joins them. function parseAlbums(value) { if (!value) return null; const names = value.split(",").filter(Boolean).map(decodeURIComponent); return names.length ? names : null; } function albumsParam(names) { return names.map(encodeURIComponent).join(","); } // ── configuration ──────────────────────────────────────────────────────────── // What the upload is aimed at, in the only form the browser is ever given: the // server URL, whether a key exists, and the uploader's version. function configurationCard(preflight) { const credentials = preflight.credentials; const uploader = preflight.uploader; return el( "div", { class: "card", "data-testid": "upload-config" }, el("h2", {}, "Configuration"), el( "dl", {}, el("dt", {}, "Immich server"), el("dd", { "data-testid": "config-server" }, credentials.server_url || "not configured"), el("dt", {}, "API key"), // Presence, never the value — and never a length or prefix either. el( "dd", { "data-testid": "config-key" }, credentials.api_key_configured ? "configured (never shown)" : "missing" ), el("dt", {}, "Reachable"), el( "dd", { "data-testid": "config-reachable" }, preflight.server.reachable ? "yes" : `no — ${preflight.server.detail || "unknown"}` ), el("dt", {}, "Uploader"), el( "dd", { "data-testid": "config-uploader" }, uploader.installed ? uploader.version || "installed" : `${uploader.binary} is not installed` ) ) ); } function preflightBlockers(preflight) { if (!preflight.blockers.length) return null; return el( "div", { class: "alert", role: "alert", "data-testid": "preflight-blockers" }, el("strong", {}, "This scope cannot be uploaded yet"), el( "ul", {}, ...preflight.blockers.map((blocker) => el( "li", { "data-testid": "preflight-blocker", "data-code": blocker.code }, `${blocker.code}: ${blocker.message}` ) ) ) ); } // ── scope ──────────────────────────────────────────────────────────────────── function scopeSection(preflight, params, albums, allowPartial) { const selected = new Set(albums || preflight.albums.map((album) => album.album)); function toggle(name, checked) { const next = new Set(selected); if (checked) next.add(name); else next.delete(name); // An empty selection means "everything" again, which is also what an absent // parameter means — there is no way to preflight nothing. navigate("/uploads", { ...params, albums: albumsParam([...next]), batch: params.batch }); } const rows = preflight.albums.map((album) => el( "tr", { "data-testid": "album-row", "data-album": album.album }, el( "td", {}, el("input", { type: "checkbox", "data-testid": "album-selected", "aria-label": `Include ${album.album}`, checked: selected.has(album.album) ? "checked" : false, onchange: (event) => toggle(album.album, event.target.checked), }) ), el("td", { "data-testid": "album-name" }, album.album), // What Immich will call it, which is not always what the folder is called here. el("td", { "data-testid": "album-immich-name" }, album.album_name), el("td", { class: "path", "data-testid": "album-folder" }, album.folder), el("td", { "data-testid": "album-eligible" }, String(album.eligible_count)), el("td", { "data-testid": "album-blocked" }, String(album.blocked_count)), 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 ) ) ), // The exact invocation, as the server built it. Shown so the upload holds no // surprises; the key is masked at the source, not here. el("td", { class: "path", "data-testid": "album-command" }, album.command_preview.join(" ")) ) ); const totals = preflight.totals; return el( "div", { "data-testid": "upload-scope" }, el("h2", {}, "Scope"), el( "div", { class: "decision-bar" }, el("span", { class: "badge", "data-testid": "total-albums" }, `${totals.albums} album(s)`), el("span", { class: "badge", "data-testid": "total-eligible" }, `${totals.eligible} ready`), totals.blocked ? el( "span", { class: "badge attention", "data-testid": "total-blocked" }, `${totals.blocked} blocked` ) : null, el( "label", {}, el("input", { type: "checkbox", "data-testid": "allow-partial", checked: allowPartial ? "checked" : false, onchange: (event) => navigate("/uploads", { ...params, partial: event.target.checked ? "1" : "" }), }), " Upload ready photos and leave the blocked ones behind" ) ), rows.length ? el( "table", { class: "grid", "data-testid": "albums" }, el( "thead", {}, el( "tr", {}, ...["", "Album", "Immich album", "Folder", "Ready", "Blocked", "State", "Command"].map( (label) => el("th", { scope: "col" }, label) ) ) ), el("tbody", {}, ...rows) ) : el("p", { class: "muted", "data-testid": "no-albums" }, "No album is ready to upload.") ); } // ── confirmation ───────────────────────────────────────────────────────────── function confirmBlock(preflight, params, allowPartial) { const ready = preflight.state === "ready"; const totals = preflight.totals; const albums = preflight.albums.map((album) => album.album); return el( "div", { class: "card", "data-testid": "confirm" }, el("h2", {}, "Confirm"), // The token is shown, not merely sent: a confirmation the user cannot see is a // confirmation they cannot check against the preview above. el( "p", { class: "muted", "data-testid": "confirm-token" }, `Preflight ${preflight.token.slice(0, 20)}… · ${allowPartial ? "partial" : "complete"} scope` ), el( "p", { "data-testid": "upload-note" }, "Uploading is not reversible from here: Immich decides what to do with each " + "file, and the app can only record what it reports. One album is sent at a " + "time and a running album can be stopped." ), el( "div", { class: "toolbar" }, el( "button", { class: "primary", "data-testid": "start-upload", disabled: ready ? false : "disabled", title: ready ? false : "resolve the blockers above first", onclick: () => run(async () => { const created = await api.createUploadBatches({ albums, token: preflight.token, allow_partial: allowPartial, }); // One lane: the first batch starts now, the rest wait with their own // start buttons rather than queueing behind a lock that would reject // them. const first = created.batches.find((batch) => !batch.retry_blockers.length); if (!first) return { batches: created.batches.length, started: null }; const started = await api.startUploadBatch(first.id); watch(started.job.id, first.id); return { batches: created.batches.length, started: first.album }; }), }, `Upload ${totals.albums} album(s) · ${totals.eligible} photo(s)` ) ) ); } // ── batches ────────────────────────────────────────────────────────────────── function batchList(batches, selectedId) { if (!batches.length) { return el("p", { class: "muted", "data-testid": "no-batches" }, "No upload has been started yet."); } return el( "div", { "data-testid": "upload-batches" }, el("h2", {}, "Batches"), el( "table", { class: "grid", "data-testid": "batches" }, el( "thead", {}, el( "tr", {}, ...["Album", "State", "Evidence", "Attempts", "Photos"].map((label) => el("th", { scope: "col" }, label) ) ) ), el( "tbody", {}, ...batches.map((batch) => el( "tr", { "data-testid": "batch-row", "data-album": batch.album, "aria-current": batch.id === selectedId ? "true" : false, }, el( "td", {}, el("a", { class: "link", href: `#/uploads?batch=${encodeURIComponent(batch.id)}` }, batch.album) ), el( "td", {}, el("span", { class: `badge ${batch.state}`, "data-testid": "batch-state" }, batch.state) ), el("td", { "data-testid": "batch-outcome-state" }, batch.outcome_state || "not parsed"), el("td", {}, String(batch.attempt_count)), el("td", {}, String(batch.asset_count)) ) ) ) ) ); } function batchDetail(batch, history) { // Derived from the items, not from the batch's parsed-report summary: verifying // or resolving an item changes what is true without re-parsing a report, and the // progress line must show the current answer rather than the uploader's old one. const counts = {}; for (const item of batch.items) { const key = item.outcome || "unknown"; counts[key] = (counts[key] || 0) + 1; } const blockers = batch.retry_blockers; const uncertain = batch.outcome_state === "requires_verification" || counts.unknown > 0; const nodes = [ el("h2", {}, `${batch.album} — attempt ${batch.attempt_count}`), el( "div", { class: "decision-bar", "data-testid": "batch-progress" }, el("span", { class: `badge ${batch.state}`, "data-testid": "detail-state" }, batch.state), ...OUTCOME_LABEL.map(([key, label]) => el( "span", { class: `badge ${key}`, "data-testid": `count-${label}` }, `${label}: ${counts[key] ?? 0}` ) ) ), ]; if (batch.stale_bytes) { nodes.push( el( "div", { class: "alert", role: "alert", "data-testid": "stale-bytes" }, "Files in this batch changed after they were uploaded. Immich still holds the " + "bytes that were sent; re-approve the album through a fresh preflight rather " + "than uploading the new bytes over it." ) ); } if (batch.error_code) { nodes.push( el( "div", { class: "alert", role: "alert", "data-testid": "batch-error" }, `${batch.error_code}: ${batch.error_message || ""}` ) ); } if (uncertain) { nodes.push( el( "div", { class: "alert", role: "alert", "data-testid": "uncertain" }, el("strong", {}, "This upload's outcome is not fully known"), el( "p", {}, "The uploader's report does not account for every file. Retrying could create " + "a second copy of something Immich already accepted, so verify it first: the " + "check asks Immich whether it holds the exact bytes that were sent." ) ) ); } nodes.push( el( "div", { class: "toolbar" }, // A start button exists only when the server would accept one. An uncertain // outcome and changed bytes therefore offer verification, never a retry. blockers.length ? el( "div", { class: "blocker", "data-testid": "retry-blocked" }, blockers.map((blocker) => `${blocker.code}: ${blocker.message}`).join("; ") ) : el( "button", { class: "primary", "data-testid": "start-batch", onclick: () => run(async () => { const started = await api.startUploadBatch(batch.id); watch(started.job.id, batch.id); return { started: batch.album }; }), }, batch.attempt_count ? "Run this album again" : "Upload this album" ), ["planned", "running"].includes(batch.state) ? el( "button", { "data-testid": "cancel-batch", onclick: () => run(() => api.cancelUploadBatch(batch.id)), }, batch.state === "running" ? "Stop after the current file" : "Cancel this album" ) : null, // Offered for anything that has run, not only for uncertain outcomes: // re-checking is read-only and idempotent, and it is how a file edited after // its upload is discovered. batch.attempt_count ? el( "button", { "data-testid": "verify-batch", onclick: () => run(() => api.verifyUploadBatch(batch.id)), }, "Verify against Immich" ) : null ), itemsTable(batch) ); if (history.length) nodes.push(historyList(history)); return el( "div", { class: "card", "data-testid": "batch-detail", "data-batch": batch.id }, ...nodes ); } function itemsTable(batch) { if (!batch.items.length) { return el("p", { class: "muted", "data-testid": "no-items" }, "This batch has no photos."); } return el( "table", { class: "grid", "data-testid": "items" }, el( "thead", {}, el( "tr", {}, ...["Photo", "Outcome", "Evidence", "Verification", "Bytes now", "Resolve"].map((label) => el("th", { scope: "col" }, label) ) ) ), el( "tbody", {}, ...batch.items.map((item) => el( "tr", { "data-testid": "item-row", "data-asset-id": item.asset_id }, el("td", { class: "path", "data-testid": "item-path" }, item.path), el( "td", {}, el( "span", { class: `badge ${item.outcome || ""}`, "data-testid": "item-outcome" }, item.outcome === "uploaded" ? "new" : item.outcome || "pending" ) ), el("td", { class: "muted", "data-testid": "item-evidence" }, item.evidence || "—"), el("td", { "data-testid": "item-verification" }, item.verification || "—"), el( "td", {}, item.changed_after_upload ? el("span", { class: "badge attention", "data-testid": "item-changed" }, "changed") : el("span", { class: "muted" }, "unchanged") ), el("td", {}, resolveForm(batch, item)) ) ) ) ); } // Manual resolution is evidence, not permission: the note and the author are // required by the server, so the form collects both and offers no default. function resolveForm(batch, item) { const unresolved = !item.outcome || item.outcome === "unknown" || item.verification === "inconclusive"; if (!unresolved) return el("span", { class: "muted" }, "—"); const outcomeSelect = el( "select", { "data-testid": "resolve-outcome", "aria-label": `Outcome for ${item.path}` }, ...OUTCOME_LABEL.map(([key, label]) => el("option", { value: key }, label)) ); const evidence = el("input", { type: "text", "data-testid": "resolve-evidence", "aria-label": `What you checked for ${item.path}`, placeholder: "What did you check?", }); const actor = el("input", { type: "text", "data-testid": "resolve-actor", "aria-label": `Who checked ${item.path}`, placeholder: "Who are you?", }); return el( "div", { class: "toolbar" }, outcomeSelect, evidence, actor, el( "button", { "data-testid": "resolve-item", onclick: () => run(() => api.resolveUploadItem(batch.id, { asset_id: item.asset_id, outcome: outcomeSelect.value, evidence: evidence.value, actor: actor.value, }) ), }, "Record" ) ); } function historyList(history) { return el( "div", { "data-testid": "verification-history" }, el("h3", {}, "Verification history"), el( "ul", {}, ...history.map((entry) => el( "li", { "data-testid": "history-entry", "data-source": entry.source }, `${entry.created_at || ""} · ${entry.action} · ${entry.source} · ${entry.result} → ` + `${entry.outcome || "unresolved"} — ${entry.evidence}` + (entry.actor ? ` (${entry.actor})` : "") ) ) ) ); } // ── 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. Events are appended to the log node and the running batch's // panel is refreshed on its own tick — the uploader reports per file, not per job // event, so waiting for the next event would leave the panel behind. Only that // panel is rebuilt: a full re-render would re-run preflight, which re-hashes the // library, so that happens once when the job ends. const REFRESH_MS = 1000; function watch(jobId, batchId) { activity = [`Started upload job ${jobId}`]; const tick = setInterval(() => refreshBatch(batchId), REFRESH_MS); subscribeJob(jobId, { onEvent: (event) => { activity.push(`${event.type}${event.message ? ": " + event.message : ""}`); const log = document.querySelector('[data-testid="upload-activity"]'); if (log) log.textContent = activity.join("\n"); }, onDone: () => { clearInterval(tick); activity.push("done"); render(); }, }); } async function refreshBatch(batchId) { const node = document.querySelector(`[data-testid="batch-detail"][data-batch="${batchId}"]`); if (!node) return; // the user navigated away from the running batch try { const [batch, verifications] = await Promise.all([ api.getUploadBatch(batchId), api.uploadVerifications(batchId), ]); node.replaceWith(batchDetail(batch, verifications.verifications)); } 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": "upload-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 uploaded; 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": "upload-error" }, `Failed: ${outcome.error.message}` ); } const result = outcome.result; if (result && result.batches !== undefined) { return el( "div", { class: "alert", role: "status", "data-testid": "upload-result" }, `Approved ${result.batches} album batch(es).` + (result.started ? ` Uploading ${result.started} now.` : " Nothing could be started yet.") ); } return el( "div", { class: "alert", role: "status", "data-testid": "upload-result" }, "Done — the state below is the server's." ); }