Files
silo-server/web/src/components/AdminJobHistory.tsx
T

175 lines
6.9 KiB
TypeScript

import type { AdminJob } from "@/api/types";
import { useAllAdminJobs } from "@/hooks/queries/admin/libraries";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";
import { formatJobProgress } from "./adminCatalogMaintenanceFormatters";
const JOB_TYPE_LABELS: Record<string, string> = {
delete_library: "Library Delete",
image_cache_cleanup: "Image Cache Cleanup",
catalog_export: "Catalog Export",
catalog_import: "Catalog Import",
item_refresh: "Item Refresh",
library_refresh: "Library Refresh",
};
function jobTypeLabel(jobType: string) {
return JOB_TYPE_LABELS[jobType] ?? jobType;
}
function statusVariant(status: string) {
switch (status) {
case "failed":
return "destructive" as const;
case "completed":
return "secondary" as const;
case "running":
return "default" as const;
default:
return "outline" as const;
}
}
function jobDescription(job: AdminJob) {
const payload = job.request_payload as Record<string, unknown>;
switch (job.job_type) {
case "delete_library":
case "image_cache_cleanup":
return payload.library_name ? `"${payload.library_name}"` : `Library #${payload.library_id}`;
case "item_refresh":
case "library_refresh":
return payload.library_name
? `"${payload.library_name}"`
: payload.library_id
? `Library #${payload.library_id}`
: "All libraries";
case "catalog_export": {
const ids = payload.library_ids as number[] | undefined;
if (ids && ids.length > 0) return `${ids.length} librar${ids.length === 1 ? "y" : "ies"}`;
return "All libraries";
}
case "catalog_import":
return (payload.source_label as string) || (payload.source_key as string) || "Catalog seed";
default:
return "";
}
}
function jobResult(job: AdminJob) {
if (job.status !== "completed") return null;
const result = job.result_payload as Record<string, unknown>;
switch (job.job_type) {
case "library_refresh": {
const total = result.total_items ?? 0;
const withIDs = result.items_with_ids ?? 0;
const withoutIDs = result.items_without_ids ?? 0;
const refreshedOK = result.refreshed_ok ?? 0;
const refreshedFailed = result.refreshed_failed ?? 0;
const pipelineOK = result.pipeline_ok ?? 0;
const pipelineFailed = result.pipeline_failed ?? 0;
if (total === 0) {
return "No library items to refresh";
}
return `Total ${total}, ${withIDs} direct, ${withoutIDs} unmatched, direct ${refreshedOK} ok/${refreshedFailed} failed, pipeline ${pipelineOK} ok/${pipelineFailed} failed`;
}
case "delete_library": {
const files = typeof result.deleted_media_files === "number" ? result.deleted_media_files : 0;
const items =
typeof result.deleted_orphaned_items === "number" ? result.deleted_orphaned_items : 0;
const cleanupQueued = result.image_cleanup_queued === true;
const cleanupDirs =
typeof result.image_cleanup_dirs === "number" ? result.image_cleanup_dirs : 0;
const parts = [];
if (files > 0) parts.push(`${files} files`);
if (items > 0) parts.push(`${items} items`);
if (cleanupQueued && cleanupDirs > 0) {
parts.push(
`queued cache cleanup for ${cleanupDirs} director${cleanupDirs === 1 ? "y" : "ies"}`,
);
}
return parts.length > 0 ? `Deleted ${parts.join(", ")}` : "Deleted (empty)";
}
case "image_cache_cleanup": {
const deletedPrefixes =
typeof result.deleted_prefixes === "number" ? result.deleted_prefixes : 0;
const deletedObjects =
typeof result.deleted_s3_objects === "number" ? result.deleted_s3_objects : 0;
return `Deleted ${deletedObjects} cached object${deletedObjects === 1 ? "" : "s"} across ${deletedPrefixes} prefix${deletedPrefixes === 1 ? "" : "es"}`;
}
case "catalog_export":
return typeof result.items_exported === "number"
? `Exported ${result.items_exported} items, ${typeof result.files_exported === "number" ? result.files_exported : 0} files`
: null;
case "catalog_import":
return typeof result.items_created === "number"
? `Imported ${result.items_created} items, ${typeof result.files_created === "number" ? result.files_created : 0} files`
: null;
default:
return null;
}
}
export default function AdminJobHistory() {
const jobsQuery = useAllAdminJobs();
const jobs = jobsQuery.data ?? [];
return (
<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">Job History</h3>
<p className="text-muted-foreground text-xs">Recent background jobs across all types.</p>
</div>
<div className="flex items-center gap-2">
{jobsQuery.isFetching ? (
<Badge variant="outline">Refreshing</Badge>
) : (
<Badge variant="secondary">{jobs.length}</Badge>
)}
<Button variant="ghost" size="icon" onClick={() => jobsQuery.refetch()}>
<RefreshCw className={`h-4 w-4 ${jobsQuery.isFetching ? "animate-spin" : ""}`} />
</Button>
</div>
</div>
<div className="divide-border/60 divide-y">
{jobs.length === 0 ? (
<div className="text-muted-foreground px-4 py-5 text-sm">No jobs yet.</div>
) : (
jobs.map((job) => {
const desc = jobDescription(job);
const result = jobResult(job);
const progress = formatJobProgress(job);
return (
<div key={job.id} className="flex flex-col gap-1 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusVariant(job.status)}>{job.status}</Badge>
<Badge variant="outline">{jobTypeLabel(job.job_type)}</Badge>
{desc && <span className="text-sm font-medium">{desc}</span>}
<span className="text-muted-foreground text-xs">
{new Date(job.requested_at).toLocaleString()}
</span>
</div>
<div className="text-muted-foreground flex flex-wrap gap-3 text-xs">
{job.message && <span>{job.message}</span>}
{(job.status === "running" || job.status === "queued") && (
<span>Progress: {progress}</span>
)}
{result && <span>{result}</span>}
{job.completed_at && (
<span>Finished: {new Date(job.completed_at).toLocaleString()}</span>
)}
</div>
{job.error_message && (
<div className="text-destructive text-xs">{job.error_message}</div>
)}
</div>
);
})
)}
</div>
</div>
);
}