From af3149901ef53dcd36d93ce0a6ae7eb932ebc1ac Mon Sep 17 00:00:00 2001 From: pphua Date: Mon, 24 Aug 2026 19:30:35 +0800 Subject: [PATCH] feat(ui): add traffic and tool detail drawers --- web/src/app/(main)/function/commands/page.tsx | 188 ++++---- web/src/app/(main)/function/traffic/page.tsx | 409 +++++++++++------- web/src/components/agent-editor.tsx | 7 +- web/src/components/copy-button.tsx | 67 +++ web/src/components/transcript.tsx | 7 +- web/src/lib/utils.ts | 29 ++ 6 files changed, 436 insertions(+), 271 deletions(-) create mode 100644 web/src/components/copy-button.tsx diff --git a/web/src/app/(main)/function/commands/page.tsx b/web/src/app/(main)/function/commands/page.tsx index 918805a..7d58b48 100644 --- a/web/src/app/(main)/function/commands/page.tsx +++ b/web/src/app/(main)/function/commands/page.tsx @@ -1,37 +1,19 @@ "use client"; import * as React from "react"; -import { - TerminalIcon, - SearchIcon, - ChevronLeftIcon, - ChevronRightIcon, - Loader2Icon, - XIcon, -} from "lucide-react"; -import { Card } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; +import { ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react"; + import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; +import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { api } from "@/lib/api"; import type { CommandRecord } from "@/lib/types"; +import { cn } from "@/lib/utils"; function fmtTime(ts: string) { return new Date(ts).toLocaleString("zh-CN", { @@ -50,7 +32,9 @@ function toolInput(raw: string): string { const obj = JSON.parse(raw); if (obj && typeof obj.command === "string") return obj.command; return JSON.stringify(obj, null, 2); - } catch { /* not JSON */ } + } catch { + /* not JSON */ + } return raw; } @@ -75,7 +59,7 @@ export default function CommandsPage() { const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(false); - // Inline detail panel (like traffic page, not a dialog) + // Selected execution is rendered in a right-side detail sheet. const [selected, setSelected] = React.useState(null); // Debounce search input. @@ -85,6 +69,7 @@ export default function CommandsPage() { }, [query]); // Reset page on filter change. + // biome-ignore lint/correctness/useExhaustiveDependencies: every filter change intentionally resets pagination. React.useEffect(() => { setPage(0); }, [queryQ, taskFilter, size]); @@ -100,9 +85,15 @@ export default function CommandsPage() { setCommands(r.commands ?? []); setTotal(r.total ?? 0); }) - .catch(() => {}) + .catch(() => { + if (!alive) return; + setCommands([]); + setTotal(0); + }) .finally(() => alive && setLoading(false)); - return () => { alive = false; }; + return () => { + alive = false; + }; }, [page, size, queryQ, taskFilter]); const totalPages = Math.max(1, Math.ceil(total / size)); @@ -178,8 +169,8 @@ export default function CommandsPage() { - {/* History table + inline detail (Burp-style split) */} -
+ {/* History table */} +
@@ -210,18 +201,13 @@ export default function CommandsPage() { commands.map((cmd) => ( setSelected(cmd)} > {fmtTime(cmd.created_at)} - - #{cmd.exploration_id} - + #{cmd.exploration_id} {cmd.worker || "-"} @@ -239,9 +225,13 @@ export default function CommandsPage() { {cmd.is_error ? ( - 失败 + + 失败 + ) : ( - 成功 + + 成功 + )} @@ -251,67 +241,63 @@ export default function CommandsPage() {
- - {/* Inline detail panel */} - {selected && ( - - {/* Detail header */} -
- - #{selected.exploration_id} - - - {selected.worker || "-"} - - - {selected.tool || "-"} - - - {fmtTime(selected.created_at)} - - {selected.is_error ? ( - 失败 - ) : ( - 成功 - )} - -
- {/* Input / Output split */} -
-
-
- 输入 Input -
-
-
-                    {toolInput(selected.command)}
-                  
-
-
-
-
- 输出 Output -
-
-
-                    {selected.output || "(空)"}
-                  
-
-
-
-
- )}
+ + !open && setSelected(null)}> + + {selected && ( + <> + + 工具执行详情 + {fmtTime(selected.created_at)} +
+ + #{selected.exploration_id} + + + {selected.worker || "-"} + + + {selected.tool || "-"} + + {selected.is_error ? ( + + 失败 + + ) : ( + + 成功 + + )} +
+
+
+
+
输入 Input
+
+
+                      {toolInput(selected.command)}
+                    
+
+
+
+
输出 Output
+
+
+                      {selected.output || "(空)"}
+                    
+
+
+
+ + )} +
+
); } diff --git a/web/src/app/(main)/function/traffic/page.tsx b/web/src/app/(main)/function/traffic/page.tsx index 5f97c63..9ccf7d0 100644 --- a/web/src/app/(main)/function/traffic/page.tsx +++ b/web/src/app/(main)/function/traffic/page.tsx @@ -1,15 +1,15 @@ "use client"; import * as React from "react"; + import { - RadioTowerIcon, - SearchIcon, ChevronLeftIcon, ChevronRightIcon, - XIcon, - Loader2Icon, - Trash2Icon, ListChecksIcon, + Loader2Icon, + RadioTowerIcon, + SearchIcon, + Trash2Icon, } from "lucide-react"; import { @@ -22,35 +22,19 @@ import { 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"; import { Button } from "@/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { Card } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; -import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api } from "@/lib/api"; -import type { TrafficExchange, TrafficResp, TrafficDetail, TrafficHost } from "@/lib/types"; +import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types"; +import { cn } from "@/lib/utils"; function fmtTime(ts: string) { return new Date(ts).toLocaleString("zh-CN", { @@ -66,7 +50,7 @@ function fmtBytes(n: number) { if (n <= 0) return "0 B"; const units = ["B", "KB", "MB"]; const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); - const v = n / Math.pow(1024, i); + const v = n / 1024 ** i; return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`; } @@ -78,6 +62,141 @@ function statusTone(status: number) { return "text-muted-foreground"; } +function MethodBadge({ method }: { method: string }) { + return {method}; +} + +// Older captures may predate Host persistence because net/http keeps Host +// outside Request.Header. Fill it for display while newly recorded traffic is +// fixed at the recorder layer as well. +function requestWithHost(raw: string, exchange: TrafficExchange): string { + if (!raw.trim() || /^host\s*:/im.test(raw)) return raw; + let host = exchange.host; + try { + host = new URL(exchange.url).host || host; + } catch { + // Relative or legacy URLs fall back to the indexed host. + } + const newline = raw.includes("\r\n") ? "\r\n" : "\n"; + const firstLineEnd = raw.indexOf(newline); + if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`; + return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`; +} + +function StartLine({ line }: { line: string }) { + const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line); + if (response) { + return ( + <> + {response[1]} + {response[2]} + {response[3]} + {response[4]} + + ); + } + + const request = /^([A-Z]+)(\s+)(\S+)(\s+)(HTTP\/\S+)$/.exec(line); + if (request) { + return ( + <> + {request[1]} + {request[2]} + {request[3]} + {request[4]} + {request[5]} + + ); + } + + return line; +} + +function HeaderLine({ line }: { line: string }) { + const separator = line.indexOf(":"); + if (separator <= 0) return line; + return ( + <> + {line.slice(0, separator)} + : + {line.slice(separator + 1)} + + ); +} + +function JsonBody({ body }: { body: string }) { + const parts: React.ReactNode[] = []; + const tokens = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g; + let cursor = 0; + for (const match of body.matchAll(tokens)) { + const index = match.index ?? 0; + if (index > cursor) parts.push(body.slice(cursor, index)); + let className = "text-chart-4"; + if (match[1]) className = match[2] ? "text-primary" : "text-chart-2"; + else if (match[3]) className = "text-chart-3"; + parts.push( + + {match[0]} + , + ); + cursor = index + match[0].length; + } + if (cursor < body.length) parts.push(body.slice(cursor)); + return parts; +} + +function MarkupBody({ body }: { body: string }) { + const parts: React.ReactNode[] = []; + const tags = /<\/?[A-Za-z][^>]*>|/g; + let cursor = 0; + for (const match of body.matchAll(tags)) { + const index = match.index ?? 0; + if (index > cursor) parts.push(body.slice(cursor, index)); + parts.push( + + {match[0]} + , + ); + cursor = index + match[0].length; + } + if (cursor < body.length) parts.push(body.slice(cursor)); + return parts; +} + +function HighlightedBody({ body }: { body: string }) { + const trimmed = body.trimStart(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return ; + if (trimmed.startsWith("<")) return ; + return body; +} + +function HttpCodeBlock({ raw }: { raw: string }) { + const value = raw || "(空)"; + const lines = value.replaceAll("\r\n", "\n").split("\n"); + const separator = lines.indexOf(""); + const headerLines = separator >= 0 ? lines.slice(0, separator) : lines; + const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : ""; + + return ( +
+      
+        {headerLines.map((line, index) => (
+          
+            {index === 0 ?  : }
+            {index < headerLines.length - 1 ? "\n" : null}
+          
+        ))}
+        {separator >= 0 && (
+          <>
+            {"\n\n"}
+            
+          
+        )}
+      
+    
+ ); +} + // Fixed method set (server filters exact-match); avoids deriving options from a // single page, which would only ever list the methods on that page. const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]; @@ -116,12 +235,14 @@ export default function TrafficPage() { }, [query]); // Any filter/size change resets to the first page. + // biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset. React.useEffect(() => { setPage(0); }, [hostQ, queryQ, method, size]); // Load the current page. Auto-refresh only on page 0 (newest) so paging back // through history isn't yanked out from under the user. + // biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger. React.useEffect(() => { let alive = true; const load = () => { @@ -130,13 +251,17 @@ export default function TrafficPage() { .then((r) => { if (alive) setTraffic(r); }) - .catch(() => {}); + .catch(() => { + // Keep the last successful snapshot during transient refresh failures. + }); api .trafficHosts() .then((r) => { if (alive) setHosts(r.hosts ?? []); }) - .catch(() => {}); + .catch(() => { + // Keep the last successful host list during transient refresh failures. + }); }; load(); const t = setInterval(() => { @@ -155,22 +280,21 @@ export default function TrafficPage() { const confirmDelete = () => { setDeleting(true); - const p = - deleteMode === "selected" - ? api.trafficDeleteHosts(selectedHosts) - : api.trafficDeleteHost(hostQ); + const p = deleteMode === "selected" ? api.trafficDeleteHosts(selectedHosts) : api.trafficDeleteHost(hostQ); p.then(() => { - setDeleteMode(null); - setSelected(null); - setDetail(null); - if (deleteMode === "selected") { - setSelectedHosts([]); - setPickerOpen(false); - } - setPage(0); - setReloadTick((t) => t + 1); + setDeleteMode(null); + setSelected(null); + setDetail(null); + if (deleteMode === "selected") { + setSelectedHosts([]); + setPickerOpen(false); + } + setPage(0); + setReloadTick((t) => t + 1); + }) + .catch(() => { + // Keep the confirmation open so the user can retry a failed deletion. }) - .catch(() => {}) .finally(() => setDeleting(false)); }; @@ -210,9 +334,7 @@ export default function TrafficPage() {

