Files
silo-server/web/src/components/AdminCatalogMaintenance.tsx
T
2e0e145721 feat(settings): per-user date and time display format settings (#304)
* feat(settings): per-user date and time display format settings

Add ui.date_format (auto, DD/MM/YYYY, MM/DD/YYYY, YYYY-MM-DD) and
ui.time_format (auto, 12h, 24h) as validated user-scoped settings, a
shared preference-aware formatter module (web/src/lib/datetime.ts)
synced via DateTimeFormatProvider, a Date & time section in Appearance
settings, and convert all absolute date/time display call sites to the
shared formatters.

Closes #303

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): make loaded API settings authoritative for date/time formats

Address adversarial review: once the authenticated settings request
resolves, a missing ui.date_format/ui.time_format key means "auto"
instead of falling back to device-wide localStorage (which could carry
another user's preference), and failed saves roll back through the
query cache. Layout and AdminLayout subscribe to the format store so
all routed pages re-render live when the preference changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): reliable re-render and rollback for date/time format changes

ReactiveAppRoutes re-renders the routed page tree when the format
preference changes (a Layout-level subscription cannot re-render stable
children elements); memoized AdminLogs rows and the out-of-route
PlayingNextScreen subscribe directly. useSetSetting now rolls back only
the mutated key on error and invalidates the settings list on settle so
overlapping saves cannot resurrect stale values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): guard same-key rollback against newer optimistic saves

Roll back a failed setting save only while its optimistic value is
still current in the cache, invalidate the detail query on settle, and
add a regression test for overlapping same-key mutations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): owner-bind the datetime format warm start, pad 24h hours

Address PR review: the localStorage warm start is now tagged with the
user id that mirrored it and is ignored for a different authenticated
user, so a failed settings request can no longer leak another account's
format on a shared browser. The 24h branch of formatTime defaults to
2-digit hours ("09:04") since h23 alone does not guarantee padding in
every locale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 16:45:06 -04:00

637 lines
26 KiB
TypeScript

