feat(api): 集群列表读改走 informer 缓存,审核队列、我的提交与备份列表改为服务端分页筛选并一次批量查构建,面板三页跟进
This commit is contained in:
41 files changed
+1642
-295
No files matched your search
@@ -487,17 +487,26 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
});
|
||||
|
||||
describe("submissions", () => {
|
||||
it("listSubmissions GETs from /submissions", async () => {
|
||||
it("listSubmissions GETs a page from /submissions", async () => {
|
||||
const submissions = [{ id: "sub-1", display_name: "test", status: "pending_review" }];
|
||||
const fetchSpy = fakeFetch({ submissions });
|
||||
const fetchSpy = fakeFetch({ submissions, total: 31, counts: { pending_review: 4, approved: 27, rejected: 0 } });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listSubmissions();
|
||||
expect(res).toEqual(submissions);
|
||||
expect(res).toEqual({ submissions, total: 31, counts: { pending_review: 4, approved: 27, rejected: 0 } });
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/submissions");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("listSubmissions puts the filter and page on the query string", async () => {
|
||||
const fetchSpy = fakeFetch({ submissions: [], total: 0, counts: {} });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listSubmissions({ status: "approved", query: "sky block", limit: 10, offset: 20 });
|
||||
expect(res).toEqual({ submissions: [], total: 0, counts: { pending_review: 0, approved: 0, rejected: 0 } });
|
||||
const [url] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/submissions?status=approved&query=sky+block&limit=10&offset=20");
|
||||
});
|
||||
|
||||
it("approveSubmission POSTs to /submissions/{id}/approve", async () => {
|
||||
const sub = { id: "sub-1", status: "approved" };
|
||||
const fetchSpy = fakeFetch(sub);
|
||||
@@ -544,14 +553,14 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect(JSON.parse((opts as RequestInit).body as string)).toEqual({ reason: "bad" });
|
||||
});
|
||||
|
||||
it("listMySubmissions GETs from /me/submissions", async () => {
|
||||
it("listMySubmissions GETs a page from /me/submissions", async () => {
|
||||
const submissions = [{ id: "sub-2", display_name: "my test", status: "pending_review" }];
|
||||
const fetchSpy = fakeFetch({ submissions });
|
||||
const fetchSpy = fakeFetch({ submissions, total: 1, counts: { pending_review: 1, approved: 0, rejected: 2 } });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listMySubmissions();
|
||||
expect(res).toEqual(submissions);
|
||||
const res = await api.listMySubmissions({ status: "rejected", offset: 10 });
|
||||
expect(res).toEqual({ submissions, total: 1, counts: { pending_review: 1, approved: 0, rejected: 2 } });
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/me/submissions");
|
||||
expect(String(url)).toBe("/me/submissions?status=rejected&offset=10");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
@@ -663,6 +672,26 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect((opts as RequestInit).body).toBeUndefined();
|
||||
});
|
||||
|
||||
it("listBackups asks for one server's page and keeps the total", async () => {
|
||||
const backup = { id: "bk1", server_name: "survival", status: "present", created_at: "2026-09-01T00:00:00Z" };
|
||||
const fetchSpy = fakeFetch({ backups: [backup], total: 45 });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listBackups({ server: "survival", limit: 20, offset: 40 });
|
||||
expect(res).toEqual({ backups: [backup], total: 45 });
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/backups?server=survival&limit=20&offset=40");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
it("listBackups with no filter reads /backups and fills an empty page", async () => {
|
||||
const fetchSpy = fakeFetch({});
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.listBackups();
|
||||
expect(res).toEqual({ backups: [], total: 0 });
|
||||
const [url] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/backups");
|
||||
});
|
||||
|
||||
it("restoreBackup sends backup_id, and safety_snapshot only when turned off", async () => {
|
||||
const fetchSpy = fakeFetch(
|
||||
{ name: "survival", status: "restoring", backup_id: "bk1", safety_snapshot: true },
|
||||
|
||||
+38
-10
@@ -28,6 +28,8 @@ import type {
|
||||
WhitelistImage,
|
||||
WhitelistResult,
|
||||
Submission,
|
||||
SubmissionListParams,
|
||||
SubmissionPage,
|
||||
UpdateWindow,
|
||||
DBBackupStatus,
|
||||
} from "./types";
|
||||
@@ -222,6 +224,26 @@ function rejectingSync<T extends Record<string, unknown>>(methods: T): T {
|
||||
return out as T;
|
||||
}
|
||||
|
||||
// submissionPage reads one page of a submission list. Filtering and paging run on
|
||||
// the server; a status count the API left out reads as zero.
|
||||
function submissionPage(path: string, params?: SubmissionListParams): Promise<SubmissionPage> {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.status) sp.set("status", params.status);
|
||||
if (params?.query) sp.set("query", params.query);
|
||||
if (params?.limit) sp.set("limit", String(params.limit));
|
||||
if (params?.offset) sp.set("offset", String(params.offset));
|
||||
const qs = sp.toString();
|
||||
return request<Partial<SubmissionPage>>("GET", `${path}${qs ? `?${qs}` : ""}`).then((r) => ({
|
||||
submissions: r.submissions ?? [],
|
||||
total: r.total ?? 0,
|
||||
counts: {
|
||||
pending_review: r.counts?.pending_review ?? 0,
|
||||
approved: r.counts?.approved ?? 0,
|
||||
rejected: r.counts?.rejected ?? 0,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
// Setup bootstrap (spec §B). The one-time token from `felis setup` is redeemed for
|
||||
// a lockdown session; the response (and /setup/status) reports which onboarding
|
||||
// steps remain so the Setup wizard can drive email verification + passkey enrollment.
|
||||
@@ -464,12 +486,19 @@ export const api = rejectingSync({
|
||||
|
||||
// World backups (spec §7). listBackups is the app-tier read: an admin sees every
|
||||
// present backup, a user only the backups of worlds they formerly owned — the
|
||||
// scope is decided server-side from the principal, not by any client filter, so a
|
||||
// user cannot widen it. Only present (restorable) rows come back, newest first;
|
||||
// there is no per-server backups endpoint, so the panel filters by server_name
|
||||
// client-side and the first matching row is the one a restore would recover.
|
||||
listBackups: () =>
|
||||
request<{ backups: BackupView[] }>("GET", "/backups").then((r) => r.backups ?? []),
|
||||
// scope is decided server-side from the principal, so a user cannot widen it.
|
||||
// server narrows the page to one server inside that scope. Only present
|
||||
// (restorable) rows come back, newest first, one page at a time with the total.
|
||||
listBackups: (params?: { server?: string; limit?: number; offset?: number }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.server) sp.set("server", params.server);
|
||||
if (params?.limit) sp.set("limit", String(params.limit));
|
||||
if (params?.offset) sp.set("offset", String(params.offset));
|
||||
const qs = sp.toString();
|
||||
return request<{ backups: BackupView[]; total: number }>("GET", `/backups${qs ? `?${qs}` : ""}`).then(
|
||||
(r) => ({ backups: r.backups ?? [], total: r.total ?? 0 }),
|
||||
);
|
||||
},
|
||||
|
||||
// restoreBackup starts an ASYNC restore of a server's world from a backup
|
||||
// (spec §7 POST restore-backup). It accepts an optional backupId in the body: when
|
||||
@@ -650,8 +679,8 @@ export const api = rejectingSync({
|
||||
{ code },
|
||||
),
|
||||
|
||||
listSubmissions: () =>
|
||||
request<{ submissions: Submission[] }>("GET", "/submissions").then((r) => r.submissions ?? []),
|
||||
// The admin review queue, one page at a time (see submissionPage).
|
||||
listSubmissions: (params?: SubmissionListParams) => submissionPage("/submissions", params),
|
||||
|
||||
// expectedDigest is the sha256 of the context the reviewer looked at; the API
|
||||
// refuses the approval (409 context_changed) when the upload has since changed.
|
||||
@@ -684,8 +713,7 @@ export const api = rejectingSync({
|
||||
return digest;
|
||||
},
|
||||
|
||||
listMySubmissions: () =>
|
||||
request<{ submissions: Submission[] }>("GET", "/me/submissions").then((r) => r.submissions ?? []),
|
||||
listMySubmissions: (params?: SubmissionListParams) => submissionPage("/me/submissions", params),
|
||||
|
||||
createSubmission: (displayName: string) =>
|
||||
request<Submission>("POST", "/me/submissions", { display_name: displayName }),
|
||||
|
||||
@@ -1054,7 +1054,10 @@ export interface paths {
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List world backups (admin sees all; a user sees only worlds they formerly owned). */
|
||||
/**
|
||||
* List world backups (admin sees all; a user sees only worlds they formerly owned).
|
||||
* @description One page of the present backups in the caller's scope, newest first. server narrows the page to one server's backups inside that scope; it never widens it.
|
||||
*/
|
||||
get: operations["listBackups"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
@@ -4933,14 +4936,19 @@ export interface operations {
|
||||
};
|
||||
listBackups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
/** @description Only this server's backups */
|
||||
server?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Visible backups. */
|
||||
/** @description A page of visible backups plus how many match. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -4948,9 +4956,11 @@ export interface operations {
|
||||
content: {
|
||||
"application/json": {
|
||||
backups: components["schemas"]["BackupView"][];
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
@@ -6888,14 +6898,20 @@ export interface operations {
|
||||
};
|
||||
mySubmissions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
status?: "pending_review" | "approved" | "rejected";
|
||||
/** @description Part of the id, the submitter or the display name; case-insensitive */
|
||||
query?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description The caller's submissions, newest first; rows with a linked build additionally carry build_status/build_error so the submitter can see whether their build succeeded or failed (and why). */
|
||||
/** @description One page of the caller's submissions, newest first; rows with a linked build additionally carry build_status/build_error so the submitter can see whether their build succeeded or failed (and why). */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -6903,9 +6919,18 @@ export interface operations {
|
||||
content: {
|
||||
"application/json": {
|
||||
submissions: components["schemas"]["Submission"][];
|
||||
/** @description How many submissions match status and query in all. */
|
||||
total: number;
|
||||
/** @description How many of the scope's submissions sit in each status, whatever status and query say. */
|
||||
counts: {
|
||||
pending_review: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
};
|
||||
@@ -7322,14 +7347,20 @@ export interface operations {
|
||||
};
|
||||
listSubmissions: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
status?: "pending_review" | "approved" | "rejected";
|
||||
/** @description Part of the id, the submitter or the display name; case-insensitive */
|
||||
query?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description All submissions, newest first. */
|
||||
/** @description One page of every user's submissions, newest first. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -7337,9 +7368,18 @@ export interface operations {
|
||||
content: {
|
||||
"application/json": {
|
||||
submissions: components["schemas"]["Submission"][];
|
||||
/** @description How many submissions match status and query in all. */
|
||||
total: number;
|
||||
/** @description How many of the scope's submissions sit in each status, whatever status and query say. */
|
||||
counts: {
|
||||
pending_review: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
|
||||
@@ -331,6 +331,23 @@ export interface Submission {
|
||||
context_sha256?: string;
|
||||
}
|
||||
|
||||
/** SubmissionListParams picks one page of a submission list (server-side filter
|
||||
* and paging; the scope is the endpoint, never a parameter). */
|
||||
export interface SubmissionListParams {
|
||||
status?: SubmissionStatus;
|
||||
query?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/** SubmissionPage is one page of a submission list: total counts the rows that
|
||||
* match status and query, counts the scope's rows per status regardless. */
|
||||
export interface SubmissionPage {
|
||||
submissions: Submission[];
|
||||
total: number;
|
||||
counts: Record<SubmissionStatus, number>;
|
||||
}
|
||||
|
||||
export interface UpdateWindow {
|
||||
start: string | null;
|
||||
end: string | null;
|
||||
|
||||
Reference in new issue
Block a user