feat(cms): add recovery history and operational reliability tools

This commit is contained in:
Simo committed 2026-09-09 21:57:56 +02:00
1 parent 89526af344
commit 1ef405be0a
92 files changed
+6384 -267

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { type RefObject, useEffect } from "react";
export function useFormErrorFocus(
form: RefObject<HTMLFormElement | null>,
errors: Record<string, string[]>,
pending: boolean,
) {
useEffect(() => {
if (pending) return;
for (const field of Object.keys(errors)) {
const control = form.current?.elements.namedItem(field);
if (control instanceof HTMLElement && !control.hasAttribute("disabled")) {
control.focus();
break;
}
}
}, [form, errors, pending]);
}
export function FormFieldError({
name,
errors,
}: {
name: string;
errors: Record<string, string[]>;
}) {
const messages = errors[name];
return messages?.length ? (
<p id={`error-${name}`} role="alert" className="text-sm text-destructive">
{messages.join(" · ")}
</p>
) : null;
}
@@ -0,0 +1,28 @@
"use client";
import { useTranslations } from "next-intl";
import { CatalogExportClient } from "@/app/admin/studio/sync/catalog-export-client";
import Link from "@/components/link";
import { FurnitureJobHistory } from "./studio/furniture-jobs";
import { useFurnitureJobs } from "./studio/use-furniture-jobs";
export function OperationsCenter() {
const t = useTranslations("pages.admin.operationsCenter");
const jobs = useFurnitureJobs(() => {}, true);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t("title")}</h1>
<p className="text-[var(--admin-text-muted)]">{t("hint")}</p>
<FurnitureJobHistory state={jobs} expanded />
<CatalogExportClient monitorOnly />
<section className="admin-card space-y-3">
<h2 className="text-lg font-semibold">{t("syncTitle")}</h2>
<p>{t("syncHint")}</p>
<Link href="/admin/studio/sync" className="btn btn-outline">
{t("openSync")}
</Link>
<Link href="/admin/studio/furni" className="btn btn-outline">
{t("openImports")}
</Link>
</section>
</div>
);
}
+1 -1
View File
@@ -58,7 +58,7 @@ export async function OperationsInbox() {
source: "imports",
count: total,
severity: 2,
href: "/admin/studio/furni",
href: "/admin/operations",
},
];
},
+35 -12
View File
@@ -7,14 +7,19 @@ import { retryableJobItems } from "@/lib/furni/import-job-retry";
import type { useFurnitureJobs } from "./use-furniture-jobs";
export function FurnitureJobHistory({
state,
expanded = false,
}: {
state: ReturnType<typeof useFurnitureJobs>;
expanded?: boolean;
}) {
const t = useTranslations("pages.admin.importHistory");
const format = useFormatter();
const [limits, setLimits] = useState<Record<string, number>>({});
return (
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
<details
open={expanded || undefined}
className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"
>
<summary className="cursor-pointer">
{t("summary", {
count: state.jobs.filter(
@@ -22,15 +27,40 @@ export function FurnitureJobHistory({
).length,
})}
</summary>
<div className="max-h-72 space-y-2 overflow-auto py-2">
<div
className={
expanded ? "space-y-2 py-2" : "max-h-72 space-y-2 overflow-auto py-2"
}
>
{state.error && <p role="alert">{state.error}</p>}
<p className="text-xs text-[var(--admin-text-muted)]">
{t("historyLimit")}
{state.paginated ? t("ownerHistory") : t("historyLimit")}
</p>
<Button variant="outline" onClick={() => void state.refresh()}>
{t("refreshHistory")}
</Button>
{!state.jobs.length && <p>{t("empty")}</p>}
{state.paginated && (
<div className="flex items-center gap-3">
<Button
variant="outline"
disabled={state.loading || state.page === 1}
onClick={() => void state.navigate("previous")}
>
{t("previousPage")}
</Button>
<span>{t("page", { page: state.page })}</span>
<Button
variant="outline"
disabled={state.loading || !state.nextCursor}
onClick={() => void state.navigate("next")}
>
{t("nextPage")}
</Button>
</div>
)}
{!state.jobs.length && !state.loading && !state.error && (
<p>{t("empty")}</p>
)}
{state.jobs.map((job) => {
const retryItems = retryableJobItems(job);
const done = job.items.filter((i) => i.state === "done"),
@@ -123,14 +153,7 @@ export function FurnitureJobHistory({
<Button
variant="outline"
disabled={state.busy}
onClick={() =>
void state.submit(retryItems, {
sourceId: job.sourceId,
mode: job.mode,
translate: job.translate,
langs: job.langs,
})
}
onClick={() => void state.retry(job.id)}
>
{t(
job.state === "interrupted" || job.state === "cancelled"
@@ -5,8 +5,13 @@ 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";
export function useFurnitureJobs(onComplete: () => void) {
export function useFurnitureJobs(onComplete: () => void, paginated = false) {
const t = useTranslations("pages.admin.importHistory");
const cursor = useRef<string | null>(null);
const previous = useRef<Array<string | null>>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [cancelling, setCancelling] = useState<string | null>(null);
const cancelInFlight = useRef(false);
const [jobs, setJobs] = useState<ImportJob[]>([]),
@@ -21,14 +26,18 @@ export function useFurnitureJobs(onComplete: () => void) {
const refresh = useCallback(async () => {
if (refreshing.current) return;
refreshing.current = true;
setLoading(true);
try {
const response = await adminFetch("/api/admin/studio/import-jobs");
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[])
@@ -48,8 +57,45 @@ export function useFurnitureJobs(onComplete: () => void) {
);
} finally {
refreshing.current = false;
setLoading(false);
}
}, []);
}, [paginated]);
const navigate = async (direction: "next" | "previous") => {
if (refreshing.current) 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;
setPage(previous.current.length + 1);
setJobs([]);
setNextCursor(null);
await refresh();
};
const retry = async (id: string) => {
if (submitting.current) return;
submitting.current = true;
setBusy(true);
try {
const response = await adminFetch("/api/admin/studio/import-jobs", {
method: "PATCH",
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 refresh();
} catch (error) {
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
} finally {
submitting.current = false;
setBusy(false);
}
};
const active = jobs.some(
(job) => job.state === "queued" || job.state === "running",
);
@@ -120,5 +166,19 @@ export function useFurnitureJobs(onComplete: () => void) {
setBusy(false);
}
};
return { jobs, busy, error, submit, refresh, cancel, cancelling };
return {
jobs,
busy,
error,
submit,
refresh,
cancel,
cancelling,
retry,
navigate,
page,
nextCursor,
loading,
paginated,
};
}