12ea853170
An upload through the File Explorer is now sent in 8 MB parts that the
server stages on the host (<data dir>/upload-staging/<user>/<id>/,
server/lib/upload-staging.js) and writes only once every byte is there,
through the same upload/uploadBatch code and guards (?staged=<id>). A
restart, an update or a crash pauses it: the browser waits up to 3 min,
asks how much arrived (GET /fs/upload-part) and sends the rest; the dock
says the dashboard is restarting meanwhile. A commit's answer is stored
and replayed, never written twice; a refused one (EEXISTS) keeps the
bytes for a retry with another conflict policy.
A file uploaded into a member's folder is now written to a hidden temp
name and moved onto its name only when whole: write-as cannot tell a
dropped connection from the end of a file, and `replace` left a truncated
file under the real name when the server died mid-upload.
New: GET|POST|DELETE /api/{lanes/:id,homes/:who}/fs/upload-part; 409
EOFFSET/EINCOMPLETE/EBUSY, 429 ETOOMANY. Verified on a sandbox server:
SIGTERM mid-upload of a 60 MB file in a real browser, resumed and
sha256-identical.
173 lines
5.3 KiB
TypeScript
173 lines
5.3 KiB
TypeScript
/**
|
|
* @file uploadJobs.ts
|
|
* @description The app-wide list of File Explorer uploads, shown by
|
|
* `components/BackgroundJobsDock.tsx` so an upload's progress outlives the
|
|
* Files page. The upload loop itself stays in `FileExplorer.tsx`; it reports
|
|
* here (`beginUpload` → `update` → `end`) and checks the job's AbortSignal,
|
|
* which **Stop** (`stopUpload`) fires. The bytes live in the browser, so an
|
|
* upload cannot survive a reload: while one runs, a small record of it is
|
|
* kept in localStorage (refreshed every ~2 s), and the next page load shows
|
|
* a record that stopped being refreshed as an interrupted upload until it is
|
|
* dismissed. A fresh record belongs to another open tab and is left alone.
|
|
* A dashboard RESTART is not an interruption: the upload is sent in parts
|
|
* (`api.files.uploadStaged`) and the job shows `waiting` until it resumes.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useSyncExternalStore } from "react";
|
|
|
|
export interface UploadJob {
|
|
id: string;
|
|
/** Where the files go, as the user reads it ("workspace/folder"). */
|
|
folder: string;
|
|
/** The file (or batch) being sent now. */
|
|
name: string;
|
|
done: number;
|
|
total: number;
|
|
sent: number;
|
|
bytes: number;
|
|
state: "running" | "stopping" | "interrupted";
|
|
/** The dashboard is away (a restart): the upload waits and then resumes. */
|
|
waiting?: boolean;
|
|
}
|
|
|
|
type Stored = UploadJob & { beat: number };
|
|
|
|
const KEY = "agenthub.uploads.inflight";
|
|
const BEAT_MS = 2000;
|
|
/** A running record older than this is from a page that is gone. */
|
|
const STALE_MS = 6000;
|
|
|
|
const listeners = new Set<() => void>();
|
|
const controllers = new Map<string, AbortController>();
|
|
/** Ids this page owns in storage (its own uploads and the interrupted ones it shows). */
|
|
const mine = new Set<string>();
|
|
let uploads: UploadJob[] = [];
|
|
let beat: ReturnType<typeof setInterval> | undefined;
|
|
let lastWrite = 0;
|
|
|
|
const emit = () => listeners.forEach((l) => l());
|
|
|
|
function readStored(): Stored[] {
|
|
try {
|
|
const v: unknown = JSON.parse(localStorage.getItem(KEY) || "[]");
|
|
return Array.isArray(v) ? (v as Stored[]).filter((r) => r && typeof r.id === "string") : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function persist() {
|
|
lastWrite = Date.now();
|
|
try {
|
|
const others = readStored().filter((r) => !mine.has(r.id));
|
|
const own = uploads.map((u) => ({ ...u, beat: lastWrite }));
|
|
const all = [...others, ...own];
|
|
if (all.length) localStorage.setItem(KEY, JSON.stringify(all));
|
|
else localStorage.removeItem(KEY);
|
|
} catch {
|
|
/* storage off: the dock still works for this page */
|
|
}
|
|
}
|
|
|
|
/** Load what a previous page left behind: a stale running record = interrupted. */
|
|
function loadInterrupted() {
|
|
const now = Date.now();
|
|
const left = readStored().filter(
|
|
(r) => r.state === "interrupted" || now - (Number(r.beat) || 0) > STALE_MS
|
|
);
|
|
for (const r of left) mine.add(r.id);
|
|
uploads = left.map((r) => ({
|
|
id: r.id,
|
|
folder: String(r.folder ?? ""),
|
|
name: String(r.name ?? ""),
|
|
done: Number(r.done) || 0,
|
|
total: Number(r.total) || 0,
|
|
sent: Number(r.sent) || 0,
|
|
bytes: Number(r.bytes) || 0,
|
|
state: "interrupted",
|
|
}));
|
|
if (left.length) persist();
|
|
}
|
|
loadInterrupted();
|
|
|
|
function set(id: string, patch: Partial<UploadJob>) {
|
|
uploads = uploads.map((u) => (u.id === id ? { ...u, ...patch } : u));
|
|
emit();
|
|
if (Date.now() - lastWrite > 1000) persist();
|
|
}
|
|
|
|
function heartbeat() {
|
|
const running = uploads.some((u) => u.state !== "interrupted");
|
|
if (running && !beat) beat = setInterval(persist, BEAT_MS);
|
|
if (!running && beat) {
|
|
clearInterval(beat);
|
|
beat = undefined;
|
|
}
|
|
}
|
|
|
|
/** Register an upload; the loop reports through the handle and checks `signal`. */
|
|
export function beginUpload(init: { folder: string; total: number; bytes: number }) {
|
|
const id = `up-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const ctrl = new AbortController();
|
|
controllers.set(id, ctrl);
|
|
mine.add(id);
|
|
uploads = [...uploads, { id, name: "", done: 0, sent: 0, state: "running", ...init }];
|
|
emit();
|
|
persist();
|
|
heartbeat();
|
|
return {
|
|
id,
|
|
signal: ctrl.signal,
|
|
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent" | "waiting">>) =>
|
|
set(id, patch),
|
|
end: () => {
|
|
controllers.delete(id);
|
|
uploads = uploads.filter((u) => u.id !== id);
|
|
emit();
|
|
persist();
|
|
mine.delete(id);
|
|
heartbeat();
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Stop a running upload: the request in flight is dropped, the loop ends. */
|
|
export function stopUpload(id: string): void {
|
|
const ctrl = controllers.get(id);
|
|
if (!ctrl) return;
|
|
set(id, { state: "stopping" });
|
|
ctrl.abort();
|
|
}
|
|
|
|
/** Forget an interrupted upload's record. */
|
|
export function dismissUpload(id: string): void {
|
|
uploads = uploads.filter((u) => u.id !== id);
|
|
emit();
|
|
persist();
|
|
mine.delete(id);
|
|
}
|
|
|
|
function subscribe(l: () => void) {
|
|
listeners.add(l);
|
|
return () => {
|
|
listeners.delete(l);
|
|
};
|
|
}
|
|
|
|
export function useUploadJobs(): UploadJob[] {
|
|
return useSyncExternalStore(subscribe, () => uploads);
|
|
}
|
|
|
|
/** Tests only: re-read storage as a fresh page load would. */
|
|
export function reloadUploadsForTests(): void {
|
|
for (const c of controllers.values()) c.abort();
|
|
controllers.clear();
|
|
mine.clear();
|
|
if (beat) clearInterval(beat);
|
|
beat = undefined;
|
|
lastWrite = 0;
|
|
loadInterrupted();
|
|
emit();
|
|
}
|