Files
photoanalyzer/frontend/js/uploads.js

705 lines
23 KiB
JavaScript

// 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."
);
}