Files
upl/static/app.js
T
ddidderr 8d81b436e5 fix: clarify saved upload completion UI
The previous page showed a static "Server online" pill even though it did not
track backend liveness. It also left the selected file in an uploadable state
after completion, which made it too easy to start the same file again and then
land in a saved record that could only fail with "complete file already
exists".

Remove the misleading server-status UI and make saved uploads describe their
next action. Records with every chunk uploaded now show a Finish action, stale
server records are cleared, and a terminal "complete file already exists"
response clears the saved browser progress instead of inviting another resume.
A successful completion also clears the active file selection so the primary
actions settle back to idle.

Test Plan:
- just check

Refs: none
2026-05-30 17:39:44 +02:00

619 lines
16 KiB
JavaScript

const DB_NAME = "upl";
const DB_VERSION = 1;
const STORE_NAME = "uploads";
const CONCURRENCY = 3;
const MAX_RETRIES = 5;
const BASE_RETRY_DELAY_MS = 500;
const COMPLETE_EXISTS_MESSAGE = "complete file already exists";
const fileInput = document.querySelector("#file-input");
const pickButton = document.querySelector("#pick-button");
const fileSummary = document.querySelector("#file-summary");
const fileName = document.querySelector("#file-name");
const fileSize = document.querySelector("#file-size");
const startButton = document.querySelector("#start-button");
const pauseButton = document.querySelector("#pause-button");
const resumeButton = document.querySelector("#resume-button");
const eventLog = document.querySelector("#event-log");
const progressBar = document.querySelector("#progress-bar");
const progressMeta = document.querySelector("#progress-meta");
const pendingSection = document.querySelector("#pending-section");
const pendingList = document.querySelector("#pending-list");
const state = {
abortController: null,
completedChunks: new Set(),
file: null,
fileHandle: null,
pendingRecords: [],
record: null,
resumeAfterReselect: null,
running: false,
};
const dbReady = "indexedDB" in window ? openDatabase() : Promise.resolve(null);
let saveChain = Promise.resolve();
function formatBytes(bytes) {
const formatter = new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
});
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let value = bytes;
let unit = units[0];
for (const candidate of units) {
unit = candidate;
if (value < 1024 || candidate === units[units.length - 1]) {
break;
}
value /= 1024;
}
return `${formatter.format(value)} ${unit}`;
}
function log(message) {
const item = document.createElement("li");
item.textContent = message;
eventLog.prepend(item);
while (eventLog.children.length > 8) {
eventLog.lastElementChild.remove();
}
}
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.addEventListener("upgradeneeded", () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "upload_id" });
}
});
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
}).catch((error) => {
log(`IndexedDB unavailable: ${error.message}`);
return null;
});
}
function requestToPromise(request) {
return new Promise((resolve, reject) => {
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
}
async function withStore(mode, callback) {
const db = await dbReady;
if (!db) {
return null;
}
const transaction = db.transaction(STORE_NAME, mode);
return requestToPromise(callback(transaction.objectStore(STORE_NAME)));
}
async function loadRecords() {
const records = (await withStore("readonly", (store) => store.getAll())) ?? [];
records.sort((left, right) => right.updated_at.localeCompare(left.updated_at));
state.pendingRecords = records;
renderPendingRecords();
}
async function saveRecord(record) {
const nextSave = saveChain.catch(() => null).then(() => writeRecord(record));
saveChain = nextSave;
await nextSave;
}
async function writeRecord(record) {
const storedRecord = { ...record, updated_at: new Date().toISOString() };
try {
await withStore("readwrite", (store) => store.put(storedRecord));
} catch (error) {
if (!storedRecord.file_handle) {
throw error;
}
delete storedRecord.file_handle;
await withStore("readwrite", (store) => store.put(storedRecord));
log("Saved resume state without a reusable file handle.");
}
state.record = storedRecord;
await loadRecords();
}
async function deleteRecord(uploadId) {
await withStore("readwrite", (store) => store.delete(uploadId));
if (state.record?.upload_id === uploadId) {
state.record = null;
}
await loadRecords();
}
function renderPendingRecords() {
pendingList.replaceChildren();
pendingSection.hidden = state.pendingRecords.length === 0;
for (const record of state.pendingRecords) {
const item = document.createElement("li");
item.className = "pending-item";
const meta = document.createElement("div");
meta.className = "pending-meta";
const title = document.createElement("strong");
title.textContent = record.name;
const detail = document.createElement("span");
detail.textContent = savedUploadDetail(record);
const resume = document.createElement("button");
resume.type = "button";
resume.className = "secondary";
resume.textContent = isReadyToFinish(record) ? "Finish" : "Resume";
resume.addEventListener("click", () => {
void resumePendingRecord(record);
});
const remove = document.createElement("button");
remove.type = "button";
remove.className = "danger";
remove.textContent = "Clear";
remove.addEventListener("click", () => {
void deleteRecord(record.upload_id);
});
meta.append(title, detail);
item.append(meta, resume, remove);
pendingList.append(item);
}
}
function renderFile(file) {
if (!file) {
fileSummary.hidden = true;
fileName.textContent = "";
fileSize.textContent = "";
return;
}
fileName.textContent = file.name;
fileSize.textContent = formatBytes(file.size);
fileSummary.hidden = false;
}
function renderButtons() {
startButton.disabled = !state.file || state.running || Boolean(state.record);
pauseButton.disabled = !state.running;
resumeButton.disabled = !state.file || state.running || !state.record;
resumeButton.textContent =
state.record && isReadyToFinish(state.record) ? "Finish" : "Resume";
}
function updateProgress(completedCount, totalChunks) {
const percentage = totalChunks === 0 ? 100 : (completedCount / totalChunks) * 100;
progressBar.style.width = `${percentage}%`;
progressMeta.textContent = `${completedCount} of ${totalChunks} chunks`;
}
function sameFile(record, file) {
return (
record.name === file.name &&
record.size === file.size &&
record.last_modified === file.lastModified
);
}
function findPendingRecord(file) {
return state.pendingRecords.find((record) => sameFile(record, file)) ?? null;
}
function completedChunkCount(record) {
return Math.min(record.completed_chunks ?? 0, record.total_chunks ?? 0);
}
function isReadyToFinish(record) {
const totalChunks = record.total_chunks ?? 0;
return totalChunks === 0 || completedChunkCount(record) >= totalChunks;
}
function savedUploadDetail(record) {
const totalChunks = record.total_chunks ?? 0;
const completedChunks = completedChunkCount(record);
if (isReadyToFinish(record)) {
return `${formatBytes(record.size)} - ready to finish`;
}
if (completedChunks === 0) {
return `${formatBytes(record.size)} - not uploaded yet`;
}
return `${formatBytes(record.size)} - ${completedChunks} of ${totalChunks} chunks uploaded`;
}
function clearSelection({ resetProgress = true } = {}) {
state.file = null;
state.fileHandle = null;
state.record = null;
state.completedChunks = new Set();
state.resumeAfterReselect = null;
fileInput.value = "";
renderFile(null);
if (resetProgress) {
updateProgress(0, 0);
}
renderButtons();
}
async function selectFile(file, fileHandle = null, record = null) {
if (record && !sameFile(record, file)) {
log("Selected file does not match the pending upload.");
return;
}
state.file = file;
state.fileHandle = fileHandle;
state.record = record ?? findPendingRecord(file);
state.completedChunks = new Set();
renderFile(file);
updateProgress(state.record?.completed_chunks ?? 0, state.record?.total_chunks ?? 0);
renderButtons();
if (!state.record) {
log("Ready to create an upload record.");
} else if (isReadyToFinish(state.record)) {
log("Ready to finish saved upload.");
} else {
log("Ready to resume upload.");
}
}
async function pickFile() {
if ("showOpenFilePicker" in window) {
try {
const [handle] = await window.showOpenFilePicker({ multiple: false });
const file = await handle.getFile();
await selectFile(file, handle);
return;
} catch (error) {
if (isAbortError(error)) {
return;
}
log(`File picker failed: ${error.message}`);
}
}
fileInput.click();
}
fileInput.addEventListener("change", () => {
const [file] = fileInput.files;
if (!file) {
renderFile(null);
renderButtons();
log("Choose a file to begin.");
return;
}
const record = state.resumeAfterReselect ?? findPendingRecord(file);
state.resumeAfterReselect = null;
void selectFile(file, null, record);
});
pickButton.addEventListener("click", () => {
void pickFile();
});
startButton.addEventListener("click", () => {
void runUpload();
});
pauseButton.addEventListener("click", () => {
if (state.abortController) {
state.abortController.abort();
}
});
resumeButton.addEventListener("click", () => {
void runUpload();
});
async function resumePendingRecord(record) {
if (state.running) {
return;
}
if (record.file_handle) {
const granted = await requestFileHandlePermission(record.file_handle);
if (granted) {
const file = await record.file_handle.getFile();
await selectFile(file, record.file_handle, record);
await runUpload();
return;
}
}
state.resumeAfterReselect = record;
log(
isReadyToFinish(record)
? "Select the same file to finish."
: "Select the same file to resume.",
);
fileInput.click();
}
async function requestFileHandlePermission(handle) {
if (!("queryPermission" in handle) || !("requestPermission" in handle)) {
return false;
}
const options = { mode: "read" };
if ((await handle.queryPermission(options)) === "granted") {
return true;
}
return (await handle.requestPermission(options)) === "granted";
}
async function runUpload() {
if (!state.file || state.running) {
return;
}
const controller = new AbortController();
state.abortController = controller;
state.running = true;
renderButtons();
try {
if (!state.record) {
await createUploadRecord();
}
const progress = await fetchJson(`/api/uploads/${state.record.upload_id}`, {
signal: controller.signal,
});
state.completedChunks = new Set(progress.completed_chunks);
await saveRecord({
...state.record,
completed_chunks: state.completedChunks.size,
chunk_size: progress.chunk_size,
total_chunks: progress.total_chunks,
});
updateProgress(state.completedChunks.size, progress.total_chunks);
const missingChunks = buildMissingChunkList(progress.total_chunks, state.completedChunks);
log(
missingChunks.length === 0
? "All chunks already uploaded."
: `Uploading ${missingChunks.length} missing chunks.`,
);
await runPool(missingChunks, (index) =>
uploadChunkWithRetry(index, progress.chunk_size, progress.total_chunks, controller.signal),
);
if (controller.signal.aborted) {
return;
}
log("Completing upload.");
const uploadId = state.record.upload_id;
const complete = await fetchJson(`/api/uploads/${uploadId}/complete`, {
method: "POST",
signal: controller.signal,
});
updateProgress(progress.total_chunks, progress.total_chunks);
log(`Complete: ${complete.file_path}`);
await deleteRecord(uploadId);
clearSelection({ resetProgress: false });
} catch (error) {
if (controller.signal.aborted || isAbortError(error)) {
log("Upload paused.");
} else if (await handleTerminalUploadError(error)) {
controller.abort();
} else {
controller.abort();
log(`Upload failed: ${error.message}`);
}
} finally {
if (state.abortController === controller) {
state.abortController = null;
}
state.running = false;
renderButtons();
}
}
async function handleTerminalUploadError(error) {
if (!state.record || typeof error.status !== "number") {
return false;
}
const uploadId = state.record.upload_id;
if (error.status === 404) {
await deleteRecord(uploadId);
clearSelection();
log("Saved upload progress no longer exists on the server.");
return true;
}
if (error.status === 409 && error.message === COMPLETE_EXISTS_MESSAGE) {
await deleteRecord(uploadId);
clearSelection();
log("The completed file already exists on the server. Cleared saved upload progress.");
return true;
}
return false;
}
async function createUploadRecord() {
const response = await fetchJson("/api/uploads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: state.file.name,
size: state.file.size,
last_modified: state.file.lastModified,
}),
});
const record = {
upload_id: response.upload_id,
name: state.file.name,
size: state.file.size,
last_modified: state.file.lastModified,
chunk_size: response.chunk_size,
total_chunks: response.total_chunks,
completed_chunks: 0,
file_handle: state.fileHandle,
updated_at: new Date().toISOString(),
};
await saveRecord(record);
updateProgress(0, response.total_chunks);
log("Upload record created.");
}
function buildMissingChunkList(totalChunks, completedChunks) {
const missing = [];
for (let index = 0; index < totalChunks; index += 1) {
if (!completedChunks.has(index)) {
missing.push(index);
}
}
return missing;
}
async function uploadChunkWithRetry(index, chunkSize, totalChunks, signal) {
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
throwIfAborted(signal);
try {
await uploadChunk(index, chunkSize, signal);
state.completedChunks.add(index);
updateProgress(state.completedChunks.size, totalChunks);
await saveRecord({
...state.record,
completed_chunks: state.completedChunks.size,
});
return;
} catch (error) {
if (isAbortError(error) || attempt === MAX_RETRIES) {
throw error;
}
const delayMs = BASE_RETRY_DELAY_MS * 2 ** attempt;
log(`Retrying chunk ${index} after ${delayMs} ms.`);
await delay(delayMs, signal);
}
}
}
async function uploadChunk(index, chunkSize, signal) {
const start = index * chunkSize;
const end = Math.min(state.file.size, start + chunkSize);
const body = state.file.slice(start, end);
const response = await fetch(`/api/uploads/${state.record.upload_id}/chunks/${index}`, {
method: "PUT",
headers: { "Content-Type": "application/octet-stream" },
body,
signal,
});
if (!response.ok) {
throw new Error(await responseError(response));
}
}
async function runPool(items, worker) {
let nextIndex = 0;
const workers = Array.from(
{ length: Math.min(CONCURRENCY, items.length) },
async () => {
while (nextIndex < items.length) {
const item = items[nextIndex];
nextIndex += 1;
await worker(item);
}
},
);
await Promise.all(workers);
}
class ApiRequestError extends Error {
constructor(message, status) {
super(message);
this.name = "ApiRequestError";
this.status = status;
}
}
async function fetchJson(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
throw new ApiRequestError(await responseError(response), response.status);
}
return response.json();
}
async function responseError(response) {
const text = await response.text();
if (!text) {
return `${response.status} ${response.statusText}`;
}
try {
return JSON.parse(text).error ?? text;
} catch {
return text;
}
}
function delay(ms, signal) {
return new Promise((resolve, reject) => {
throwIfAborted(signal);
const id = window.setTimeout(resolve, ms);
signal.addEventListener(
"abort",
() => {
window.clearTimeout(id);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
}
function throwIfAborted(signal) {
if (signal.aborted) {
throw new DOMException("Aborted", "AbortError");
}
}
function isAbortError(error) {
return error?.name === "AbortError";
}
async function initialize() {
await loadRecords();
renderButtons();
}
void initialize();