Files
silo-server/web/src/components/AdminJobHistory.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

182 lines
7.1 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";
import { formatDateTime } from "@/lib/datetime";
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"
aria-label="Refresh job history"
onClick={() => jobsQuery.refetch()}
>
<RefreshCw
className={`h-4 w-4 ${jobsQuery.isFetching ? "animate-spin" : ""}`}
aria-hidden="true"
/>
</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">
{formatDateTime(job.requested_at)}
</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: {formatDateTime(job.completed_at)}</span>}
</div>
{job.error_message && (
<div className="text-destructive text-xs">{job.error_message}</div>
)}
</div>
);
})
)}
</div>
</div>
);
}