流量

-

- 全局录制代理 · 所有 HTTP 往来 -

+

全局录制代理 · 所有 HTTP 往来

{traffic?.enabled ? "录制中" : "已停用"} - {traffic?.proxy && ( - - {traffic.proxy} - - )} + {traffic?.proxy && {traffic.proxy}} 共 {traffic?.count ?? 0} 条 @@ -266,29 +384,25 @@ export default function TrafficPage() {
{hosts.length === 0 ? ( -
- 暂无流量记录 -
+
暂无流量记录
) : ( - hosts.map((h) => ( + hosts.map((h, index) => ( )) )} @@ -310,12 +424,7 @@ export default function TrafficPage() {
- setHost(e.target.value)} - className="h-8" - /> + setHost(e.target.value)} className="h-8" />
- {/* History table + detail (Burp-style split) */} -
+ {/* History table */} +
@@ -411,50 +520,29 @@ export default function TrafficPage() { {exchanges.map((e) => ( setSelected(e)} > - - {fmtTime(e.ts)} - + {fmtTime(e.ts)} {e.host} - - {e.method} - + - - {e.url} - + {e.url} - + {e.status} - - {e.content_type} - - - {fmtBytes(e.resp_len)} - + {e.content_type} + {fmtBytes(e.resp_len)} ))} {exchanges.length === 0 && ( - + {traffic === null ? "加载中…" : "没有匹配的流量。"} @@ -463,66 +551,60 @@ export default function TrafficPage() {
- - {/* Raw request/response pane */} - {selected && ( - -
- - {selected.method} - - - {selected.host} - {selected.url} - - - {selected.status} - - -
-
- {( - [ - ["请求 Request", detail?.req], - ["响应 Response", detail?.resp], - ] as const - ).map(([label, body]) => ( -
-
- {label} -
-
- {detailLoading ? ( -
- - 加载报文… -
- ) : ( -
-                        {body || "(空)"}
-                      
- )} -
-
- ))} -
-
- )}
- { if (!o) setDeleteMode(null); }}> + !open && setSelected(null)}> + + {selected && ( + <> + +
+ + + {selected.status} + + {fmtTime(selected.ts)} +
+ {selected.host} + {selected.url} +
+ + + 请求 Request + 响应 Response + + + {detailLoading ? ( +
+ + 加载报文… +
+ ) : ( + + )} +
+ + {detailLoading ? ( +
+ + 加载报文… +
+ ) : ( + + )} +
+
+ + )} +
+
+ + { + if (!o) setDeleteMode(null); + }} + > @@ -533,9 +615,7 @@ export default function TrafficPage() { {deleteMode === "selected" ? ( <> - 将永久删除{" "} - {selectedHosts.length}{" "} - 个目标( + 将永久删除 {selectedHosts.length} 个目标( {selectedHosts.slice(0, 3).join("、")} {selectedHosts.length > 3 ? "…" : ""} @@ -544,8 +624,7 @@ export default function TrafficPage() { ) : ( <> - 将永久删除 host 包含{" "} - {hostQ}{" "} + 将永久删除 host 包含 {hostQ}{" "} 的所有流量记录(含请求/响应原文),此操作不可撤销。 )} diff --git a/web/src/components/agent-editor.tsx b/web/src/components/agent-editor.tsx index ac9208e..ec7413a 100644 --- a/web/src/components/agent-editor.tsx +++ b/web/src/components/agent-editor.tsx @@ -23,6 +23,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Markdown } from "@/components/markdown"; import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; import type { Agent, AgentDetail, AgentTrigger, MCPServer, PromptVar, PromptVersion, Settings, SkillItem, Tool } from "@/lib/types"; @@ -397,9 +398,9 @@ export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?: 渲染预览 所有 {`{{.Var}}`} 已由后端用示例值替换。 -
-                  {preview}
-                
+
+ +
+ ); +} diff --git a/web/src/components/transcript.tsx b/web/src/components/transcript.tsx index 6fa21b2..f2925a8 100644 --- a/web/src/components/transcript.tsx +++ b/web/src/components/transcript.tsx @@ -18,6 +18,7 @@ import { import { toast } from "sonner"; import { api } from "@/lib/api"; import { Markdown } from "@/components/markdown"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import type { Activity } from "@/lib/types"; @@ -208,7 +209,7 @@ function InterceptCard({ }, [pendingId]); async function decide(decision: "allowed" | "denied") { - if (!pendingId || deciding) return; + if (step.inherited || !pendingId || deciding) return; setDeciding(true); try { await api.interceptDecide(pendingId, decision); @@ -246,7 +247,9 @@ function InterceptCard({
- {decided ? ( + {step.inherited ? ( + 历史记录 · 只读 + ) : decided ? ( { + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // 继续走降级方案 + } + } + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + return ok; + } catch { + return false; + } +} + export const getInitials = (str: string): string => { if (typeof str !== "string" || !str.trim()) return "?";