ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
|
||
import { BarChart3Icon, ChevronLeftIcon, ChevronRightIcon, Loader2Icon, SearchIcon, TerminalIcon } from "lucide-react";
|
||
import { useTranslations } from "next-intl";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card } from "@/components/ui/card";
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||
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, ToolStat } from "@/lib/types";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
function fmtTime(ts: string) {
|
||
return new Date(ts).toLocaleString("ko-KR", {
|
||
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 t = useTranslations("commands");
|
||
const tp = useTranslations("pagination");
|
||
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);
|
||
|
||
// 도구별 호출 횟수. 팝업을 열 때만 가져온다(집계 질의가 한 번 더 들어가므로 페이지를 넘길 때마다 부담하지 않는다).
|
||
const [statsOpen, setStatsOpen] = React.useState(false);
|
||
const [stats, setStats] = React.useState<ToolStat[]>([]);
|
||
const [statsLoading, setStatsLoading] = 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]);
|
||
|
||
// 통계는 필터 조건을 따라가며, 표가 보여 주는 것과 같은 기록 묶음을 대상으로 한다(단 페이지는 나누지 않는다).
|
||
React.useEffect(() => {
|
||
if (!statsOpen) return;
|
||
let alive = true;
|
||
setStatsLoading(true);
|
||
api
|
||
.commandStats({ task: taskFilter || undefined, q: queryQ || undefined })
|
||
.then((r) => alive && setStats(r.stats ?? []))
|
||
.catch(() => alive && setStats([]))
|
||
.finally(() => alive && setStatsLoading(false));
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [statsOpen, queryQ, taskFilter]);
|
||
|
||
const statsTotal = stats.reduce((n, s) => n + s.total, 0);
|
||
const statsErrors = stats.reduce((n, s) => n + s.errors, 0);
|
||
const statsMax = stats.reduce((n, s) => Math.max(n, s.total), 0);
|
||
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">{t("title")}</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={t("searchPlaceholder")}
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
className="h-8 pl-8"
|
||
/>
|
||
</div>
|
||
<Input
|
||
placeholder={t("taskIdPlaceholder")}
|
||
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}
|
||
{tp("perPage")}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
<Button variant="outline" size="sm" className="h-8" onClick={() => setStatsOpen(true)}>
|
||
<BarChart3Icon className="size-4" />
|
||
{t("stats")}
|
||
</Button>
|
||
|
||
<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]">{t("colTime")}</TableHead>
|
||
<TableHead className="w-[60px]">{t("colTask")}</TableHead>
|
||
<TableHead className="w-[90px]">Worker</TableHead>
|
||
<TableHead className="w-[110px]">{t("colTool")}</TableHead>
|
||
<TableHead>{t("colInput")}</TableHead>
|
||
<TableHead className="w-[60px]">{t("colStatus")}</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">
|
||
{t("empty")}
|
||
</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">
|
||
{t("failed")}
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||
{t("success")}
|
||
</Badge>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* 도구 호출 통계: 표와 같은 기록 묶음(같은 필터, 페이지 나눔 없음) */}
|
||
<Dialog open={statsOpen} onOpenChange={setStatsOpen}>
|
||
<DialogContent className="sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>{t("statsTitle")}</DialogTitle>
|
||
<DialogDescription>
|
||
{taskFilter || queryQ ? t("statsDescFiltered") : t("statsDescAll")}
|
||
{stats.length > 0 && (
|
||
<>
|
||
{" · "}
|
||
<span className="tabular-nums">{stats.length}</span>
|
||
{t("unitTools")} · <span className="tabular-nums">{statsTotal}</span>
|
||
{t("unitCalls")}
|
||
{statsErrors > 0 && (
|
||
<>
|
||
{" · "}
|
||
{t("failed")} <span className="tabular-nums text-red-600 dark:text-red-400">{statsErrors}</span>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{statsLoading && stats.length === 0 ? (
|
||
<div className="py-10 text-center">
|
||
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : stats.length === 0 ? (
|
||
<div className="py-10 text-center text-sm text-muted-foreground">{t("statsEmpty")}</div>
|
||
) : (
|
||
<div className="-mr-2 max-h-[55vh] space-y-1 overflow-auto pr-2">
|
||
{stats.map((s) => (
|
||
<div key={s.tool} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md p-2">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate font-mono text-xs font-medium">{s.tool}</span>
|
||
{s.errors > 0 && (
|
||
<span className="text-[11px] tabular-nums text-red-600 dark:text-red-400">
|
||
{t("failed")} {s.errors}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
className="h-full rounded-full bg-primary"
|
||
style={{ width: `${statsMax > 0 ? (s.total / statsMax) * 100 : 0}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="text-xs font-semibold tabular-nums">{s.total}</div>
|
||
<div className="text-[11px] tabular-nums text-muted-foreground">
|
||
{statsTotal > 0 ? ((s.total / statsTotal) * 100).toFixed(1) : "0.0"}%
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<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">{t("detailTitle")}</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">
|
||
{t("failed")}
|
||
</Badge>
|
||
) : (
|
||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||
{t("success")}
|
||
</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">{t("inputLabel")}</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">{t("outputLabel")}</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 || t("emptyOutput")}
|
||
</pre>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
);
|
||
}
|