Files
playlink/web/src/pages/Activity.tsx
T
cdnutterandClaude Opus 5.5 42161e4287 Add renaming slave folders to the master's spelling
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>
2026-09-28 20:20:52 -07:00

185 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.`;
}
}