feat(admin): reorganize housekeeping and strengthen shared workflows
CI / check (push) Successful in 1m5s
CI / deploy (push) Successful in 1m4s

This commit is contained in:
Simo committed 2026-09-06 17:17:30 +02:00
1 parent 35f66d879f
commit a070004e7b
35 files changed
+1667 -462

No files matched your search

+35 -15
View File
@@ -1,26 +1,29 @@
"use client";
import { useFormatter, useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { retryableJobItems } from "@/lib/furni/import-job-retry";
import type { useFurnitureJobs } from "./use-furniture-jobs";
export function FurnitureJobHistory({
state,
}: {
state: ReturnType<typeof useFurnitureJobs>;
}) {
const t = useTranslations("pages.admin.importHistory");
const format = useFormatter();
return (
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
<summary className="cursor-pointer">
Import history ·{" "}
{
state.jobs.filter(
(j) => j.state === "queued" || j.state === "running",
).length
}{" "}
active
{t("summary", {
count: state.jobs.filter(
(job) => job.state === "queued" || job.state === "running",
).length,
})}
</summary>
<div className="max-h-72 space-y-2 overflow-auto py-2">
{state.error && <p role="alert">{state.error}</p>}
{!state.jobs.length && <p>No background imports yet.</p>}
{!state.jobs.length && <p>{t("empty")}</p>}
{state.jobs.map((job) => {
const retryItems = retryableJobItems(job);
const done = job.items.filter((i) => i.state === "done"),
failed = job.items.filter((i) => i.state === "failed");
return (
@@ -29,14 +32,27 @@ export function FurnitureJobHistory({
className="rounded border border-[var(--admin-border)] p-2"
>
<summary className="cursor-pointer">
{new Date(job.createdAt).toLocaleString()} · {job.state} ·{" "}
{done.length}/{job.items.length} imported · {failed.length}{" "}
failed
{format.dateTime(new Date(job.createdAt), {
dateStyle: "short",
timeStyle: "short",
})}{" "}
· {t(job.state)} ·{" "}
{t("result", {
done: done.length,
total: job.items.length,
failed: failed.length,
})}
</summary>
<progress
className="mt-2 h-2 w-full"
value={done.length + failed.length}
max={Math.max(1, job.items.length)}
aria-label={t("progress")}
/>
<ul className="space-y-1 py-2">
{job.items.map((item) => (
<li key={item.classname}>
<strong>{item.classname}</strong> · {item.state}
<strong>{item.classname}</strong> · {t(item.state)}
{item.itemId ? ` · ID ${item.itemId}` : ""}
{item.error && (
<p className="text-[var(--admin-warning)]">
@@ -54,12 +70,12 @@ export function FurnitureJobHistory({
</li>
))}
</ul>
{failed.length > 0 && job.state !== "running" && (
{retryItems.length > 0 && (
<Button
variant="outline"
disabled={state.busy}
onClick={() =>
void state.submit(failed, {
void state.submit(retryItems, {
sourceId: job.sourceId,
mode: job.mode,
translate: job.translate,
@@ -67,7 +83,11 @@ export function FurnitureJobHistory({
})
}
>
Retry failed items only
{t(
job.state === "interrupted"
? "retryUnfinished"
: "retryFailed",
)}
</Button>
)}
</details>
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, 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";
export function useFurnitureJobs(onComplete: () => void) {
@@ -12,7 +13,10 @@ export function useFurnitureJobs(onComplete: () => void) {
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;
try {
const response = await adminFetch("/api/admin/studio/import-jobs");
const data = await response.json();
@@ -36,13 +40,11 @@ export function useFurnitureJobs(onComplete: () => void) {
setError(
error instanceof Error ? error.message : "Cannot load import history",
);
} finally {
refreshing.current = false;
}
}, []);
useEffect(() => {
void refresh();
const timer = setInterval(() => void refresh(), 5000);
return () => clearInterval(timer);
}, [refresh]);
useVisiblePolling(refresh, 5000);
const submit = async (
items: ImportJobItem[],
options: {