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

+14 -5
View File
@@ -7,13 +7,13 @@ import { MOCK } from "@/lib/mock/enabled";
import { mockHandle } from "@/lib/mock/handler";
import type {
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Activity,
Audit, TrafficResp, TrafficDetail, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
Tool, Conversation, AgentTrigger,
InterceptRule, InterceptPending, InterceptApprovalRow,
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
WorkspaceListing, WorkspaceFile,
CommandRecord, LLMRecordItem, LLMRecordDetail,
CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask,
} from "@/lib/types";
function getToken(): string | null {
@@ -91,7 +91,7 @@ const get = <T>(p: string) => http<T>(p);
const post = <T>(p: string, body?: unknown) => http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
const put = <T>(p: string, body?: unknown) => http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined });
const patch = <T>(p: string, body?: unknown) => http<T>(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined });
const del = <T>(p: string) => http<T>(p, { method: "DELETE" });
const del = <T>(p: string, body?: unknown) => http<T>(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined });
// Go serializes nil slices as JSON null — coerce to [].
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
@@ -253,6 +253,11 @@ export const api = {
),
trafficExchange: (id: string) =>
get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`),
trafficDeleteHost: (host: string) =>
del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`),
trafficDeleteHosts: (hosts: string[]) =>
del<{ deleted: number }>(`/traffic/hosts`, { hosts }),
// ---- app settings (runtime toggles) ----
settings: () => get<Settings>(`/settings`),
@@ -543,13 +548,17 @@ export const api = {
},
// ---- LLM records ----
llmRecords: (params?: { model?: string; session?: string; page?: number; size?: number }) => {
llmRecords: (params?: { model?: string; session?: string; task?: string; page?: number; size?: number }) => {
const sp = new URLSearchParams();
if (params?.model) sp.set("model", params.model);
if (params?.session) sp.set("session", params.session);
sp.set("page", String(params?.page ?? 0));
if (params?.task) sp.set("task", params.task);
if (params?.page !== undefined) sp.set("page", String(params.page));
sp.set("size", String(params?.size ?? 50));
return get<{ records: LLMRecordItem[]; total: number }>(`/llm/records?${sp}`);
},
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`),
llmRecordsDeleteTask: (task: string) =>
del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
};
+11 -1
View File
@@ -8,7 +8,7 @@ import type {
ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow,
InterceptPending, InterceptRule, LLMProfile, MCPServer, MCPTool, PromptVar,
PromptVersion, Settings, SkillItem, Stats, TaskNode, Task, TokenTotal,
TokenUsage, Tool, TrafficResp, TrafficDetail,
TokenUsage, Tool, TrafficResp, TrafficDetail, TrafficHost, LLMTask,
} from "@/lib/types";
const T = (iso: string) => iso; // readability helper for timestamps
@@ -341,6 +341,11 @@ export const traffic: TrafficResp = {
})),
};
// Distinct hosts with counts, derived from the exchanges above (target picker).
const hostCounts: Record<string, number> = {};
for (const [, host] of exchanges) hostCounts[host] = (hostCounts[host] ?? 0) + 1;
export const trafficHosts: TrafficHost[] = Object.entries(hostCounts).map(([host, count]) => ({ host, count }));
export const trafficDetail: TrafficDetail = {
req: `GET /v1/orders?id=1002 HTTP/1.1
Host: api.acme.com
@@ -641,6 +646,11 @@ export const llmRecords = [
{ id: 4, ts: T("2026-08-09T02:16:05Z"), model: "claude-opus-4-8", profile_name: "默认", session_id: "exp1-worker-i18", task_id: "t-001", worker: "worker-2", latency_ms: 900, input_tokens: 7600, output_tokens: 0, cache_read: 5200, cache_write: 0, status: "error", error: "429 Too Many Requests(已退避重试)" },
];
// Distinct tasks with counts, derived from llmRecords above (task picker).
const llmTaskCounts: Record<string, number> = {};
for (const r of llmRecords) if (r.task_id) llmTaskCounts[r.task_id] = (llmTaskCounts[r.task_id] ?? 0) + 1;
export const llmTasks: LLMTask[] = Object.entries(llmTaskCounts).map(([task_id, count]) => ({ task_id, count }));
export function llmRecordDetail(id: number) {
const item = llmRecords.find((r) => r.id === id) ?? llmRecords[0];
return {
+5
View File
@@ -90,6 +90,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── traffic / audit / settings ──
if (path === "/audit") return D.audit;
if (path === "/traffic" && m === "DELETE") return { deleted: 0 };
if (path === "/traffic/hosts" && m === "DELETE") return { deleted: (b.hosts as unknown[])?.length ?? 0 };
if (path === "/traffic/hosts") return { hosts: D.trafficHosts };
if (path === "/traffic") return D.traffic;
if (path === "/traffic/exchange") return D.trafficDetail;
if (path === "/settings" && m === "GET") return D.settings;
@@ -104,6 +107,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── LLM ──
if (path === "/llm/records" && m === "GET") return { records: D.llmRecords, total: D.llmRecords.length };
if (path === "/llm/records" && m === "DELETE") return { deleted: 0 };
if (path === "/llm/records/tasks") return { tasks: D.llmTasks };
if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") return D.llmRecordDetail(Number(seg[2]));
if (path === "/llm" && m === "GET") return D.llmConfig;
if (path === "/llm" && m === "POST") return { ok: true };
+12
View File
@@ -403,6 +403,12 @@ export interface TrafficDetail {
resp: string;
}
// One distinct recorded host with its exchange count (target picker).
export interface TrafficHost {
host: string;
count: number;
}
// ---- App settings (runtime toggles) ----
export interface Settings {
traffic_capture: boolean;
@@ -657,3 +663,9 @@ export interface LLMRecordDetail extends LLMRecordItem {
request_body: string;
response_body: string;
}
// One distinct task with its LLM-record count (task picker on the records page).
export interface LLMTask {
task_id: string;
count: number;
}