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
This commit is contained in:
+95
-10
@@ -4,6 +4,7 @@ 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");
|
||||
@@ -151,12 +152,12 @@ function renderPendingRecords() {
|
||||
title.textContent = record.name;
|
||||
|
||||
const detail = document.createElement("span");
|
||||
detail.textContent = `${formatBytes(record.size)} - ${record.completed_chunks ?? 0} of ${record.total_chunks} chunks`;
|
||||
detail.textContent = savedUploadDetail(record);
|
||||
|
||||
const resume = document.createElement("button");
|
||||
resume.type = "button";
|
||||
resume.className = "secondary";
|
||||
resume.textContent = "Resume";
|
||||
resume.textContent = isReadyToFinish(record) ? "Finish" : "Resume";
|
||||
resume.addEventListener("click", () => {
|
||||
void resumePendingRecord(record);
|
||||
});
|
||||
@@ -164,7 +165,7 @@ function renderPendingRecords() {
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "danger";
|
||||
remove.textContent = "Remove";
|
||||
remove.textContent = "Clear";
|
||||
remove.addEventListener("click", () => {
|
||||
void deleteRecord(record.upload_id);
|
||||
});
|
||||
@@ -192,6 +193,8 @@ 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) {
|
||||
@@ -212,6 +215,44 @@ 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.");
|
||||
@@ -227,7 +268,13 @@ async function selectFile(file, fileHandle = null, record = null) {
|
||||
updateProgress(state.record?.completed_chunks ?? 0, state.record?.total_chunks ?? 0);
|
||||
renderButtons();
|
||||
|
||||
log(state.record ? "Ready to resume upload." : "Ready to create an upload record.");
|
||||
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() {
|
||||
@@ -297,7 +344,11 @@ async function resumePendingRecord(record) {
|
||||
}
|
||||
|
||||
state.resumeAfterReselect = record;
|
||||
log("Select the same file to resume.");
|
||||
log(
|
||||
isReadyToFinish(record)
|
||||
? "Select the same file to finish."
|
||||
: "Select the same file to resume.",
|
||||
);
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
@@ -357,19 +408,21 @@ async function runUpload() {
|
||||
}
|
||||
|
||||
log("Completing upload.");
|
||||
const complete = await fetchJson(`/api/uploads/${state.record.upload_id}/complete`, {
|
||||
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(state.record.upload_id);
|
||||
state.completedChunks = new Set();
|
||||
state.record = null;
|
||||
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}`);
|
||||
@@ -383,6 +436,30 @@ async function runUpload() {
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
@@ -478,10 +555,18 @@ async function runPool(items, worker) {
|
||||
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 Error(await responseError(response));
|
||||
throw new ApiRequestError(await responseError(response), response.status);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
+1
-2
@@ -15,7 +15,6 @@
|
||||
<h1 id="app-title">upl</h1>
|
||||
<p class="subtle">Resumable uploads to this machine.</p>
|
||||
</div>
|
||||
<span class="status-pill" id="connection-status">Server online</span>
|
||||
</div>
|
||||
|
||||
<div class="file-picker">
|
||||
@@ -40,7 +39,7 @@
|
||||
</div>
|
||||
|
||||
<section class="pending-section" id="pending-section" hidden>
|
||||
<h2>Pending uploads</h2>
|
||||
<h2>Saved upload progress</h2>
|
||||
<ul class="pending-list" id="pending-list"></ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -71,17 +71,6 @@ h1 {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--accent-strong);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.file-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -236,10 +225,6 @@ h2 {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.upload-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ async fn serves_index_page() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
assert!(body.contains("<title>upl</title>"));
|
||||
assert!(body.contains("Choose file"));
|
||||
assert!(body.contains("Pending uploads"));
|
||||
assert!(body.contains("Saved upload progress"));
|
||||
assert!(!body.contains("Server online"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user