"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([]); const [total, setTotal] = React.useState(0); const [loading, setLoading] = React.useState(false); // 도구별 호출 횟수. 팝업을 열 때만 가져온다(집계 질의가 한 번 더 들어가므로 페이지를 넘길 때마다 부담하지 않는다). const [statsOpen, setStatsOpen] = React.useState(false); const [stats, setStats] = React.useState([]); const [statsLoading, setStatsLoading] = React.useState(false); // Selected execution is rendered in a right-side detail sheet. const [selected, setSelected] = React.useState(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 (
{/* Header */}

{t("title")}

{total}
{/* Toolbar */}
setQuery(e.target.value)} className="h-8 pl-8" />
setTaskFilter(e.target.value.replace(/\D/g, ""))} />
{rangeStart}–{rangeEnd} / {total} {page + 1} / {totalPages}
{/* History table */}
{t("colTime")} {t("colTask")} Worker {t("colTool")} {t("colInput")} {t("colStatus")} {loading && commands.length === 0 ? ( ) : commands.length === 0 ? ( {t("empty")} ) : ( commands.map((cmd) => ( setSelected(cmd)} > {fmtTime(cmd.created_at)} #{cmd.exploration_id} {cmd.worker || "-"} {cmd.tool || "-"} {truncate(toolInput(cmd.command), CMD_MAX_LEN)} {cmd.is_error ? ( {t("failed")} ) : ( {t("success")} )} )) )}
{/* 도구 호출 통계: 표와 같은 기록 묶음(같은 필터, 페이지 나눔 없음) */} {t("statsTitle")} {taskFilter || queryQ ? t("statsDescFiltered") : t("statsDescAll")} {stats.length > 0 && ( <> {" · "} {stats.length} {t("unitTools")} · {statsTotal} {t("unitCalls")} {statsErrors > 0 && ( <> {" · "} {t("failed")} {statsErrors} )} )} {statsLoading && stats.length === 0 ? (
) : stats.length === 0 ? (
{t("statsEmpty")}
) : (
{stats.map((s) => (
{s.tool} {s.errors > 0 && ( {t("failed")} {s.errors} )}
0 ? (s.total / statsMax) * 100 : 0}%` }} />
{s.total}
{statsTotal > 0 ? ((s.total / statsTotal) * 100).toFixed(1) : "0.0"}%
))}
)}
!open && setSelected(null)}> {selected && ( <> {t("detailTitle")} {fmtTime(selected.created_at)}
#{selected.exploration_id} {selected.worker || "-"} {selected.tool || "-"} {selected.is_error ? ( {t("failed")} ) : ( {t("success")} )}
{t("inputLabel")}
                      {toolInput(selected.command)}
                    
{t("outputLabel")}
                      {selected.output || t("emptyOutput")}
                    
)}
); }