304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
|
||
import { ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
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", {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
second: "2-digit",
|
||
});
|
||
}
|
||
|
||
// toolInput renders a tool's raw input for display. Bash's {"command":"..."} is
|
||
// unwrapped to the bare command; other tools show their pretty-printed JSON args.
|
||
function toolInput(raw: string): string {
|
||
try {
|
||
const obj = JSON.parse(raw);
|
||
if (obj && typeof obj.command === "string") return obj.command;
|
||
return JSON.stringify(obj, null, 2);
|
||
} catch {
|
||
/* not JSON */
|
||
}
|
||
return raw;
|
||
}
|
||
|
||
// truncate clips a string to maxLen characters.
|
||
function truncate(s: string, maxLen: number): string {
|
||
const first = s.split("\n")[0]; // single-line preview
|
||
if (first.length <= maxLen) return first;
|
||
return first.slice(0, maxLen) + "…";
|
||
}
|
||
|
||
const PAGE_SIZES = [25, 50, 100];
|
||
const CMD_MAX_LEN = 80;
|
||
|
||
export default function CommandsPage() {
|
||
const [page, setPage] = React.useState(0);
|
||
const [size, setSize] = React.useState(50);
|
||
const [query, setQuery] = React.useState("");
|
||
const [queryQ, setQueryQ] = React.useState("");
|
||
const [taskFilter, setTaskFilter] = React.useState("");
|
||
|
||
const [commands, setCommands] = React.useState<CommandRecord[]>([]);
|
||
const [total, setTotal] = React.useState(0);
|
||
const [loading, setLoading] = React.useState(false);
|
||
|
||
// Selected execution is rendered in a right-side detail sheet.
|
||
const [selected, setSelected] = React.useState<CommandRecord | null>(null);
|
||
|
||
// Debounce search input.
|
||
React.useEffect(() => {
|
||
const t = setTimeout(() => setQueryQ(query.trim()), 300);
|
||
return () => clearTimeout(t);
|
||
}, [query]);
|
||
|
||
// Reset page on filter change.
|
||
// biome-ignore lint/correctness/useExhaustiveDependencies: every filter change intentionally resets pagination.
|
||
React.useEffect(() => {
|
||
setPage(0);
|
||
}, [queryQ, taskFilter, size]);
|
||
|
||
// Load data.
|
||
React.useEffect(() => {
|
||
let alive = true;
|
||
setLoading(true);
|
||
api
|
||
.commands({ task: taskFilter || undefined, q: queryQ || undefined, page, size })
|
||
.then((r) => {
|
||
if (!alive) return;
|
||
setCommands(r.commands ?? []);
|
||
setTotal(r.total ?? 0);
|
||
})
|
||
.catch(() => {
|
||
if (!alive) return;
|
||
setCommands([]);
|
||
setTotal(0);
|
||
})
|
||
.finally(() => alive && setLoading(false));
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [page, size, queryQ, taskFilter]);
|
||
|
||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||
const rangeStart = total === 0 ? 0 : page * size + 1;
|
||
const rangeEnd = page * size + commands.length;
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div className="flex items-center gap-2">
|
||
<TerminalIcon className="h-5 w-5 text-muted-foreground" />
|
||
<h1 className="text-xl font-semibold tracking-tight">工具执行</h1>
|
||
<Badge variant="secondary">{total}</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Toolbar */}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="relative max-w-sm flex-1">
|
||
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
placeholder="搜索工具 / 参数..."
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
className="h-8 pl-8"
|
||
/>
|
||
</div>
|
||
<Input
|
||
placeholder="任务 ID"
|
||
className="h-8 w-28"
|
||
value={taskFilter}
|
||
onChange={(e) => setTaskFilter(e.target.value.replace(/\D/g, ""))}
|
||
/>
|
||
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
|
||
<SelectTrigger size="sm" className="w-28">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{PAGE_SIZES.map((n) => (
|
||
<SelectItem key={n} value={String(n)}>
|
||
{n} / 页
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||
<span className="tabular-nums">
|
||
{rangeStart}–{rangeEnd} / {total}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="size-8"
|
||
disabled={page <= 0}
|
||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||
>
|
||
<ChevronLeftIcon />
|
||
</Button>
|
||
<span className="tabular-nums">
|
||
{page + 1} / {totalPages}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="size-8"
|
||
disabled={page + 1 >= totalPages}
|
||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||
>
|
||
<ChevronRightIcon />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* History table */}
|
||
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col">
|
||
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
|
||
<div className="min-h-0 flex-1 overflow-auto">
|
||
<Table>
|
||
<TableHeader className="sticky top-0 z-10 bg-card">
|
||
<TableRow>
|
||
<TableHead className="w-[130px]">时间</TableHead>
|
||
<TableHead className="w-[60px]">任务</TableHead>
|
||
<TableHead className="w-[90px]">Worker</TableHead>
|
||
<TableHead className="w-[110px]">工具</TableHead>
|
||
<TableHead>输入</TableHead>
|
||
<TableHead className="w-[60px]">状态</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{loading && commands.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="py-12 text-center">
|
||
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
|
||
</TableCell>
|
||
</TableRow>
|
||
) : commands.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={6} className="py-12 text-center text-sm text-muted-foreground">
|
||
暂无工具执行记录
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
commands.map((cmd) => (
|
||
<TableRow
|
||
key={cmd.id}
|
||
className={cn("cursor-pointer", selected?.id === cmd.id && "bg-accent hover:bg-accent")}
|
||
onClick={() => setSelected(cmd)}
|
||
>
|
||
<TableCell className="text-xs text-muted-foreground tabular-nums">
|
||
{fmtTime(cmd.created_at)}
|
||
</TableCell>
|
||
<TableCell className="text-xs font-mono text-muted-foreground">#{cmd.exploration_id}</TableCell>
|
||
<TableCell>
|
||
<Badge variant="outline" className="text-xs font-mono">
|
||
{cmd.worker || "-"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant="secondary" className="text-xs font-mono">
|
||
{cmd.tool || "-"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="max-w-0">
|
||
<code className="block truncate font-mono text-xs">
|
||
{truncate(toolInput(cmd.command), CMD_MAX_LEN)}
|
||
</code>
|
||
</TableCell>
|
||
<TableCell>
|
||
{cmd.is_error ? (
|
||
<Badge variant="destructive" className="text-xs">
|
||
失败
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||
成功
|
||
</Badge>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[46rem]! sm:max-w-[46rem]!">
|
||
{selected && (
|
||
<>
|
||
<SheetHeader className="border-b px-5 py-4">
|
||
<SheetTitle className="pr-8">工具执行详情</SheetTitle>
|
||
<SheetDescription>{fmtTime(selected.created_at)}</SheetDescription>
|
||
<div className="flex flex-wrap items-center gap-2 pt-2">
|
||
<Badge variant="outline" className="text-xs font-mono">
|
||
#{selected.exploration_id}
|
||
</Badge>
|
||
<Badge variant="outline" className="text-xs font-mono">
|
||
{selected.worker || "-"}
|
||
</Badge>
|
||
<Badge variant="secondary" className="text-xs font-mono">
|
||
{selected.tool || "-"}
|
||
</Badge>
|
||
{selected.is_error ? (
|
||
<Badge variant="destructive" className="text-xs">
|
||
失败
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||
成功
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</SheetHeader>
|
||
<div className="grid min-h-0 flex-1 grid-rows-2 divide-y">
|
||
<div className="flex min-h-0 min-w-0 flex-col">
|
||
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">输入 Input</div>
|
||
<div className="min-h-0 flex-1 overflow-auto">
|
||
<pre className="p-5 font-mono text-xs break-all whitespace-pre-wrap">
|
||
{toolInput(selected.command)}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
<div className="flex min-h-0 min-w-0 flex-col">
|
||
<div className="border-b px-5 py-2 text-xs font-medium text-muted-foreground">输出 Output</div>
|
||
<div className="min-h-0 flex-1 overflow-auto">
|
||
<pre
|
||
className={cn(
|
||
"p-5 font-mono text-xs break-all whitespace-pre-wrap",
|
||
selected.is_error && "text-red-600 dark:text-red-400",
|
||
)}
|
||
>
|
||
{selected.output || "(空)"}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
);
|
||
}
|