The "N folders are named differently" warning now offers to rename the slave folders to match the master, marked as the recommended first step after a scan (also linked from the dashboard card). - A preview lists each rename with the differing characters highlighted; renames are selectable, and conflicts (the slave already has a folder with the master's name, symlinks, the quarantine) are skipped with a reason. - Only renames the server computes itself are applied; a request can only narrow them down, never name arbitrary paths. Only slave folders are renamed. - Case-only renames go through a temporary name so they also work on case-insensitive filesystems; a rename interrupted by a crash is completed on startup (the folder is never removed). - Renaming everything repeats until folders revealed inside renamed ones (an album under a renamed artist) are fixed too, then rescans. - Each rename is an operation in History that can be undone (which also rescans). Dry run records renames as planned. Also fixes the Review list not refreshing after a job that started and finished between two status polls, and logs the new path of operations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
8.1 KiB
TypeScript
185 lines
8.1 KiB
TypeScript
import { useState } from "react";
|
||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { Archive, History, RotateCcw, Trash2 } from "lucide-react";
|
||
import { api, type Operation, type Page, type Pair } from "../api";
|
||
import { Badge, Button, Card, Empty, Modal, Spinner, cx, formatAgo, formatBytes } from "../ui";
|
||
import { useToast } from "../toast";
|
||
|
||
export function ActivityPage() {
|
||
const [view, setView] = useState<"all" | "quarantine">("all");
|
||
return (
|
||
<div className="space-y-5">
|
||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold tracking-tight">Activity</h1>
|
||
<p className="text-sm text-muted">Every change playlink makes is recorded here and can be undone.</p>
|
||
</div>
|
||
<div className="flex rounded-lg border border-line p-0.5">
|
||
{(["all", "quarantine"] as const).map((v) => (
|
||
<button
|
||
key={v}
|
||
onClick={() => setView(v)}
|
||
className={cx("rounded-md px-3 py-1 text-sm capitalize cursor-pointer", view === v ? "bg-panel-2 text-white" : "text-muted")}
|
||
>
|
||
{v === "all" ? "History" : "Quarantine"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<OpsTable quarantine={view === "quarantine"} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const actionLabel: Record<Operation["action"], string> = {
|
||
link: "Linked slave → master",
|
||
promote: "Promoted slave → master",
|
||
build: "Built into slave",
|
||
remove: "Removed from slave",
|
||
rename: "Renamed slave folder",
|
||
};
|
||
|
||
const statusTone: Record<Operation["status"], "green" | "red" | "blue" | "amber" | "gray"> = {
|
||
done: "green",
|
||
failed: "red",
|
||
planned: "blue",
|
||
interrupted: "amber",
|
||
running: "gray",
|
||
};
|
||
|
||
export function OpsTable({ pairId, quarantine }: { pairId?: number; quarantine?: boolean }) {
|
||
const [page, setPage] = useState(0);
|
||
const [confirm, setConfirm] = useState<null | { op: Operation; kind: "undo" | "purge" }>(null);
|
||
const qc = useQueryClient();
|
||
const toast = useToast();
|
||
const pairs = useQuery({ queryKey: ["pairs"], queryFn: () => api.get<Pair[]>("/pairs") });
|
||
const names = Object.fromEntries((pairs.data ?? []).map((p) => [p.id, p.name]));
|
||
const ops = useQuery({
|
||
queryKey: ["ops", pairId, quarantine, page],
|
||
queryFn: () => api.get<Page<Operation>>(`/operations?limit=50&offset=${page * 50}${pairId ? `&pair=${pairId}` : ""}${quarantine ? "&quarantine=1" : ""}`),
|
||
placeholderData: keepPreviousData,
|
||
});
|
||
const act = useMutation({
|
||
mutationFn: ({ op, kind }: { op: Operation; kind: "undo" | "purge" }) => api.post(`/operations/${op.id}/${kind}`),
|
||
onSuccess: (_, { kind }) => {
|
||
toast("ok", kind === "undo" ? "Operation undone" : "Quarantined file permanently deleted");
|
||
setConfirm(null);
|
||
qc.invalidateQueries({ queryKey: ["ops"] });
|
||
qc.invalidateQueries({ queryKey: ["matches"] });
|
||
qc.invalidateQueries({ queryKey: ["pair"] });
|
||
},
|
||
onError: (e) => toast("err", e.message),
|
||
});
|
||
|
||
if (ops.isLoading) return <Spinner />;
|
||
const items = ops.data?.items ?? [];
|
||
|
||
return (
|
||
<>
|
||
<Card className="overflow-hidden">
|
||
{items.length === 0 && (
|
||
<Empty icon={quarantine ? <Archive className="size-8" /> : <History className="size-8" />} title={quarantine ? "Quarantine is empty" : "No activity yet"}>
|
||
{quarantine ? "Originals replaced by a non-identical file show up here until they are purged." : undefined}
|
||
</Empty>
|
||
)}
|
||
{items.map((o) => {
|
||
const canUndo = o.status === "done" && !o.undone_at && !o.purged_at;
|
||
const inQuarantine = !!o.quarantine_path && !o.purged_at && !o.undone_at && (o.status === "done" || o.status === "interrupted");
|
||
return (
|
||
<div key={o.id} className="flex flex-wrap items-center gap-3 border-b border-line px-4 py-2.5 last:border-0">
|
||
<div className="w-40 shrink-0">
|
||
<div className="text-sm">{actionLabel[o.action]}</div>
|
||
<div className="text-[11px] text-muted">
|
||
{formatAgo(o.created_at)} · {o.trigger}
|
||
{!pairId && names[o.pair_id] && ` · ${names[o.pair_id]}`}
|
||
</div>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate mono text-[13px]" title={o.rel_slave}>
|
||
{o.rel_slave}
|
||
</div>
|
||
{o.rel_new && o.rel_new !== o.rel_slave && o.rel_new !== o.rel_master && (
|
||
<div className="truncate text-xs text-muted mono">now {o.rel_new}</div>
|
||
)}
|
||
{o.error && <div className="text-xs text-red-300">{o.error}</div>}
|
||
{inQuarantine && (
|
||
<div className="truncate text-xs text-amber-300/80 mono" title={o.quarantine_path}>
|
||
{o.quarantine_side} original kept in quarantine
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
{o.bytes > 0 && o.status === "done" && !o.undone_at && <Badge tone="green">−{formatBytes(o.bytes)}</Badge>}
|
||
<Badge tone={statusTone[o.status]}>{o.dry_run ? "dry run" : o.status}</Badge>
|
||
{o.undone_at && <Badge>undone</Badge>}
|
||
{o.purged_at && <Badge>purged</Badge>}
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
{canUndo && (
|
||
<Button size="sm" onClick={() => setConfirm({ op: o, kind: "undo" })}>
|
||
<RotateCcw className="size-3.5" /> {o.quarantine_path ? "Restore" : "Undo"}
|
||
</Button>
|
||
)}
|
||
{inQuarantine && (
|
||
<Button size="sm" variant="ghost" onClick={() => setConfirm({ op: o, kind: "purge" })} title="Delete quarantined original">
|
||
<Trash2 className="size-3.5" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</Card>
|
||
{ops.data && ops.data.total > 50 && (
|
||
<div className="mt-3 flex justify-end gap-2">
|
||
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||
Previous
|
||
</Button>
|
||
<Button size="sm" disabled={(page + 1) * 50 >= ops.data.total} onClick={() => setPage(page + 1)}>
|
||
Next
|
||
</Button>
|
||
</div>
|
||
)}
|
||
<Modal open={!!confirm} onClose={() => setConfirm(null)} title={confirm?.kind === "undo" ? "Undo this operation?" : "Permanently delete?"}>
|
||
{confirm && (
|
||
<p className="text-sm text-gray-300">
|
||
{confirm.kind === "purge" ? (
|
||
<>
|
||
The quarantined {confirm.op.quarantine_side} original of <code className="mono break-all">{confirm.op.rel_slave}</code> will be deleted.{" "}
|
||
<b>This cannot be undone.</b>
|
||
</>
|
||
) : (
|
||
undoText(confirm.op)
|
||
)}
|
||
</p>
|
||
)}
|
||
<div className="mt-5 flex justify-end gap-2">
|
||
<Button variant="ghost" onClick={() => setConfirm(null)}>
|
||
Cancel
|
||
</Button>
|
||
<Button variant={confirm?.kind === "purge" ? "danger" : "primary"} loading={act.isPending} onClick={() => confirm && act.mutate(confirm)}>
|
||
{confirm?.kind === "undo" ? "Undo" : "Delete forever"}
|
||
</Button>
|
||
</div>
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function undoText(o: Operation) {
|
||
switch (o.action) {
|
||
case "link":
|
||
return o.quarantine_path
|
||
? "The original slave file is restored from quarantine and the hardlink is removed."
|
||
: "The slave gets its own independent copy of the file again, which uses disk space.";
|
||
case "promote":
|
||
return "The original master file is restored from quarantine. The slave keeps its own file.";
|
||
case "build":
|
||
return "The hardlink is removed from the slave library. The master file is not affected.";
|
||
case "remove":
|
||
return "The hardlink is recreated in the slave library.";
|
||
case "rename":
|
||
return `The slave folder is renamed back from "${o.rel_new}" to "${o.rel_slave}", and the library is rescanned.`;
|
||
}
|
||
}
|