perf(studio): defer import history and Nitro scale tools until opened
This commit is contained in:
1 parent
13665e0c3c
commit
afea80708c
5 files changed
+339
-199
No files matched your search
@@ -33,10 +33,28 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
|||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const mutations: string[] = [];
|
const mutations: string[] = [];
|
||||||
let metadataRequests = 0;
|
let metadataRequests = 0;
|
||||||
|
let qualityRequests = 0;
|
||||||
page.on("pageerror", (error) => errors.push(error.message));
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
await page.route("**/api/**", async (route) => {
|
await page.route("**/api/**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const url = new URL(request.url());
|
const url = new URL(request.url());
|
||||||
|
if (url.pathname === "/api/admin/studio/nitro-quality") {
|
||||||
|
qualityRequests++;
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
report: {
|
||||||
|
scales: [32, 64].map((size) => ({
|
||||||
|
size,
|
||||||
|
state: "missing",
|
||||||
|
assets: [],
|
||||||
|
animations: [],
|
||||||
|
directions: [],
|
||||||
|
issues: [],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (url.pathname === "/api/admin/studio/inspect") {
|
if (url.pathname === "/api/admin/studio/inspect") {
|
||||||
const { classnames } = request.postDataJSON() as { classnames: string[] };
|
const { classnames } = request.postDataJSON() as { classnames: string[] };
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
@@ -113,6 +131,17 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
|||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "View Fixture chair", exact: true }),
|
page.getByRole("button", { name: "View Fixture chair", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
const history = page.getByRole("button", {
|
||||||
|
name: "Refresh history",
|
||||||
|
includeHidden: true,
|
||||||
|
});
|
||||||
|
await expect(history).toHaveCount(0);
|
||||||
|
await page.locator("summary").filter({ hasText: "Import history" }).click();
|
||||||
|
await expect(history).toBeVisible();
|
||||||
|
await page.locator("summary").filter({ hasText: "Import history" }).click();
|
||||||
|
await expect(history).toBeHidden();
|
||||||
|
await page.locator("summary").filter({ hasText: "Import history" }).click();
|
||||||
|
await expect(history).toBeVisible();
|
||||||
expect(metadataRequests).toBe(0);
|
expect(metadataRequests).toBe(0);
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "View Fixture chair", exact: true })
|
.getByRole("button", { name: "View Fixture chair", exact: true })
|
||||||
@@ -138,6 +167,20 @@ test("Studio opens and dismisses detail, Nitro editor and import review", async
|
|||||||
editor.getByRole("switch", { name: "Can Sit On", exact: true }),
|
editor.getByRole("switch", { name: "Can Sit On", exact: true }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
expect(metadataRequests).toBe(1);
|
expect(metadataRequests).toBe(1);
|
||||||
|
expect(qualityRequests).toBe(0);
|
||||||
|
await editor.getByRole("tab", { name: "32 / 64", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
editor.getByRole("button", { name: "Preview generation", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
editor.getByText("Scale 32 · Not included", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(qualityRequests).toBe(1);
|
||||||
|
await editor.getByRole("tab", { name: "Visual", exact: true }).click();
|
||||||
|
await expect(
|
||||||
|
editor.getByRole("switch", { name: "Can Sit On", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||||
await expect(editor).toHaveCount(0);
|
await expect(editor).toHaveCount(0);
|
||||||
await page
|
await page
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useEffectEvent, useState } from "react";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { lazy, Suspense, useEffect, useEffectEvent, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { NitroQualityPanel } from "@/components/admin/studio/nitro-quality-panel";
|
|
||||||
import { NitroScale32Generator } from "@/components/admin/studio/nitro-scale32-generator";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +41,17 @@ import {
|
|||||||
nitroAnimationStatesCount,
|
nitroAnimationStatesCount,
|
||||||
} from "@/lib/furni/auto-interaction";
|
} from "@/lib/furni/auto-interaction";
|
||||||
|
|
||||||
|
const NitroQualityPanel = lazy(() =>
|
||||||
|
import("@/components/admin/studio/nitro-quality-panel").then((module) => ({
|
||||||
|
default: module.NitroQualityPanel,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const NitroScale32Generator = lazy(() =>
|
||||||
|
import("@/components/admin/studio/nitro-scale32-generator").then(
|
||||||
|
(module) => ({ default: module.NitroScale32Generator }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
interface NitroEditorDialogProps {
|
interface NitroEditorDialogProps {
|
||||||
classname: string;
|
classname: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -135,6 +146,7 @@ export function NitroEditorDialog({
|
|||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: NitroEditorDialogProps) {
|
}: NitroEditorDialogProps) {
|
||||||
|
const loadingT = useTranslations("pages.admin.housekeeping");
|
||||||
const [metadata, setMetadata] = useState<NitroMetadata | null>(null);
|
const [metadata, setMetadata] = useState<NitroMetadata | null>(null);
|
||||||
const [originalMetadata, setOriginalMetadata] =
|
const [originalMetadata, setOriginalMetadata] =
|
||||||
useState<NitroMetadata | null>(null);
|
useState<NitroMetadata | null>(null);
|
||||||
@@ -448,19 +460,21 @@ export function NitroEditorDialog({
|
|||||||
value="quality"
|
value="quality"
|
||||||
className="min-h-0 overflow-y-auto flex-1 px-4 sm:px-6 py-4"
|
className="min-h-0 overflow-y-auto flex-1 px-4 sm:px-6 py-4"
|
||||||
>
|
>
|
||||||
<NitroScale32Generator
|
<Suspense fallback={<p role="status">{loadingT("loading")}</p>}>
|
||||||
classname={classname}
|
<NitroScale32Generator
|
||||||
disabled={hasChanges || saving}
|
classname={classname}
|
||||||
onChanged={(updated) => {
|
disabled={hasChanges || saving}
|
||||||
setBundleRevision((value) => value + 1);
|
onChanged={(updated) => {
|
||||||
setMetadata(updated as NitroMetadata);
|
setBundleRevision((value) => value + 1);
|
||||||
setOriginalMetadata(
|
setMetadata(updated as NitroMetadata);
|
||||||
structuredClone(updated) as NitroMetadata,
|
setOriginalMetadata(
|
||||||
);
|
structuredClone(updated) as NitroMetadata,
|
||||||
setJsonText(JSON.stringify(updated, null, 2));
|
);
|
||||||
}}
|
setJsonText(JSON.stringify(updated, null, 2));
|
||||||
/>
|
}}
|
||||||
<NitroQualityPanel key={bundleRevision} classname={classname} />
|
/>
|
||||||
|
<NitroQualityPanel key={bundleRevision} classname={classname} />
|
||||||
|
</Suspense>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{/* ── Visual Tab ── */}
|
{/* ── Visual Tab ── */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Link from "next/link";
|
import { useTranslations } from "next-intl";
|
||||||
import { useFormatter, useTranslations } from "next-intl";
|
import { lazy, Suspense, useState } from "react";
|
||||||
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";
|
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||||
|
|
||||||
|
const HistoryBody = lazy(() =>
|
||||||
|
import("./furniture-job-history-body").then((module) => ({
|
||||||
|
default: module.FurnitureJobHistoryBody,
|
||||||
|
})),
|
||||||
|
);
|
||||||
export function FurnitureJobHistory({
|
export function FurnitureJobHistory({
|
||||||
state,
|
state,
|
||||||
expanded = false,
|
expanded = false,
|
||||||
@@ -14,12 +16,14 @@ export function FurnitureJobHistory({
|
|||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("pages.admin.importHistory");
|
const t = useTranslations("pages.admin.importHistory");
|
||||||
const format = useFormatter();
|
const common = useTranslations("pages.admin.housekeeping");
|
||||||
const progressT = useTranslations("pages.admin.importProgress");
|
const [opened, setOpened] = useState(expanded);
|
||||||
const [limits, setLimits] = useState<Record<string, number>>({});
|
|
||||||
return (
|
return (
|
||||||
<details
|
<details
|
||||||
open={expanded || undefined}
|
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"
|
className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<summary className="cursor-pointer">
|
<summary className="cursor-pointer">
|
||||||
@@ -29,180 +33,17 @@ export function FurnitureJobHistory({
|
|||||||
).length,
|
).length,
|
||||||
})}
|
})}
|
||||||
</summary>
|
</summary>
|
||||||
<div
|
{(opened || expanded) && (
|
||||||
className={
|
<Suspense
|
||||||
expanded ? "space-y-2 py-2" : "max-h-72 space-y-2 overflow-auto py-2"
|
fallback={
|
||||||
}
|
<p role="status" className="py-2">
|
||||||
>
|
{common("loading")}
|
||||||
{state.error && <p role="alert">{state.error}</p>}
|
</p>
|
||||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
}
|
||||||
{state.paginated ? t("ownerHistory") : t("historyLimit")}
|
>
|
||||||
</p>
|
<HistoryBody state={state} expanded={expanded} />
|
||||||
<Button variant="outline" onClick={() => void state.refresh()}>
|
</Suspense>
|
||||||
{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>
|
|
||||||
</details>
|
</details>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
it("keeps scale preview and generation outside the initial Nitro editor bundle", async () => {
|
||||||
|
const entry = "src/app/admin/import/furni/nitro-editor-dialog.tsx";
|
||||||
|
const result = await build({
|
||||||
|
entryPoints: [entry],
|
||||||
|
bundle: true,
|
||||||
|
splitting: true,
|
||||||
|
format: "esm",
|
||||||
|
write: false,
|
||||||
|
outdir: "storage/bundle-check",
|
||||||
|
metafile: true,
|
||||||
|
packages: "external",
|
||||||
|
alias: { "@": path.resolve("src") },
|
||||||
|
});
|
||||||
|
const outputs = result.metafile.outputs;
|
||||||
|
const start = Object.keys(outputs).find(
|
||||||
|
(key) => outputs[key].entryPoint === entry,
|
||||||
|
);
|
||||||
|
expect(start).toBeDefined();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
function visit(key: string) {
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
for (const dependency of outputs[key].imports) {
|
||||||
|
if (dependency.kind !== "dynamic-import" && !dependency.external)
|
||||||
|
visit(dependency.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!start) throw new Error("Missing editor entry bundle");
|
||||||
|
visit(start);
|
||||||
|
const initialInputs = new Set(
|
||||||
|
[...seen].flatMap((key) => Object.keys(outputs[key].inputs)),
|
||||||
|
);
|
||||||
|
for (const name of [
|
||||||
|
"nitro-quality-panel.tsx",
|
||||||
|
"nitro-scale32-generator.tsx",
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
Object.keys(result.metafile.inputs).some((input) => input.endsWith(name)),
|
||||||
|
).toBe(true);
|
||||||
|
expect([...initialInputs].some((input) => input.endsWith(name))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
Reference in new issue
Block a user