feat: delete traffic targets and LLM task conversations from UI

Add delete controls to the Traffic and LLM Records pages so users can
purge recorded data without hand-editing the data volume.

Traffic page:
- Host target picker (Popover with checkboxes + select-all toggle)
- Exact-match batch delete for multiple hosts (DELETE /api/traffic/hosts)
- Substring delete for the host-filter input (DELETE /api/traffic?host=)
- Orphaned content-addressed blob garbage collection after deletes
- Distinct-hosts endpoint (GET /api/traffic/hosts) for the picker

LLM Records page:
- Task picker dropdown (distinct task_id + record counts)
- Per-task delete (DELETE /api/llm/records?task=)

Other:
- Add /dist/ to .gitignore (build artifact)
- Extend traffic_test.go with DeleteHost, DeleteHostsExact, Hosts tests
- Fix Popover render bug (replace ScrollArea that broke height calc)
- Remove fade/zoom animation on the host picker Popover
This commit is contained in:
FileShare Developer committed 2026-08-10 11:10:58 +08:00
1 parent 5962826f2d
commit c76d0f4c93
12 files changed
+883 -20

No files matched your search

@@ -8,8 +8,20 @@ import {
ChevronRightIcon,
Loader2Icon,
XIcon,
Trash2Icon,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
@@ -32,7 +44,7 @@ import {
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { LLMRecordItem, LLMRecordDetail } from "@/lib/types";
import type { LLMRecordItem, LLMRecordDetail, LLMTask } from "@/lib/types";
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN", {
@@ -85,6 +97,13 @@ export default function LLMRecordsPage() {
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
// Per-task delete (task picker + confirm dialog)
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
const [pickedTask, setPickedTask] = React.useState("");
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger
// Load recording toggle state on mount.
React.useEffect(() => {
let alive = true;
@@ -117,14 +136,14 @@ export default function LLMRecordsPage() {
// Reset page on filter change.
React.useEffect(() => {
setPage(0);
}, [sessionQ, model, size]);
}, [sessionQ, model, size, pickedTask]);
// Load list.
React.useEffect(() => {
let alive = true;
setLoading(true);
api
.llmRecords({ model: model || undefined, session: sessionQ || undefined, page, size })
.llmRecords({ model: model || undefined, session: sessionQ || undefined, task: pickedTask || undefined, page, size })
.then((r) => {
if (!alive) return;
setRecords(r.records ?? []);
@@ -132,8 +151,29 @@ export default function LLMRecordsPage() {
})
.catch(() => {})
.finally(() => alive && setLoading(false));
api
.llmTasks()
.then((r) => { if (alive) setTasks(r.tasks ?? []); })
.catch(() => {});
return () => { alive = false; };
}, [page, size, sessionQ, model]);
}, [page, size, sessionQ, model, pickedTask, reloadTick]);
// Delete every LLM record for the picked task, then refetch.
const confirmDelete = () => {
setDeleting(true);
api
.llmRecordsDeleteTask(pickedTask)
.then(() => {
setDeleteOpen(false);
setSelected(null);
setDetail(null);
setPickedTask("");
setPage(0);
setReloadTick((t) => t + 1);
})
.catch(() => {})
.finally(() => setDeleting(false));
};
// Lazy-load full request/response when a row is selected.
React.useEffect(() => {
@@ -184,6 +224,36 @@ export default function LLMRecordsPage() {
value={model}
onChange={(e) => setModel(e.target.value)}
/>
<Select value={pickedTask} onValueChange={setPickedTask}>
<SelectTrigger size="sm" className="w-56">
<SelectValue placeholder="选择任务…" />
</SelectTrigger>
<SelectContent>
{tasks.length === 0 ? (
<SelectItem value="__none__" disabled>
暂无任务记录
</SelectItem>
) : (
tasks.map((t) => (
<SelectItem key={t.task_id} value={t.task_id}>
<span className="font-mono">#{t.task_id}</span>
<span className="ml-2 text-muted-foreground">({t.count})</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
<Button
variant="destructive"
size="sm"
className="h-8"
disabled={!pickedTask || deleting}
title={pickedTask ? undefined : "先在上方选择任务"}
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon className="size-3.5" />
删除任务对话
</Button>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
@@ -409,6 +479,30 @@ export default function LLMRecordsPage() {
</Card>
)}
</div>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除任务「{pickedTask}」的全部 LLM 对话?</AlertDialogTitle>
<AlertDialogDescription>
将永久删除该任务的所有 LLM 调用记录(含请求/响应原文),此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
confirmDelete();
}}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "删除中…" : "确认删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}