perf(studio): defer import history and Nitro scale tools until opened
CI / check (push) Successful in 3m7s
CI / deploy (push) Successful in 1m2s
CI / publish-container (push) Successful in 1m8s

This commit is contained in:
Simo committed 2026-09-13 18:29:24 +02:00
1 parent 13665e0c3c
commit afea80708c
5 files changed
+339 -199

No files matched your search

@@ -0,0 +1,194 @@
"use client";
import Link from "next/link";
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 FurnitureJobHistoryBody({
state,
expanded = false,
}: {
state: ReturnType<typeof useFurnitureJobs>;
expanded?: boolean;
}) {
const t = useTranslations("pages.admin.importHistory");
const format = useFormatter();
const progressT = useTranslations("pages.admin.importProgress");
const [limits, setLimits] = useState<Record<string, number>>({});
return (
<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)]">
{state.paginated ? t("ownerHistory") : t("historyLimit")}
</p>
<Button variant="outline" onClick={() => void state.refresh()}>
{t("refreshHistory")}
</Button>
{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"),
failed = job.items.filter((i) => i.state === "failed");
return (
<details
key={job.id}
className="rounded border border-[var(--admin-border)] p-2"
>
<summary className="cursor-pointer">
{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.slice(0, limits[job.id] ?? 50).map((item) => (
<li key={item.classname}>
<strong>{item.classname}</strong> · {t(item.state)}
{item.itemId ? ` · ID ${item.itemId}` : ""}
{item.phase &&
(item.state === "running" ||
item.state === "failed" ||
item.state === "interrupted") && (
<p className="text-xs text-[var(--admin-text-muted)]">
{progressT(
item.state === "running" ? "current" : "last",
{ phase: progressT(item.phase) },
)}
{item.state === "running" &&
item.phaseStartedAt &&
` · ${progressT("elapsed", { seconds: Math.max(0, Math.floor((Date.now() - Date.parse(item.phaseStartedAt)) / 1000)) })}`}
{item.sourceAttempt && ` · ${item.sourceAttempt}`}
</p>
)}
{item.recoveredSource && (
<p className="break-words text-xs text-[var(--admin-text-muted)]">
{progressT("recovered", {
source: item.recoveredSource.sourceName,
revision: item.recoveredSource.revision,
})}
</p>
)}
{item.error && (
<p className="text-[var(--admin-warning)]">{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}
className="text-xs text-[var(--admin-text-muted)]"
>
{warning}
</p>
))}
</li>
))}
</ul>
{job.items.length > (limits[job.id] ?? 50) && (
<Button
variant="outline"
onClick={() =>
setLimits((current) => ({
...current,
[job.id]: (current[job.id] ?? 50) + 50,
}))
}
>
{t("showMoreItems")}
</Button>
)}
{(job.state === "queued" || job.state === "running") && (
<div className="space-y-2 py-2">
<p className="text-xs text-[var(--admin-text-muted)]">
{t(job.cancelRequested ? "cancelRequested" : "cancelHint")}
</p>
<Button
variant="outline"
disabled={state.cancelling !== null || job.cancelRequested}
onClick={() => void state.cancel(job.id)}
>
{t("cancelRemaining")}
</Button>
</div>
)}
{job.items.some(
(item) =>
item.state === "interrupted" ||
item.error?.startsWith("Server restarted during import.") ||
(item.state === "running" && job.state === "interrupted"),
) && (
<p role="alert" className="py-2 text-[var(--admin-warning)]">
{t("uncertainOutcome")}{" "}
<Link href="/admin/studio/audit" className="underline">
{t("reviewLocalData")}
</Link>
</p>
)}
{retryItems.length > 0 && (
<Button
variant="outline"
disabled={state.busy}
onClick={() => void state.retry(job.id)}
>
{t(
job.state === "interrupted" || job.state === "cancelled"
? "retryUnfinished"
: "retryFailed",
)}
</Button>
)}
</details>
);
})}
</div>
);
}
+24 -183
View File
@@ -1,11 +1,13 @@
"use client";
import Link from "next/link";
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 { useTranslations } from "next-intl";
import { lazy, Suspense, useState } from "react";
import type { useFurnitureJobs } from "./use-furniture-jobs";
const HistoryBody = lazy(() =>
import("./furniture-job-history-body").then((module) => ({
default: module.FurnitureJobHistoryBody,
})),
);
export function FurnitureJobHistory({
state,
expanded = false,
@@ -14,12 +16,14 @@ export function FurnitureJobHistory({
expanded?: boolean;
}) {
const t = useTranslations("pages.admin.importHistory");
const format = useFormatter();
const progressT = useTranslations("pages.admin.importProgress");
const [limits, setLimits] = useState<Record<string, number>>({});
const common = useTranslations("pages.admin.housekeeping");
const [opened, setOpened] = useState(expanded);
return (
<details
open={expanded || undefined}
onToggle={(event) => {
if (event.currentTarget.open) setOpened(true);
}}
className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"
>
<summary className="cursor-pointer">
@@ -29,180 +33,17 @@ export function FurnitureJobHistory({
).length,
})}
</summary>
<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)]">
{state.paginated ? t("ownerHistory") : t("historyLimit")}
</p>
<Button variant="outline" onClick={() => void state.refresh()}>
{t("refreshHistory")}
</Button>
{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"),
failed = job.items.filter((i) => i.state === "failed");
return (
<details
key={job.id}
className="rounded border border-[var(--admin-border)] p-2"
>
<summary className="cursor-pointer">
{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.slice(0, limits[job.id] ?? 50).map((item) => (
<li key={item.classname}>
<strong>{item.classname}</strong> · {t(item.state)}
{item.itemId ? ` · ID ${item.itemId}` : ""}
{item.phase &&
(item.state === "running" ||
item.state === "failed" ||
item.state === "interrupted") && (
<p className="text-xs text-[var(--admin-text-muted)]">
{progressT(
item.state === "running" ? "current" : "last",
{ phase: progressT(item.phase) },
)}
{item.state === "running" &&
item.phaseStartedAt &&
` · ${progressT("elapsed", { seconds: Math.max(0, Math.floor((Date.now() - Date.parse(item.phaseStartedAt)) / 1000)) })}`}
{item.sourceAttempt && ` · ${item.sourceAttempt}`}
</p>
)}
{item.recoveredSource && (
<p className="break-words text-xs text-[var(--admin-text-muted)]">
{progressT("recovered", {
source: item.recoveredSource.sourceName,
revision: item.recoveredSource.revision,
})}
</p>
)}
{item.error && (
<p className="text-[var(--admin-warning)]">
{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}
className="text-xs text-[var(--admin-text-muted)]"
>
{warning}
</p>
))}
</li>
))}
</ul>
{job.items.length > (limits[job.id] ?? 50) && (
<Button
variant="outline"
onClick={() =>
setLimits((current) => ({
...current,
[job.id]: (current[job.id] ?? 50) + 50,
}))
}
>
{t("showMoreItems")}
</Button>
)}
{(job.state === "queued" || job.state === "running") && (
<div className="space-y-2 py-2">
<p className="text-xs text-[var(--admin-text-muted)]">
{t(job.cancelRequested ? "cancelRequested" : "cancelHint")}
</p>
<Button
variant="outline"
disabled={state.cancelling !== null || job.cancelRequested}
onClick={() => void state.cancel(job.id)}
>
{t("cancelRemaining")}
</Button>
</div>
)}
{job.items.some(
(item) =>
item.state === "interrupted" ||
item.error?.startsWith("Server restarted during import.") ||
(item.state === "running" && job.state === "interrupted"),
) && (
<p role="alert" className="py-2 text-[var(--admin-warning)]">
{t("uncertainOutcome")}{" "}
<Link href="/admin/studio/audit" className="underline">
{t("reviewLocalData")}
</Link>
</p>
)}
{retryItems.length > 0 && (
<Button
variant="outline"
disabled={state.busy}
onClick={() => void state.retry(job.id)}
>
{t(
job.state === "interrupted" || job.state === "cancelled"
? "retryUnfinished"
: "retryFailed",
)}
</Button>
)}
</details>
);
})}
</div>
{(opened || expanded) && (
<Suspense
fallback={
<p role="status" className="py-2">
{common("loading")}
</p>
}
>
<HistoryBody state={state} expanded={expanded} />
</Suspense>
)}
</details>
);
}