import { useState } from "react";
import type { FormEvent } from "react";
import type {
AdminJob,
CatalogSeedExportResult,
CatalogSeedImportSource,
CatalogPathRewrite,
} from "@/api/types";
import {
useCatalogExportJobs,
useCatalogImportJobs,
useCatalogImportSources,
useCreateCatalogExportJob,
useImportCatalogSeed,
useLocalImportSources,
usePublishCatalogExportJob,
} from "@/hooks/queries/admin/libraries";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
addEmptyPathRewrite,
createEmptyPathRewrite,
removePathRewrite,
type PathRewriteRow,
updatePathRewrite,
} from "./adminCatalogMaintenancePathRewrites";
import { formatExportProgressLabel, formatJobProgress } from "./adminCatalogMaintenanceFormatters";
import { Download, Plus, RefreshCw, Trash2, Upload } from "lucide-react";
import { formatDateTime } from "@/lib/datetime";
export default function AdminCatalogMaintenance() {
const [importDialogOpen, setImportDialogOpen] = useState(false);
const exportJobsQuery = useCatalogExportJobs();
const importJobsQuery = useCatalogImportJobs();
const importSourcesQuery = useCatalogImportSources();
const exportMutation = useCreateCatalogExportJob();
const publishMutation = usePublishCatalogExportJob();
const importMutation = useImportCatalogSeed();
const exportJobs = exportJobsQuery.data ?? [];
const importJobs = importJobsQuery.data ?? [];
const completedExportJobs = exportJobs.filter((job) => job.status === "completed");
const bucketImportSources = importSourcesQuery.data ?? [];
const localImportSourcesQuery = useLocalImportSources();
const localImportSources = localImportSourcesQuery.data ?? [];
const [localPath, setLocalPath] = useState("/catalog-seeds/");
const [remoteURL, setRemoteURL] = useState("");
const [importSource, setImportSource] = useState<
"local_path" | "export_job" | "bucket_artifact" | "remote_url"
>("local_path");
const [selectedExportJobId, setSelectedExportJobId] = useState("");
const [selectedArtifactKey, setSelectedArtifactKey] = useState("");
const [conflictMode, setConflictMode] = useState<"skip_existing" | "overwrite_existing">(
"skip_existing",
);
const [pathRewrites, setPathRewrites] = useState<PathRewriteRow[]>([createEmptyPathRewrite()]);
function updateRewrite(index: number, field: keyof CatalogPathRewrite, value: string) {
setPathRewrites((current) => updatePathRewrite(current, index, field, value));
}
function addRewrite() {
setPathRewrites((current) => addEmptyPathRewrite(current));
}
function removeRewrite(index: number) {
setPathRewrites((current) => removePathRewrite(current, index));
}
function resetImportState() {
setImportSource("local_path");
setLocalPath("/catalog-seeds/");
setSelectedExportJobId("");
setSelectedArtifactKey("");
setRemoteURL("");
setConflictMode("skip_existing");
setPathRewrites([createEmptyPathRewrite()]);
}
function handleImportSubmit(e: FormEvent) {
e.preventDefault();
if (importSource === "local_path" && !localPath.trim()) return;
if (importSource === "export_job" && !selectedExportJobId) return;
if (importSource === "bucket_artifact" && !selectedArtifactKey) return;
if (importSource === "remote_url" && !remoteURL.trim()) return;
const filteredRewrites = pathRewrites.filter(
(rewrite) => rewrite.from.trim() && rewrite.to.trim(),
);
importMutation.mutate(
{
source: importSource,
...(importSource === "local_path"
? { local_path: localPath.trim() }
: importSource === "export_job"
? { export_job_id: selectedExportJobId }
: importSource === "bucket_artifact"
? { artifact_key: selectedArtifactKey }
: { remote_url: remoteURL.trim() }),
conflict_mode: conflictMode,
path_rewrites: filteredRewrites,
},
{
onSuccess: () => {
setImportDialogOpen(false);
resetImportState();
},
},
);
}
const isImportSubmitDisabled =
importMutation.isPending ||
(importSource === "local_path"
? !localPath.trim()
: importSource === "export_job"
? !selectedExportJobId
: importSource === "bucket_artifact"
? !selectedArtifactKey
: !remoteURL.trim());
return (
<div className="space-y-6">
<div className="border-border/70 bg-card/60 flex flex-col gap-4 rounded-lg border p-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold">Catalog Import & Export</h2>
<p className="text-muted-foreground text-sm">
Queue full catalog exports, import seeds from uploads or S3, and watch background job
progress in one place.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => exportMutation.mutate({})}
disabled={exportMutation.isPending}
>
<Download
className={`mr-1 h-4 w-4 ${exportMutation.isPending ? "animate-pulse" : ""}`}
/>
Start Export
</Button>
<Dialog
open={importDialogOpen}
onOpenChange={(open) => {
setImportDialogOpen(open);
if (!open) {
resetImportState();
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Upload className="mr-1 h-4 w-4" />
Import Catalog
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Import Catalog Seed</DialogTitle>
</DialogHeader>
<form onSubmit={handleImportSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Import Source</Label>
<Select
value={importSource}
onValueChange={(value) =>
setImportSource(value as "local_path" | "export_job" | "bucket_artifact")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local_path">Local File</SelectItem>
<SelectItem value="export_job">Local Export Job</SelectItem>
<SelectItem value="bucket_artifact">Bucket Artifact</SelectItem>
<SelectItem value="remote_url">Remote URL</SelectItem>
</SelectContent>
</Select>
</div>
{importSource === "local_path" ? (
<div className="space-y-2">
<Label>File Path</Label>
<Input
value={localPath}
onChange={(e) => setLocalPath(e.target.value)}
placeholder="/catalog-seeds/my-catalog.json.gz"
/>
{localImportSources.length > 0 && (
<>
<div className="flex items-center justify-between gap-2">
<Label className="text-muted-foreground text-xs">Detected Files</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => localImportSourcesQuery.refetch()}
disabled={localImportSourcesQuery.isFetching}
>
<RefreshCw
className={`mr-1 h-4 w-4 ${localImportSourcesQuery.isFetching ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
<Select value="" onValueChange={(value) => setLocalPath(value)}>
<SelectTrigger>
<SelectValue placeholder="Select a detected file" />
</SelectTrigger>
<SelectContent>
{localImportSources.map((source) => (
<SelectItem key={source.key} value={source.key}>
{describeImportSource(source)}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
<p className="text-muted-foreground text-xs">
Enter the absolute path to a <span className="font-mono">.json.gz</span>{" "}
catalog seed file on the server, or select a detected file from{" "}
<span className="font-mono">/catalog-seeds/</span>.
</p>
</div>
) : importSource === "export_job" ? (
<div className="space-y-2">
<Label>Completed Export</Label>
<Select value={selectedExportJobId} onValueChange={setSelectedExportJobId}>
<SelectTrigger>
<SelectValue placeholder="Choose a completed export job" />
</SelectTrigger>
<SelectContent>
{completedExportJobs.length === 0 ? (
<SelectItem value="__none" disabled>
No completed exports yet
</SelectItem>
) : (
completedExportJobs.map((job) => (
<SelectItem key={job.id} value={job.id}>
{describeExportJob(job)}
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
Silo will load the selected seed directly from the configured operational S3
bucket.
</p>
</div>
) : importSource === "bucket_artifact" ? (
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label>Detected Bucket Artifacts</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => importSourcesQuery.refetch()}
disabled={importSourcesQuery.isFetching}
>
<RefreshCw
className={`mr-1 h-4 w-4 ${importSourcesQuery.isFetching ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
<Select value={selectedArtifactKey} onValueChange={setSelectedArtifactKey}>
<SelectTrigger>
<SelectValue placeholder="Choose a catalog seed from the bucket" />
</SelectTrigger>
<SelectContent>
{bucketImportSources.length === 0 ? (
<SelectItem value="__none" disabled>
No catalog seed objects found
</SelectItem>
) : (
bucketImportSources.map((source) => (
<SelectItem key={source.key} value={source.key}>
{describeImportSource(source)}
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
This reads any detected `catalog-seeds/*.json.gz` object in the private
internal S3 bucket, including exports from other installs.
</p>
</div>
) : (
<div className="space-y-2">
<Label>Remote URL</Label>
<Input
value={remoteURL}
onChange={(e) => setRemoteURL(e.target.value)}
placeholder="https://example.com/catalog-seeds/export.json.gz"
/>
<p className="text-muted-foreground text-xs">
Paste a public <span className="font-mono">.json.gz</span> catalog seed URL.
Silo will download it server-side before importing.
</p>
</div>
)}
<div className="space-y-2">
<Label>Conflict Mode</Label>
<Select
value={conflictMode}
onValueChange={(value) =>
setConflictMode(value as "skip_existing" | "overwrite_existing")
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip_existing">Skip Existing</SelectItem>
<SelectItem value="overwrite_existing">Overwrite Existing</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-3">
<div className="space-y-1">
<Label>Path Rewrites</Label>
<p className="text-muted-foreground text-xs">
Rewrites use prefix matching. Mapping{" "}
<span className="font-mono">/srv/media</span> to{" "}
<span className="font-mono">/media</span> rewrites every nested library and
file path under that root.
</p>
</div>
{pathRewrites.map((rewrite, index) => (
<div key={rewrite.id} className="grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
<Input
value={rewrite.from}
onChange={(e) => updateRewrite(index, "from", e.target.value)}
placeholder="/srv/media"
/>
<Input
value={rewrite.to}
onChange={(e) => updateRewrite(index, "to", e.target.value)}
placeholder="/media"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="shrink-0"
aria-label="Remove path rewrite"
onClick={() => removeRewrite(index)}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addRewrite}>
<Plus className="mr-1 h-4 w-4" /> Add Rewrite
</Button>
</div>
<div className="border-border/60 bg-muted/30 text-muted-foreground rounded-md border p-3 text-xs">
Import validates the rewritten library roots before writing anything, so missing
or incomplete rewrites will fail fast instead of seeding broken paths.
</div>
<Button type="submit" className="w-full" disabled={isImportSubmitDisabled}>
{importMutation.isPending ? "Importing..." : "Import Catalog"}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
</div>
<div className="border-border/70 bg-card/60 rounded-lg border">
<div className="border-border/70 flex items-center justify-between border-b px-4 py-3">
<div>
<h3 className="text-sm font-semibold">Recent Catalog Imports</h3>
<p className="text-muted-foreground text-xs">
Imports run in the background so progress stays visible while validation and writes
are in flight.
</p>
</div>
{importJobsQuery.isFetching ? (
<Badge variant="outline">Refreshing</Badge>
) : (
<Badge variant="secondary">{importJobs.length}</Badge>
)}
</div>
<div className="divide-border/60 divide-y">
{importJobs.length === 0 ? (
<div className="text-muted-foreground px-4 py-5 text-sm">
No catalog import jobs yet.
</div>
) : (
importJobs.map((job) => {
const importResult = job.result_payload as Record<string, number | undefined>;
const progressPercent = getJobProgressPercent(job);
return (
<div
key={job.id}
className="flex flex-col gap-3 px-4 py-4 lg:flex-row lg:items-center lg:justify-between"
>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={
job.status === "failed"
? "destructive"
: job.status === "completed"
? "secondary"
: "outline"
}
>
{job.status}
</Badge>
<span className="text-sm font-medium">{describeImportJob(job)}</span>
<span className="text-muted-foreground text-xs">
requested {formatDateTime(job.requested_at)}
</span>
</div>
<div className="text-muted-foreground text-sm">
{job.message || "Catalog import job"}
</div>
<div className="bg-muted h-2 overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width] duration-300"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="text-muted-foreground flex flex-wrap gap-4 text-xs">
<span>Progress: {formatJobProgress(job)}</span>
{job.completed_at ? (
<span>Finished: {formatDateTime(job.completed_at)}</span>
) : null}
{job.status === "completed" ? (
<span>
Imported {importResult.items_created ?? 0} items and{" "}
{importResult.files_created ?? 0} files
</span>
) : null}
</div>
{job.error_message ? (
<div className="text-destructive text-xs">{job.error_message}</div>
) : null}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => importJobsQuery.refetch()}>
<RefreshCw className="mr-1 h-4 w-4" />
Refresh
</Button>
</div>
</div>
);
})
)}
</div>
</div>
<div className="border-border/70 bg-card/60 rounded-lg border">
<div className="border-border/70 flex items-center justify-between border-b px-4 py-3">
<div>
<h3 className="text-sm font-semibold">Recent Catalog Exports</h3>
<p className="text-muted-foreground text-xs">
Export jobs run in the background and upload finished seeds to the private internal S3
bucket.
</p>
</div>
{exportJobsQuery.isFetching ? (
<Badge variant="outline">Refreshing</Badge>
) : (
<Badge variant="secondary">{exportJobs.length}</Badge>
)}
</div>
<div className="divide-border/60 divide-y">
{exportJobs.length === 0 ? (
<div className="text-muted-foreground px-4 py-5 text-sm">
No catalog export jobs yet.
</div>
) : (
exportJobs.map((job) => {
const exportRequest = job.request_payload as { library_ids?: number[] };
const exportResult = job.result_payload as Partial<CatalogSeedExportResult>;
const scopeLabel =
exportRequest.library_ids && exportRequest.library_ids.length > 0
? `${exportRequest.library_ids.length} librar${exportRequest.library_ids.length === 1 ? "y" : "ies"}`
: "All libraries";
const progressLabel = formatExportProgressLabel(
job.progress_current,
job.progress_total,
job.status,
);
return (
<div
key={job.id}
className="flex flex-col gap-3 px-4 py-4 lg:flex-row lg:items-center lg:justify-between"
>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={
job.status === "failed"
? "destructive"
: job.status === "completed"
? "secondary"
: "outline"
}
>
{job.status}
</Badge>
<span className="text-sm font-medium">{scopeLabel}</span>
<span className="text-muted-foreground text-xs">
requested {formatDateTime(job.requested_at)}
</span>
</div>
<div className="text-muted-foreground text-sm">
{job.message || "Catalog export job"}
</div>
<div className="text-muted-foreground flex flex-wrap gap-4 text-xs">
<span>Progress: {progressLabel}</span>
{job.completed_at ? (
<span>Finished: {formatDateTime(job.completed_at)}</span>
) : null}
{exportResult.items_exported ? (
<span>
Exported {exportResult.items_exported} items and{" "}
{exportResult.files_exported ?? 0} files
</span>
) : null}
</div>
{job.error_message ? (
<div className="text-destructive text-xs">{job.error_message}</div>
) : null}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => exportJobsQuery.refetch()}>
<RefreshCw className="mr-1 h-4 w-4" />
Refresh
</Button>
{job.download_url ? (
<Button
variant="default"
size="sm"
onClick={() =>
window.open(job.download_url, "_blank", "noopener,noreferrer")
}
>
<Download className="mr-1 h-4 w-4" />
Download
</Button>
) : null}
{job.status === "completed" && !job.public_url ? (
<Button
variant="outline"
size="sm"
onClick={() => publishMutation.mutate(job.id)}
disabled={publishMutation.isPending}
>
Publish
</Button>
) : null}
{job.public_url ? (
<Button
variant="outline"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(job.public_url ?? "");
}}
>
Copy URL
</Button>
) : null}
</div>
</div>
);
})
)}
</div>
</div>
</div>
);
}
function describeExportJob(job: AdminJob) {
const exportRequest = job.request_payload as { library_ids?: number[] };
const scopeLabel =
exportRequest.library_ids && exportRequest.library_ids.length > 0
? `${exportRequest.library_ids.length} librar${exportRequest.library_ids.length === 1 ? "y" : "ies"}`
: "All libraries";
return `${scopeLabel} • ${formatDateTime(job.requested_at)}`;
}
function describeImportJob(job: AdminJob) {
const importRequest = job.request_payload as {
source_label?: string;
source_key?: string;
};
return importRequest.source_label || importRequest.source_key || "Catalog seed";
}
function describeImportSource(source: CatalogSeedImportSource) {
const label = source.last_modified ? formatDateTime(source.last_modified) : source.key;
return `${label} • ${source.key}`;
}
function getJobProgressPercent(job: AdminJob) {
if (job.progress_total > 0) {
return Math.min(100, Math.max(0, (job.progress_current / job.progress_total) * 100));
}
if (job.status === "completed") {
return 100;
}
if (job.status === "running") {
return 12;
}
return 4;
}