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([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 (

Catalog Import & Export

Queue full catalog exports, import seeds from uploads or S3, and watch background job progress in one place.

{ setImportDialogOpen(open); if (!open) { resetImportState(); } }} > Import Catalog Seed
{importSource === "local_path" ? (
setLocalPath(e.target.value)} placeholder="/catalog-seeds/my-catalog.json.gz" /> {localImportSources.length > 0 && ( <>
)}

Enter the absolute path to a .json.gz{" "} catalog seed file on the server, or select a detected file from{" "} /catalog-seeds/.

) : importSource === "export_job" ? (

Silo will load the selected seed directly from the configured operational S3 bucket.

) : importSource === "bucket_artifact" ? (

This reads any detected `catalog-seeds/*.json.gz` object in the private internal S3 bucket, including exports from other installs.

) : (
setRemoteURL(e.target.value)} placeholder="https://example.com/catalog-seeds/export.json.gz" />

Paste a public .json.gz catalog seed URL. Silo will download it server-side before importing.

)}

Rewrites use prefix matching. Mapping{" "} /srv/media to{" "} /media rewrites every nested library and file path under that root.

{pathRewrites.map((rewrite, index) => (
updateRewrite(index, "from", e.target.value)} placeholder="/srv/media" /> updateRewrite(index, "to", e.target.value)} placeholder="/media" />
))}
Import validates the rewritten library roots before writing anything, so missing or incomplete rewrites will fail fast instead of seeding broken paths.

Recent Catalog Imports

Imports run in the background so progress stays visible while validation and writes are in flight.

{importJobsQuery.isFetching ? ( Refreshing ) : ( {importJobs.length} )}
{importJobs.length === 0 ? (
No catalog import jobs yet.
) : ( importJobs.map((job) => { const importResult = job.result_payload as Record; const progressPercent = getJobProgressPercent(job); return (
{job.status} {describeImportJob(job)} requested {formatDateTime(job.requested_at)}
{job.message || "Catalog import job"}
Progress: {formatJobProgress(job)} {job.completed_at ? ( Finished: {formatDateTime(job.completed_at)} ) : null} {job.status === "completed" ? ( Imported {importResult.items_created ?? 0} items and{" "} {importResult.files_created ?? 0} files ) : null}
{job.error_message ? (
{job.error_message}
) : null}
); }) )}

Recent Catalog Exports

Export jobs run in the background and upload finished seeds to the private internal S3 bucket.

{exportJobsQuery.isFetching ? ( Refreshing ) : ( {exportJobs.length} )}
{exportJobs.length === 0 ? (
No catalog export jobs yet.
) : ( exportJobs.map((job) => { const exportRequest = job.request_payload as { library_ids?: number[] }; const exportResult = job.result_payload as Partial; 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 (
{job.status} {scopeLabel} requested {formatDateTime(job.requested_at)}
{job.message || "Catalog export job"}
Progress: {progressLabel} {job.completed_at ? ( Finished: {formatDateTime(job.completed_at)} ) : null} {exportResult.items_exported ? ( Exported {exportResult.items_exported} items and{" "} {exportResult.files_exported ?? 0} files ) : null}
{job.error_message ? (
{job.error_message}
) : null}
{job.download_url ? ( ) : null} {job.status === "completed" && !job.public_url ? ( ) : null} {job.public_url ? ( ) : null}
); }) )}
); } 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; }