feat(cms): recover drafts and failed imports with verified UI workflows
This commit is contained in:
1 parent
410a1e466c
commit
88790d1a0d
84 files changed
+3952
-288
No files matched your search
@@ -0,0 +1,266 @@
|
||||
import { onlineManager, QueryObserver } from "@tanstack/react-query";
|
||||
import { delay, HttpResponse, http } from "msw";
|
||||
import { setupServer } from "msw/node";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import {
|
||||
furnitureCursorFixture,
|
||||
furnitureJobFixture,
|
||||
} from "@/test/furniture-jobs-fixtures";
|
||||
import {
|
||||
createFurnitureJobsClient,
|
||||
furnitureJobsQueryOptions,
|
||||
readFurnitureJobResponse,
|
||||
} from "./furniture-jobs-query";
|
||||
|
||||
const endpoint = "http://localhost/api/admin/studio/import-jobs";
|
||||
const server = setupServer();
|
||||
const clients: ReturnType<typeof createFurnitureJobsClient>[] = [];
|
||||
function client() {
|
||||
const value = createFurnitureJobsClient();
|
||||
clients.push(value);
|
||||
return value;
|
||||
}
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
clients.map(async (value) => {
|
||||
await value.cancelQueries();
|
||||
value.clear();
|
||||
}),
|
||||
);
|
||||
clients.length = 0;
|
||||
server.resetHandlers();
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
describe("furniture history HTTP query", () => {
|
||||
it("deduplicates simultaneous refreshes and caches their validated result", async () => {
|
||||
let calls = 0;
|
||||
let release: (() => void) | undefined;
|
||||
server.use(
|
||||
http.get(endpoint, async () => {
|
||||
calls++;
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return HttpResponse.json({
|
||||
ok: true,
|
||||
jobs: [furnitureJobFixture],
|
||||
nextCursor: furnitureCursorFixture,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const cache = client();
|
||||
const options = furnitureJobsQueryOptions(true, null);
|
||||
const first = cache.fetchQuery(options);
|
||||
const second = cache.fetchQuery(options);
|
||||
await vi.waitFor(() => expect(calls).toBe(1));
|
||||
release?.();
|
||||
const [a, b] = await Promise.all([first, second]);
|
||||
expect(a).toEqual(b);
|
||||
expect(a.jobs[0].id).toBe(furnitureJobFixture.id);
|
||||
expect(cache.getQueryData(options.queryKey)).toEqual(a);
|
||||
});
|
||||
it("keeps different pages and mounted history clients isolated", async () => {
|
||||
const urls: string[] = [];
|
||||
server.use(
|
||||
http.get(endpoint, ({ request }) => {
|
||||
urls.push(request.url);
|
||||
return HttpResponse.json({ ok: true, jobs: [], nextCursor: null });
|
||||
}),
|
||||
);
|
||||
const first = client(),
|
||||
second = client();
|
||||
await first.fetchQuery(furnitureJobsQueryOptions(true, null));
|
||||
await first.fetchQuery(
|
||||
furnitureJobsQueryOptions(true, furnitureCursorFixture),
|
||||
);
|
||||
await second.fetchQuery(furnitureJobsQueryOptions(true, null));
|
||||
expect(urls).toHaveLength(3);
|
||||
expect(new URL(urls[1]).searchParams.get("before")).toBe(
|
||||
furnitureCursorFixture,
|
||||
);
|
||||
first.clear();
|
||||
expect(
|
||||
second.getQueryData(furnitureJobsQueryOptions(true, null).queryKey),
|
||||
).toEqual({ jobs: [], nextCursor: null });
|
||||
});
|
||||
it.each([403, 500])(
|
||||
"surfaces HTTP %i without automatic retries or a false empty result",
|
||||
async (status) => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get(endpoint, () => {
|
||||
calls++;
|
||||
return HttpResponse.json(
|
||||
{ error: "History unavailable" },
|
||||
{ status },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
await expect(cache.fetchQuery(options)).rejects.toMatchObject({
|
||||
status,
|
||||
message: "History unavailable",
|
||||
});
|
||||
expect(calls).toBe(1);
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
it.each([
|
||||
{ ok: true, jobs: null },
|
||||
{ ok: true, jobs: [{ ...furnitureJobFixture, state: "invented" }] },
|
||||
{
|
||||
ok: true,
|
||||
jobs: [
|
||||
{
|
||||
...furnitureJobFixture,
|
||||
items: [{ ...furnitureJobFixture.items[0], state: "unknown" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ ok: true, jobs: [{ ...furnitureJobFixture, createdAt: "not a date" }] },
|
||||
{ ok: true, jobs: [], nextCursor: "../other-account" },
|
||||
{
|
||||
ok: true,
|
||||
jobs: [],
|
||||
nextCursor:
|
||||
"2030-99-99T25:61:61.000Z|aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
},
|
||||
])("rejects malformed successful payloads", async (payload) => {
|
||||
server.use(http.get(endpoint, () => HttpResponse.json(payload)));
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
await expect(cache.fetchQuery(options)).rejects.toThrow(
|
||||
"Invalid import history response",
|
||||
);
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
});
|
||||
it("reports invalid JSON as a transport failure", async () => {
|
||||
server.use(
|
||||
http.get(
|
||||
endpoint,
|
||||
() => new HttpResponse("<html>unexpected</html>", { status: 200 }),
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
|
||||
).rejects.toThrow("Invalid import history response");
|
||||
});
|
||||
it("times out a stalled HTTP request without retries", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get(endpoint, async () => {
|
||||
calls++;
|
||||
await delay(100);
|
||||
return HttpResponse.json({ ok: true, jobs: [] });
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null, 10)),
|
||||
).rejects.toThrow("Import history request timed out");
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
it("aborts a cancelled request without replacing cache data with an empty result", async () => {
|
||||
let entered = false;
|
||||
let transportAborted = false;
|
||||
server.use(
|
||||
http.get(endpoint, async ({ request }) => {
|
||||
request.signal.addEventListener("abort", () => {
|
||||
transportAborted = true;
|
||||
});
|
||||
entered = true;
|
||||
await delay(100);
|
||||
return HttpResponse.json({ ok: true, jobs: [] });
|
||||
}),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
const pending = cache.fetchQuery(options);
|
||||
const rejection = expect(pending).rejects.toThrow();
|
||||
await vi.waitFor(() => expect(entered).toBe(true));
|
||||
await cache.cancelQueries({ queryKey: options.queryKey });
|
||||
await rejection;
|
||||
await vi.waitFor(() => expect(transportAborted).toBe(true));
|
||||
expect(cache.getQueryData(options.queryKey)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("history refresh and mutation response integrity", () => {
|
||||
it("retains the last valid page while a refresh fails", async () => {
|
||||
server.use(
|
||||
http.get(endpoint, () =>
|
||||
HttpResponse.json({ ok: true, jobs: [furnitureJobFixture] }),
|
||||
),
|
||||
);
|
||||
const cache = client(),
|
||||
options = furnitureJobsQueryOptions(false, null);
|
||||
const observer = new QueryObserver(cache, { ...options, enabled: false });
|
||||
const unsubscribe = observer.subscribe(() => {});
|
||||
try {
|
||||
await cache.fetchQuery(options);
|
||||
server.use(
|
||||
http.get(endpoint, () =>
|
||||
HttpResponse.json({ error: "offline" }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
await expect(cache.fetchQuery(options)).rejects.toThrow("offline");
|
||||
expect(observer.getCurrentResult().data?.jobs[0].id).toBe(
|
||||
furnitureJobFixture.id,
|
||||
);
|
||||
expect(observer.getCurrentResult().error?.message).toBe("offline");
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
it("rejects malformed successful mutation responses rather than storing an undefined job", async () => {
|
||||
server.use(
|
||||
http.patch(endpoint, () =>
|
||||
HttpResponse.json({ ok: true, job: { id: furnitureJobFixture.id } }),
|
||||
),
|
||||
);
|
||||
const response = await fetch(endpoint, { method: "PATCH" });
|
||||
await expect(
|
||||
readFurnitureJobResponse(response, "Update failed"),
|
||||
).rejects.toThrow("Invalid import job response");
|
||||
});
|
||||
it("reports mutation HTTP failure without issuing a second write", async () => {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.post(endpoint, () => {
|
||||
calls++;
|
||||
return HttpResponse.json(
|
||||
{ error: "Queue unavailable" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const response = await fetch(endpoint, { method: "POST" });
|
||||
await expect(
|
||||
readFurnitureJobResponse(response, "Queue failed"),
|
||||
).rejects.toMatchObject({ status: 500, message: "Queue unavailable" });
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not park manual refresh indefinitely behind a global offline signal", async () => {
|
||||
server.use(
|
||||
http.get(endpoint, () => HttpResponse.json({ ok: true, jobs: [] })),
|
||||
);
|
||||
onlineManager.setOnline(false);
|
||||
try {
|
||||
await expect(
|
||||
client().fetchQuery(furnitureJobsQueryOptions(false, null)),
|
||||
).resolves.toEqual({ jobs: [], nextCursor: null });
|
||||
} finally {
|
||||
onlineManager.setOnline(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { QueryClient, queryOptions } from "@tanstack/react-query";
|
||||
import { z } from "zod/mini";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { ImportJob } from "@/lib/furni/import-job";
|
||||
|
||||
const endpoint = "/api/admin/studio/import-jobs";
|
||||
const historyTimeoutMs = 15_000;
|
||||
const jobSchema = z.looseObject({
|
||||
id: z.uuid(),
|
||||
userId: z.int().check(z.positive()),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
state: z.enum(["queued", "running", "completed", "interrupted", "cancelled"]),
|
||||
translate: z.boolean(),
|
||||
mode: z.optional(z.literal("repair")),
|
||||
syncKind: z.optional(z.enum(["official", "clone"])),
|
||||
operationId: z.optional(z.string()),
|
||||
retryOf: z.optional(z.string()),
|
||||
sourceId: z.optional(z.string()),
|
||||
cancelRequested: z.optional(z.boolean()),
|
||||
langs: z.optional(z.array(z.string())),
|
||||
items: z.array(
|
||||
z.looseObject({
|
||||
id: z.int().check(z.nonnegative()),
|
||||
classname: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
type: z.string(),
|
||||
revision: z.number(),
|
||||
category: z.string(),
|
||||
state: z.enum([
|
||||
"pending",
|
||||
"running",
|
||||
"done",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"interrupted",
|
||||
]),
|
||||
error: z.optional(z.string()),
|
||||
warnings: z.optional(z.array(z.string())),
|
||||
itemId: z.optional(z.number()),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const historySchema = z.object({
|
||||
ok: z.literal(true),
|
||||
jobs: z.array(jobSchema).check(z.maxLength(30)),
|
||||
nextCursor: z.optional(
|
||||
z.nullable(
|
||||
z.string().check(
|
||||
z.regex(
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i,
|
||||
),
|
||||
z.refine((value) => {
|
||||
const [timestamp, id] = value.split("|");
|
||||
return (
|
||||
z.iso.datetime({ precision: 3 }).safeParse(timestamp).success &&
|
||||
z.uuid().safeParse(id).success
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
export interface FurnitureJobsPage {
|
||||
jobs: ImportJob[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
export class FurnitureJobsHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
async function responseData(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<unknown> {
|
||||
const data: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
"error" in data &&
|
||||
typeof data.error === "string"
|
||||
? data.error
|
||||
: `${fallback} (${response.status})`;
|
||||
throw new FurnitureJobsHttpError(message, response.status);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
export async function readFurnitureJobResponse(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<ImportJob> {
|
||||
const data = await responseData(response, fallback);
|
||||
const parsed = z
|
||||
.object({ ok: z.literal(true), job: jobSchema })
|
||||
.safeParse(data);
|
||||
if (!parsed.success) throw Error("Invalid import job response");
|
||||
return parsed.data.job as ImportJob;
|
||||
}
|
||||
/** Each mounted history owns this client; no persisted or cross-page account cache. */
|
||||
export function createFurnitureJobsClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
networkMode: "always",
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
},
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
export function furnitureJobsQueryOptions(
|
||||
paginated: boolean,
|
||||
cursor: string | null,
|
||||
timeoutMs = historyTimeoutMs,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: [
|
||||
"furniture-import-history",
|
||||
paginated,
|
||||
paginated ? cursor : null,
|
||||
] as const,
|
||||
queryFn: async ({ signal }): Promise<FurnitureJobsPage> => {
|
||||
const timeout = AbortSignal.timeout(timeoutMs);
|
||||
const combined = AbortSignal.any([signal, timeout]);
|
||||
const url = new URL(
|
||||
endpoint,
|
||||
typeof window === "undefined"
|
||||
? "http://localhost"
|
||||
: window.location.origin,
|
||||
);
|
||||
if (paginated) {
|
||||
url.searchParams.set("page", "1");
|
||||
if (cursor) url.searchParams.set("before", cursor);
|
||||
}
|
||||
try {
|
||||
const response = await adminFetch(url, {
|
||||
signal: combined,
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await responseData(response, "Import history unavailable");
|
||||
const parsed = historySchema.safeParse(data);
|
||||
if (!parsed.success) throw Error("Invalid import history response");
|
||||
return {
|
||||
jobs: parsed.data.jobs as ImportJob[],
|
||||
nextCursor: parsed.data.nextCursor ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (timeout.aborted && !signal.aborted)
|
||||
throw Error("Import history request timed out");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useFormatter, useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
import { ImportAttachmentRecovery } from "./import-attachment-recovery";
|
||||
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||
export function FurnitureJobHistory({
|
||||
state,
|
||||
@@ -98,6 +99,18 @@ export function FurnitureJobHistory({
|
||||
{item.error}
|
||||
</p>
|
||||
)}
|
||||
{!job.syncKind &&
|
||||
item.state === "failed" &&
|
||||
retryItems.some(
|
||||
(retryItem) => retryItem.classname === item.classname,
|
||||
) && (
|
||||
<ImportAttachmentRecovery
|
||||
jobId={job.id}
|
||||
classname={item.classname}
|
||||
disabled={state.busy}
|
||||
onResumed={state.refresh}
|
||||
/>
|
||||
)}
|
||||
{[...new Set(item.warnings ?? [])].map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
export function ImportAttachmentRecovery({
|
||||
jobId,
|
||||
classname,
|
||||
disabled,
|
||||
onResumed,
|
||||
}: {
|
||||
jobId: string;
|
||||
classname: string;
|
||||
disabled: boolean;
|
||||
onResumed: () => Promise<unknown>;
|
||||
}) {
|
||||
const t = useTranslations("admin.studio.importDiagnostics");
|
||||
const lock = useRef(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [attachmentId, setAttachmentId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
async function attach(file: File) {
|
||||
if (disabled || lock.current) return;
|
||||
lock.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setAttachmentId("");
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.set("classname", classname);
|
||||
form.set("file", file);
|
||||
const response = await adminFetch("/api/admin/studio/import-attachment", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok || typeof data.attachmentId !== "string") {
|
||||
setError(
|
||||
t(data.code === "mismatchedFile" ? "mismatchedFile" : "invalidFile"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setAttachmentId(data.attachmentId);
|
||||
} catch {
|
||||
setError(t("network"));
|
||||
} finally {
|
||||
lock.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function resume() {
|
||||
if (disabled || lock.current || !attachmentId) return;
|
||||
lock.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
id: jobId,
|
||||
action: "retry",
|
||||
attachments: { [classname]: attachmentId },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
const code =
|
||||
data.code === "alreadyStarted"
|
||||
? "alreadyStarted"
|
||||
: data.code === "notEligible"
|
||||
? "notEligible"
|
||||
: data.code === "invalidFile"
|
||||
? "invalidFile"
|
||||
: "resumeError";
|
||||
setError(t(code));
|
||||
return;
|
||||
}
|
||||
setAttachmentId("");
|
||||
await onResumed();
|
||||
} catch {
|
||||
setError(t("network"));
|
||||
} finally {
|
||||
lock.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<details className="my-2 rounded border border-[var(--admin-border)] p-2">
|
||||
<summary className="cursor-pointer">{t("recover")}</summary>
|
||||
<p className="my-2 text-xs text-[var(--admin-text-muted)]">
|
||||
{t("recoveryHint", { file: `${classname.split("*")[0]}.nitro` })}
|
||||
</p>
|
||||
<label className="block text-xs">
|
||||
{t("attach")}
|
||||
<input
|
||||
type="file"
|
||||
accept=".nitro"
|
||||
className="my-2 block max-w-full"
|
||||
disabled={disabled || busy}
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void attach(file);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{busy && <p role="status">{t("working")}</p>}
|
||||
{attachmentId && (
|
||||
<>
|
||||
<p role="status">{t("attached")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled || busy}
|
||||
onClick={() => void resume()}
|
||||
>
|
||||
{t("resume")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-[var(--admin-warning)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { adminFetch } from "@/lib/admin-fetch";
|
||||
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
|
||||
import { compareFurniture } from "@/lib/furni/studio-inspection";
|
||||
import { FurnitureComparison } from "./furniture-inspector";
|
||||
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
|
||||
import type { FurniItem } from "./studio-types";
|
||||
import { useFurnitureInspection } from "./use-furniture-inspection";
|
||||
import { useSourceAssetChecks } from "./use-source-asset-checks";
|
||||
@@ -38,6 +39,7 @@ export function ImportReview({
|
||||
busy?: boolean;
|
||||
onConfirm: (items: FurniItem[]) => void;
|
||||
}) {
|
||||
const diagnosticsT = useTranslations("admin.studio.importDiagnostics");
|
||||
const completenessT = useTranslations("admin.studio.completeness");
|
||||
const [attachments, setAttachments] = useState<Record<string, string>>({});
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
@@ -57,7 +59,7 @@ export function ImportReview({
|
||||
...current,
|
||||
[item.classname]: data.attachmentId,
|
||||
}));
|
||||
toast.success("Matching .nitro attached to this import");
|
||||
toast.success(diagnosticsT("attached"));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Upload failed");
|
||||
} finally {
|
||||
@@ -260,8 +262,8 @@ export function ImportReview({
|
||||
{local.nitro.exists !== true && (
|
||||
<label className="mt-3 block text-sm">
|
||||
{attachments[item.classname]
|
||||
? "Matching .nitro attached"
|
||||
: "Attach the original .nitro"}
|
||||
? diagnosticsT("attached")
|
||||
: diagnosticsT("attach")}
|
||||
<input
|
||||
className="mt-2 block max-w-full"
|
||||
type="file"
|
||||
@@ -278,6 +280,15 @@ export function ImportReview({
|
||||
)}
|
||||
</label>
|
||||
)}
|
||||
{assets.items.find(
|
||||
(check) => check.classname === item.classname,
|
||||
) && (
|
||||
<ImportSourceDiagnostics
|
||||
check={assets.items.find(
|
||||
(check) => check.classname === item.classname,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FurnitureComparison item={item} local={local} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string, args?: Record<string, unknown>) =>
|
||||
`${key}${args?.status ?? ""}`,
|
||||
}));
|
||||
it("shows filenames, attempted addresses and distinct reasons inline", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ImportSourceDiagnostics
|
||||
check={{
|
||||
classname: "chair",
|
||||
revision: 1,
|
||||
state: "missing",
|
||||
alternatives: [],
|
||||
attempts: [
|
||||
{
|
||||
file: "chair.swf",
|
||||
url: "https://source.test/1/chair.swf",
|
||||
state: "missing",
|
||||
reason: "http",
|
||||
status: 404,
|
||||
},
|
||||
{
|
||||
file: "chair.nitro",
|
||||
url: "https://source.test/chair.nitro",
|
||||
state: "unknown",
|
||||
reason: "timeout",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("chair.swf");
|
||||
expect(html).toContain("http404");
|
||||
expect(html).toContain("timeout");
|
||||
expect(html).toContain("https://source.test/1/chair.swf");
|
||||
expect(html).not.toContain("href=");
|
||||
});
|
||||
it("does not invent diagnostics for earlier responses", () => {
|
||||
expect(
|
||||
renderToStaticMarkup(
|
||||
<ImportSourceDiagnostics
|
||||
check={{
|
||||
classname: "chair",
|
||||
revision: 1,
|
||||
state: "missing",
|
||||
alternatives: [],
|
||||
}}
|
||||
/>,
|
||||
),
|
||||
).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SourceAssetCheck } from "@/lib/furni/source-assets";
|
||||
export function ImportSourceDiagnostics({
|
||||
check,
|
||||
}: {
|
||||
check?: SourceAssetCheck;
|
||||
}) {
|
||||
const t = useTranslations("admin.studio.importDiagnostics");
|
||||
if (!check?.attempts?.length) return null;
|
||||
return (
|
||||
<details className="mt-2 text-xs">
|
||||
<summary className="cursor-pointer">{t("attempts")}</summary>
|
||||
<p className="mt-2 text-[var(--admin-text-muted)]">{t("preliminary")}</p>
|
||||
<ul className="space-y-2 py-2">
|
||||
{[
|
||||
...new Map(
|
||||
check.attempts.map((attempt) => [attempt.url, attempt]),
|
||||
).values(),
|
||||
].map((attempt) => (
|
||||
<li key={attempt.url}>
|
||||
<strong>{attempt.file}</strong> ·{" "}
|
||||
{attempt.reason === "http"
|
||||
? t("http", { status: attempt.status ?? 0 })
|
||||
: t(attempt.reason)}
|
||||
<code className="block break-all">{attempt.url}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +1,87 @@
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
|
||||
import type { ImportJobItem } from "@/lib/furni/import-job";
|
||||
import {
|
||||
createFurnitureJobsClient,
|
||||
FurnitureJobsHttpError,
|
||||
type FurnitureJobsPage,
|
||||
furnitureJobsQueryOptions,
|
||||
readFurnitureJobResponse,
|
||||
} from "./furniture-jobs-query";
|
||||
|
||||
export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const [client] = useState(createFurnitureJobsClient);
|
||||
const cursor = useRef<string | null>(null);
|
||||
const previous = useRef<Array<string | null>>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [pageCursor, setPageCursor] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const query = useQuery(
|
||||
{ ...furnitureJobsQueryOptions(paginated, pageCursor), enabled: false },
|
||||
client,
|
||||
);
|
||||
const unauthorized =
|
||||
query.error instanceof FurnitureJobsHttpError &&
|
||||
[401, 403].includes(query.error.status);
|
||||
const jobs = unauthorized ? [] : (query.data?.jobs ?? []);
|
||||
const nextCursor = unauthorized ? null : (query.data?.nextCursor ?? null);
|
||||
const loading = query.isFetching;
|
||||
const error = query.error?.message ?? "";
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
const cancelInFlight = useRef(false);
|
||||
const [jobs, setJobs] = useState<ImportJob[]>([]),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const completed = useRef(new Set<string>()),
|
||||
callback = useRef(onComplete);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const completed = useRef(new Set<string>());
|
||||
const callback = useRef(onComplete);
|
||||
callback.current = onComplete;
|
||||
const request = useRef<{ payload: string; id: string } | null>(null);
|
||||
const submitting = useRef(false);
|
||||
const refreshing = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshing.current) return;
|
||||
refreshing.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await adminFetch(
|
||||
`/api/admin/studio/import-jobs${paginated ? `?page=1${cursor.current ? `&before=${encodeURIComponent(cursor.current)}` : ""}` : ""}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
data.error || `Import history unavailable (${response.status})`,
|
||||
);
|
||||
setJobs(data.jobs);
|
||||
setNextCursor(data.nextCursor ?? null);
|
||||
setError("");
|
||||
let changed = false;
|
||||
for (const job of data.jobs as ImportJob[])
|
||||
if (
|
||||
(job.state === "completed" ||
|
||||
job.state === "interrupted" ||
|
||||
job.state === "cancelled") &&
|
||||
!completed.current.has(job.id)
|
||||
) {
|
||||
completed.current.add(job.id);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) callback.current();
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Cannot load import history",
|
||||
);
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
setLoading(false);
|
||||
useEffect(
|
||||
() => () => {
|
||||
client.clear();
|
||||
},
|
||||
[client],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!query.data || unauthorized) return;
|
||||
let changed = false;
|
||||
for (const job of query.data.jobs) {
|
||||
if (
|
||||
["completed", "interrupted", "cancelled"].includes(job.state) &&
|
||||
!completed.current.has(job.id)
|
||||
) {
|
||||
completed.current.add(job.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}, [paginated]);
|
||||
if (changed) callback.current();
|
||||
}, [query.data, unauthorized]);
|
||||
const refresh = useCallback(async () => {
|
||||
// fetchQuery shares an in-flight GET. Query owns data, errors and loading.
|
||||
await client
|
||||
.fetchQuery(furnitureJobsQueryOptions(paginated, cursor.current))
|
||||
.catch(() => undefined);
|
||||
}, [client, paginated]);
|
||||
const resetPage = () => {
|
||||
cursor.current = null;
|
||||
previous.current = [];
|
||||
setPageCursor(null);
|
||||
setPage(1);
|
||||
};
|
||||
const navigate = async (direction: "next" | "previous") => {
|
||||
if (refreshing.current) return;
|
||||
if (client.isFetching()) return;
|
||||
if (direction === "next" && nextCursor) {
|
||||
previous.current.push(cursor.current);
|
||||
cursor.current = nextCursor;
|
||||
} else if (direction === "previous" && previous.current.length)
|
||||
cursor.current = previous.current.pop() ?? null;
|
||||
else return;
|
||||
setPageCursor(cursor.current);
|
||||
setPage(previous.current.length + 1);
|
||||
setJobs([]);
|
||||
setNextCursor(null);
|
||||
await refresh();
|
||||
};
|
||||
const retry = async (id: string) => {
|
||||
@@ -83,11 +94,9 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, action: "retry" }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || t("cancelFailed"));
|
||||
cursor.current = null;
|
||||
previous.current = [];
|
||||
setPage(1);
|
||||
await readFurnitureJobResponse(response, t("cancelFailed"));
|
||||
await client.cancelQueries();
|
||||
resetPage();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
|
||||
@@ -99,6 +108,7 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
const active = jobs.some(
|
||||
(job) => job.state === "queued" || job.state === "running",
|
||||
);
|
||||
// Retain settle-then-poll timing and visibility behavior without global focus state.
|
||||
useVisiblePolling(refresh, active ? 5000 : 30000);
|
||||
const cancel = async (id: string) => {
|
||||
if (cancelInFlight.current) return;
|
||||
@@ -110,10 +120,17 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || t("cancelFailed"));
|
||||
setJobs((current) =>
|
||||
current.map((job) => (job.id === id ? data.job : job)),
|
||||
const job = await readFurnitureJobResponse(response, t("cancelFailed"));
|
||||
await client.cancelQueries();
|
||||
client.setQueriesData<FurnitureJobsPage>(
|
||||
{ queryKey: ["furniture-import-history"] },
|
||||
(current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
jobs: current.jobs.map((item) => (item.id === id ? job : item)),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
@@ -145,12 +162,10 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...body, id: request.current.id }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
data.error || `Could not queue import (${response.status})`,
|
||||
);
|
||||
await readFurnitureJobResponse(response, "Could not queue import");
|
||||
request.current = null;
|
||||
await client.cancelQueries();
|
||||
resetPage();
|
||||
await refresh();
|
||||
toast.success(
|
||||
"Import queued. You can leave this page and return to its history.",
|
||||
|
||||
Reference in new issue
Block a user