feat(files): 大文件分片上传、停服解压 zip 先列冲突再覆盖、文件和文件夹可下载;导出和下载不再带出 RCON 密码与转发密钥
This commit is contained in:
76 files changed
+10542
-569
No files matched your search
@@ -23,6 +23,8 @@ interface Props {
|
||||
/** Runs the action. The dialog closes when it resolves; a rejection is shown in
|
||||
* the dialog, which stays open so the action can be retried or dismissed. */
|
||||
onConfirm: () => Promise<void>;
|
||||
/** Shown under the description, for what a sentence cannot hold (a list). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// ConfirmDialog asks before an action that cannot be taken back, in the panel's
|
||||
@@ -38,6 +40,7 @@ export function ConfirmDialog({
|
||||
cancelLabel,
|
||||
confirmVariant = "destructive",
|
||||
onConfirm,
|
||||
children,
|
||||
}: Props) {
|
||||
const { t } = useTranslation("common");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -69,6 +72,7 @@ export function ConfirmDialog({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
{children}
|
||||
{error && <MessageLine kind="error" message={error} compact />}
|
||||
<ConfirmFooter
|
||||
onCancel={() => setOpen(false)}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import type { FileOp } from "@/lib/types";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
import { FileOps } from "./FileOps";
|
||||
import { opErrorText } from "./opText";
|
||||
|
||||
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(`files:${key}`, opts ?? {});
|
||||
|
||||
function op(over: Partial<FileOp> = {}): FileOp {
|
||||
return { id: "a", op: "unzip", path: "pack.zip", state: "running", started_at: "2026-09-28T00:00:00Z", done: 0, total: 0, ...over };
|
||||
}
|
||||
|
||||
function show(ops: FileOp[], error: unknown = null) {
|
||||
const onDismiss = vi.fn();
|
||||
const onConflicts = vi.fn();
|
||||
const view = render(<FileOps ops={ops} error={error} onDismiss={onDismiss} onConflicts={onConflicts} />);
|
||||
return { ...view, onDismiss, onConflicts };
|
||||
}
|
||||
|
||||
describe("FileOps", () => {
|
||||
it("shows nothing with no ops and nothing to say", () => {
|
||||
const { container } = show([]);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("counts a running op in whole percents, rounding down and never past 100", () => {
|
||||
show([op({ id: "a", path: "a.zip", done: 999, total: 1000 }), op({ id: "b", path: "b.zip", done: 5, total: 4 })]);
|
||||
|
||||
expect(screen.getByText(t("op_progress", { done: "999 B", total: "1000 B", percent: 99 }))).toBeTruthy();
|
||||
expect(screen.getByText(t("op_progress", { done: "5 B", total: "4 B", percent: 100 }))).toBeTruthy();
|
||||
const bars = screen.getAllByRole("progressbar");
|
||||
expect(bars.map((b) => [b.getAttribute("aria-label"), b.getAttribute("aria-valuenow")])).toEqual([
|
||||
[t("op_progress_label", { path: "a.zip" }), "99"],
|
||||
[t("op_progress_label", { path: "b.zip" }), "100"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("claims no progress before the Job has counted, and offers no dismissal while it runs", () => {
|
||||
show([op()]);
|
||||
|
||||
expect(screen.getByText(t("op_preparing"))).toBeTruthy();
|
||||
expect(screen.getByRole("progressbar").hasAttribute("aria-valuenow")).toBe(false);
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("words how each kind of op ended", () => {
|
||||
show([
|
||||
op({ id: "u", op: "upload", path: "big.jar", state: "succeeded" }),
|
||||
op({ id: "z", path: "pack.zip", state: "succeeded", files: 1, bytes: 2048 }),
|
||||
op({ id: "f", op: "upload", path: "lost.jar", state: "failed" }),
|
||||
]);
|
||||
|
||||
expect(screen.getByText(t("op_upload", { path: "big.jar" }))).toBeTruthy();
|
||||
expect(screen.getByText(t("op_upload_done"))).toBeTruthy();
|
||||
expect(screen.getByText(t("op_unzip", { path: "pack.zip" }))).toBeTruthy();
|
||||
expect(screen.getByText(t("op_unzip_done", { count: 1, bytes: "2.0 KiB" }))).toBeTruthy();
|
||||
// A failure the Job left unexplained still says something.
|
||||
expect(screen.getByText(opErrorText("upload", { code: "job_failed", message: "" }))).toBeTruthy();
|
||||
expect(screen.queryByRole("progressbar")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the conflicts of an extraction only, and dismisses each ended op by its id", () => {
|
||||
const exists = { code: "file_exists", message: "", conflicts: ["a.txt"] };
|
||||
const refused = op({ id: "z", path: "pack.zip", state: "failed", error: exists });
|
||||
const { onDismiss, onConflicts } = show([
|
||||
refused,
|
||||
op({ id: "u", op: "upload", path: "a.txt", state: "failed", error: exists }),
|
||||
op({ id: "bad", path: "bad.zip", state: "failed", error: { code: "archive_invalid", message: "" } }),
|
||||
]);
|
||||
|
||||
const conflicts = screen.getAllByRole("button", { name: t("op_conflicts") });
|
||||
expect(conflicts).toHaveLength(1);
|
||||
fireEvent.click(conflicts[0]);
|
||||
expect(onConflicts.mock.calls).toEqual([[refused]]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: t("op_dismiss", { path: "a.txt" }) }));
|
||||
expect(onDismiss.mock.calls).toEqual([["u"]]);
|
||||
});
|
||||
|
||||
it("says why the ops could not be read, over the ones it has", () => {
|
||||
const broke = { status: 500, code: "internal", message: "" };
|
||||
show([op({ state: "succeeded", files: 1, bytes: 8 })], broke);
|
||||
|
||||
const region = screen.getByRole("region", { name: t("ops_label") });
|
||||
expect(within(region).getByText(t("ops_refresh_failed", { reason: humanizeError(broke) }))).toBeTruthy();
|
||||
expect(within(region).getByText(t("op_unzip_done", { count: 1, bytes: "8 B" }))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AlertCircle, CheckCircle2, FileArchive, Loader2, Upload, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MessageLine } from "@/components/MessageLine";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { FileOp } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { opErrorText } from "./opText";
|
||||
|
||||
interface Props {
|
||||
ops: readonly FileOp[];
|
||||
/** Why the latest read of the ops failed, if it did. */
|
||||
error: unknown;
|
||||
onDismiss: (id: string) => void;
|
||||
/** Opens the list of files an extraction stopped short of replacing. */
|
||||
onConflicts: (op: FileOp) => void;
|
||||
}
|
||||
|
||||
// FileOps shows the server's background operations: how far a running one has
|
||||
// got, and how each recent one ended, until it is dismissed.
|
||||
export function FileOps({ ops, error, onDismiss, onConflicts }: Props) {
|
||||
const { t } = useTranslation("files");
|
||||
if (ops.length === 0 && error == null) return null;
|
||||
|
||||
return (
|
||||
<section aria-label={t("ops_label")} className="border-b border-border bg-muted/20">
|
||||
{error != null && (
|
||||
<div className="px-4 pt-3">
|
||||
<MessageLine kind="error" message={t("ops_refresh_failed", { reason: humanizeError(error) })} compact />
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-border/60 px-4 py-1">
|
||||
{ops.map((op) => (
|
||||
<OpRow key={op.id} op={op} onDismiss={() => onDismiss(op.id)} onConflicts={() => onConflicts(op)} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OpRow({ op, onDismiss, onConflicts }: { op: FileOp; onDismiss: () => void; onConflicts: () => void }) {
|
||||
const { t } = useTranslation("files");
|
||||
const title = t(op.op === "unzip" ? "op_unzip" : "op_upload", { path: op.path });
|
||||
const Kind = op.op === "unzip" ? FileArchive : Upload;
|
||||
const counted = op.total > 0;
|
||||
const percent = counted ? Math.min(100, Math.floor((op.done * 100) / op.total)) : 0;
|
||||
const failure = op.error ?? { code: "job_failed", message: "" };
|
||||
const conflicts = op.state === "failed" && op.op === "unzip" && failure.code === "file_exists";
|
||||
|
||||
let status: string;
|
||||
if (op.state === "running") {
|
||||
status = counted
|
||||
? t("op_progress", { done: formatBytes(op.done), total: formatBytes(op.total), percent })
|
||||
: t("op_preparing");
|
||||
} else if (op.state === "succeeded") {
|
||||
status =
|
||||
op.op === "unzip"
|
||||
? t("op_unzip_done", { count: op.files ?? 0, bytes: formatBytes(op.bytes ?? 0) })
|
||||
: t("op_upload_done");
|
||||
} else {
|
||||
status = opErrorText(op.op, failure);
|
||||
}
|
||||
|
||||
const icon =
|
||||
op.state === "running" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
) : op.state === "succeeded" ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 text-destructive" />
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="py-2">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-0.5 shrink-0">{icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex min-w-0 items-center gap-1.5">
|
||||
<Kind className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-mono text-xs" title={op.path}>
|
||||
{title}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"mt-0.5 text-xs tabular-nums",
|
||||
op.state === "failed" ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</p>
|
||||
{op.state === "running" && (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={t("op_progress_label", { path: op.path })}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={counted ? percent : undefined}
|
||||
className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-muted"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full bg-primary transition-[width] duration-300 ease-out",
|
||||
!counted && "w-full animate-pulse",
|
||||
)}
|
||||
style={counted ? { width: `${percent}%` } : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{op.state !== "running" && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{conflicts && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onConflicts}>
|
||||
{t("op_conflicts")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={onDismiss}
|
||||
aria-label={t("op_dismiss", { path: op.path })}
|
||||
title={t("op_dismiss", { path: op.path })}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -79,10 +79,16 @@ function UploadRow({
|
||||
}) {
|
||||
const { t } = useTranslation("files");
|
||||
const { file, state } = item;
|
||||
const percent = file.size > 0 ? Math.min(100, Math.floor((item.sent * 100) / file.size)) : 100;
|
||||
// The body is all sent and the Job is landing it: seconds more, with no bytes
|
||||
// left to count.
|
||||
// The body is all sent and the Job is landing it. A file sent in one request
|
||||
// lands in seconds, with no bytes left to count; one sent in parts is fetched
|
||||
// by its Job afresh, which reports how far it has got.
|
||||
const landing = state === "uploading" && item.sent >= file.size;
|
||||
const written = landing && item.landing !== null && item.landing.total > 0 ? item.landing : null;
|
||||
const percent = written
|
||||
? Math.min(100, Math.floor((written.done * 100) / written.total))
|
||||
: file.size > 0
|
||||
? Math.min(100, Math.floor((item.sent * 100) / file.size))
|
||||
: 100;
|
||||
|
||||
const icon = {
|
||||
queued: <Clock className="h-4 w-4 text-muted-foreground" />,
|
||||
@@ -94,6 +100,7 @@ function UploadRow({
|
||||
|
||||
let status: string;
|
||||
if (state === "queued") status = t("upload_queued");
|
||||
else if (written) status = t("upload_landing_progress", { percent });
|
||||
else if (landing) status = t("upload_landing");
|
||||
else if (state === "uploading") status = t("upload_sending", { sent: formatBytes(item.sent), total: formatBytes(file.size), percent });
|
||||
else if (state === "done") status = t("upload_done");
|
||||
@@ -140,7 +147,7 @@ function UploadRow({
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full bg-primary transition-[width] duration-300 ease-out",
|
||||
landing && "animate-pulse",
|
||||
landing && !written && "animate-pulse",
|
||||
)}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
import { opErrorText } from "./opText";
|
||||
|
||||
const e = (code: string, over: Record<string, unknown> = {}) => ({ code, message: "raw words", ...over });
|
||||
|
||||
describe("opErrorText", () => {
|
||||
it("tells an upload that found its file there from an extraction that would replace files", () => {
|
||||
expect(opErrorText("upload", e("file_exists"))).toBe(
|
||||
"A file with this name is already here, so nothing was replaced. Upload it again and choose Replace.",
|
||||
);
|
||||
expect(opErrorText("unzip", e("file_exists", { conflicts: ["a", "b"], conflict_count: 250 }))).toBe(
|
||||
"It would replace 250 files already here, so nothing was extracted yet.",
|
||||
);
|
||||
// Without the count, the list is counted.
|
||||
expect(opErrorText("unzip", e("file_exists", { conflicts: ["a"] }))).toBe(
|
||||
"It would replace 1 file already here, so nothing was extracted yet.",
|
||||
);
|
||||
});
|
||||
|
||||
it("names both sizes of a volume too small, and falls back when they are missing", () => {
|
||||
expect(opErrorText("unzip", e("volume_full", { need: 3 * 1024 * 1024, avail: 1024 }))).toBe(
|
||||
"Not enough room on the world volume: 3.0 MiB needed, 1.0 KiB free. Nothing was changed.",
|
||||
);
|
||||
expect(opErrorText("upload", e("volume_full"))).toBe(humanizeError({ status: 0, code: "volume_full", message: "raw words" }));
|
||||
});
|
||||
|
||||
it("names the archive entry at fault", () => {
|
||||
expect(opErrorText("unzip", e("archive_invalid"))).toBe("The archive is damaged, or not a zip file. Nothing was changed.");
|
||||
expect(opErrorText("unzip", e("archive_invalid", { entry: "world/level.dat" }))).toBe(
|
||||
"world/level.dat in the archive is damaged (its size or checksum does not match). Nothing was changed.",
|
||||
);
|
||||
expect(opErrorText("unzip", e("archive_unsafe", { entry: "../../etc/passwd" }))).toBe(
|
||||
"../../etc/passwd in the archive would land outside this folder, or is a device file. Nothing in the archive was extracted.",
|
||||
);
|
||||
expect(opErrorText("unzip", e("archive_symlink", { entry: "world/link" }))).toBe(
|
||||
"world/link in the archive is a symbolic link. Nothing in the archive was extracted.",
|
||||
);
|
||||
expect(opErrorText("unzip", e("type_conflict", { entry: "plugins" }))).toBe(
|
||||
"plugins is a file on one side and a folder on the other, which replacing cannot resolve. Rename or delete plugins here, then extract again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives a Job that ran out of time its own words", () => {
|
||||
expect(opErrorText("unzip", e("job_failed", { message: "job failed: DeadlineExceeded" }))).toBe(
|
||||
"The background task did not finish within 2 hours and was stopped. Refresh the list to check, then try again.",
|
||||
);
|
||||
expect(opErrorText("upload", e("job_failed", { message: "job failed: BackoffLimitExceeded" }))).toBe(
|
||||
"The background task ended without saying why. Refresh the list to check, then try again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("words any other code as the file routes do", () => {
|
||||
expect(opErrorText("upload", e("file_changed"))).toBe(humanizeError({ status: 0, code: "file_changed", message: "raw words" }));
|
||||
expect(opErrorText("upload", e("file_changed"))).not.toBe("raw words");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import i18next from "i18next";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { FileOp, FileOpError } from "@/lib/types";
|
||||
|
||||
/** opErrorText says why an op ended failed. The codes an extraction refuses an
|
||||
* archive with name the entry at fault; the rest are the codes the file routes
|
||||
* answer, worded as they are there. */
|
||||
export function opErrorText(op: FileOp["op"], e: FileOpError): string {
|
||||
const t = i18next.getFixedT(null, "files");
|
||||
const entry = e.entry ?? "";
|
||||
switch (e.code) {
|
||||
case "file_exists":
|
||||
return op === "upload"
|
||||
? t("op_upload_exists")
|
||||
: t("op_unzip_conflicts", { count: e.conflict_count ?? e.conflicts?.length ?? 0 });
|
||||
case "volume_full":
|
||||
return e.need !== undefined && e.avail !== undefined
|
||||
? t("op_volume_full", { need: formatBytes(e.need), avail: formatBytes(e.avail) })
|
||||
: humanizeError({ status: 0, code: e.code, message: e.message });
|
||||
case "archive_invalid":
|
||||
return entry ? t("archive_invalid_entry", { entry }) : t("archive_invalid");
|
||||
case "archive_unsafe":
|
||||
case "archive_symlink":
|
||||
case "type_conflict":
|
||||
return t(e.code, { entry });
|
||||
// The Job's own condition reason rides in the message; a deadline is the
|
||||
// one worth its own words.
|
||||
case "job_failed":
|
||||
return e.message.includes("DeadlineExceeded") ? t("job_timed_out") : t("job_failed");
|
||||
default:
|
||||
return humanizeError({ status: 0, code: e.code, message: e.message });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { FileOp, FileUploadSession } from "@/lib/types";
|
||||
import { MAX_ATTEMPTS } from "@/lib/contextUpload";
|
||||
import {
|
||||
MAX_OP_MISSES,
|
||||
OP_POLL_MS,
|
||||
STALE_SESSION_MS,
|
||||
discardSession,
|
||||
forgetSession,
|
||||
sendInParts,
|
||||
watchOp,
|
||||
} from "./sessionUpload";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getServerFileUpload: vi.fn(),
|
||||
beginServerFileUpload: vi.fn(),
|
||||
putServerFileUploadPart: vi.fn(),
|
||||
deleteServerFileUpload: vi.fn(),
|
||||
commitServerFileUpload: vi.fn(),
|
||||
listServerFileOps: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...mocks } };
|
||||
});
|
||||
|
||||
const KEY = "felis-file-upload:lobby:world.zip";
|
||||
const PART = 4;
|
||||
const NOW = 1_000_000_000;
|
||||
|
||||
// A 10-byte file sent in parts of 4: offsets 0, 4 and 8.
|
||||
function file(body = "0123456789", modified = 111) {
|
||||
return new File([body], "world.zip", { lastModified: modified });
|
||||
}
|
||||
|
||||
function session(received: number, over: Partial<FileUploadSession> = {}): FileUploadSession {
|
||||
return { id: "s1", path: "world.zip", size: 10, received, part_max_bytes: PART, ...over };
|
||||
}
|
||||
|
||||
function op(over: Partial<FileOp> = {}): FileOp {
|
||||
return {
|
||||
id: "op1",
|
||||
op: "upload",
|
||||
path: "world.zip",
|
||||
state: "running",
|
||||
started_at: "2026-09-28T00:00:00Z",
|
||||
done: 0,
|
||||
total: 0,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const netErr = { status: 0, code: "network_error", message: "" };
|
||||
const gone = { status: 404, code: "upload_not_found", message: "no such upload" };
|
||||
const held = { status: 409, code: "maintenance_in_progress", message: "held" };
|
||||
|
||||
// The server takes every part whole and reports where the session stands.
|
||||
function serverTakesParts() {
|
||||
mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) =>
|
||||
session(offset + part.size, { id }),
|
||||
);
|
||||
}
|
||||
|
||||
// Pauses at once. A loop that never gives up would never yield either, so
|
||||
// the hundredth pause fails the test instead of hanging it.
|
||||
const sleep = vi.fn(async (_ms: number, _signal?: AbortSignal) => {
|
||||
if (sleep.mock.calls.length > 100) throw new Error("runaway retry loop");
|
||||
});
|
||||
const opts = (over: Partial<Parameters<typeof sendInParts>[3]> = {}) => ({
|
||||
overwrite: false,
|
||||
sleep,
|
||||
now: () => NOW,
|
||||
...over,
|
||||
});
|
||||
const offsets = () => mocks.putServerFileUploadPart.mock.calls.map((c) => c[2]);
|
||||
const stored = () => JSON.parse(localStorage.getItem(KEY) ?? "null");
|
||||
|
||||
beforeEach(() => {
|
||||
for (const m of Object.values(mocks)) m.mockReset();
|
||||
sleep.mockClear();
|
||||
localStorage.clear();
|
||||
mocks.beginServerFileUpload.mockResolvedValue(session(0));
|
||||
mocks.commitServerFileUpload.mockResolvedValue({ op: op() });
|
||||
serverTakesParts();
|
||||
});
|
||||
|
||||
describe("sendInParts", () => {
|
||||
it("sends each part at the offset the server has reached, then commits", async () => {
|
||||
const seen: number[] = [];
|
||||
const sessions: string[] = [];
|
||||
|
||||
const got = await sendInParts("lobby", "world.zip", file(), opts({
|
||||
overwrite: true,
|
||||
onProgress: (n) => seen.push(n),
|
||||
onSession: (id) => sessions.push(id),
|
||||
}));
|
||||
|
||||
expect(got).toEqual(op());
|
||||
expect(mocks.beginServerFileUpload.mock.calls).toEqual([["lobby", "world.zip", 10]]);
|
||||
expect(offsets()).toEqual([0, 4, 8]);
|
||||
expect(mocks.putServerFileUploadPart.mock.calls.map((c) => (c[3] as Blob).size)).toEqual([4, 4, 2]);
|
||||
expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s1", true]]);
|
||||
expect(seen).toEqual([0, 4, 8, 10]);
|
||||
expect(sessions).toEqual(["s1"]);
|
||||
});
|
||||
|
||||
it("remembers the session under the server and path, with the file it holds, touched at each part", async () => {
|
||||
const f = file("0123456789", 777);
|
||||
let clock = NOW;
|
||||
let seenMidway: unknown = null;
|
||||
mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) => {
|
||||
if (offset === 4) seenMidway = stored();
|
||||
clock += 1000;
|
||||
return session(offset + part.size, { id });
|
||||
});
|
||||
|
||||
await sendInParts("lobby", "world.zip", f, opts({ now: () => clock }));
|
||||
|
||||
expect(seenMidway).toEqual({ server: "lobby", path: "world.zip", id: "s1", size: 10, modified: 777, touched: NOW + 1000 });
|
||||
expect(stored().touched).toBe(NOW + 3000);
|
||||
});
|
||||
|
||||
it("carries on a remembered session from where the server says it stands", async () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", size: 10, modified: 111, touched: 0 }));
|
||||
mocks.getServerFileUpload.mockResolvedValue(session(8, { id: "s7" }));
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts());
|
||||
|
||||
expect(mocks.getServerFileUpload.mock.calls).toEqual([["lobby", "s7"]]);
|
||||
expect(mocks.beginServerFileUpload).not.toHaveBeenCalled();
|
||||
expect(offsets()).toEqual([8]);
|
||||
expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s7", false]]);
|
||||
});
|
||||
|
||||
it("commits at once when the server already holds the whole file", async () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", size: 10, modified: 111, touched: 0 }));
|
||||
mocks.getServerFileUpload.mockResolvedValue(session(10, { id: "s7" }));
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts());
|
||||
|
||||
expect(offsets()).toEqual([]);
|
||||
expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s7", false]]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a different size", { size: 11, modified: 111 }],
|
||||
["a different modification time", { size: 10, modified: 112 }],
|
||||
])("starts over when the remembered session was for %s", async (_label, held) => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", touched: 0, ...held }));
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts());
|
||||
|
||||
expect(mocks.getServerFileUpload).not.toHaveBeenCalled();
|
||||
expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(1);
|
||||
expect(offsets()).toEqual([0, 4, 8]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["is gone", () => mocks.getServerFileUpload.mockRejectedValue(gone)],
|
||||
["now stands for another file", () => mocks.getServerFileUpload.mockResolvedValue(session(8, { id: "s7", size: 99 }))],
|
||||
])("begins a new session when the remembered one %s", async (_label, arrange) => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ server: "lobby", path: "world.zip", id: "s7", size: 10, modified: 111, touched: 0 }));
|
||||
arrange();
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts());
|
||||
|
||||
expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(1);
|
||||
expect(offsets()).toEqual([0, 4, 8]);
|
||||
expect(stored().id).toBe("s1");
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives back only the stale sessions this browser left behind when there are too many, then begins", async () => {
|
||||
const old = { server: "lobby", path: "old.zip", id: "old", size: 5, modified: 1, touched: NOW - STALE_SESSION_MS };
|
||||
const fresh = { server: "lobby", path: "fresh.zip", id: "fresh", size: 5, modified: 1, touched: NOW - STALE_SESSION_MS + 1 };
|
||||
localStorage.setItem("felis-file-upload:lobby:old.zip", JSON.stringify(old));
|
||||
localStorage.setItem("felis-file-upload:lobby:fresh.zip", JSON.stringify(fresh));
|
||||
localStorage.setItem("unrelated", JSON.stringify({ id: "x", server: "lobby", path: "x" }));
|
||||
mocks.beginServerFileUpload
|
||||
.mockRejectedValueOnce({ status: 429, code: "too_many_uploads", message: "" })
|
||||
.mockResolvedValueOnce(session(0));
|
||||
mocks.deleteServerFileUpload.mockResolvedValue(null);
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts());
|
||||
|
||||
expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "old"]]);
|
||||
expect(localStorage.getItem("felis-file-upload:lobby:old.zip")).toBeNull();
|
||||
expect(localStorage.getItem("felis-file-upload:lobby:fresh.zip")).not.toBeNull();
|
||||
expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(2);
|
||||
expect(offsets()).toEqual([0, 4, 8]);
|
||||
});
|
||||
|
||||
it("reports too many uploads when none of this browser's sessions could be given back", async () => {
|
||||
const refusal = { status: 429, code: "too_many_uploads", message: "" };
|
||||
const old = { server: "lobby", path: "old.zip", id: "old", size: 5, modified: 1, touched: 0 };
|
||||
localStorage.setItem("felis-file-upload:lobby:old.zip", JSON.stringify(old));
|
||||
mocks.beginServerFileUpload.mockRejectedValue(refusal);
|
||||
mocks.deleteServerFileUpload.mockRejectedValue(gone);
|
||||
|
||||
await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(refusal);
|
||||
|
||||
expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(1);
|
||||
expect(offsets()).toEqual([]);
|
||||
});
|
||||
|
||||
it("asks where the session stands after a dropped part, and resends from there", async () => {
|
||||
let first = true;
|
||||
mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) => {
|
||||
if (offset === 4 && first) {
|
||||
first = false;
|
||||
throw netErr;
|
||||
}
|
||||
return session(offset + part.size, { id });
|
||||
});
|
||||
// Half of the dropped part had arrived.
|
||||
mocks.getServerFileUpload.mockResolvedValue(session(6));
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts());
|
||||
|
||||
expect(mocks.getServerFileUpload.mock.calls).toEqual([["lobby", "s1"]]);
|
||||
expect(offsets()).toEqual([0, 4, 6]);
|
||||
expect(sleep.mock.calls.map((c) => c[0])).toEqual([1000]);
|
||||
});
|
||||
|
||||
it("begins a new session when the one in use went away mid-upload", async () => {
|
||||
mocks.putServerFileUploadPart
|
||||
.mockImplementationOnce(async (_n: string, id: string, offset: number, part: Blob) => session(offset + part.size, { id }))
|
||||
.mockRejectedValueOnce(gone);
|
||||
mocks.beginServerFileUpload.mockResolvedValueOnce(session(0)).mockResolvedValueOnce(session(0, { id: "s2" }));
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts());
|
||||
|
||||
expect(mocks.getServerFileUpload).not.toHaveBeenCalled();
|
||||
expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(2);
|
||||
expect(offsets()).toEqual([0, 4, 0, 4, 8]);
|
||||
expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s2", false]]);
|
||||
});
|
||||
|
||||
it("stops at a refusal sending again cannot change", async () => {
|
||||
const full = { status: 507, code: "upload_staging_full", message: "" };
|
||||
mocks.putServerFileUploadPart.mockRejectedValue(full);
|
||||
|
||||
await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(full);
|
||||
|
||||
expect(offsets()).toEqual([0]);
|
||||
expect(sleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`gives up after ${MAX_ATTEMPTS} dropped attempts in a row`, async () => {
|
||||
mocks.putServerFileUploadPart.mockRejectedValue(netErr);
|
||||
mocks.getServerFileUpload.mockResolvedValue(session(0));
|
||||
|
||||
await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(netErr);
|
||||
|
||||
expect(offsets()).toHaveLength(MAX_ATTEMPTS);
|
||||
});
|
||||
|
||||
it("counts only drops in a row", async () => {
|
||||
let received = 0;
|
||||
let drops = 0;
|
||||
mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) => {
|
||||
// Each part lands only after MAX_ATTEMPTS - 1 drops.
|
||||
if (drops++ < MAX_ATTEMPTS - 1) throw netErr;
|
||||
drops = 0;
|
||||
received = offset + part.size;
|
||||
return session(received, { id });
|
||||
});
|
||||
mocks.getServerFileUpload.mockImplementation(async () => session(received));
|
||||
|
||||
await sendInParts("lobby", "world.zip", file(), opts());
|
||||
|
||||
expect(offsets()).toHaveLength(3 * MAX_ATTEMPTS);
|
||||
expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the session remembered when stopped, even in its first part, for a later resume", async () => {
|
||||
const ctrl = new AbortController();
|
||||
mocks.putServerFileUploadPart.mockImplementation(async (_n: string, id: string, offset: number, part: Blob) => {
|
||||
if (offset === 0) {
|
||||
ctrl.abort();
|
||||
throw new DOMException("cancelled", "AbortError");
|
||||
}
|
||||
return session(offset + part.size, { id });
|
||||
});
|
||||
|
||||
await expect(sendInParts("lobby", "world.zip", file(), opts({ signal: ctrl.signal }))).rejects.toMatchObject({
|
||||
name: "AbortError",
|
||||
});
|
||||
|
||||
expect(mocks.putServerFileUploadPart.mock.calls[0][4]).toMatchObject({ signal: ctrl.signal });
|
||||
expect(stored().id).toBe("s1");
|
||||
expect(mocks.deleteServerFileUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("a commit whose answer was lost", () => {
|
||||
it("takes the running upload of this path as its answer when the world is held", async () => {
|
||||
const landing = op({ id: "op9" });
|
||||
mocks.commitServerFileUpload.mockRejectedValueOnce(netErr).mockRejectedValueOnce(held);
|
||||
mocks.listServerFileOps.mockResolvedValue({
|
||||
ops: [op({ id: "other", path: "else.zip" }), op({ id: "old", state: "failed" }), landing],
|
||||
});
|
||||
|
||||
expect(await sendInParts("lobby", "world.zip", file(), opts())).toEqual(landing);
|
||||
expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("takes the upload of this path in any state when the session is already gone", async () => {
|
||||
const landed = op({ id: "op9", state: "succeeded" });
|
||||
mocks.commitServerFileUpload.mockRejectedValueOnce(netErr).mockRejectedValueOnce(gone);
|
||||
mocks.listServerFileOps.mockResolvedValue({ ops: [op({ id: "u", op: "unzip" }), landed] });
|
||||
|
||||
expect(await sendInParts("lobby", "world.zip", file(), opts())).toEqual(landed);
|
||||
});
|
||||
|
||||
it("reports the session gone when no upload of this path is there", async () => {
|
||||
mocks.commitServerFileUpload.mockRejectedValueOnce(netErr).mockRejectedValueOnce(gone);
|
||||
mocks.listServerFileOps.mockResolvedValue({ ops: [op({ path: "else.zip" })] });
|
||||
|
||||
await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(gone);
|
||||
});
|
||||
|
||||
it("asks again while the world is held by something else", async () => {
|
||||
mocks.commitServerFileUpload
|
||||
.mockRejectedValueOnce(netErr)
|
||||
.mockRejectedValueOnce(held)
|
||||
.mockResolvedValueOnce({ op: op({ id: "op2" }) });
|
||||
mocks.listServerFileOps.mockResolvedValue({ ops: [op({ id: "done", state: "succeeded" })] });
|
||||
|
||||
expect(await sendInParts("lobby", "world.zip", file(), opts())).toEqual(op({ id: "op2" }));
|
||||
expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
it(`gives up a commit after ${MAX_ATTEMPTS} attempts that did not get through`, async () => {
|
||||
mocks.commitServerFileUpload.mockRejectedValue(netErr);
|
||||
|
||||
await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(netErr);
|
||||
|
||||
expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(MAX_ATTEMPTS);
|
||||
});
|
||||
|
||||
it("reads a held world on the first commit as the refusal it is", async () => {
|
||||
mocks.commitServerFileUpload.mockRejectedValue(held);
|
||||
|
||||
await expect(sendInParts("lobby", "world.zip", file(), opts())).rejects.toBe(held);
|
||||
|
||||
expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.listServerFileOps).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("watchOp", () => {
|
||||
const watch = (over: Parameters<typeof watchOp>[2] = {}) => watchOp("lobby", "op1", { sleep, ...over });
|
||||
|
||||
it("reads every OP_POLL_MS, reports progress, and answers the op once it ends", async () => {
|
||||
const seen: number[] = [];
|
||||
mocks.listServerFileOps
|
||||
.mockResolvedValueOnce({ ops: [op({ done: 3, total: 10 })] })
|
||||
.mockResolvedValueOnce({ ops: [op({ id: "x", state: "succeeded" }), op({ done: 7, total: 10 })] })
|
||||
.mockResolvedValueOnce({ ops: [op({ state: "succeeded", done: 10, total: 10 })] });
|
||||
|
||||
const end = await watch({ onProgress: (o) => seen.push(o.done) });
|
||||
|
||||
expect(end).toEqual(op({ state: "succeeded", done: 10, total: 10 }));
|
||||
expect(seen).toEqual([3, 7]);
|
||||
expect(sleep.mock.calls.map((c) => c[0])).toEqual([OP_POLL_MS, OP_POLL_MS, OP_POLL_MS]);
|
||||
});
|
||||
|
||||
it("backs off after a read that did not get through, and carries on", async () => {
|
||||
mocks.listServerFileOps
|
||||
.mockRejectedValueOnce(netErr)
|
||||
.mockRejectedValueOnce(netErr)
|
||||
.mockRejectedValueOnce(netErr)
|
||||
.mockResolvedValueOnce({ ops: [op()] })
|
||||
.mockResolvedValueOnce({ ops: [op({ state: "failed" })] });
|
||||
|
||||
expect((await watch()).state).toBe("failed");
|
||||
expect(sleep.mock.calls.map((c) => c[0])).toEqual([OP_POLL_MS, 1000, 2000, 4000, OP_POLL_MS]);
|
||||
});
|
||||
|
||||
it(`gives up after ${MAX_ATTEMPTS} reads in a row did not get through`, async () => {
|
||||
mocks.listServerFileOps.mockRejectedValue(netErr);
|
||||
|
||||
await expect(watch()).rejects.toBe(netErr);
|
||||
expect(mocks.listServerFileOps).toHaveBeenCalledTimes(MAX_ATTEMPTS);
|
||||
});
|
||||
|
||||
it("stops at a read refused for good", async () => {
|
||||
const refused = { status: 403, code: "forbidden", message: "" };
|
||||
mocks.listServerFileOps.mockRejectedValue(refused);
|
||||
|
||||
await expect(watch()).rejects.toBe(refused);
|
||||
expect(mocks.listServerFileOps).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it(`reports the op lost after ${MAX_OP_MISSES} reads in a row without it`, async () => {
|
||||
mocks.listServerFileOps.mockResolvedValue({ ops: [op({ id: "other" })] });
|
||||
|
||||
await expect(watch()).rejects.toMatchObject({ code: "op_lost" });
|
||||
expect(mocks.listServerFileOps).toHaveBeenCalledTimes(MAX_OP_MISSES);
|
||||
});
|
||||
|
||||
it("counts only misses in a row", async () => {
|
||||
const without = { ops: [] };
|
||||
for (let i = 0; i < MAX_OP_MISSES - 1; i++) mocks.listServerFileOps.mockResolvedValueOnce(without);
|
||||
mocks.listServerFileOps.mockResolvedValueOnce({ ops: [op()] });
|
||||
for (let i = 0; i < MAX_OP_MISSES - 1; i++) mocks.listServerFileOps.mockResolvedValueOnce(without);
|
||||
mocks.listServerFileOps.mockResolvedValueOnce({ ops: [op({ state: "succeeded" })] });
|
||||
|
||||
expect((await watch()).state).toBe("succeeded");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discardSession and forgetSession", () => {
|
||||
it("forgets the session and cancels it, waiting out a part still arriving", async () => {
|
||||
localStorage.setItem(KEY, "{}");
|
||||
mocks.deleteServerFileUpload
|
||||
.mockRejectedValueOnce({ status: 409, code: "upload_busy", message: "" })
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
await discardSession("lobby", "world.zip", "s1", sleep);
|
||||
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
expect(mocks.deleteServerFileUpload.mock.calls).toEqual([
|
||||
["lobby", "s1"],
|
||||
["lobby", "s1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("tries three times at most, and never past another refusal", async () => {
|
||||
mocks.deleteServerFileUpload.mockRejectedValue({ status: 409, code: "upload_busy", message: "" });
|
||||
await discardSession("lobby", "world.zip", "s1", sleep);
|
||||
expect(mocks.deleteServerFileUpload).toHaveBeenCalledTimes(3);
|
||||
|
||||
mocks.deleteServerFileUpload.mockReset();
|
||||
mocks.deleteServerFileUpload.mockRejectedValue(gone);
|
||||
await discardSession("lobby", "world.zip", "s1", sleep);
|
||||
expect(mocks.deleteServerFileUpload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgetSession drops only the one path", () => {
|
||||
localStorage.setItem(KEY, "{}");
|
||||
localStorage.setItem("felis-file-upload:lobby:other.zip", "{}");
|
||||
|
||||
forgetSession("lobby", "world.zip");
|
||||
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
expect(localStorage.getItem("felis-file-upload:lobby:other.zip")).toBe("{}");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,293 @@
|
||||
import { api, clientError } from "@/lib/api";
|
||||
import { MAX_ATTEMPTS, isTransient, retryDelay, wait } from "@/lib/contextUpload";
|
||||
import type { FileOp, FileUploadSession } from "@/lib/types";
|
||||
|
||||
// A file bigger than one request carries goes up as a session (api
|
||||
// beginServerFileUpload): parts of at most part_max_bytes, each at the byte
|
||||
// offset the session has reached, so a dropped connection resumes where the
|
||||
// server says it stands rather than starting over. The commit answers at once
|
||||
// with the op landing the file, and watchOp follows that op to its end.
|
||||
//
|
||||
// The session is also remembered in this browser, under the server and path it
|
||||
// lands at, with the file's size and modification time. Choosing the same file
|
||||
// again after a reload, a closed tab or a lost connection carries on from the
|
||||
// bytes already sent. A remembered session no page has touched for a while is
|
||||
// what a refusal for too many sessions gives back first.
|
||||
|
||||
type Sleep = (ms: number, signal?: AbortSignal) => Promise<void>;
|
||||
|
||||
/** How often a running op is read. */
|
||||
export const OP_POLL_MS = 2000;
|
||||
/** Reads of the ops list that may miss an op before it counts as lost. */
|
||||
export const MAX_OP_MISSES = 5;
|
||||
/** How long a remembered session must sit untouched before it is given back
|
||||
* to make room for a new one; one another tab is still sending is younger. */
|
||||
export const STALE_SESSION_MS = 5 * 60 * 1000;
|
||||
|
||||
const KEY_PREFIX = "felis-file-upload:";
|
||||
|
||||
interface Remembered {
|
||||
server: string;
|
||||
path: string;
|
||||
id: string;
|
||||
size: number;
|
||||
modified: number;
|
||||
touched: number;
|
||||
}
|
||||
|
||||
const code = (e: unknown) => (e as { code?: unknown } | null)?.code;
|
||||
|
||||
// Browser storage can be missing or refuse (a private window, blocked site
|
||||
// data); a session is then simply not remembered.
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const keyOf = (server: string, path: string) => `${KEY_PREFIX}${server}:${path}`;
|
||||
|
||||
function remember(r: Remembered) {
|
||||
try {
|
||||
storage()?.setItem(keyOf(r.server, r.path), JSON.stringify(r));
|
||||
} catch {
|
||||
/* not remembered: a reload starts this file over */
|
||||
}
|
||||
}
|
||||
|
||||
function recalled(server: string, path: string, file: File): Remembered | null {
|
||||
try {
|
||||
const raw = storage()?.getItem(keyOf(server, path));
|
||||
const r = raw ? (JSON.parse(raw) as Remembered) : null;
|
||||
return r && r.id && r.size === file.size && r.modified === file.lastModified ? r : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberedAll(): Remembered[] {
|
||||
const s = storage();
|
||||
const out: Remembered[] = [];
|
||||
try {
|
||||
for (let i = 0; s && i < s.length; i++) {
|
||||
const k = s.key(i);
|
||||
if (!k?.startsWith(KEY_PREFIX)) continue;
|
||||
const r = JSON.parse(s.getItem(k) ?? "null") as Remembered | null;
|
||||
if (r?.id && r.server && typeof r.path === "string") out.push(r);
|
||||
}
|
||||
} catch {
|
||||
/* whatever was read so far */
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** forgetSession stops remembering the session for path on server, once its
|
||||
* file has landed. */
|
||||
export function forgetSession(server: string, path: string) {
|
||||
try {
|
||||
storage()?.removeItem(keyOf(server, path));
|
||||
} catch {
|
||||
/* nothing to forget */
|
||||
}
|
||||
}
|
||||
|
||||
/** discardSession cancels a session and forgets it. A part still arriving holds
|
||||
* it briefly (upload_busy), so that is waited out a few times; whatever still
|
||||
* fails is left for the server to drop after 6 idle hours. */
|
||||
export async function discardSession(server: string, path: string, id: string, sleep: Sleep = wait) {
|
||||
forgetSession(server, path);
|
||||
for (let n = 1; n <= 3; n++) {
|
||||
try {
|
||||
await api.deleteServerFileUpload(server, id);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (code(e) !== "upload_busy") return;
|
||||
await sleep(retryDelay(n));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dropStale cancels the remembered sessions nothing has sent a part to lately
|
||||
// and reports whether any of them was still held.
|
||||
async function dropStale(now: number): Promise<boolean> {
|
||||
let freed = false;
|
||||
for (const r of rememberedAll()) {
|
||||
if (now - r.touched < STALE_SESSION_MS) continue;
|
||||
forgetSession(r.server, r.path);
|
||||
try {
|
||||
await api.deleteServerFileUpload(r.server, r.id);
|
||||
freed = true;
|
||||
} catch {
|
||||
/* gone already, or someone else's */
|
||||
}
|
||||
}
|
||||
return freed;
|
||||
}
|
||||
|
||||
export interface SendOptions {
|
||||
overwrite: boolean;
|
||||
/** Bytes of the file the server holds so far, plus the part in flight. */
|
||||
onProgress?: (sent: number) => void;
|
||||
/** The session the file goes up in, once there is one. */
|
||||
onSession?: (id: string) => void;
|
||||
/** Stops sending; the session stays, remembered, for a later resume. */
|
||||
signal?: AbortSignal;
|
||||
/** Test seam for the pause between attempts. */
|
||||
sleep?: Sleep;
|
||||
/** Test seam for the clock the remembered sessions are aged by. */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** sendInParts sends file to path on server as an upload session, commits it,
|
||||
* and answers the op landing it. */
|
||||
export async function sendInParts(server: string, path: string, file: File, opts: SendOptions): Promise<FileOp> {
|
||||
const { signal, onProgress } = opts;
|
||||
const sleep = opts.sleep ?? wait;
|
||||
const now = opts.now ?? Date.now;
|
||||
const size = file.size;
|
||||
|
||||
let id: string | null = recalled(server, path, file)?.id ?? null;
|
||||
// offset is where the next part starts; null means "ask the server first".
|
||||
let offset: number | null = null;
|
||||
let partMax = 0;
|
||||
let failures = 0;
|
||||
for (;;) {
|
||||
try {
|
||||
if (offset === null) {
|
||||
const at = await standing(server, path, file, id, now);
|
||||
id = at.id;
|
||||
opts.onSession?.(id);
|
||||
partMax = at.part_max_bytes;
|
||||
offset = at.received;
|
||||
onProgress?.(offset);
|
||||
}
|
||||
if (offset >= size) break;
|
||||
const start = offset;
|
||||
const part = file.slice(start, Math.min(start + partMax, size));
|
||||
const at = await api.putServerFileUploadPart(server, id!, start, part, {
|
||||
signal,
|
||||
onProgress: (sent) => onProgress?.(start + sent),
|
||||
});
|
||||
offset = at.received;
|
||||
failures = 0;
|
||||
remember({ server, path, id: id!, size, modified: file.lastModified, touched: now() });
|
||||
onProgress?.(offset);
|
||||
} catch (e) {
|
||||
// The session went away under the upload (felis-api restarted, or it sat
|
||||
// idle too long): the next pass begins a new one.
|
||||
if (code(e) === "upload_not_found") {
|
||||
id = null;
|
||||
} else if (!isTransient(e)) {
|
||||
throw e;
|
||||
}
|
||||
if (++failures >= MAX_ATTEMPTS) throw e;
|
||||
await sleep(retryDelay(failures), signal);
|
||||
offset = null;
|
||||
}
|
||||
}
|
||||
return commit(server, path, id!, opts.overwrite, sleep, signal);
|
||||
}
|
||||
|
||||
// standing answers the session to carry on with: id's when it still stands for
|
||||
// this file, else a new one. Four sessions per account is the server's bound;
|
||||
// when that refuses, the sessions this browser left behind are given back
|
||||
// first.
|
||||
async function standing(
|
||||
server: string,
|
||||
path: string,
|
||||
file: File,
|
||||
id: string | null,
|
||||
now: () => number,
|
||||
): Promise<FileUploadSession> {
|
||||
if (id) {
|
||||
try {
|
||||
const at = await api.getServerFileUpload(server, id);
|
||||
if (at.path === path && at.size === file.size) return at;
|
||||
} catch (e) {
|
||||
if (code(e) !== "upload_not_found") throw e;
|
||||
}
|
||||
}
|
||||
let at: FileUploadSession;
|
||||
try {
|
||||
at = await api.beginServerFileUpload(server, path, file.size);
|
||||
} catch (e) {
|
||||
if (code(e) !== "too_many_uploads" || !(await dropStale(now()))) throw e;
|
||||
at = await api.beginServerFileUpload(server, path, file.size);
|
||||
}
|
||||
remember({ server, path, id: at.id, size: file.size, modified: file.lastModified, touched: now() });
|
||||
return at;
|
||||
}
|
||||
|
||||
// commit lands the session. A commit whose answer was lost may still have
|
||||
// started the Job, so asking again is read in that light: the world held
|
||||
// (maintenance_in_progress) by an upload of this path running now, or the
|
||||
// session gone because that Job already fetched it, means the first commit
|
||||
// went through, and its op is the answer.
|
||||
async function commit(
|
||||
server: string,
|
||||
path: string,
|
||||
id: string,
|
||||
overwrite: boolean,
|
||||
sleep: Sleep,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FileOp> {
|
||||
let lost = false;
|
||||
for (let failures = 1; ; failures++) {
|
||||
try {
|
||||
return (await api.commitServerFileUpload(server, id, overwrite)).op;
|
||||
} catch (e) {
|
||||
const c = code(e);
|
||||
if (lost && (c === "maintenance_in_progress" || c === "upload_not_found")) {
|
||||
const { ops } = await api.listServerFileOps(server);
|
||||
const op = ops.find(
|
||||
(o) => o.op === "upload" && o.path === path && (c === "upload_not_found" || o.state === "running"),
|
||||
);
|
||||
if (op) return op;
|
||||
if (c === "upload_not_found") throw e;
|
||||
} else if (isTransient(e)) {
|
||||
lost = true;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
if (failures >= MAX_ATTEMPTS) throw e;
|
||||
await sleep(retryDelay(failures), signal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface WatchOptions {
|
||||
signal?: AbortSignal;
|
||||
sleep?: Sleep;
|
||||
/** Each read of the op while it runs. */
|
||||
onProgress?: (op: FileOp) => void;
|
||||
}
|
||||
|
||||
/** watchOp reads server's ops until the op id has ended, and answers it. A read
|
||||
* that did not get through is tried again, backing off; an op missing from
|
||||
* MAX_OP_MISSES reads in a row is reported as op_lost. */
|
||||
export async function watchOp(server: string, id: string, opts: WatchOptions = {}): Promise<FileOp> {
|
||||
const sleep = opts.sleep ?? wait;
|
||||
let failures = 0;
|
||||
let misses = 0;
|
||||
for (;;) {
|
||||
await sleep(failures > 0 ? retryDelay(failures) : OP_POLL_MS, opts.signal);
|
||||
let ops: FileOp[];
|
||||
try {
|
||||
ops = (await api.listServerFileOps(server)).ops;
|
||||
failures = 0;
|
||||
} catch (e) {
|
||||
if (!isTransient(e) || ++failures >= MAX_ATTEMPTS) throw e;
|
||||
continue;
|
||||
}
|
||||
const op = ops.find((o) => o.id === id);
|
||||
if (!op) {
|
||||
if (++misses >= MAX_OP_MISSES) throw clientError("op_lost");
|
||||
continue;
|
||||
}
|
||||
misses = 0;
|
||||
if (op.state !== "running") return op;
|
||||
opts.onProgress?.(op);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { FileOp } from "@/lib/types";
|
||||
import { OP_POLL_MS } from "./sessionUpload";
|
||||
import { useFileOps } from "./useFileOps";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ listServerFileOps: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, ...mocks } };
|
||||
});
|
||||
|
||||
function op(id: string, over: Partial<FileOp> = {}): FileOp {
|
||||
return { id, op: "unzip", path: `${id}.zip`, state: "running", started_at: "2026-09-28T00:00:00Z", done: 0, total: 0, ...over };
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (v: T) => void;
|
||||
const promise = new Promise<T>((res) => (resolve = res));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const ids = (ops: readonly FileOp[]) => ops.map((o) => `${o.id}:${o.state}`);
|
||||
const tick = () => act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.listServerFileOps.mockReset();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe("useFileOps", () => {
|
||||
it("reads nothing until enabled, then once, and polls no further while nothing runs", async () => {
|
||||
mocks.listServerFileOps.mockResolvedValue({ ops: [op("a", { state: "succeeded" })] });
|
||||
const { result, rerender } = renderHook(({ on }) => useFileOps("lobby", on, () => {}), { initialProps: { on: false } });
|
||||
await tick();
|
||||
expect(mocks.listServerFileOps).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ on: true });
|
||||
await act(async () => {});
|
||||
expect(mocks.listServerFileOps.mock.calls).toEqual([["lobby"]]);
|
||||
expect(ids(result.current.ops)).toEqual(["a:succeeded"]);
|
||||
expect(result.current.running).toBe(false);
|
||||
|
||||
await tick();
|
||||
await tick();
|
||||
expect(mocks.listServerFileOps).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("polls while one runs, and tells of each op it saw running once that op ends", async () => {
|
||||
const ended = vi.fn();
|
||||
mocks.listServerFileOps
|
||||
.mockResolvedValueOnce({ ops: [op("a", { done: 1, total: 4 }), op("old", { state: "failed" })] })
|
||||
.mockResolvedValueOnce({ ops: [op("a", { done: 3, total: 4 }), op("old", { state: "failed" })] })
|
||||
.mockResolvedValue({ ops: [op("a", { state: "succeeded" }), op("old", { state: "failed" })] });
|
||||
const { result } = renderHook(() => useFileOps("lobby", true, ended));
|
||||
await act(async () => {});
|
||||
expect(result.current.running).toBe(true);
|
||||
|
||||
await tick();
|
||||
expect(result.current.ops[0].done).toBe(3);
|
||||
expect(ended).not.toHaveBeenCalled();
|
||||
|
||||
await tick();
|
||||
expect(result.current.running).toBe(false);
|
||||
// Only the op seen running here: "old" had ended before the page looked.
|
||||
expect(ended.mock.calls.map(([o]) => o.id)).toEqual(["a"]);
|
||||
|
||||
await tick();
|
||||
await tick();
|
||||
expect(mocks.listServerFileOps).toHaveBeenCalledTimes(3);
|
||||
expect(ended).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows an op this page started at once, over a read already on its way", async () => {
|
||||
const slow = deferred<{ ops: FileOp[] }>();
|
||||
mocks.listServerFileOps.mockReturnValueOnce(slow.promise);
|
||||
const { result } = renderHook(() => useFileOps("lobby", true, () => {}));
|
||||
|
||||
act(() => result.current.started(op("new")));
|
||||
expect(ids(result.current.ops)).toEqual(["new:running"]);
|
||||
await act(async () => slow.resolve({ ops: [] }));
|
||||
|
||||
expect(ids(result.current.ops)).toEqual(["new:running"]);
|
||||
expect(result.current.running).toBe(true);
|
||||
});
|
||||
|
||||
it("tells of the end of an op this page started even when no read saw it running", async () => {
|
||||
const ended = vi.fn();
|
||||
mocks.listServerFileOps.mockResolvedValueOnce({ ops: [] }).mockResolvedValue({ ops: [op("new", { state: "failed" })] });
|
||||
const { result } = renderHook(() => useFileOps("lobby", true, ended));
|
||||
await act(async () => {});
|
||||
|
||||
act(() => result.current.started(op("new")));
|
||||
await tick();
|
||||
|
||||
expect(ended.mock.calls.map(([o]) => `${o.id}:${o.state}`)).toEqual(["new:failed"]);
|
||||
});
|
||||
|
||||
it("starts no read over one still on its way", async () => {
|
||||
const slow = deferred<{ ops: FileOp[] }>();
|
||||
mocks.listServerFileOps.mockResolvedValueOnce({ ops: [op("a")] }).mockReturnValueOnce(slow.promise);
|
||||
renderHook(() => useFileOps("lobby", true, () => {}));
|
||||
await act(async () => {});
|
||||
|
||||
await tick();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
expect(mocks.listServerFileOps).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps an ignored op out from then on, and never tells of its end", async () => {
|
||||
const ended = vi.fn();
|
||||
mocks.listServerFileOps
|
||||
.mockResolvedValueOnce({ ops: [op("mine", { op: "upload" }), op("theirs")] })
|
||||
.mockResolvedValue({ ops: [op("mine", { op: "upload", state: "succeeded" }), op("theirs", { state: "succeeded" })] });
|
||||
const { result } = renderHook(() => useFileOps("lobby", true, ended));
|
||||
await act(async () => {});
|
||||
|
||||
act(() => result.current.ignore("mine"));
|
||||
expect(ids(result.current.ops)).toEqual(["theirs:running"]);
|
||||
await tick();
|
||||
|
||||
expect(ids(result.current.ops)).toEqual(["theirs:succeeded"]);
|
||||
expect(ended.mock.calls.map(([o]) => o.id)).toEqual(["theirs"]);
|
||||
});
|
||||
|
||||
it("an ignored op that runs alone holds nothing", async () => {
|
||||
mocks.listServerFileOps.mockResolvedValue({ ops: [op("mine", { op: "upload" })] });
|
||||
const { result } = renderHook(() => useFileOps("lobby", true, () => {}));
|
||||
await act(async () => {});
|
||||
expect(result.current.running).toBe(true);
|
||||
|
||||
act(() => result.current.ignore("mine"));
|
||||
|
||||
expect(result.current.running).toBe(false);
|
||||
});
|
||||
|
||||
it("hides a dismissed op", async () => {
|
||||
mocks.listServerFileOps.mockResolvedValue({ ops: [op("a", { state: "failed" }), op("b", { state: "succeeded" })] });
|
||||
const { result } = renderHook(() => useFileOps("lobby", true, () => {}));
|
||||
await act(async () => {});
|
||||
|
||||
act(() => result.current.dismiss("a"));
|
||||
|
||||
expect(ids(result.current.ops)).toEqual(["b:succeeded"]);
|
||||
});
|
||||
|
||||
it("says why a read failed, and clears it once one gets through", async () => {
|
||||
const refused = { status: 500, code: "internal", message: "" };
|
||||
mocks.listServerFileOps.mockRejectedValueOnce(refused).mockResolvedValueOnce({ ops: [] });
|
||||
const { result } = renderHook(() => useFileOps("lobby", true, () => {}));
|
||||
await act(async () => {});
|
||||
expect(result.current.error).toBe(refused);
|
||||
|
||||
await act(async () => result.current.refresh());
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { usePolling } from "@/lib/hooks";
|
||||
import type { FileOp } from "@/lib/types";
|
||||
import { OP_POLL_MS } from "./sessionUpload";
|
||||
|
||||
// useFileOps follows a server's background ops (an extraction, or the landing of
|
||||
// a file sent in parts): read once when `enabled` turns on, then again every
|
||||
// OP_POLL_MS while one runs. They carry on with the page closed, so a visit
|
||||
// after one began still shows it and how it ended. onEnded runs once for each op
|
||||
// seen running here that has since ended.
|
||||
export function useFileOps(server: string, enabled: boolean, onEnded: (op: FileOp) => void) {
|
||||
const [ops, setOps] = useState<FileOp[]>([]);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [dismissed, setDismissed] = useState<ReadonlySet<string>>(new Set());
|
||||
const seenRunning = useRef(new Set<string>());
|
||||
const ended = useRef(onEnded);
|
||||
ended.current = onEnded;
|
||||
// Only the newest read lands, and a read is not started over one in flight:
|
||||
// a slow answer must not undo a newer one, nor pile up behind the interval.
|
||||
const seq = useRef(0);
|
||||
const inFlight = useRef(false);
|
||||
// Ops the upload queue follows itself (the landing of a file it sent in
|
||||
// parts): its own row shows them, so they are left out here.
|
||||
const ignored = useRef(new Set<string>());
|
||||
|
||||
const read = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
const ticket = ++seq.current;
|
||||
try {
|
||||
const r = await api.listServerFileOps(server);
|
||||
if (ticket !== seq.current) return;
|
||||
const all = (r.ops ?? []).filter((op) => !ignored.current.has(op.id));
|
||||
setError(null);
|
||||
setOps(all);
|
||||
for (const op of all) {
|
||||
if (op.state === "running") seenRunning.current.add(op.id);
|
||||
else if (seenRunning.current.delete(op.id)) ended.current(op);
|
||||
}
|
||||
} catch (e) {
|
||||
if (ticket === seq.current) setError(e);
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, [server]);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled) void read();
|
||||
}, [enabled, read]);
|
||||
|
||||
const running = ops.some((op) => op.state === "running");
|
||||
const poll = useCallback(() => void read(), [read]);
|
||||
usePolling(poll, enabled && running ? OP_POLL_MS : null);
|
||||
|
||||
/** started shows an op this page just began at once, ahead of the next read,
|
||||
* and watches it from there. */
|
||||
const started = useCallback((op: FileOp) => {
|
||||
if (op.state === "running") seenRunning.current.add(op.id);
|
||||
seq.current++; // a read already in flight predates it
|
||||
setOps((all) => [op, ...all.filter((o) => o.id !== op.id)]);
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback((id: string) => setDismissed((s) => new Set(s).add(id)), []);
|
||||
/** ignore leaves the op id out from now on. */
|
||||
const ignore = useCallback((id: string) => {
|
||||
ignored.current.add(id);
|
||||
setOps((all) => all.filter((o) => o.id !== id));
|
||||
}, []);
|
||||
|
||||
return { ops: ops.filter((op) => !dismissed.has(op.id)), running, error, refresh: poll, started, dismiss, ignore };
|
||||
}
|
||||
@@ -2,12 +2,15 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import i18next from "i18next";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { ServerFileEntry } from "@/lib/types";
|
||||
import type { FileOpError, ServerFileEntry } from "@/lib/types";
|
||||
import { joinPath } from "./names";
|
||||
import { opErrorText } from "./opText";
|
||||
import { discardSession, forgetSession, sendInParts, watchOp } from "./sessionUpload";
|
||||
|
||||
/** The upload ceiling, mirrored from fileedit.MaxUploadBytes (server truth, 413
|
||||
* above it). Checked here so a file too large is refused before it is sent. */
|
||||
export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
||||
/** The most one upload request carries, mirrored from fileedit.MaxUploadBytes
|
||||
* (413 above it). A larger file goes up in parts instead (sessionUpload.ts),
|
||||
* with no ceiling but the room on the world volume. */
|
||||
export const ONE_REQUEST_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** queued waits its turn; exists found a file already at its path and waits for
|
||||
* replace or skip; failed stays until it is retried or dismissed. */
|
||||
@@ -22,21 +25,50 @@ export interface UploadItem {
|
||||
sent: number;
|
||||
overwrite: boolean;
|
||||
error: string | null;
|
||||
/** false for a refusal sending again cannot change (too large, a folder there). */
|
||||
/** false for a refusal sending again cannot change (a folder there). */
|
||||
retryable: boolean;
|
||||
/** How far the Job landing a file sent in parts has got, once it reports. */
|
||||
landing: { done: number; total: number } | null;
|
||||
}
|
||||
|
||||
/** An op that ended failed, thrown so the queue settles it like any refusal. */
|
||||
interface OpFailure {
|
||||
status: 0;
|
||||
code: string;
|
||||
message: string;
|
||||
opError: FileOpError;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
/** Runs after each upload that landed, with the folder it landed in. */
|
||||
onLanded: (dir: string) => void;
|
||||
/** Runs with the id of each op a file sent in parts is landed by. */
|
||||
onOp?: (id: string) => void;
|
||||
/** Holds the queue: nothing new starts while it is true. */
|
||||
hold?: boolean;
|
||||
/** Bytes free on the world volume, from the latest listing. */
|
||||
free?: number | null;
|
||||
}
|
||||
|
||||
// useUploads runs a queue of uploads into a server's world volume, one at a time:
|
||||
// each is a file Job that holds the world lock, so a second one sent alongside
|
||||
// would only be refused with 409 maintenance_in_progress. onLanded runs after
|
||||
// each upload that landed, with the folder it landed in.
|
||||
export function useUploads(server: string, onLanded: (dir: string) => void) {
|
||||
// would only be refused with 409 maintenance_in_progress.
|
||||
export function useUploads(server: string, { onLanded, onOp, hold = false, free = null }: Options) {
|
||||
const [items, setItems] = useState<UploadItem[]>([]);
|
||||
const current = useRef(items);
|
||||
current.current = items;
|
||||
const nextId = useRef(1);
|
||||
const running = useRef<number | null>(null);
|
||||
const abort = useRef<AbortController | null>(null);
|
||||
const landed = useRef(onLanded);
|
||||
landed.current = onLanded;
|
||||
// An upload cancelled on purpose gives its session back; one stopped because
|
||||
// the page went away keeps it for a resume.
|
||||
const cancelled = useRef<number | null>(null);
|
||||
// The session each file sent in parts went up in, by item.
|
||||
const sessions = useRef(new Map<number, { path: string; id: string }>());
|
||||
const hooks = useRef({ onLanded, onOp });
|
||||
hooks.current = { onLanded, onOp };
|
||||
const room = useRef(free);
|
||||
room.current = free;
|
||||
|
||||
const patch = useCallback((id: number, change: Partial<UploadItem>) => {
|
||||
setItems((all) => all.map((it) => (it.id === id ? { ...it, ...change } : it)));
|
||||
@@ -44,81 +76,171 @@ export function useUploads(server: string, onLanded: (dir: string) => void) {
|
||||
const drop = useCallback((id: number) => {
|
||||
setItems((all) => all.filter((it) => it.id !== id));
|
||||
}, []);
|
||||
// discard gives back the session an item holds, if any.
|
||||
const discard = useCallback(
|
||||
(id: number) => {
|
||||
const s = sessions.current.get(id);
|
||||
sessions.current.delete(id);
|
||||
if (s) void discardSession(server, s.path, s.id);
|
||||
},
|
||||
[server],
|
||||
);
|
||||
|
||||
// Leaving the page stops the upload in flight; the queued ones were never sent.
|
||||
useEffect(() => () => abort.current?.abort(), []);
|
||||
|
||||
// sendLarge sends a file in parts and follows the op landing it to its end.
|
||||
const sendLarge = useCallback(
|
||||
async (it: UploadItem, path: string, signal: AbortSignal) => {
|
||||
const op = await sendInParts(server, path, it.file, {
|
||||
overwrite: it.overwrite,
|
||||
signal,
|
||||
onProgress: (sent) => patch(it.id, { sent }),
|
||||
onSession: (id) => sessions.current.set(it.id, { path, id }),
|
||||
});
|
||||
hooks.current.onOp?.(op.id);
|
||||
patch(it.id, { sent: it.file.size });
|
||||
const end =
|
||||
op.state === "running"
|
||||
? await watchOp(server, op.id, {
|
||||
signal,
|
||||
onProgress: (o) => patch(it.id, { landing: { done: o.done, total: o.total } }),
|
||||
})
|
||||
: op;
|
||||
if (end.state === "failed") {
|
||||
const opError = end.error ?? { code: "job_failed", message: "" };
|
||||
const failure: OpFailure = { status: 0, code: opError.code, message: opError.message, opError };
|
||||
throw failure;
|
||||
}
|
||||
sessions.current.delete(it.id);
|
||||
forgetSession(server, path);
|
||||
},
|
||||
[server, patch],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (running.current !== null) return;
|
||||
if (hold || running.current !== null) return;
|
||||
const next = items.find((it) => it.state === "queued");
|
||||
if (!next) return;
|
||||
running.current = next.id;
|
||||
const ctrl = new AbortController();
|
||||
abort.current = ctrl;
|
||||
patch(next.id, { state: "uploading", sent: 0, error: null });
|
||||
patch(next.id, { state: "uploading", sent: 0, error: null, landing: null });
|
||||
const settle = () => {
|
||||
running.current = null;
|
||||
abort.current = null;
|
||||
};
|
||||
api
|
||||
.uploadServerFile(server, joinPath(next.dir, next.file.name), next.file, next.overwrite, {
|
||||
signal: ctrl.signal,
|
||||
onProgress: (sent) => patch(next.id, { sent }),
|
||||
})
|
||||
.then(
|
||||
() => {
|
||||
settle();
|
||||
patch(next.id, { state: "done", sent: next.file.size });
|
||||
landed.current(next.dir);
|
||||
},
|
||||
(e: unknown) => {
|
||||
settle();
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
drop(next.id);
|
||||
} else if ((e as { code?: string }).code === "file_exists") {
|
||||
// Someone put a file there since the listing: ask, as for one listed.
|
||||
patch(next.id, { state: "exists", sent: 0 });
|
||||
} else {
|
||||
patch(next.id, { state: "failed", sent: 0, error: humanizeError(e), retryable: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [items, server, patch, drop]);
|
||||
const path = joinPath(next.dir, next.file.name);
|
||||
const sending =
|
||||
next.file.size > ONE_REQUEST_BYTES
|
||||
? sendLarge(next, path, ctrl.signal)
|
||||
: api.uploadServerFile(server, path, next.file, next.overwrite, {
|
||||
signal: ctrl.signal,
|
||||
onProgress: (sent) => patch(next.id, { sent }),
|
||||
});
|
||||
sending.then(
|
||||
() => {
|
||||
settle();
|
||||
patch(next.id, { state: "done", sent: next.file.size });
|
||||
hooks.current.onLanded(next.dir);
|
||||
},
|
||||
(e: unknown) => {
|
||||
settle();
|
||||
if (e instanceof DOMException && e.name === "AbortError") {
|
||||
if (cancelled.current === next.id) discard(next.id);
|
||||
cancelled.current = null;
|
||||
drop(next.id);
|
||||
} else if ((e as { code?: string }).code === "file_exists") {
|
||||
// Someone put a file there since the listing: ask, as for one listed.
|
||||
// A file sent in parts keeps its session, so replacing it lands the
|
||||
// bytes already sent.
|
||||
patch(next.id, { state: "exists", sent: 0, landing: null });
|
||||
} else {
|
||||
const opError = (e as Partial<OpFailure>).opError;
|
||||
patch(next.id, {
|
||||
state: "failed",
|
||||
sent: 0,
|
||||
landing: null,
|
||||
error: opError ? opErrorText("upload", opError) : humanizeError(e),
|
||||
retryable: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [items, hold, server, patch, drop, discard, sendLarge]);
|
||||
|
||||
/** add queues files for dir. `entries` is dir's listing: a file of the same
|
||||
* name there waits for replace or skip instead of being sent to be refused. */
|
||||
* name there waits for replace or skip instead of being sent to be refused.
|
||||
* Files the world volume has no room for are refused before any is sent,
|
||||
* counting the ones ahead of them in the same batch. */
|
||||
const add = useCallback((files: readonly File[], dir: string, entries: readonly ServerFileEntry[] | null) => {
|
||||
const t = i18next.getFixedT(null, "files");
|
||||
let budget = room.current;
|
||||
const added = files.map((file): UploadItem => {
|
||||
const base = { id: nextId.current++, file, dir, sent: 0, overwrite: false, error: null, retryable: false };
|
||||
const base = {
|
||||
id: nextId.current++,
|
||||
file,
|
||||
dir,
|
||||
sent: 0,
|
||||
overwrite: false,
|
||||
error: null,
|
||||
retryable: false,
|
||||
landing: null,
|
||||
};
|
||||
const there = entries?.find((e) => e.name === file.name);
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
return { ...base, state: "failed", error: t("upload_too_large", { limit: formatBytes(MAX_UPLOAD_BYTES) }) };
|
||||
}
|
||||
if (there?.is_dir) {
|
||||
return { ...base, state: "failed", error: t("upload_folder_there") };
|
||||
}
|
||||
if (budget !== null && file.size > budget) {
|
||||
return { ...base, state: "failed", error: noRoom(file, budget), retryable: true };
|
||||
}
|
||||
if (budget !== null) budget -= file.size;
|
||||
return { ...base, state: there ? "exists" : "queued" };
|
||||
});
|
||||
setItems((all) => [...all, ...added]);
|
||||
}, []);
|
||||
|
||||
const replace = useCallback((id: number) => patch(id, { state: "queued", overwrite: true }), [patch]);
|
||||
const retry = useCallback((id: number) => patch(id, { state: "queued", error: null }), [patch]);
|
||||
// requeue sends the items picked again once the volume has room for each by
|
||||
// the latest listing; one it has none for stays failed and says so again.
|
||||
const requeue = useCallback((pick: (it: UploadItem) => boolean, change: Partial<UploadItem>) => {
|
||||
const free = room.current;
|
||||
setItems((all) =>
|
||||
all.map((it) => {
|
||||
if (!pick(it)) return it;
|
||||
if (free !== null && it.file.size > free) {
|
||||
return { ...it, state: "failed", error: noRoom(it.file, free), retryable: true };
|
||||
}
|
||||
return { ...it, ...change, state: "queued", error: null };
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const replace = useCallback((id: number) => requeue((it) => it.id === id, { overwrite: true }), [requeue]);
|
||||
const retry = useCallback((id: number) => requeue((it) => it.id === id, {}), [requeue]);
|
||||
/** remove cancels an upload in flight, or takes any other one off the list. */
|
||||
const remove = useCallback(
|
||||
(id: number) => {
|
||||
if (running.current === id) abort.current?.abort();
|
||||
else drop(id);
|
||||
if (running.current === id) {
|
||||
cancelled.current = id;
|
||||
abort.current?.abort();
|
||||
} else {
|
||||
discard(id);
|
||||
drop(id);
|
||||
}
|
||||
},
|
||||
[drop],
|
||||
[drop, discard],
|
||||
);
|
||||
const replaceAll = useCallback(() => {
|
||||
setItems((all) => all.map((it) => (it.state === "exists" ? { ...it, state: "queued", overwrite: true } : it)));
|
||||
}, []);
|
||||
const skipAll = useCallback(() => setItems((all) => all.filter((it) => it.state !== "exists")), []);
|
||||
const replaceAll = useCallback(() => requeue((it) => it.state === "exists", { overwrite: true }), [requeue]);
|
||||
const skipAll = useCallback(() => {
|
||||
for (const it of current.current) if (it.state === "exists") discard(it.id);
|
||||
setItems((all) => all.filter((it) => it.state !== "exists"));
|
||||
}, [discard]);
|
||||
const clearDone = useCallback(() => setItems((all) => all.filter((it) => it.state !== "done")), []);
|
||||
|
||||
const busy = items.some((it) => it.state === "queued" || it.state === "uploading");
|
||||
return { items, busy, add, replace, retry, remove, replaceAll, skipAll, clearDone };
|
||||
}
|
||||
|
||||
function noRoom(file: File, free: number): string {
|
||||
return i18next.t("files:upload_no_room", { free: formatBytes(free), size: formatBytes(file.size) });
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
"job_scheduled": "Scheduled backup",
|
||||
"job_export_world": "World export",
|
||||
"job_export_backup": "Backup download",
|
||||
"job_export_files": "File download",
|
||||
"job_running": "Running",
|
||||
"job_succeeded": "Succeeded",
|
||||
"job_failed": "Failed",
|
||||
|
||||
@@ -71,6 +71,9 @@
|
||||
"upload_staging_full": "The panel's upload space is nearly full right now, so the file was not passed on. Try again later, or ask an admin to free space on the uploads volume.",
|
||||
"upload_incomplete": "The upload stopped before the whole file arrived, so nothing was changed. Try again.",
|
||||
"length_required": "The upload did not say how large it is, so it was refused. Upload it again from the panel.",
|
||||
"upload_not_found": "This upload is gone: it was cancelled, already landed, sat idle for 6 hours, or the panel service restarted. Upload the file again.",
|
||||
"too_many_uploads": "You already have 4 large uploads in progress. Wait for one to finish, or cancel one, and try again.",
|
||||
"op_lost": "The operation's progress can no longer be read. Refresh the list to see whether the file landed.",
|
||||
"jobs_unavailable": "The background job service isn't available right now.",
|
||||
"already_terminal": "This build already finished — there is nothing to cancel.",
|
||||
"build_unavailable": "Image builds aren't available right now.",
|
||||
@@ -82,7 +85,7 @@
|
||||
"submission_cooldown": "Too many submission requests — try again shortly.",
|
||||
"submissions_unavailable": "Submissions aren't available right now.",
|
||||
"uploads_unavailable": "Uploads aren't available right now.",
|
||||
"upload_busy": "Another upload of this submission is still running — wait a moment and try again.",
|
||||
"upload_busy": "Another part of this upload is still being sent — wait a moment and try again.",
|
||||
"upload_offset_mismatch": "The upload fell out of step with the server — try again to pick up where it stopped.",
|
||||
"uploads_store_unavailable": "The uploads store did not answer — try again in a moment; what was already sent is kept.",
|
||||
"part_too_large": "A piece of the upload was larger than the server accepts.",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"new_file": "New file",
|
||||
"new_folder": "New folder",
|
||||
"upload": "Upload",
|
||||
"upload_hint": "Upload files into this folder (up to {{limit}} each), or drop them onto the list",
|
||||
"upload_hint": "Upload files into this folder, or drop them on the list. Zip a folder before uploading it",
|
||||
"in_folder": "In {{dir}}",
|
||||
"name_label": "Name",
|
||||
"create": "Create",
|
||||
@@ -60,8 +60,8 @@
|
||||
"delete_folder_body": "The folder and everything in it are deleted for good. Take a backup first if you may want any of it back.",
|
||||
"secret_config_unreadable": "config/paper-global.yml holds the proxy forwarding secret every server shares, so the editor does not open it.",
|
||||
"drop_here": "Drop to upload into {{dir}}",
|
||||
"upload_no_folders_one": "A folder cannot be uploaded, so it was skipped. Make the folder here, then upload the files inside it.",
|
||||
"upload_no_folders_other": "Folders cannot be uploaded, so {{count}} were skipped. Make the folders here, then upload the files inside them.",
|
||||
"upload_no_folders_one": "Folders cannot be uploaded, so one was skipped: loose files cut off halfway leave a broken save behind. Zip it, upload the .zip, then choose Extract here on it.",
|
||||
"upload_no_folders_other": "Folders cannot be uploaded, so {{count}} were skipped: loose files cut off halfway leave a broken save behind. Zip them, upload the .zip, then choose Extract here on it.",
|
||||
"uploads_label": "Uploads",
|
||||
"uploads_title": "Uploads · {{done}} of {{total}} done",
|
||||
"upload_queued": "Waiting",
|
||||
@@ -78,6 +78,48 @@
|
||||
"upload_dismiss": "Dismiss {{name}}",
|
||||
"upload_clear_done": "Clear finished",
|
||||
"upload_progress_label": "Uploading {{name}}",
|
||||
"upload_too_large": "Larger than the {{limit}} upload limit.",
|
||||
"upload_folder_there": "A folder with this name is already here."
|
||||
"upload_folder_there": "A folder with this name is already here.",
|
||||
"upload_no_room": "The world volume has {{free}} free, not enough for this {{size}} file. Delete files you do not need, then retry.",
|
||||
"upload_landing_progress": "Writing it to the server… {{percent}}%",
|
||||
"wait_for_op": "Wait for the background operation to finish first.",
|
||||
"wait_for_download": "Wait until the download is ready first.",
|
||||
"unzip_item": "Extract {{name}} here",
|
||||
"unzip_conflicts_title_one": "Extracting {{name}} replaces {{count}} file",
|
||||
"unzip_conflicts_title_other": "Extracting {{name}} replaces {{count}} files",
|
||||
"unzip_conflicts_body": "These files in the archive are already here. Confirm to replace them with the archive's versions; everything else extracts as usual. Take a backup first if you may want the old versions back.",
|
||||
"unzip_conflicts_more": "{{count}} more not listed.",
|
||||
"unzip_overwrite": "Replace and extract",
|
||||
"download_item": "Download {{name}}",
|
||||
"download_folder_item": "Download the folder {{name}} as a .zip",
|
||||
"download_preparing": "Preparing the download of {{name}}…",
|
||||
"download_started": "Downloading {{filename}}.",
|
||||
"download_started_props": "Downloading {{filename}}. Its rcon.password is redacted.",
|
||||
"download_started_config": "Downloading {{filename}}. paper-global.yml, the proxy forwarding secret every server shares, is left out.",
|
||||
"download_failed": "The download could not be prepared.",
|
||||
"download_failed_because": "The download could not be prepared: {{reason}}",
|
||||
"download_busy": "Too many file downloads are being prepared (two at a time per person, thirty an hour). Try again in a few minutes.",
|
||||
"secret_config_no_download": "config/paper-global.yml holds the proxy forwarding secret every server shares, so it cannot be downloaded.",
|
||||
"ops_label": "Background operations",
|
||||
"ops_refresh_failed": "Could not read the background operations: {{reason}}",
|
||||
"op_unzip": "Extract {{path}}",
|
||||
"op_upload": "Write {{path}}",
|
||||
"op_preparing": "Getting ready…",
|
||||
"op_progress": "{{done}} of {{total}} · {{percent}}%",
|
||||
"op_progress_label": "Progress of {{path}}",
|
||||
"op_unzip_done_one": "Extracted {{count}} file, {{bytes}} in all.",
|
||||
"op_unzip_done_other": "Extracted {{count}} files, {{bytes}} in all.",
|
||||
"op_upload_done": "Written.",
|
||||
"op_dismiss": "Dismiss the result for {{path}}",
|
||||
"op_conflicts": "Review conflicts",
|
||||
"op_upload_exists": "A file with this name is already here, so nothing was replaced. Upload it again and choose Replace.",
|
||||
"op_unzip_conflicts_one": "It would replace {{count}} file already here, so nothing was extracted yet.",
|
||||
"op_unzip_conflicts_other": "It would replace {{count}} files already here, so nothing was extracted yet.",
|
||||
"op_volume_full": "Not enough room on the world volume: {{need}} needed, {{avail}} free. Nothing was changed.",
|
||||
"archive_invalid": "The archive is damaged, or not a zip file. Nothing was changed.",
|
||||
"archive_invalid_entry": "{{entry}} in the archive is damaged (its size or checksum does not match). Nothing was changed.",
|
||||
"archive_unsafe": "{{entry}} in the archive would land outside this folder, or is a device file. Nothing in the archive was extracted.",
|
||||
"archive_symlink": "{{entry}} in the archive is a symbolic link. Nothing in the archive was extracted.",
|
||||
"type_conflict": "{{entry}} is a file on one side and a folder on the other, which replacing cannot resolve. Rename or delete {{entry}} here, then extract again.",
|
||||
"job_failed": "The background task ended without saying why. Refresh the list to check, then try again.",
|
||||
"job_timed_out": "The background task did not finish within 2 hours and was stopped. Refresh the list to check, then try again."
|
||||
}
|
||||
@@ -43,6 +43,7 @@
|
||||
"job_scheduled": "定时备份",
|
||||
"job_export_world": "世界导出",
|
||||
"job_export_backup": "备份下载",
|
||||
"job_export_files": "文件下载",
|
||||
"job_running": "进行中",
|
||||
"job_succeeded": "成功",
|
||||
"job_failed": "失败",
|
||||
|
||||
@@ -71,6 +71,9 @@
|
||||
"upload_staging_full": "面板的上传暂存空间快满了,这个文件没有转存过去。稍后再试,或者请管理员清理上传卷。",
|
||||
"upload_incomplete": "文件还没传完上传就中断了,什么都没有改动。请重试。",
|
||||
"length_required": "这次上传没有声明文件大小,被拒绝了。请从面板重新上传。",
|
||||
"upload_not_found": "这次分片上传已经不在了(取消过、已经写入、闲置超过 6 小时,或者面板服务重启过)。请重新上传。",
|
||||
"too_many_uploads": "你同时进行的大文件上传已经有 4 个了。等其中一个完成,或者取消一个再试。",
|
||||
"op_lost": "看不到这次操作的进度了。刷新列表看看文件有没有写入。",
|
||||
"jobs_unavailable": "后台任务服务当前不可用。",
|
||||
"already_terminal": "该构建已经结束,无法重复取消。",
|
||||
"build_unavailable": "构建功能当前不可用。",
|
||||
@@ -82,7 +85,7 @@
|
||||
"submission_cooldown": "操作太频繁——请稍后再试。",
|
||||
"submissions_unavailable": "提交流程当前不可用。",
|
||||
"uploads_unavailable": "上传功能当前不可用。",
|
||||
"upload_busy": "这个投稿的另一次上传仍在进行——请稍等片刻再试。",
|
||||
"upload_busy": "这次上传的另一部分仍在发送——请稍等片刻再试。",
|
||||
"upload_offset_mismatch": "上传进度与服务器对不上——重试即可从中断处接着传。",
|
||||
"uploads_store_unavailable": "上传存储暂时没有响应——稍后重试即可,已传的部分会保留。",
|
||||
"part_too_large": "上传的某一片超过了服务器接受的大小。",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"new_file": "新建文件",
|
||||
"new_folder": "新建文件夹",
|
||||
"upload": "上传",
|
||||
"upload_hint": "上传文件到这个文件夹(每个最大 {{limit}}),也可以直接拖到列表上",
|
||||
"upload_hint": "上传文件到这个文件夹,也可以直接拖到列表上。文件夹请先压成 .zip 再上传",
|
||||
"in_folder": "位置:{{dir}}",
|
||||
"name_label": "名称",
|
||||
"create": "创建",
|
||||
@@ -60,7 +60,7 @@
|
||||
"delete_folder_body": "文件夹和里面的所有内容都会被永久删除。之后可能还要用的话,先做一次备份。",
|
||||
"secret_config_unreadable": "config/paper-global.yml 里有所有服务器共用的代理转发密钥,编辑器不打开它。",
|
||||
"drop_here": "松开即可上传到 {{dir}}",
|
||||
"upload_no_folders": "文件夹没法直接上传,已跳过 {{count}} 个。先在这里新建同名文件夹,再上传里面的文件。",
|
||||
"upload_no_folders": "不支持上传文件夹(已跳过 {{count}} 个):散文件传到一半断掉会留下残缺存档。请先压成 .zip 上传,再对它点「解压到此处」。",
|
||||
"uploads_label": "上传",
|
||||
"uploads_title": "上传 · 已完成 {{done}}/{{total}}",
|
||||
"upload_queued": "等待中",
|
||||
@@ -77,6 +77,45 @@
|
||||
"upload_dismiss": "移除 {{name}}",
|
||||
"upload_clear_done": "清除已完成",
|
||||
"upload_progress_label": "正在上传 {{name}}",
|
||||
"upload_too_large": "超过 {{limit}} 的上传上限。",
|
||||
"upload_folder_there": "这里已经有同名文件夹。"
|
||||
"upload_folder_there": "这里已经有同名文件夹。",
|
||||
"upload_no_room": "世界卷只剩 {{free}},放不下这个 {{size}} 的文件。先删掉些用不着的文件再重试。",
|
||||
"upload_landing_progress": "正在写入服务器… {{percent}}%",
|
||||
"wait_for_op": "等后台操作完成后再改动。",
|
||||
"wait_for_download": "等下载准备好后再操作。",
|
||||
"unzip_item": "把 {{name}} 解压到此处",
|
||||
"unzip_conflicts_title": "解压 {{name}} 会覆盖 {{count}} 个文件",
|
||||
"unzip_conflicts_body": "压缩包里的这些文件在这里已经存在。确认后会用压缩包里的版本替换它们,其余文件照常解压。旧版本之后可能还要用的话,先做一次备份。",
|
||||
"unzip_conflicts_more": "还有 {{count}} 个没有列出。",
|
||||
"unzip_overwrite": "覆盖并解压",
|
||||
"download_item": "下载 {{name}}",
|
||||
"download_folder_item": "把文件夹 {{name}} 打包成 .zip 下载",
|
||||
"download_preparing": "正在准备 {{name}} 的下载…",
|
||||
"download_started": "已开始下载 {{filename}}。",
|
||||
"download_started_props": "已开始下载 {{filename}}。里面的 rcon.password 已隐去。",
|
||||
"download_started_config": "已开始下载 {{filename}}。所有服务器共用的代理转发密钥 paper-global.yml 不在里面。",
|
||||
"download_failed": "下载没有准备好。",
|
||||
"download_failed_because": "下载没有准备好:{{reason}}",
|
||||
"download_busy": "正在准备的文件下载太多了(每人同时两个、每小时最多三十个),请过几分钟再试。",
|
||||
"secret_config_no_download": "config/paper-global.yml 里有所有服务器共用的代理转发密钥,不能下载。",
|
||||
"ops_label": "后台操作",
|
||||
"ops_refresh_failed": "读取后台操作失败:{{reason}}",
|
||||
"op_unzip": "解压 {{path}}",
|
||||
"op_upload": "写入 {{path}}",
|
||||
"op_preparing": "准备中…",
|
||||
"op_progress": "{{done}} / {{total}} · {{percent}}%",
|
||||
"op_progress_label": "{{path}} 的进度",
|
||||
"op_unzip_done": "已解压 {{count}} 个文件,共 {{bytes}}。",
|
||||
"op_upload_done": "已写入。",
|
||||
"op_dismiss": "关闭 {{path}} 的结果",
|
||||
"op_conflicts": "查看冲突",
|
||||
"op_upload_exists": "这里已经有同名文件,没有替换。重新上传时选「替换」即可。",
|
||||
"op_unzip_conflicts": "会覆盖 {{count}} 个已有文件,还没有解压。",
|
||||
"op_volume_full": "世界卷空间不够:需要 {{need}},只剩 {{avail}}。什么都没有改动。",
|
||||
"archive_invalid": "这个压缩包损坏了,或者不是 zip 文件。什么都没有改动。",
|
||||
"archive_invalid_entry": "压缩包里的 {{entry}} 损坏了(大小或校验和对不上)。什么都没有改动。",
|
||||
"archive_unsafe": "压缩包里的 {{entry}} 会写到这个文件夹外面,或者是设备文件。整个压缩包都没有解压。",
|
||||
"archive_symlink": "压缩包里的 {{entry}} 是符号链接。整个压缩包都没有解压。",
|
||||
"type_conflict": "压缩包里的 {{entry}} 和这里已有的同名项一个是文件、一个是文件夹,覆盖解决不了。先把这里的 {{entry}} 改名或删掉再解压。",
|
||||
"job_failed": "后台任务没说明原因就结束了。刷新列表确认一下,再试一次。",
|
||||
"job_timed_out": "后台任务 2 小时还没做完,被停下了。刷新列表确认一下,再试一次。"
|
||||
}
|
||||
@@ -1440,6 +1440,107 @@ describe("server file manager wire shapes", () => {
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(FakeXHR.last).toBeUndefined();
|
||||
});
|
||||
|
||||
const session = { id: "s1", path: "worlds/big world.zip", size: 100_000_000, received: 0, part_max_bytes: 33_554_432 };
|
||||
const op = {
|
||||
id: "op1",
|
||||
op: "upload",
|
||||
path: "worlds/big world.zip",
|
||||
state: "running",
|
||||
started_at: "2026-09-28T00:00:00Z",
|
||||
done: 0,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
it("beginServerFileUpload POSTs the size, with the path in the query", async () => {
|
||||
const fetchSpy = fakeFetch(session);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
expect(await api.beginServerFileUpload("survival", "worlds/big world.zip", 100_000_000)).toEqual(session);
|
||||
const [url, opts] = sent(fetchSpy);
|
||||
expect(url).toBe("/servers/survival/files/uploads?path=worlds%2Fbig%20world.zip");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.body).toBe(JSON.stringify({ size: 100_000_000 }));
|
||||
});
|
||||
|
||||
it("getServerFileUpload and deleteServerFileUpload name the session in the path", async () => {
|
||||
const fetchSpy = fakeFetch(session);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
expect(await api.getServerFileUpload("survival", "s1")).toEqual(session);
|
||||
await api.deleteServerFileUpload("survival", "s1");
|
||||
const calls = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls.map(([u, o]) => [
|
||||
String(u),
|
||||
(o as RequestInit).method,
|
||||
]);
|
||||
expect(calls).toEqual([
|
||||
["/servers/survival/files/uploads/s1", "GET"],
|
||||
["/servers/survival/files/uploads/s1", "DELETE"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("putServerFileUploadPart PUTs the part's raw bytes at its offset and reports progress", async () => {
|
||||
const part = new Blob(["part bytes"]);
|
||||
const seen: number[] = [];
|
||||
const done = api.putServerFileUploadPart("survival", "s1", 33_554_432, part, { onProgress: (n) => seen.push(n) });
|
||||
const xhr = await sentXHR();
|
||||
expect(xhr.method).toBe("PUT");
|
||||
expect(xhr.url).toBe("/servers/survival/files/uploads/s1?offset=33554432");
|
||||
expect(xhr.withCredentials).toBe(true);
|
||||
expect(xhr.body).toBe(part);
|
||||
xhr.upload.onprogress?.({ loaded: 3 });
|
||||
xhr.respond(200, JSON.stringify({ ...session, received: 33_554_442 }));
|
||||
expect(await done).toEqual({ ...session, received: 33_554_442 });
|
||||
expect(seen).toEqual([3]);
|
||||
});
|
||||
|
||||
it("commitServerFileUpload POSTs overwrite and answers the op landing the file", async () => {
|
||||
const fetchSpy = fakeFetch({ op });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
expect(await api.commitServerFileUpload("survival", "s1", true)).toEqual({ op });
|
||||
const [url, opts] = sent(fetchSpy);
|
||||
expect(url).toBe("/servers/survival/files/uploads/s1/commit");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.body).toBe(JSON.stringify({ overwrite: true }));
|
||||
});
|
||||
|
||||
it("unzipServerFile POSTs overwrite with the archive in the query", async () => {
|
||||
const unzip = { ...op, op: "unzip", path: "maps/Spawn 2.zip" };
|
||||
const fetchSpy = fakeFetch({ op: unzip });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
expect(await api.unzipServerFile("survival", "maps/Spawn 2.zip", false)).toEqual({ op: unzip });
|
||||
const [url, opts] = sent(fetchSpy);
|
||||
expect(url).toBe("/servers/survival/files/unzip?path=maps%2FSpawn%202.zip");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.body).toBe(JSON.stringify({ overwrite: false }));
|
||||
});
|
||||
|
||||
it("listServerFileOps GETs the server's ops", async () => {
|
||||
const fetchSpy = fakeFetch({ ops: [op] });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
expect(await api.listServerFileOps("survival")).toEqual({ ops: [op] });
|
||||
const [url, opts] = sent(fetchSpy);
|
||||
expect(url).toBe("/servers/survival/files/ops");
|
||||
expect(opts.method).toBe("GET");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[false, "server.properties", "/servers/survival/files/download?path=server.properties&dir=false"],
|
||||
[true, "world/data", "/servers/survival/files/download?path=world%2Fdata&dir=true"],
|
||||
])("downloadServerFile POSTs the path and whether it is a folder (dir=%s)", async (dir, path, want) => {
|
||||
const ticket = { ticket: "t1", state: "pending", filename: "x" };
|
||||
const fetchSpy = fakeFetch(ticket);
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
expect(await api.downloadServerFile("survival", path, dir)).toEqual(ticket);
|
||||
const [url, opts] = sent(fetchSpy);
|
||||
expect(url).toBe(want);
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(opts.body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("words the upload session codes in the panel's own copy", () => {
|
||||
expect(humanizeError({ status: 404, code: "upload_not_found", message: "raw" })).toMatch(/^This upload is gone/);
|
||||
expect(humanizeError({ status: 429, code: "too_many_uploads", message: "raw" })).toMatch(/4 large uploads in progress/);
|
||||
expect(humanizeError({ status: 0, code: "op_lost", message: "" })).toMatch(/progress can no longer be read/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scheduled task wire shapes", () => {
|
||||
|
||||
+75
-2
@@ -12,6 +12,8 @@ import type {
|
||||
CreateUserRequest,
|
||||
ExportStatus,
|
||||
ExportTicket,
|
||||
FileOp,
|
||||
FileUploadSession,
|
||||
FleetServer,
|
||||
Identity,
|
||||
KickResult,
|
||||
@@ -748,9 +750,11 @@ export const api = rejectingSync({
|
||||
// volume is RWO), so callers gate on phase === "Stopped". The path travels as a
|
||||
// query parameter — a file path contains "/" and never round-trips through a
|
||||
// path segment. Content is []byte on the wire, which Go's encoding/json renders
|
||||
// as base64, so it is binary-safe in both directions.
|
||||
// as base64, so it is binary-safe in both directions. A listing also says how
|
||||
// much room the world volume has (free_bytes), so an upload too big for it is
|
||||
// refused before it is sent.
|
||||
listServerFiles: (name: string, path: string) =>
|
||||
request<{ path: string; entries: ServerFileEntry[]; truncated: boolean }>(
|
||||
request<{ path: string; entries: ServerFileEntry[]; truncated: boolean; free_bytes: number }>(
|
||||
"GET",
|
||||
urlPath`/servers/${name}/files` + `?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
@@ -829,6 +833,66 @@ export const api = rejectingSync({
|
||||
opts,
|
||||
),
|
||||
|
||||
// A file too big for one request goes up in parts (components/files/
|
||||
// sessionUpload.ts drives it): begin a session for its path and size, which
|
||||
// reserves room for all of it; put each part at its byte offset; then commit,
|
||||
// which answers at once with the op landing it (watch listServerFileOps). A
|
||||
// session answers only the account and server it was begun for, stays until
|
||||
// its file has been fetched whole once, and is dropped after 6 hours idle.
|
||||
beginServerFileUpload: (name: string, path: string, size: number) =>
|
||||
request<FileUploadSession>(
|
||||
"POST",
|
||||
urlPath`/servers/${name}/files/uploads` + `?path=${encodeURIComponent(path)}`,
|
||||
{ size },
|
||||
),
|
||||
|
||||
getServerFileUpload: (name: string, id: string) =>
|
||||
request<FileUploadSession>("GET", urlPath`/servers/${name}/files/uploads/${id}`),
|
||||
|
||||
putServerFileUploadPart: (
|
||||
name: string,
|
||||
id: string,
|
||||
offset: number,
|
||||
part: Blob,
|
||||
opts?: { onProgress?: (sent: number) => void; signal?: AbortSignal },
|
||||
) =>
|
||||
sendWithProgress<FileUploadSession>(
|
||||
"PUT",
|
||||
urlPath`/servers/${name}/files/uploads/${id}` + `?offset=${offset}`,
|
||||
part,
|
||||
opts,
|
||||
),
|
||||
|
||||
deleteServerFileUpload: (name: string, id: string) =>
|
||||
request<null>("DELETE", urlPath`/servers/${name}/files/uploads/${id}`),
|
||||
|
||||
commitServerFileUpload: (name: string, id: string, overwrite: boolean) =>
|
||||
request<{ op: FileOp }>("POST", urlPath`/servers/${name}/files/uploads/${id}/commit`, { overwrite }),
|
||||
|
||||
// unzipServerFile extracts a .zip into the folder holding it, in the
|
||||
// background. Without overwrite an archive that would replace files fails
|
||||
// file_exists and lists them, to be confirmed and run again with overwrite.
|
||||
unzipServerFile: (name: string, path: string, overwrite: boolean) =>
|
||||
request<{ op: FileOp }>(
|
||||
"POST",
|
||||
urlPath`/servers/${name}/files/unzip` + `?path=${encodeURIComponent(path)}`,
|
||||
{ overwrite },
|
||||
),
|
||||
|
||||
// listServerFileOps is the server's running op, if any, and those that ended
|
||||
// within the last 30 minutes, newest first.
|
||||
listServerFileOps: (name: string) =>
|
||||
request<{ ops: FileOp[] }>("GET", urlPath`/servers/${name}/files/ops`),
|
||||
|
||||
// downloadServerFile starts an export of one file, or of a folder as a zip,
|
||||
// and answers its ticket (exportStatus, then exportDownloadURL). The world is
|
||||
// held until the download ends.
|
||||
downloadServerFile: (name: string, path: string, dir: boolean) =>
|
||||
request<ExportTicket>(
|
||||
"POST",
|
||||
urlPath`/servers/${name}/files/download` + `?path=${encodeURIComponent(path)}&dir=${dir}`,
|
||||
),
|
||||
|
||||
// Account linking (spec §10). Both are POST: start reports status from the
|
||||
// session principal (no body, side-effect-free), verify consumes a code the
|
||||
// player was shown in-game. The panel can never mint a code — that is the
|
||||
@@ -1307,6 +1371,15 @@ export function humanizeError(e: unknown): string {
|
||||
return t("upload_incomplete");
|
||||
case "length_required":
|
||||
return t("length_required");
|
||||
// An upload sent in parts: the session is gone (cancelled, landed, idle for
|
||||
// 6 hours, or felis-api restarted), or the account holds four already.
|
||||
case "upload_not_found":
|
||||
return t("upload_not_found");
|
||||
case "too_many_uploads":
|
||||
return t("too_many_uploads");
|
||||
// Client-side: the op being watched dropped out of the ops list.
|
||||
case "op_lost":
|
||||
return t("op_lost");
|
||||
case "jobs_unavailable":
|
||||
return t("jobs_unavailable");
|
||||
// Builds, uploads and review: terminal-state conflicts and unwired subsystems.
|
||||
|
||||
@@ -48,7 +48,8 @@ export function retryDelay(n: number): number {
|
||||
return Math.min(1000 * 2 ** (n - 1), 15000);
|
||||
}
|
||||
|
||||
function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
/** wait pauses ms, or rejects with an AbortError as soon as signal aborts. */
|
||||
export function wait(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("The upload was cancelled", "AbortError"));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { api } from "./api";
|
||||
import type { ExportStatus } from "./types";
|
||||
|
||||
/** How often a ticket is read while its export Job gets the archive ready. */
|
||||
export const EXPORT_POLL_MS = 2000;
|
||||
|
||||
const pause = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** awaitExport reads a download's ticket until felis-api holds the archive
|
||||
* ("ready") or its Job gave up ("failed"), and returns that answer; null once
|
||||
* `alive` says the page asking has gone (the export is then abandoned: nobody
|
||||
* fetches it). A ready ticket waits 90 s for the browser, so the caller opens
|
||||
* it at once. A pending ticket ends on its own (410 export_expired after 10
|
||||
* min), which bounds the wait. */
|
||||
export async function awaitExport(
|
||||
ticket: string,
|
||||
alive: () => boolean,
|
||||
sleep: (ms: number) => Promise<void> = pause,
|
||||
): Promise<ExportStatus | null> {
|
||||
for (;;) {
|
||||
await sleep(EXPORT_POLL_MS);
|
||||
if (!alive()) return null;
|
||||
const s = await api.exportStatus(ticket);
|
||||
if (s.state !== "pending") return s;
|
||||
}
|
||||
}
|
||||
|
||||
/** saveDownload hands a ready export to the browser the way a link would: a
|
||||
* hidden <a download> it clicks once. felis-api answers with an attachment, so
|
||||
* the browser's own download manager streams the archive to disk; reading it
|
||||
* into a Blob first would hold a whole world in the tab's memory. */
|
||||
export function saveDownload(url: string, filename: string) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.rel = "noopener";
|
||||
a.hidden = true;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
@@ -148,7 +148,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* Stream one staged file upload to the Job landing it (one-time bearer token).
|
||||
* @description PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk and creates a Job to land it in the world volume; the Job fetches the bytes here. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the upload is the whole check. The token opens its upload once. An unknown id, a wrong or missing token and a spent token are all the same 404, so the route says nothing about which uploads exist.
|
||||
* @description PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk and creates a Job to land it in the world volume; the Job fetches the bytes here. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the upload is the whole check. The token opens its upload once. An unknown id, a wrong or missing token and a spent token are all the same 404, so the route says nothing about which uploads exist. An upload session committed through POST …/files/uploads/{id}/commit is fetched here the same way, under the session id; it stays staged until it has been sent whole once, so a Job that failed before then can be committed again.
|
||||
*/
|
||||
get: operations["internalFileUpload"];
|
||||
put?: never;
|
||||
@@ -1285,7 +1285,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Start downloading one backup (owner-or-admin plus a former-owner match).
|
||||
* @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The archive is checked against the sha256 recorded when it was written as it streams; a mismatch aborts the download. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour.
|
||||
* @description Starts a Job that reads the archive from the backup store and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The Job checks the archive against the sha256 recorded when it was written as it streams; a mismatch cuts the download off short of its end. On the way out config/paper-global.yml (the cluster's forwarding secret) is left out and server.properties has its rcon.password redacted, so the download carries no Content-Length. A user gets 404 for a backup outside their scope, as their list never shows it. One export per user at a time, 2 across the install, 6 per user per hour.
|
||||
*/
|
||||
post: operations["exportBackup"];
|
||||
delete?: never;
|
||||
@@ -1305,7 +1305,7 @@ export interface paths {
|
||||
put?: never;
|
||||
/**
|
||||
* Start downloading a stopped server's world as it is now (owner-or-admin).
|
||||
* @description Starts a Job that archives the server's data volume, read-only, and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The server must be fully stopped, and it cannot start until the download has ended or the Job's 2 hour deadline passes. Same limits as a backup export.
|
||||
* @description Starts a Job that archives the server's data volume, read-only, and hands it to felis-api, which streams it to the browser (poll GET /exports/{ticket}, then open its download). The server must be fully stopped, and it cannot start until the download has ended or the Job's 2 hour deadline passes. The same two files are guarded as in a backup export, matched by the file itself, so a link to either under another name is guarded too. Same limits as a backup export.
|
||||
*/
|
||||
post: operations["exportWorld"];
|
||||
delete?: never;
|
||||
@@ -1462,6 +1462,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/files/download": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Start downloading one file or folder of a stopped server's world (owner-or-admin).
|
||||
* @description An export (poll GET /exports/{ticket}, then open its download): a Job reads the file, or zips the folder, from the world volume read-only and hands it to felis-api, which streams it to the browser. A file saves under its own name with its length; a folder as NAME.zip, streamed without one, with symbolic links, devices and sockets left out. config/paper-global.yml, the cluster's forwarding secret, is refused as a file and left out of a folder, and server.properties goes out with its rcon.password redacted; both are matched by the file itself, so a link to either under another name is guarded too. The server cannot start until the download has ended. Two file downloads per user at a time, 4 across the install, 30 per user per hour, counted apart from world and backup exports. Audited as file.download.
|
||||
*/
|
||||
post: operations["downloadServerFile"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/files/upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1472,7 +1492,7 @@ export interface paths {
|
||||
get?: never;
|
||||
/**
|
||||
* Upload a file into a server's world volume (owner-or-admin; server must be stopped).
|
||||
* @description Lands the raw request body as the file at path, up to 64 MiB — a plugin jar, a datapack, a world region. Content-Length is required (411 length_required). An existing file is 409 file_exists unless overwrite=true; a folder at the path is 400 bad_path either way. The body is staged on felis-api's disk first and then fetched by the file Job with a one-time token, so the world lock is taken only after the body has arrived and a slow upload holds off no backup. The file lands atomically: a synced temporary sibling is checked against the staged size and SHA-256, then renamed into place, so a failed upload leaves the old file whole. Same stopped-gate and os.Root containment as a write. Audited as file.upload.
|
||||
* @description Lands the raw request body as the file at path, up to 64 MiB — a plugin jar, a datapack, a world region; a bigger file goes up as an upload session (POST …/files/uploads). Content-Length is required (411 length_required). An existing file is 409 file_exists unless overwrite=true; a folder at the path is 400 bad_path either way. The body is staged on felis-api's disk first and then fetched by the file Job with a one-time token, so the world lock is taken only after the body has arrived and a slow upload holds off no backup. The file lands atomically: a synced temporary sibling is checked against the staged size and SHA-256, then renamed into place, so a failed upload leaves the old file whole. Same stopped-gate and os.Root containment as a write. Audited as file.upload.
|
||||
*/
|
||||
put: operations["uploadServerFile"];
|
||||
post?: never;
|
||||
@@ -1482,6 +1502,111 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/files/uploads": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Begin an upload session for a file too big for one request (owner-or-admin; server must be stopped).
|
||||
* @description A file of any size goes up in parts: this begins a session for path and the file's size, PUT …/uploads/{id}?offset= sends each part (at most part_max_bytes, 32 MiB, so each fits the edge's body limit), and POST …/uploads/{id}/commit lands it. There is no size ceiling but felis-api's staging disk, and room for the whole file is reserved here, so an upload that begins is one the disk can finish (507 upload_staging_full otherwise). A session belongs to the account and server it was begun for, answers no one else, and is dropped after 6 hours untouched. Four sessions per account at a time. Sessions do not survive a felis-api restart.
|
||||
*/
|
||||
post: operations["beginServerFileUpload"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/files/uploads/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Where an upload session stands (owner-or-admin, the account that began it).
|
||||
* @description received is where the next part starts: after a lost answer or a 409 upload_offset_mismatch, read it here and continue from there. Needs no stopped server.
|
||||
*/
|
||||
get: operations["getServerFileUpload"];
|
||||
/**
|
||||
* Send one part of an upload session (owner-or-admin, the account that began it).
|
||||
* @description The raw body is appended at offset, which must be where the session ends. Content-Length is required, and the part is taken whole or not at all: one cut short leaves the session where it was. Parts go one at a time (409 upload_busy while one arrives). Needs no stopped server, so starting the server midway costs only the commit's refusal until it is stopped again.
|
||||
*/
|
||||
put: operations["putServerFileUploadPart"];
|
||||
post?: never;
|
||||
/** Cancel an upload session and free its room (owner-or-admin, the account that began it). */
|
||||
delete: operations["deleteServerFileUpload"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/files/uploads/{id}/commit": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Land a finished upload session in the world volume (owner-or-admin; server must be stopped).
|
||||
* @description Starts the Job that fetches the session's bytes from felis-api and lands them at its path, checked against their size and SHA-256 and renamed into place, so a failed landing leaves the old file whole. It answers at once with the op; GET …/files/ops reports how it ends (file_exists when a file is at the path and overwrite is not true). The Job holds the world volume while it runs, so the server cannot start meanwhile. A Job that fails before it has every byte leaves the session to commit again; once the bytes have gone to the Job the session is gone. Audited as file.upload.
|
||||
*/
|
||||
post: operations["commitServerFileUpload"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/files/unzip": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Extract a .zip into the folder holding it (owner-or-admin; server must be stopped).
|
||||
* @description Starts a Job that extracts the archive into a temporary folder beside it and moves the result into place, and answers at once with the op; GET …/files/ops reports how it ends. Nothing changes unless every entry is safe: an entry leaving the folder, an absolute path, or a link ends archive_unsafe or archive_symlink; an entry whose size differs from what the archive declares ends archive_invalid; a file where the archive has a folder, or the reverse, ends type_conflict. Without overwrite an archive that would replace any file ends file_exists with the files it would replace, for the caller to confirm and run again with overwrite. Names stored in GBK, as Windows zips in a Chinese locale have them, are read as such. The Job holds the world volume while it runs. Audited as file.unzip.
|
||||
*/
|
||||
post: operations["unzipServerFile"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/files/ops": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* A server's background uploads and extractions (owner-or-admin).
|
||||
* @description Newest first: the one running, if any, and those that ended within the last 30 minutes, at most 10. Needs no stopped server.
|
||||
*/
|
||||
get: operations["listServerFileOps"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/schedules": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2908,6 +3033,88 @@ export interface components {
|
||||
/** @description What the download saves as. */
|
||||
filename: string;
|
||||
};
|
||||
/** @description Where an upload session stands (internal/api/handlers_fileops.go fileSessionView). */
|
||||
FileUploadSession: {
|
||||
/** @description 32 hex characters. */
|
||||
id: string;
|
||||
/** @description Where the file lands */
|
||||
path: string;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description The file's length.
|
||||
*/
|
||||
size: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description Bytes here so far; the next part starts here.
|
||||
*/
|
||||
received: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description The most one part may carry.
|
||||
*/
|
||||
part_max_bytes: number;
|
||||
};
|
||||
StartFileOp: {
|
||||
/** @description Replace files already there. */
|
||||
overwrite?: boolean;
|
||||
};
|
||||
/** @description One background upload or extraction (internal/api/handlers_fileops.go fileOpView). */
|
||||
FileOp: {
|
||||
id: string;
|
||||
/** @enum {string} */
|
||||
op: "upload" | "unzip";
|
||||
/** @description The file landed */
|
||||
path: string;
|
||||
/** @enum {string} */
|
||||
state: "running" | "succeeded" | "failed";
|
||||
/** Format: date-time */
|
||||
started_at: string;
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description Omitted while it runs.
|
||||
*/
|
||||
finished_at?: string;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description Bytes landed or extracted so far; 0 before the first report.
|
||||
*/
|
||||
done: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description Bytes in all; 0 before the first report.
|
||||
*/
|
||||
total: number;
|
||||
/** @description Files an extraction wrote. Omitted otherwise. */
|
||||
files?: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description Bytes an extraction wrote. Omitted otherwise.
|
||||
*/
|
||||
bytes?: number;
|
||||
error?: components["schemas"]["FileOpError"];
|
||||
};
|
||||
/** @description Why an op failed (internal/api/handlers_fileops.go fileOpError). code is what the synchronous file routes answer for the same refusal (file_exists, volume_full, file_changed, not_found, bad_path), an extraction's own (archive_invalid, archive_unsafe, archive_symlink, type_conflict), or job_failed for a Job that ended without saying why. */
|
||||
FileOpError: {
|
||||
code: string;
|
||||
message: string;
|
||||
/** @description The archive entry refused */
|
||||
entry?: string;
|
||||
/** @description On file_exists from an extraction, the first 200 files it would replace, sorted. */
|
||||
conflicts?: string[];
|
||||
/** @description How many files it would replace in all. */
|
||||
conflict_count?: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description On volume_full
|
||||
*/
|
||||
need?: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description On volume_full
|
||||
*/
|
||||
avail?: number;
|
||||
};
|
||||
/** @description Where an export stands (internal/api/exports.go exportStatusView). */
|
||||
ExportStatus: {
|
||||
/**
|
||||
@@ -6460,7 +6667,7 @@ export interface operations {
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The tar.gz, as an attachment. */
|
||||
/** @description The export as an attachment: a world or a backup as a tar.gz, a downloaded folder as a zip, a downloaded file as its bytes. */
|
||||
200: {
|
||||
headers: {
|
||||
"Content-Disposition"?: string;
|
||||
@@ -6468,6 +6675,8 @@ export interface operations {
|
||||
};
|
||||
content: {
|
||||
"application/gzip": string;
|
||||
"application/zip": string;
|
||||
"application/octet-stream": string;
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
@@ -6523,7 +6732,7 @@ export interface operations {
|
||||
jobs: {
|
||||
name: string;
|
||||
/** @enum {string} */
|
||||
kind: "backup" | "restore" | "export_world" | "export_backup";
|
||||
kind: "backup" | "restore" | "export_world" | "export_backup" | "export_files";
|
||||
/** @enum {string} */
|
||||
state: "running" | "succeeded" | "failed";
|
||||
message?: string;
|
||||
@@ -6587,6 +6796,11 @@ export interface operations {
|
||||
path: string;
|
||||
/** @description The listing hit the entry cap and is incomplete. */
|
||||
truncated: boolean;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description Bytes free on the world volume
|
||||
*/
|
||||
free_bytes: number;
|
||||
entries: {
|
||||
name: string;
|
||||
/** Format: int64 */
|
||||
@@ -7036,6 +7250,73 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
downloadServerFile: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description File or folder to download, relative to the world root. The root itself is refused. */
|
||||
path: string;
|
||||
/** @description true when path is a folder, which is sent as a zip. The Job refuses a path that is not what dir says. */
|
||||
dir?: "true" | "false";
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Download started. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ExportTicket"];
|
||||
};
|
||||
};
|
||||
/** @description Missing path (bad_request), the world root (bad_path), or a malformed server name (bad_name). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Unknown server. */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Server is not stopped (not_stopped), has no world volume yet (no_world_volume), or a restore, backup, file change or another export already holds its world volume (maintenance_in_progress). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description A file download limit is reached (export_busy); Retry-After gives the seconds to wait. */
|
||||
429: {
|
||||
headers: {
|
||||
"Retry-After"?: number;
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
uploadServerFile: {
|
||||
parameters: {
|
||||
query: {
|
||||
@@ -7114,7 +7395,7 @@ export interface operations {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description The file is over 64 MiB (too_large). */
|
||||
/** @description The file is over 64 MiB, the most one request carries (too_large); send it as an upload session instead. */
|
||||
413: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -7144,6 +7425,436 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
beginServerFileUpload: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description File to create, relative to the world root. It must stay inside it (400 bad_path); its folder is checked when the file lands. */
|
||||
path: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
/**
|
||||
* Format: int64
|
||||
* @description The file's length in bytes.
|
||||
*/
|
||||
size: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Session begun. */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["FileUploadSession"];
|
||||
};
|
||||
};
|
||||
/** @description Missing path or size, or a negative size (bad_request), a path leaving the world folder or naming the folder itself (bad_path), or a malformed server name (bad_name). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Unknown server. */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Server is not stopped (not_stopped) or has no world volume yet (no_world_volume). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description The account already has 4 uploads in progress (too_many_uploads). */
|
||||
429: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
/** @description felis-api's staging disk has no room for a file this size right now (upload_staging_full). */
|
||||
507: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getServerFileUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The session. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["FileUploadSession"];
|
||||
};
|
||||
};
|
||||
/** @description Malformed server name (bad_name). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Unknown server, or no such session for this account on this server (upload_not_found). */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
putServerFileUploadPart: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description The byte position the part starts at, the session's received. */
|
||||
offset: number;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/octet-stream": string;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Part taken. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["FileUploadSession"];
|
||||
};
|
||||
};
|
||||
/** @description A missing or malformed offset (bad_request), a body that ended before its Content-Length (upload_incomplete), or a malformed server name (bad_name). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Unknown server, or no such session for this account on this server (upload_not_found). */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description offset is not where the session ends (upload_offset_mismatch), or another part is still arriving (upload_busy). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description The request has no Content-Length (length_required). */
|
||||
411: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description The part is over part_max_bytes, or runs past the size the session began with (part_too_large). */
|
||||
413: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
/** @description felis-api's staging disk ran out of room (upload_staging_full). */
|
||||
507: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
deleteServerFileUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Cancelled. */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @description Malformed server name (bad_name). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Unknown server, or no such session for this account on this server (upload_not_found). */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description A part is still arriving (upload_busy). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
commitServerFileUpload: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["StartFileOp"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Landing started. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
op: components["schemas"]["FileOp"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Malformed body, or a malformed server name (bad_name). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Unknown server, or no such session for this account on this server (upload_not_found). */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Not every byte has arrived (upload_incomplete; the world lock is not asked for), a part is still arriving (upload_busy), the server is not stopped (not_stopped) or has no world volume yet (no_world_volume), or a restore, backup, file change or export already holds its world volume, this session's earlier commit included (maintenance_in_progress). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
unzipServerFile: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description The .zip to extract, relative to the world root. */
|
||||
path: string;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["StartFileOp"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Extraction started. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
op: components["schemas"]["FileOp"];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Missing path or malformed body (bad_request), a path not ending in .zip (bad_path), or a malformed server name (bad_name). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Unknown server. */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Server is not stopped (not_stopped), has no world volume yet (no_world_volume), or a restore, backup, file change or export already holds its world volume (maintenance_in_progress). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
listServerFileOps: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The ops. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
ops: components["schemas"]["FileOp"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Malformed server name (bad_name). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
/** @description Unknown server. */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
};
|
||||
listServerSchedules: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -41,6 +41,9 @@ export type WireParity = [
|
||||
Holds<Parity<T.BackupView, S["BackupView"]>>,
|
||||
Holds<Parity<T.ExportTicket, S["ExportTicket"]>>,
|
||||
Holds<Parity<T.ExportStatus, S["ExportStatus"]>>,
|
||||
Holds<Parity<T.FileUploadSession, S["FileUploadSession"]>>,
|
||||
Holds<Parity<T.FileOp, S["FileOp"]>>,
|
||||
Holds<Parity<T.FileOpError, S["FileOpError"]>>,
|
||||
Holds<Parity<T.Build, S["Build"]>>,
|
||||
Holds<Parity<T.BuildScan, S["BuildScan"]>>,
|
||||
Holds<Parity<T.ScanSummary, S["ScanSummary"]>>,
|
||||
|
||||
+45
-3
@@ -229,11 +229,11 @@ export interface BackupView {
|
||||
/** ServerJob is one row of GET /api/v1/servers/{name}/jobs — the observable
|
||||
* outcome of an async backup/restore/export Job. The API only enqueues Jobs, so
|
||||
* this projection is how a 202 that later failed becomes visible in the panel.
|
||||
* An export_world Job holds the world until its download ends; an
|
||||
* export_backup Job only reads the backup store. */
|
||||
* An export_world or export_files Job holds the world until its download
|
||||
* ends; an export_backup Job only reads the backup store. */
|
||||
export interface ServerJob {
|
||||
name: string;
|
||||
kind: string; // "backup" | "restore" | "export_world" | "export_backup"
|
||||
kind: string; // "backup" | "restore" | "export_world" | "export_backup" | "export_files"
|
||||
state: string; // "running" | "succeeded" | "failed"
|
||||
message?: string;
|
||||
started_at?: string;
|
||||
@@ -418,6 +418,48 @@ export interface ServerFileEntry {
|
||||
mod_time: string;
|
||||
}
|
||||
|
||||
/** FileUploadSession is where an upload sent in parts stands
|
||||
* (internal/api/handlers_fileops.go fileSessionView): the next part starts at
|
||||
* `received` and carries at most `part_max_bytes`. */
|
||||
export interface FileUploadSession {
|
||||
id: string;
|
||||
path: string;
|
||||
size: number;
|
||||
received: number;
|
||||
part_max_bytes: number;
|
||||
}
|
||||
|
||||
/** FileOp is one background upload landing or extraction
|
||||
* (handlers_fileops.go fileOpView). `done` and `total` are bytes, both 0 until
|
||||
* the Job first reports; `files` and `bytes` are what an extraction wrote. */
|
||||
export interface FileOp {
|
||||
id: string;
|
||||
op: "upload" | "unzip";
|
||||
path: string;
|
||||
state: "running" | "succeeded" | "failed";
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
done: number;
|
||||
total: number;
|
||||
files?: number;
|
||||
bytes?: number;
|
||||
error?: FileOpError;
|
||||
}
|
||||
|
||||
/** FileOpError is why an op failed (handlers_fileops.go fileOpError). On
|
||||
* file_exists from an extraction, `conflicts` lists the first 200 files it
|
||||
* would replace and `conflict_count` all of them; on volume_full, `need` and
|
||||
* `avail` are bytes. */
|
||||
export interface FileOpError {
|
||||
code: string;
|
||||
message: string;
|
||||
entry?: string;
|
||||
conflicts?: string[];
|
||||
conflict_count?: number;
|
||||
need?: number;
|
||||
avail?: number;
|
||||
}
|
||||
|
||||
/** Build mirrors an image_builds row (spec §6, §16). */
|
||||
export interface Build {
|
||||
id: string;
|
||||
|
||||
@@ -108,6 +108,27 @@ describe("ServerBackups", () => {
|
||||
expect(ops.map((el) => el.textContent)).toEqual(["Scheduled backup", "Backup"]);
|
||||
});
|
||||
|
||||
it("names each kind of recent operation, with a download icon on the downloads", async () => {
|
||||
calls.serverJobs.mockResolvedValue([
|
||||
{ name: "restore-survival-aa", kind: "restore", state: "succeeded" },
|
||||
{ name: "backup-survival-bb", kind: "backup", state: "succeeded", then_restore: "done" },
|
||||
{ name: "export-survival-cc", kind: "export_world", state: "succeeded" },
|
||||
{ name: "export-survival-dd", kind: "export_backup", state: "failed" },
|
||||
{ name: "files-survival-ee", kind: "export_files", state: "succeeded" },
|
||||
]);
|
||||
renderPage();
|
||||
await screen.findByText("File download", { selector: "li span" });
|
||||
|
||||
const rows = screen.getAllByRole("listitem").filter((li) => li.querySelector("span.text-sm"));
|
||||
expect(rows.map((li) => [li.querySelector("span.text-sm")?.textContent, li.querySelector("svg")?.classList.contains("lucide-download")])).toEqual([
|
||||
["Restore", false],
|
||||
["Pre-restore snapshot", false],
|
||||
["World export", true],
|
||||
["Backup download", true],
|
||||
["File download", true],
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the archive the reaper leaves when a server is given up or deleted", async () => {
|
||||
calls.listBackups.mockResolvedValue({ backups: [{ ...backup("bk-rel", 4), reason: "released" }], total: 1 });
|
||||
renderPage();
|
||||
|
||||
@@ -35,6 +35,7 @@ import { Loading, ErrorState, EmptyState, NotYours, RefreshError } from "@/compo
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Pagination } from "@/components/Pagination";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { awaitExport, saveDownload } from "@/lib/download";
|
||||
import { useAsync, usePolling, STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { canManage, ownershipPending } from "@/lib/ownership";
|
||||
@@ -231,21 +232,6 @@ function DownloadBackupButton({
|
||||
);
|
||||
}
|
||||
|
||||
/** saveDownload hands a ready export to the browser the way a link would: a
|
||||
* hidden <a download> it clicks once. felis-api answers with an attachment, so
|
||||
* the browser's own download manager streams the archive to disk; reading it
|
||||
* into a Blob first would hold a whole world in the tab's memory. */
|
||||
function saveDownload(url: string, filename: string) {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.rel = "noopener";
|
||||
a.hidden = true;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
/** DeleteBackupButton deletes one backup behind a confirm that says what goes: the
|
||||
* row leaves the list at once and can no longer be restored, and the archive and
|
||||
* its off-site copy follow at the next cleanup and sync. A reclaimed or released
|
||||
@@ -470,9 +456,6 @@ const POLL_MS = 2500;
|
||||
// them in game and holds the stop 30 s, then the pre-stop save and the pod's own
|
||||
// shutdown save follow.
|
||||
const MAX_POLLS = 48;
|
||||
// How often an export being prepared rereads its ticket: the Job needs a few
|
||||
// seconds to start, and a ready ticket waits 90 s for the browser.
|
||||
const EXPORT_POLL_MS = 2000;
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function RestoreControls({
|
||||
@@ -746,11 +729,9 @@ export function ServerBackups() {
|
||||
}, []);
|
||||
|
||||
// A download is prepared before it starts: the POST answers a one-time ticket,
|
||||
// an export Job hands the archive to felis-api, and this reads the ticket
|
||||
// until felis-api holds it. Only then does the browser fetch it, as a link, so
|
||||
// any size of world streams straight to disk. A ready ticket waits 90 s for
|
||||
// that fetch, so it follows at once. A pending ticket ends on its own (410
|
||||
// export_expired after 10 min), which bounds the wait.
|
||||
// an export Job hands the archive to felis-api, and awaitExport reads the
|
||||
// ticket until felis-api holds it. Only then does the browser fetch it, as a
|
||||
// link, so any size of world streams straight to disk.
|
||||
async function runExport(target: ExportTarget) {
|
||||
if (exportRun.current) return;
|
||||
exportRun.current = true;
|
||||
@@ -760,18 +741,14 @@ export function ServerBackups() {
|
||||
const tk =
|
||||
target.kind === "world" ? await api.startWorldExport(name) : await api.startBackupExport(name, target.id);
|
||||
jobsQ.reload(); // a world export now holds the world
|
||||
for (;;) {
|
||||
await sleep(EXPORT_POLL_MS);
|
||||
if (!alive.current) return;
|
||||
const s = await api.exportStatus(tk.ticket);
|
||||
if (s.state === "ready") break;
|
||||
if (s.state === "failed") {
|
||||
setBackupMsg({
|
||||
kind: "error",
|
||||
text: s.message ? t("export_failed_because", { reason: s.message }) : t("export_failed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const s = await awaitExport(tk.ticket, () => alive.current);
|
||||
if (s === null) return;
|
||||
if (s.state === "failed") {
|
||||
setBackupMsg({
|
||||
kind: "error",
|
||||
text: s.message ? t("export_failed_because", { reason: s.message }) : t("export_failed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveDownload(await api.exportDownloadURL(tk.ticket), tk.filename);
|
||||
setBackupMsg({
|
||||
@@ -1000,7 +977,7 @@ export function ServerBackups() {
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{j.kind === "restore" ? (
|
||||
<RotateCcw className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
||||
) : j.kind === "export_world" || j.kind === "export_backup" ? (
|
||||
) : j.kind === "export_world" || j.kind === "export_backup" || j.kind === "export_files" ? (
|
||||
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
||||
) : (
|
||||
<Archive className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
||||
@@ -1018,6 +995,8 @@ export function ServerBackups() {
|
||||
? t("job_export_world")
|
||||
: j.kind === "export_backup"
|
||||
? t("job_export_backup")
|
||||
: j.kind === "export_files"
|
||||
? t("job_export_files")
|
||||
: j.kind}
|
||||
</span>
|
||||
{at && (
|
||||
|
||||
@@ -6,8 +6,11 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import i18next from "i18next";
|
||||
import { ServerFiles } from "./ServerFiles";
|
||||
import { humanizeError } from "@/lib/api";
|
||||
import { MAX_UPLOAD_BYTES } from "@/components/files/useUploads";
|
||||
import { ONE_REQUEST_BYTES } from "@/components/files/useUploads";
|
||||
import { OP_POLL_MS } from "@/components/files/sessionUpload";
|
||||
import { EXPORT_POLL_MS } from "@/lib/download";
|
||||
import { STATUS_POLL_FAST_MS } from "@/lib/hooks";
|
||||
import type { FileOp } from "@/lib/types";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
writeServerFile: vi.fn(),
|
||||
@@ -20,6 +23,16 @@ const mocks = vi.hoisted(() => ({
|
||||
mkdirServerFolder: vi.fn(),
|
||||
renameServerFile: vi.fn(),
|
||||
uploadServerFile: vi.fn(),
|
||||
listServerFileOps: vi.fn(),
|
||||
unzipServerFile: vi.fn(),
|
||||
downloadServerFile: vi.fn(),
|
||||
exportStatus: vi.fn(),
|
||||
exportDownloadURL: vi.fn(),
|
||||
beginServerFileUpload: vi.fn(),
|
||||
getServerFileUpload: vi.fn(),
|
||||
putServerFileUploadPart: vi.fn(),
|
||||
deleteServerFileUpload: vi.fn(),
|
||||
commitServerFileUpload: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", async (importOriginal) => {
|
||||
@@ -38,6 +51,16 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
||||
mkdirServerFolder: mocks.mkdirServerFolder,
|
||||
renameServerFile: mocks.renameServerFile,
|
||||
uploadServerFile: mocks.uploadServerFile,
|
||||
listServerFileOps: mocks.listServerFileOps,
|
||||
unzipServerFile: mocks.unzipServerFile,
|
||||
downloadServerFile: mocks.downloadServerFile,
|
||||
exportStatus: mocks.exportStatus,
|
||||
exportDownloadURL: mocks.exportDownloadURL,
|
||||
beginServerFileUpload: mocks.beginServerFileUpload,
|
||||
getServerFileUpload: mocks.getServerFileUpload,
|
||||
putServerFileUploadPart: mocks.putServerFileUploadPart,
|
||||
deleteServerFileUpload: mocks.deleteServerFileUpload,
|
||||
commitServerFileUpload: mocks.commitServerFileUpload,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -62,6 +85,18 @@ async function openEditor() {
|
||||
return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
|
||||
}
|
||||
|
||||
let opsNow: FileOp[] = [];
|
||||
const fileOp = (over: Partial<FileOp> = {}): FileOp => ({
|
||||
id: "op1",
|
||||
op: "unzip",
|
||||
path: "pack.zip",
|
||||
state: "running",
|
||||
started_at: "2026-09-28T00:00:00Z",
|
||||
done: 0,
|
||||
total: 0,
|
||||
...over,
|
||||
});
|
||||
|
||||
const stopped = { name: "lobby", subdomain: "lobby", phase: "Stopped", desiredState: "Stopped", ready: false };
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -84,6 +119,24 @@ beforeEach(() => {
|
||||
for (const m of [mocks.createServerFile, mocks.deleteServerFile, mocks.mkdirServerFolder, mocks.renameServerFile, mocks.uploadServerFile]) {
|
||||
m.mockReset();
|
||||
}
|
||||
for (const m of [
|
||||
mocks.unzipServerFile,
|
||||
mocks.downloadServerFile,
|
||||
mocks.exportStatus,
|
||||
mocks.exportDownloadURL,
|
||||
mocks.beginServerFileUpload,
|
||||
mocks.getServerFileUpload,
|
||||
mocks.putServerFileUploadPart,
|
||||
mocks.deleteServerFileUpload,
|
||||
mocks.commitServerFileUpload,
|
||||
]) {
|
||||
m.mockReset();
|
||||
}
|
||||
// The server's background ops, as each read finds them.
|
||||
opsNow = [];
|
||||
mocks.listServerFileOps.mockReset();
|
||||
mocks.listServerFileOps.mockImplementation(async () => ({ ops: opsNow }));
|
||||
localStorage.clear();
|
||||
mocks.stop.mockReset();
|
||||
mocks.status.mockReset();
|
||||
mocks.status.mockResolvedValue(stopped);
|
||||
@@ -621,20 +674,298 @@ describe("ServerFiles uploads", () => {
|
||||
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses a name a folder holds, and a file over the limit, without sending or offering a retry", async () => {
|
||||
const sized = (name: string, size: number) => {
|
||||
const f = file(name);
|
||||
Object.defineProperty(f, "size", { value: size });
|
||||
return f;
|
||||
};
|
||||
const withFree = (free: number) =>
|
||||
mocks.listServerFiles.mockImplementation((_name: string, path: string) =>
|
||||
Promise.resolve({
|
||||
path,
|
||||
truncated: false,
|
||||
free_bytes: free,
|
||||
entries: [{ name: "world", size: 0, is_dir: true, mod_time: "2026-09-01T00:00:00Z" }],
|
||||
}),
|
||||
);
|
||||
|
||||
it("refuses a name a folder holds without sending or offering a retry", async () => {
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
const big = file("world-backup.zip");
|
||||
Object.defineProperty(big, "size", { value: MAX_UPLOAD_BYTES + 1 });
|
||||
const exact = file("exact.zip");
|
||||
Object.defineProperty(exact, "size", { value: MAX_UPLOAD_BYTES });
|
||||
|
||||
pick(file("world"), big, exact);
|
||||
pick(file("world"));
|
||||
|
||||
expect(await within(queue()).findByText(t("files:upload_folder_there"))).toBeTruthy();
|
||||
expect(within(queue()).getByText(i18next.t("files:upload_too_large", { limit: "64 MiB" }))).toBeTruthy();
|
||||
expect(within(queue()).queryByRole("button", { name: t("files:upload_retry") })).toBeNull();
|
||||
await waitFor(() => expect(sentAs()).toEqual([["exact.zip", false]]));
|
||||
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses what the volume has no room for, counting the files ahead in the batch, and retries once there is room", async () => {
|
||||
withFree(100);
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
|
||||
pick(sized("a.jar", 60), sized("b.jar", 50), sized("c.jar", 40));
|
||||
|
||||
expect(await within(queue()).findByText(i18next.t("files:upload_no_room", { free: "40 B", size: "50 B" }))).toBeTruthy();
|
||||
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]]));
|
||||
await act(async () => pending[0].resolve());
|
||||
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false], ["c.jar", false]]));
|
||||
await act(async () => pending[1].resolve());
|
||||
await waitFor(() => expect(within(queue()).getAllByText(t("files:upload_done"))).toHaveLength(2));
|
||||
|
||||
// The listing after they landed still says 100 B free.
|
||||
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_retry") }));
|
||||
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false], ["c.jar", false], ["b.jar", false]]));
|
||||
});
|
||||
|
||||
it("sends when the listing could not tell how much room there is", async () => {
|
||||
withFree(0);
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
|
||||
pick(sized("a.jar", 60));
|
||||
|
||||
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]]));
|
||||
});
|
||||
|
||||
it("keeps a retry refused while the latest listing has no room for the file", async () => {
|
||||
withFree(10);
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
pick(sized("a.jar", 60));
|
||||
await within(queue()).findByText(i18next.t("files:upload_no_room", { free: "10 B", size: "60 B" }));
|
||||
|
||||
withFree(20);
|
||||
await userEvent.click(screen.getByRole("button", { name: t("files:refresh") }));
|
||||
await waitFor(() => expect(mocks.listServerFiles).toHaveBeenCalledTimes(2));
|
||||
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_retry") }));
|
||||
|
||||
expect(await within(queue()).findByText(i18next.t("files:upload_no_room", { free: "20 B", size: "60 B" }))).toBeTruthy();
|
||||
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a file of exactly one request's worth in one request", async () => {
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
|
||||
pick(sized("edge.zip", ONE_REQUEST_BYTES));
|
||||
|
||||
await waitFor(() => expect(sentAs()).toEqual([["edge.zip", false]]));
|
||||
expect(mocks.beginServerFileUpload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("a file over one request's worth", () => {
|
||||
const PART = 32 * 1024 * 1024;
|
||||
const SIZE = ONE_REQUEST_BYTES + 1;
|
||||
const KEY = "felis-file-upload:lobby:world.zip";
|
||||
const at = (received: number) => ({ id: "s1", path: "world.zip", size: SIZE, received, part_max_bytes: PART });
|
||||
let parts: { offset: number; signal?: AbortSignal }[];
|
||||
|
||||
beforeEach(() => {
|
||||
parts = [];
|
||||
mocks.beginServerFileUpload.mockResolvedValue(at(0));
|
||||
mocks.putServerFileUploadPart.mockImplementation(
|
||||
async (_n: string, _id: string, offset: number, _part: Blob, opts?: { signal?: AbortSignal }) => {
|
||||
parts.push({ offset, signal: opts?.signal });
|
||||
return at(Math.min(offset + PART, SIZE));
|
||||
},
|
||||
);
|
||||
mocks.commitServerFileUpload.mockResolvedValue({ op: fileOp({ id: "land1", op: "upload", path: "world.zip" }) });
|
||||
mocks.deleteServerFileUpload.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("goes up in parts, shows the Job writing it, and rereads once it lands", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
const lists = mocks.listServerFiles.mock.calls.length;
|
||||
|
||||
pick(sized("world.zip", SIZE));
|
||||
|
||||
await waitFor(() => expect(mocks.commitServerFileUpload.mock.calls).toEqual([["lobby", "s1", false]]));
|
||||
expect(parts.map((p) => p.offset)).toEqual([0, PART, 2 * PART]);
|
||||
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
|
||||
expect(await within(queue()).findByText(t("files:upload_landing"))).toBeTruthy();
|
||||
|
||||
opsNow = [fileOp({ id: "land1", op: "upload", path: "world.zip", done: SIZE / 4, total: SIZE })];
|
||||
await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
expect(within(queue()).getByText(i18next.t("files:upload_landing_progress", { percent: 25 }))).toBeTruthy();
|
||||
expect(within(queue()).getByRole("progressbar").getAttribute("aria-valuenow")).toBe("25");
|
||||
// Its own row follows it; the list of background operations leaves it out.
|
||||
fireEvent.click(screen.getByRole("button", { name: t("files:refresh") }));
|
||||
await waitFor(() => expect(mocks.listServerFileOps.mock.calls.length).toBeGreaterThanOrEqual(2));
|
||||
expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull();
|
||||
const before = mocks.listServerFiles.mock.calls.length;
|
||||
|
||||
opsNow = [fileOp({ id: "land1", op: "upload", path: "world.zip", state: "succeeded", done: SIZE, total: SIZE })];
|
||||
await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
|
||||
expect(await within(queue()).findByText(t("files:upload_done"))).toBeTruthy();
|
||||
await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(before + 1));
|
||||
expect(before).toBe(lists + 1); // the refresh
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("says why the Job landing it failed, and offers a retry", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
pick(sized("world.zip", SIZE));
|
||||
await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalled());
|
||||
|
||||
opsNow = [
|
||||
fileOp({
|
||||
id: "land1",
|
||||
op: "upload",
|
||||
path: "world.zip",
|
||||
state: "failed",
|
||||
error: { code: "volume_full", message: "", need: 2048, avail: 1024 },
|
||||
}),
|
||||
];
|
||||
await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
|
||||
expect(
|
||||
await within(queue()).findByText(i18next.t("files:op_volume_full", { need: "2.0 KiB", avail: "1.0 KiB" })),
|
||||
).toBeTruthy();
|
||||
expect(within(queue()).getByRole("button", { name: t("files:upload_retry") })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("asks to replace when the Job found a file there, and lands the bytes already sent", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
pick(sized("world.zip", SIZE));
|
||||
await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1));
|
||||
opsNow = [
|
||||
fileOp({ id: "land1", op: "upload", path: "world.zip", state: "failed", error: { code: "file_exists", message: "" } }),
|
||||
];
|
||||
await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
mocks.getServerFileUpload.mockResolvedValue(at(SIZE));
|
||||
mocks.commitServerFileUpload.mockResolvedValue({
|
||||
op: fileOp({ id: "land2", op: "upload", path: "world.zip", state: "succeeded" }),
|
||||
});
|
||||
|
||||
fireEvent.click(await within(queue()).findByRole("button", { name: t("files:upload_replace") }));
|
||||
|
||||
await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenLastCalledWith("lobby", "s1", true));
|
||||
expect(parts).toHaveLength(3);
|
||||
expect(mocks.beginServerFileUpload).toHaveBeenCalledTimes(1);
|
||||
expect(await within(queue()).findByText(t("files:upload_done"))).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["dismissed", { code: "volume_full", message: "" }, "upload_dismiss", { name: "world.zip" }],
|
||||
["skipped", { code: "file_exists", message: "" }, "upload_skip", {}],
|
||||
])("gives the session back when a refused one is %s", async (_label, error, key, opts) => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
pick(sized("world.zip", SIZE));
|
||||
await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1));
|
||||
opsNow = [fileOp({ id: "land1", op: "upload", path: "world.zip", state: "failed", error })];
|
||||
await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
|
||||
fireEvent.click(await within(queue()).findByRole("button", { name: i18next.t(`files:${key}`, opts) }));
|
||||
|
||||
await waitFor(() => expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "s1"]]));
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull();
|
||||
});
|
||||
|
||||
it("gives the session back when skipped with the rest", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
pick(sized("world.zip", SIZE));
|
||||
await waitFor(() => expect(mocks.commitServerFileUpload).toHaveBeenCalledTimes(1));
|
||||
opsNow = [
|
||||
fileOp({ id: "land1", op: "upload", path: "world.zip", state: "failed", error: { code: "file_exists", message: "" } }),
|
||||
];
|
||||
await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
await within(queue()).findByRole("button", { name: t("files:upload_replace") });
|
||||
pick(file("server.properties"));
|
||||
|
||||
fireEvent.click(await within(queue()).findByRole("button", { name: t("files:upload_skip_all") }));
|
||||
|
||||
await waitFor(() => expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "s1"]]));
|
||||
expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull();
|
||||
});
|
||||
|
||||
it("gives the session back when cancelled", async () => {
|
||||
mocks.putServerFileUploadPart.mockImplementation(
|
||||
(_n: string, _id: string, offset: number, _part: Blob, opts?: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
parts.push({ offset, signal: opts?.signal });
|
||||
opts?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")));
|
||||
}),
|
||||
);
|
||||
renderFiles();
|
||||
await screen.findByText("world");
|
||||
pick(sized("world.zip", SIZE));
|
||||
await waitFor(() => expect(parts).toHaveLength(1));
|
||||
expect(localStorage.getItem(KEY)).not.toBeNull();
|
||||
|
||||
await userEvent.click(within(queue()).getByRole("button", { name: i18next.t("files:upload_cancel", { name: "world.zip" }) }));
|
||||
|
||||
await waitFor(() => expect(mocks.deleteServerFileUpload.mock.calls).toEqual([["lobby", "s1"]]));
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the session for a resume when the page goes away", async () => {
|
||||
mocks.putServerFileUploadPart.mockImplementation(
|
||||
(_n: string, _id: string, offset: number, _part: Blob, opts?: { signal?: AbortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
parts.push({ offset, signal: opts?.signal });
|
||||
opts?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")));
|
||||
}),
|
||||
);
|
||||
const { unmount } = renderFiles();
|
||||
await screen.findByText("world");
|
||||
pick(sized("world.zip", SIZE));
|
||||
await waitFor(() => expect(parts).toHaveLength(1));
|
||||
|
||||
unmount();
|
||||
await act(async () => {});
|
||||
|
||||
expect(parts[0].signal?.aborted).toBe(true);
|
||||
expect(mocks.deleteServerFileUpload).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(localStorage.getItem(KEY) ?? "null")).toMatchObject({ id: "s1", size: SIZE });
|
||||
});
|
||||
});
|
||||
|
||||
it("holds the queue and every change while a background op runs, then carries on", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
opsNow = [fileOp()];
|
||||
renderFiles();
|
||||
const ops = await screen.findByRole("region", { name: t("files:ops_label") });
|
||||
expect(within(ops).getByText(i18next.t("files:op_unzip", { path: "pack.zip" }))).toBeTruthy();
|
||||
expect(within(ops).getByText(t("files:op_preparing"))).toBeTruthy();
|
||||
const lists = mocks.listServerFiles.mock.calls.length;
|
||||
|
||||
pick(file("a.jar"));
|
||||
expect(await within(queue()).findByText(t("files:upload_queued"))).toBeTruthy();
|
||||
for (const b of [button("new_file"), button("new_folder"), button("rename_item", "world"), button("delete_item", "world")]) {
|
||||
expect(b.disabled).toBe(true);
|
||||
expect(b.title).toBe(t("files:wait_for_op"));
|
||||
}
|
||||
|
||||
opsNow = [fileOp({ done: 512, total: 2048 })];
|
||||
await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
expect(within(ops).getByText(i18next.t("files:op_progress", { done: "512 B", total: "2.0 KiB", percent: 25 }))).toBeTruthy();
|
||||
expect(within(ops).getByRole("progressbar").getAttribute("aria-valuenow")).toBe("25");
|
||||
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
|
||||
|
||||
opsNow = [fileOp({ state: "succeeded", files: 3, bytes: 2048, done: 2048, total: 2048 })];
|
||||
await act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
|
||||
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]]));
|
||||
expect(within(ops).getByText(i18next.t("files:op_unzip_done", { count: 3, bytes: "2.0 KiB" }))).toBeTruthy();
|
||||
// What it extracted is in the folder now.
|
||||
await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1));
|
||||
|
||||
fireEvent.click(within(ops).getByRole("button", { name: i18next.t("files:op_dismiss", { path: "pack.zip" }) }));
|
||||
expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull();
|
||||
});
|
||||
|
||||
it("asks about a file that appeared since the listing", async () => {
|
||||
@@ -808,3 +1139,308 @@ describe("ServerFiles uploads", () => {
|
||||
expect(fireEvent.dragOver(document.body, { dataTransfer: { types: ["text/plain"], dropEffect: "move" } })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerFiles archives and downloads", () => {
|
||||
const entry = (name: string, is_dir = false) => ({ name, size: 8, is_dir, mod_time: "2026-09-01T00:00:00Z" });
|
||||
beforeEach(() => {
|
||||
mocks.listServerFiles.mockImplementation((_name: string, path: string) =>
|
||||
Promise.resolve({
|
||||
path,
|
||||
truncated: false,
|
||||
entries:
|
||||
path === "config"
|
||||
? [entry("paper-global.yml"), entry("bukkit.yml")]
|
||||
: [entry("server.properties"), entry("Pack.ZIP"), entry("old.zip", true), entry("config", true), entry("world", true)],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const conflicted = (over: Partial<FileOp> = {}) =>
|
||||
fileOp({
|
||||
id: "u1",
|
||||
path: "Pack.ZIP",
|
||||
state: "failed",
|
||||
error: { code: "file_exists", message: "", conflicts: ["world/level.dat", "ops.json"], conflict_count: 3 },
|
||||
...over,
|
||||
});
|
||||
const opsBanner = () => screen.getByRole("region", { name: t("files:ops_label") });
|
||||
const poll = () => act(() => vi.advanceTimersByTimeAsync(OP_POLL_MS));
|
||||
|
||||
it("extracts an archive, lists what it would replace, and replaces them once confirmed", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderFiles();
|
||||
await screen.findByText("Pack.ZIP");
|
||||
expect(screen.queryByRole("button", { name: i18next.t("files:unzip_item", { name: "server.properties" }) })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: i18next.t("files:unzip_item", { name: "old.zip" }) })).toBeNull();
|
||||
const first = fileOp({ id: "u1", path: "Pack.ZIP" });
|
||||
mocks.unzipServerFile.mockResolvedValueOnce({ op: first });
|
||||
opsNow = [first];
|
||||
|
||||
fireEvent.click(button("unzip_item", "Pack.ZIP"));
|
||||
|
||||
const banner = await screen.findByRole("region", { name: t("files:ops_label") });
|
||||
expect(within(banner).getByText(i18next.t("files:op_unzip", { path: "Pack.ZIP" }))).toBeTruthy();
|
||||
expect(mocks.unzipServerFile.mock.calls).toEqual([["lobby", "Pack.ZIP", false]]);
|
||||
expect(button("unzip_item", "Pack.ZIP").disabled).toBe(true);
|
||||
expect(button("unzip_item", "Pack.ZIP").title).toBe(t("files:wait_for_op"));
|
||||
|
||||
opsNow = [conflicted()];
|
||||
await poll();
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_title", { name: "Pack.ZIP", count: 3 }))).toBeTruthy();
|
||||
expect(within(dialog).getAllByRole("listitem").map((li) => li.textContent)).toEqual(["world/level.dat", "ops.json"]);
|
||||
expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_more", { count: 1 }))).toBeTruthy();
|
||||
const lists = mocks.listServerFiles.mock.calls.length;
|
||||
|
||||
const second = fileOp({ id: "u2", path: "Pack.ZIP" });
|
||||
mocks.unzipServerFile.mockResolvedValueOnce({ op: second });
|
||||
opsNow = [second, conflicted()];
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: t("files:unzip_overwrite") }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(mocks.unzipServerFile.mock.calls).toEqual([
|
||||
["lobby", "Pack.ZIP", false],
|
||||
["lobby", "Pack.ZIP", true],
|
||||
]);
|
||||
// The refused try is dismissed: the one replacing takes its place.
|
||||
expect(within(opsBanner()).getAllByRole("listitem")).toHaveLength(1);
|
||||
expect(within(opsBanner()).getByText(t("files:op_preparing"))).toBeTruthy();
|
||||
|
||||
opsNow = [fileOp({ id: "u2", path: "Pack.ZIP", state: "succeeded", files: 5, bytes: 1024 }), conflicted()];
|
||||
await poll();
|
||||
|
||||
expect(within(opsBanner()).getByText(i18next.t("files:op_unzip_done", { count: 5, bytes: "1.0 KiB" }))).toBeTruthy();
|
||||
await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("holds every change while an extraction starts, and asks at once when it ended before a read", async () => {
|
||||
let answer!: (v: { op: FileOp }) => void;
|
||||
mocks.unzipServerFile.mockReturnValueOnce(new Promise((res) => (answer = res)));
|
||||
renderFiles();
|
||||
await screen.findByText("Pack.ZIP");
|
||||
|
||||
fireEvent.click(button("unzip_item", "Pack.ZIP"));
|
||||
|
||||
for (const b of [button("new_file"), button("download_item", "server.properties"), button("delete_item", "world")]) {
|
||||
expect(b.disabled).toBe(true);
|
||||
expect(b.title).toBe(t("files:wait_for_op"));
|
||||
}
|
||||
mocks.uploadServerFile.mockResolvedValue({ path: "a.jar", status: "uploaded", sha256: "a", size: 5 });
|
||||
fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [new File(["bytes"], "a.jar")] } });
|
||||
await act(async () => {});
|
||||
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => answer({ op: conflicted() }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_title", { name: "Pack.ZIP", count: 3 }))).toBeTruthy();
|
||||
await waitFor(() => expect(mocks.uploadServerFile.mock.calls.map((c) => c[1])).toEqual(["a.jar"]));
|
||||
});
|
||||
|
||||
it("asks nothing about an extraction started here that failed for another reason", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
const started = fileOp({ id: "u1", path: "Pack.ZIP" });
|
||||
mocks.unzipServerFile.mockResolvedValueOnce({ op: started });
|
||||
renderFiles();
|
||||
await screen.findByText("Pack.ZIP");
|
||||
opsNow = [started];
|
||||
fireEvent.click(button("unzip_item", "Pack.ZIP"));
|
||||
await screen.findByRole("region", { name: t("files:ops_label") });
|
||||
expect(mocks.unzipServerFile.mock.calls).toEqual([["lobby", "Pack.ZIP", false]]);
|
||||
|
||||
opsNow = [conflicted({ error: { code: "archive_invalid", message: "zip: not a valid zip file" } })];
|
||||
await poll();
|
||||
|
||||
expect(within(opsBanner()).getByText(t("files:archive_invalid"))).toBeTruthy();
|
||||
expect(within(opsBanner()).queryByRole("button", { name: t("files:op_conflicts") })).toBeNull();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("asks nothing at once about an extraction started elsewhere, and offers its conflicts", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
opsNow = [fileOp({ id: "u9", path: "maps/Pack.ZIP" })];
|
||||
renderFiles();
|
||||
await within(await screen.findByRole("region", { name: t("files:ops_label") })).findByText(t("files:op_preparing"));
|
||||
|
||||
opsNow = [conflicted({ id: "u9", path: "maps/Pack.ZIP", error: { code: "file_exists", message: "", conflicts: ["a.txt"] } })];
|
||||
await poll();
|
||||
|
||||
expect(within(opsBanner()).getByText(i18next.t("files:op_unzip_conflicts", { count: 1 }))).toBeTruthy();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
fireEvent.click(within(opsBanner()).getByRole("button", { name: t("files:op_conflicts") }));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(within(dialog).getByText(i18next.t("files:unzip_conflicts_title", { name: "Pack.ZIP", count: 1 }))).toBeTruthy();
|
||||
expect(within(dialog).getAllByRole("listitem").map((li) => li.textContent)).toEqual(["a.txt"]);
|
||||
expect(within(dialog).queryByText(i18next.t("files:unzip_conflicts_more", { count: 0 }))).toBeNull();
|
||||
mocks.unzipServerFile.mockResolvedValueOnce({ op: fileOp({ id: "u10", path: "maps/Pack.ZIP" }) });
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: t("files:unzip_overwrite") }));
|
||||
|
||||
await waitFor(() => expect(mocks.unzipServerFile.mock.calls).toEqual([["lobby", "maps/Pack.ZIP", true]]));
|
||||
});
|
||||
|
||||
it("says why an extraction could not start, and lets it be tried again", async () => {
|
||||
const held = { status: 409, code: "maintenance_in_progress", message: "a backup holds the world" };
|
||||
mocks.unzipServerFile.mockRejectedValueOnce(held);
|
||||
renderFiles();
|
||||
await screen.findByText("Pack.ZIP");
|
||||
|
||||
fireEvent.click(button("unzip_item", "Pack.ZIP"));
|
||||
|
||||
expect(await screen.findByText(humanizeError(held))).toBeTruthy();
|
||||
expect(button("unzip_item", "Pack.ZIP").disabled).toBe(false);
|
||||
expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull();
|
||||
});
|
||||
|
||||
it("says when the background ops cannot be read, until a refresh gets through", async () => {
|
||||
const broke = { status: 500, code: "internal", message: "" };
|
||||
mocks.listServerFileOps.mockRejectedValueOnce(broke);
|
||||
renderFiles();
|
||||
|
||||
const ops = await screen.findByRole("region", { name: t("files:ops_label") });
|
||||
expect(within(ops).getByText(i18next.t("files:ops_refresh_failed", { reason: humanizeError(broke) }))).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: t("files:refresh") }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("region", { name: t("files:ops_label") })).toBeNull());
|
||||
});
|
||||
|
||||
function watchLinks() {
|
||||
const clicked: { href: string | null; download: string; hidden: boolean; connected: boolean }[] = [];
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
|
||||
clicked.push({ href: this.getAttribute("href"), download: this.download, hidden: this.hidden, connected: this.isConnected });
|
||||
});
|
||||
return clicked;
|
||||
}
|
||||
// readyAfter answers "pending" for the first n reads of the ticket, then "ready".
|
||||
function readyAfter(n: number) {
|
||||
let reads = 0;
|
||||
mocks.exportStatus.mockImplementation(async () => ({ state: reads++ < n ? "pending" : "ready" }));
|
||||
}
|
||||
const exportPoll = () => act(() => vi.advanceTimersByTimeAsync(EXPORT_POLL_MS));
|
||||
|
||||
it("hands a file to the browser once it is ready, holding every change while it is prepared", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
const clicked = watchLinks();
|
||||
mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "server.properties" });
|
||||
mocks.exportDownloadURL.mockResolvedValue("/api/v1/exports/t1/download");
|
||||
mocks.uploadServerFile.mockResolvedValue({ path: "a.jar", status: "uploaded", sha256: "a", size: 5 });
|
||||
readyAfter(1);
|
||||
renderFiles();
|
||||
await screen.findByText("Pack.ZIP");
|
||||
|
||||
fireEvent.click(button("download_item", "server.properties"));
|
||||
|
||||
await waitFor(() => expect(mocks.downloadServerFile.mock.calls).toEqual([["lobby", "server.properties", false]]));
|
||||
expect(button("download_item", "server.properties").title).toBe(
|
||||
i18next.t("files:download_preparing", { name: "server.properties" }),
|
||||
);
|
||||
for (const b of [button("new_file"), button("download_folder_item", "world"), button("delete_item", "world")]) {
|
||||
expect(b.disabled).toBe(true);
|
||||
expect(b.title).toBe(t("files:wait_for_download"));
|
||||
}
|
||||
fireEvent.change(screen.getByTestId("upload-input"), { target: { files: [new File(["bytes"], "a.jar")] } });
|
||||
await exportPoll();
|
||||
expect(mocks.exportStatus.mock.calls).toEqual([["t1"]]);
|
||||
expect(clicked).toEqual([]);
|
||||
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
|
||||
|
||||
await exportPoll();
|
||||
|
||||
expect(clicked).toEqual([{ href: "/api/v1/exports/t1/download", download: "server.properties", hidden: true, connected: true }]);
|
||||
expect(await screen.findByText(i18next.t("files:download_started_props", { filename: "server.properties" }))).toBeTruthy();
|
||||
expect(button("new_file").disabled).toBe(false);
|
||||
await waitFor(() => expect(mocks.uploadServerFile.mock.calls.map((c) => c[1])).toEqual(["a.jar"]));
|
||||
});
|
||||
|
||||
it("abandons a download the page left before it was ready", async () => {
|
||||
const clicked = watchLinks();
|
||||
mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "world.zip" });
|
||||
readyAfter(0);
|
||||
const { unmount } = renderFiles();
|
||||
await screen.findByText("Pack.ZIP");
|
||||
vi.useFakeTimers();
|
||||
await act(async () => {
|
||||
fireEvent.click(button("download_folder_item", "world"));
|
||||
});
|
||||
|
||||
unmount();
|
||||
await exportPoll();
|
||||
|
||||
expect(mocks.exportStatus).not.toHaveBeenCalled();
|
||||
expect(clicked).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["config", "download_started_config"],
|
||||
["world", "download_started"],
|
||||
])("downloads the folder %s as a zip, and says what it leaves out", async (folder, note) => {
|
||||
watchLinks();
|
||||
mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: `lobby-${folder}.zip` });
|
||||
mocks.exportDownloadURL.mockResolvedValue("/api/v1/exports/t1/download");
|
||||
readyAfter(0);
|
||||
renderFiles();
|
||||
await screen.findByText("Pack.ZIP");
|
||||
vi.useFakeTimers();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(button("download_folder_item", folder));
|
||||
});
|
||||
await exportPoll();
|
||||
|
||||
expect(mocks.downloadServerFile.mock.calls).toEqual([["lobby", folder, true]]);
|
||||
expect(screen.getByText(i18next.t(`files:${note}`, { filename: `lobby-${folder}.zip` }))).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers no download of the proxy secret, and one of the files beside it", async () => {
|
||||
renderFiles();
|
||||
fireEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "config" }) }));
|
||||
await screen.findByText("bukkit.yml");
|
||||
|
||||
expect(button("download_item", "paper-global.yml").disabled).toBe(true);
|
||||
expect(button("download_item", "paper-global.yml").title).toBe(t("files:secret_config_no_download"));
|
||||
expect(button("download_item", "bukkit.yml").disabled).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"refused as too many",
|
||||
() => mocks.downloadServerFile.mockRejectedValue({ status: 429, code: "export_busy", message: "one at a time" }),
|
||||
() => t("files:download_busy"),
|
||||
],
|
||||
[
|
||||
"refused otherwise",
|
||||
() => mocks.downloadServerFile.mockRejectedValue({ status: 409, code: "maintenance_in_progress", message: "a backup holds the world" }),
|
||||
() =>
|
||||
i18next.t("files:download_failed_because", {
|
||||
reason: humanizeError({ status: 409, code: "maintenance_in_progress", message: "a backup holds the world" }),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"failed with a reason",
|
||||
() => mocks.exportStatus.mockResolvedValue({ state: "failed", message: "tar: world: Cannot open" }),
|
||||
() => i18next.t("files:download_failed_because", { reason: "tar: world: Cannot open" }),
|
||||
],
|
||||
["failed without one", () => mocks.exportStatus.mockResolvedValue({ state: "failed" }), () => t("files:download_failed")],
|
||||
])("says why a download could not be prepared when %s", async (_label, arrange, words) => {
|
||||
const clicked = watchLinks();
|
||||
mocks.downloadServerFile.mockResolvedValue({ ticket: "t1", state: "pending", filename: "world.zip" });
|
||||
arrange();
|
||||
renderFiles();
|
||||
await screen.findByText("Pack.ZIP");
|
||||
vi.useFakeTimers();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(button("download_folder_item", "world"));
|
||||
});
|
||||
await exportPoll();
|
||||
|
||||
expect(screen.getByText(words())).toBeTruthy();
|
||||
expect(clicked).toEqual([]);
|
||||
expect(mocks.exportDownloadURL).not.toHaveBeenCalled();
|
||||
expect(button("download_folder_item", "world").disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
+236
-17
@@ -4,6 +4,8 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowUp,
|
||||
ChevronRight,
|
||||
Download,
|
||||
FileArchive,
|
||||
FilePlus,
|
||||
FileText,
|
||||
Folder,
|
||||
@@ -25,8 +27,10 @@ import { MessageLine } from "@/components/MessageLine";
|
||||
import { InlineConfirm } from "@/components/InlineConfirm";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { NameDialog } from "@/components/files/NameDialog";
|
||||
import { FileOps } from "@/components/files/FileOps";
|
||||
import { UploadQueue } from "@/components/files/UploadQueue";
|
||||
import { MAX_UPLOAD_BYTES, useUploads } from "@/components/files/useUploads";
|
||||
import { useFileOps } from "@/components/files/useFileOps";
|
||||
import { useUploads } from "@/components/files/useUploads";
|
||||
import {
|
||||
SECRET_CONFIG_PATH,
|
||||
isManaged,
|
||||
@@ -51,8 +55,9 @@ import { api, humanizeError } from "@/lib/api";
|
||||
import { STATUS_POLL_FAST_MS, useAsync, usePolling, useUnsavedGuard } from "@/lib/hooks";
|
||||
import { useTier } from "@/lib/tier";
|
||||
import { canManage, ownershipPending } from "@/lib/ownership";
|
||||
import { awaitExport, saveDownload } from "@/lib/download";
|
||||
import { formatBytes, formatRelative } from "@/lib/format";
|
||||
import type { ServerFileEntry } from "@/lib/types";
|
||||
import type { FileOp, ServerFileEntry } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** The write ceiling, mirrored from fileedit.MaxWriteBytes (server truth). Reads
|
||||
@@ -91,6 +96,12 @@ function decodeText(bytes: Uint8Array): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** isZip says whether a file is one the page offers to extract: only .zip is,
|
||||
* since extraction reads the zip format alone. */
|
||||
function isZip(name: string): boolean {
|
||||
return name.toLowerCase().endsWith(".zip");
|
||||
}
|
||||
|
||||
/** The name questions the page asks, each tied to the folder it was asked in. */
|
||||
type Naming =
|
||||
| { kind: "file" | "folder"; dir: string }
|
||||
@@ -121,6 +132,9 @@ export function ServerFiles() {
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [listErr, setListErr] = useState<unknown>(null);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
// Bytes free on the world volume by the latest listing; null while unknown
|
||||
// (the Job reports 0 when it could not tell).
|
||||
const [free, setFree] = useState<number | null>(null);
|
||||
const [msg, setMsg] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
// Every load takes a ticket and only the newest one lands. The rows and the
|
||||
@@ -137,6 +151,7 @@ export function ServerFiles() {
|
||||
if (ticket !== loadSeq.current) return;
|
||||
setEntries(sortEntries(r.entries ?? []));
|
||||
setTruncated(r.truncated === true);
|
||||
setFree(r.free_bytes > 0 ? r.free_bytes : null);
|
||||
setDir(p);
|
||||
} catch (e) {
|
||||
if (ticket !== loadSeq.current) return;
|
||||
@@ -275,12 +290,59 @@ export function ServerFiles() {
|
||||
}
|
||||
}
|
||||
|
||||
// A background op (an extraction, or a file landing from parts) holds the
|
||||
// world until it ends, and goes on with the page closed. One that succeeded
|
||||
// changed the folder, so the listing is reread; an extraction started here
|
||||
// that stopped at files it would replace asks about them at once.
|
||||
const startedHere = useRef(new Set<string>());
|
||||
const [conflicts, setConflicts] = useState<FileOp | null>(null);
|
||||
const [conflictsOpen, setConflictsOpen] = useState(false);
|
||||
function showConflicts(op: FileOp) {
|
||||
setConflicts(op);
|
||||
setConflictsOpen(true);
|
||||
}
|
||||
function opEnded(op: FileOp) {
|
||||
if (op.state === "succeeded") void load(dir);
|
||||
else if (op.op === "unzip" && op.error?.code === "file_exists" && startedHere.current.has(op.id)) {
|
||||
showConflicts(op);
|
||||
}
|
||||
}
|
||||
const fileOps = useFileOps(name, owned && stopped, opEnded);
|
||||
|
||||
// A download holds the world while felis-api gets it ready, and a change
|
||||
// sent meanwhile could only be refused.
|
||||
const [downloading, setDownloading] = useState<string | null>(null);
|
||||
const [unzipping, setUnzipping] = useState<string | null>(null);
|
||||
const alive = useRef(true);
|
||||
useEffect(
|
||||
() => () => {
|
||||
alive.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Uploads run one at a time. The listing is reread once the queue has drained
|
||||
// rather than after each file: every listing is a Job of its own.
|
||||
const landedSince = useRef(false);
|
||||
const uploads = useUploads(name, () => {
|
||||
landedSince.current = true;
|
||||
const uploads = useUploads(name, {
|
||||
onLanded: () => {
|
||||
landedSince.current = true;
|
||||
},
|
||||
onOp: fileOps.ignore,
|
||||
hold: fileOps.running || downloading !== null || unzipping !== null,
|
||||
free,
|
||||
});
|
||||
// Each change is a Job holding the world lock, so while anything else holds
|
||||
// it a change could only be refused.
|
||||
const changing = uploads.busy || fileOps.running || downloading !== null || unzipping !== null;
|
||||
// Names what holds the lock now: queued uploads wait on an op or a download
|
||||
// too, so those come first.
|
||||
const waitTitle =
|
||||
fileOps.running || unzipping !== null
|
||||
? t("wait_for_op")
|
||||
: downloading !== null
|
||||
? t("wait_for_download")
|
||||
: t("wait_for_uploads");
|
||||
useEffect(() => {
|
||||
if (uploads.busy || !landedSince.current) return;
|
||||
landedSince.current = false;
|
||||
@@ -384,6 +446,76 @@ export function ServerFiles() {
|
||||
void load(dir);
|
||||
}
|
||||
|
||||
// unzip starts extracting the archive at p into its own folder. Without
|
||||
// overwrite the op stops before touching anything when a file would be
|
||||
// replaced, and names those files.
|
||||
async function unzip(p: string, overwrite: boolean) {
|
||||
const { op } = await api.unzipServerFile(name, p, overwrite);
|
||||
startedHere.current.add(op.id);
|
||||
fileOps.started(op);
|
||||
if (op.state !== "running") opEnded(op);
|
||||
}
|
||||
|
||||
async function startUnzip(entry: ServerFileEntry) {
|
||||
const p = joinPath(dir, entry.name);
|
||||
setMsg(null);
|
||||
setUnzipping(p);
|
||||
try {
|
||||
await unzip(p, false);
|
||||
} catch (e) {
|
||||
setMsg({ kind: "error", text: humanizeError(e) });
|
||||
} finally {
|
||||
setUnzipping(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function overwriteConflicts() {
|
||||
if (!conflicts) return;
|
||||
await unzip(conflicts.path, true);
|
||||
fileOps.dismiss(conflicts.id);
|
||||
}
|
||||
|
||||
// download has felis-api get the file, or the folder as a .zip, ready and
|
||||
// hands it to the browser, as a backup download does. The two secrets never
|
||||
// leave: server.properties comes with rcon.password redacted, and the
|
||||
// folder holding paper-global.yml comes without it.
|
||||
async function download(entry: ServerFileEntry) {
|
||||
const p = joinPath(dir, entry.name);
|
||||
setMsg(null);
|
||||
setDownloading(p);
|
||||
try {
|
||||
const tk = await api.downloadServerFile(name, p, entry.is_dir);
|
||||
const s = await awaitExport(tk.ticket, () => alive.current);
|
||||
if (s === null) return;
|
||||
if (s.state === "failed") {
|
||||
setMsg({
|
||||
kind: "error",
|
||||
text: s.message ? t("download_failed_because", { reason: s.message }) : t("download_failed"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveDownload(await api.exportDownloadURL(tk.ticket), tk.filename);
|
||||
const note =
|
||||
p === "server.properties"
|
||||
? "download_started_props"
|
||||
: p === parentOf(SECRET_CONFIG_PATH)
|
||||
? "download_started_config"
|
||||
: "download_started";
|
||||
setMsg({ kind: "success", text: t(note, { filename: tk.filename }) });
|
||||
} catch (e) {
|
||||
if (!alive.current) return;
|
||||
setMsg({
|
||||
kind: "error",
|
||||
text:
|
||||
(e as { code?: string }).code === "export_busy"
|
||||
? t("download_busy")
|
||||
: t("download_failed_because", { reason: humanizeError(e) }),
|
||||
});
|
||||
} finally {
|
||||
if (alive.current) setDownloading(null);
|
||||
}
|
||||
}
|
||||
|
||||
const back = <BackLink to={`/servers/${name}`} label={t("back_to_console")} />;
|
||||
if (statusQ.loading && !statusQ.data) {
|
||||
return (
|
||||
@@ -543,15 +675,13 @@ export function ServerFiles() {
|
||||
})}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{/* Each change is a Job holding the world lock, so while an
|
||||
upload holds it a change could only be refused. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => ask({ kind: "file", dir })}
|
||||
disabled={uploads.busy}
|
||||
disabled={changing}
|
||||
aria-label={t("new_file")}
|
||||
title={uploads.busy ? t("wait_for_uploads") : t("new_file")}
|
||||
title={changing ? waitTitle : t("new_file")}
|
||||
>
|
||||
<FilePlus className="h-4 w-4" />
|
||||
<span className="hidden md:inline">{t("new_file")}</span>
|
||||
@@ -560,9 +690,9 @@ export function ServerFiles() {
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => ask({ kind: "folder", dir })}
|
||||
disabled={uploads.busy}
|
||||
disabled={changing}
|
||||
aria-label={t("new_folder")}
|
||||
title={uploads.busy ? t("wait_for_uploads") : t("new_folder")}
|
||||
title={changing ? waitTitle : t("new_folder")}
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
<span className="hidden md:inline">{t("new_folder")}</span>
|
||||
@@ -572,7 +702,7 @@ export function ServerFiles() {
|
||||
variant="outline"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
aria-label={t("upload")}
|
||||
title={t("upload_hint", { limit: formatBytes(MAX_UPLOAD_BYTES) })}
|
||||
title={t("upload_hint")}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t("upload")}</span>
|
||||
@@ -591,7 +721,10 @@ export function ServerFiles() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void load(dir)}
|
||||
onClick={() => {
|
||||
void load(dir);
|
||||
fileOps.refresh();
|
||||
}}
|
||||
disabled={listLoading}
|
||||
aria-label={t("refresh")}
|
||||
title={t("refresh")}
|
||||
@@ -601,6 +734,13 @@ export function ServerFiles() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileOps
|
||||
ops={fileOps.ops}
|
||||
error={fileOps.error}
|
||||
onDismiss={fileOps.dismiss}
|
||||
onConflicts={showConflicts}
|
||||
/>
|
||||
|
||||
<UploadQueue
|
||||
items={uploads.items}
|
||||
dir={dir}
|
||||
@@ -672,18 +812,58 @@ export function ServerFiles() {
|
||||
{/* The actions stop at their own buttons: a click
|
||||
here must not also open the row. */}
|
||||
<div className="flex items-center justify-end gap-0.5" onClick={(ev) => ev.stopPropagation()}>
|
||||
{!e.is_dir && isZip(e.name) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void startUnzip(e)}
|
||||
disabled={changing}
|
||||
aria-label={t("unzip_item", { name: e.name })}
|
||||
title={changing ? waitTitle : t("unzip_item", { name: e.name })}
|
||||
>
|
||||
{unzipping === p ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileArchive className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void download(e)}
|
||||
disabled={p === SECRET_CONFIG_PATH || changing}
|
||||
aria-label={t(e.is_dir ? "download_folder_item" : "download_item", { name: e.name })}
|
||||
title={
|
||||
p === SECRET_CONFIG_PATH
|
||||
? t("secret_config_no_download")
|
||||
: downloading === p
|
||||
? t("download_preparing", { name: e.name })
|
||||
: changing
|
||||
? waitTitle
|
||||
: t(e.is_dir ? "download_folder_item" : "download_item", { name: e.name })
|
||||
}
|
||||
>
|
||||
{downloading === p ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => ask({ kind: "rename", dir, entry: e })}
|
||||
disabled={managed || uploads.busy}
|
||||
disabled={managed || changing}
|
||||
aria-label={t("rename_item", { name: e.name })}
|
||||
title={
|
||||
managed
|
||||
? t("managed_no_rename")
|
||||
: uploads.busy
|
||||
? t("wait_for_uploads")
|
||||
: changing
|
||||
? waitTitle
|
||||
: t("rename_item", { name: e.name })
|
||||
}
|
||||
>
|
||||
@@ -698,9 +878,9 @@ export function ServerFiles() {
|
||||
setDeleting(e);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
disabled={uploads.busy}
|
||||
disabled={changing}
|
||||
aria-label={t("delete_item", { name: e.name })}
|
||||
title={uploads.busy ? t("wait_for_uploads") : t("delete_item", { name: e.name })}
|
||||
title={changing ? waitTitle : t("delete_item", { name: e.name })}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -865,6 +1045,45 @@ export function ServerFiles() {
|
||||
confirmLabel={t("delete")}
|
||||
onConfirm={deleteEntry}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={conflictsOpen}
|
||||
onOpenChange={setConflictsOpen}
|
||||
title={t("unzip_conflicts_title", {
|
||||
name: conflicts?.path.split("/").pop() ?? "",
|
||||
count: conflictCount(conflicts),
|
||||
})}
|
||||
description={t("unzip_conflicts_body")}
|
||||
confirmLabel={t("unzip_overwrite")}
|
||||
onConfirm={overwriteConflicts}
|
||||
>
|
||||
<ConflictList op={conflicts} />
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function conflictCount(op: FileOp | null): number {
|
||||
return op?.error?.conflict_count ?? op?.error?.conflicts?.length ?? 0;
|
||||
}
|
||||
|
||||
// ConflictList names the files an extraction would replace. The op carries the
|
||||
// first ones and the full count; the rest are counted.
|
||||
function ConflictList({ op }: { op: FileOp | null }) {
|
||||
const { t } = useTranslation("files");
|
||||
const listed = op?.error?.conflicts ?? [];
|
||||
const more = conflictCount(op) - listed.length;
|
||||
if (listed.length === 0) return null;
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<ul className="max-h-48 overflow-y-auto rounded-md border border-border bg-muted/30 px-3 py-2 font-mono text-xs">
|
||||
{listed.map((path) => (
|
||||
<li key={path} className="truncate py-0.5" title={path}>
|
||||
{path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{more > 0 && <p className="text-xs text-muted-foreground">{t("unzip_conflicts_more", { count: more })}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user