First Commit
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
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
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function AuthLayout({ children }: { readonly children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { AlertTriangle, ShieldCheck } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("auth");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [termsOpen, setTermsOpen] = useState(false);
|
||||
const [readToEnd, setReadToEnd] = useState(false);
|
||||
const termsBodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 滚动到条款底部(含无需滚动即可完整展示的情况)方可点击「同意」。
|
||||
function handleTermsScroll() {
|
||||
const el = termsBodyRef.current;
|
||||
if (!el) return;
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 8) setReadToEnd(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!termsOpen) return;
|
||||
// 打开时重置,并处理内容本就不足一屏、无法触发滚动的场景。
|
||||
setReadToEnd(false);
|
||||
const el = termsBodyRef.current;
|
||||
if (el && el.scrollHeight <= el.clientHeight + 8) setReadToEnd(true);
|
||||
}, [termsOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
// 已登录直接进主界面(静态导出下无 middleware 代劳这层跳转)。
|
||||
const token = auth.getToken();
|
||||
if (token) {
|
||||
// localStorage 可能仍有凭据但 cookie 已丢失。先同步,再发起全新请求,
|
||||
// 避免服务端守卫或路由缓存把跳转送回仍处于 checking 状态的登录页。
|
||||
auth.setToken(token);
|
||||
window.location.replace("/function/tasks");
|
||||
return;
|
||||
}
|
||||
api
|
||||
.authStatus()
|
||||
.then(({ initialized }) => {
|
||||
if (!initialized) router.replace("/setup");
|
||||
})
|
||||
.catch(() => setError(t("login.errorBackend")))
|
||||
.finally(() => setChecking(false));
|
||||
}, [router, t]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!agreed) {
|
||||
setError(t("login.errorAgree"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const { token } = await api.login("ARTEX", password);
|
||||
auth.setToken(token);
|
||||
window.location.replace("/function/tasks");
|
||||
} catch {
|
||||
setError(t("login.errorCredential"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div role="status" className="flex min-h-dvh items-center justify-center text-muted-foreground">
|
||||
{t("login.checking")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh">
|
||||
{/* Left panel */}
|
||||
<div className="hidden flex-col items-center justify-center bg-primary p-12 text-center lg:flex lg:w-1/3">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="absolute size-80 rounded-full border border-primary-foreground/10" />
|
||||
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
|
||||
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/logo.png" alt="ARTEX" width={160} height={160} className="relative brightness-0 invert" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex w-full items-center justify-center bg-background p-8 lg:w-2/3">
|
||||
<div className="w-full max-w-md space-y-10 py-24 lg:py-32">
|
||||
<div className="space-y-4 text-center">
|
||||
<h2 className="text-2xl font-medium tracking-tight">{t("login.title")}</h2>
|
||||
<p className="mx-auto max-w-xl text-muted-foreground">{t("login.welcome")}</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="username">{t("login.username")}</Label>
|
||||
<Input id="username" value="ARTEX" readOnly className="bg-muted text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">{t("login.password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("login.passwordPlaceholder")}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="agree-terms"
|
||||
checked={agreed}
|
||||
onCheckedChange={(v) => setAgreed(v === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<Label htmlFor="agree-terms" className="text-sm font-normal leading-relaxed text-muted-foreground">
|
||||
{t.rich("login.agree", {
|
||||
terms: (chunks) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTermsOpen(true)}
|
||||
className="mx-0.5 font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
{chunks}
|
||||
</button>
|
||||
),
|
||||
})}
|
||||
</Label>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading || !password || !agreed}>
|
||||
{loading ? t("login.submitting") : t("login.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={termsOpen} onOpenChange={setTermsOpen}>
|
||||
<DialogContent className="gap-0 p-0 sm:max-w-2xl">
|
||||
<DialogHeader className="flex-row items-center gap-3 border-b px-6 py-4">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<ShieldCheck className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle className="text-base">{t("terms.title")}</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground">{t("terms.meta")}</p>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div
|
||||
ref={termsBodyRef}
|
||||
onScroll={handleTermsScroll}
|
||||
className="max-h-[60vh] space-y-5 overflow-y-auto px-6 py-5 text-sm leading-relaxed text-muted-foreground"
|
||||
>
|
||||
<p className="rounded-lg border bg-muted/40 p-3 text-foreground/80">
|
||||
{t("terms.intro")}
|
||||
<span className="font-medium text-foreground"> {t("terms.introEmphasis")}</span>
|
||||
</p>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<h4 className="flex items-center gap-2 font-medium text-foreground">
|
||||
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
|
||||
1
|
||||
</span>
|
||||
{t("terms.a1Title")}
|
||||
</h4>
|
||||
<p className="pl-7">{t("terms.a1Body")}</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<h4 className="flex items-center gap-2 font-medium text-foreground">
|
||||
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
|
||||
2
|
||||
</span>
|
||||
{t("terms.a2Title")}
|
||||
</h4>
|
||||
<p className="pl-7">{t("terms.a2Body")}</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h4 className="flex items-center gap-2 font-medium text-destructive">
|
||||
<span className="flex size-5 items-center justify-center rounded-md bg-destructive/10 text-xs font-semibold text-destructive">
|
||||
3
|
||||
</span>
|
||||
<AlertTriangle className="size-4" />
|
||||
{t("terms.a3Title")}
|
||||
</h4>
|
||||
<ul className="ml-7 list-decimal space-y-1.5 rounded-lg border border-destructive/20 bg-destructive/5 p-3 pl-8 text-foreground/80 marker:text-destructive/70">
|
||||
<li>{t("terms.a3Item1")}</li>
|
||||
<li>{t("terms.a3Item2")}</li>
|
||||
<li>{t("terms.a3Item3")}</li>
|
||||
<li>{t("terms.a3Item4")}</li>
|
||||
<li>{t("terms.a3Item5")}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<h4 className="flex items-center gap-2 font-medium text-foreground">
|
||||
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
|
||||
4
|
||||
</span>
|
||||
{t("terms.a4Title")}
|
||||
</h4>
|
||||
<p className="pl-7">{t("terms.a4Body")}</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<h4 className="flex items-center gap-2 font-medium text-foreground">
|
||||
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
|
||||
5
|
||||
</span>
|
||||
{t("terms.a5Title")}
|
||||
</h4>
|
||||
<p className="pl-7">{t("terms.a5Body")}</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<h4 className="flex items-center gap-2 font-medium text-foreground">
|
||||
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
|
||||
6
|
||||
</span>
|
||||
{t("terms.a6Title")}
|
||||
</h4>
|
||||
<p className="pl-7">
|
||||
{t("terms.a6Body")}
|
||||
<span className="font-medium text-foreground"> {t("terms.a6Emphasis")}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<h4 className="flex items-center gap-2 font-medium text-foreground">
|
||||
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
|
||||
7
|
||||
</span>
|
||||
{t("terms.a7Title")}
|
||||
</h4>
|
||||
<p className="pl-7">{t("terms.a7Body")}</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-1.5">
|
||||
<h4 className="flex items-center gap-2 font-medium text-foreground">
|
||||
<span className="flex size-5 items-center justify-center rounded-md bg-muted text-xs font-semibold text-muted-foreground">
|
||||
8
|
||||
</span>
|
||||
{t("terms.a8Title")}
|
||||
</h4>
|
||||
<p className="pl-7">{t("terms.a8Body")}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mx-0 mb-0 flex-col items-stretch gap-2 rounded-b-xl px-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{readToEnd ? t("terms.footerRead") : t("terms.footerNotRead")}
|
||||
</p>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!readToEnd}
|
||||
onClick={() => {
|
||||
setAgreed(true);
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
{t("terms.agreeButton")}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
export default function SetupPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("auth");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.authStatus()
|
||||
.then(({ initialized }) => {
|
||||
if (initialized) router.replace("/login");
|
||||
})
|
||||
.catch(() => setError(t("setup.errorBackend")))
|
||||
.finally(() => setChecking(false));
|
||||
}, [router, t]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (password !== confirm) {
|
||||
setError(t("setup.errorMismatch"));
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError(t("setup.errorTooShort"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const { token } = await api.initPassword(password);
|
||||
auth.setToken(token);
|
||||
router.replace("/function/tasks");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("setup.errorInit"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh">
|
||||
{/* Left panel */}
|
||||
<div className="hidden flex-col items-center justify-center bg-primary p-12 text-center lg:flex lg:w-1/3">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="absolute size-80 rounded-full border border-primary-foreground/10" />
|
||||
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
|
||||
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/logo.png" alt="ARTEX" width={160} height={160} className="relative brightness-0 invert" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex w-full items-center justify-center bg-background p-8 lg:w-2/3">
|
||||
<div className="w-full max-w-md space-y-10 py-24 lg:py-32">
|
||||
<div className="space-y-4 text-center">
|
||||
<h2 className="text-2xl font-medium tracking-tight">{t("setup.title")}</h2>
|
||||
<p className="mx-auto max-w-xl text-muted-foreground">{t("setup.desc")}</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">{t("setup.newPassword")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("setup.newPasswordPlaceholder")}
|
||||
autoFocus
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirm">{t("setup.confirmPassword")}</Label>
|
||||
<Input
|
||||
id="confirm"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder={t("setup.confirmPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading || !password || !confirm}>
|
||||
{loading ? t("setup.submitting") : t("setup.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/function/tasks");
|
||||
return <>Coming Soon</>;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { useCurrentUser } from "@/hooks/use-current-user";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AccountSwitcher } from "./sidebar/account-switcher";
|
||||
import { LayoutControls } from "./sidebar/layout-controls";
|
||||
import { SearchDialog } from "./sidebar/search-dialog";
|
||||
import { ThemeSwitcher } from "./sidebar/theme-switcher";
|
||||
import { UpdateBadge } from "./update-badge";
|
||||
|
||||
// 任务详情页保持原样:它自带头部/Tabs 与内边距,这里不再叠加全局头部和 padding。
|
||||
function isFullBleed(pathname: string) {
|
||||
const p = (() => {
|
||||
try {
|
||||
return decodeURIComponent(pathname);
|
||||
} catch {
|
||||
return pathname;
|
||||
}
|
||||
})();
|
||||
// 静态导出开了 trailingSlash,列表页自身的 pathname 是 "/function/tasks/",
|
||||
// 先去掉尾斜杠再判前缀,否则列表页会被误判成详情页而丢掉全局头部。
|
||||
const normalized = p.replace(/\/+$/, "");
|
||||
return normalized.startsWith("/function/tasks/");
|
||||
}
|
||||
|
||||
export function MainContent({ children }: { children: ReactNode }) {
|
||||
const currentUser = useCurrentUser();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("header");
|
||||
const [version, setVersion] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.health()
|
||||
.then((h) => setVersion((h.version ?? "").replace(/^v(?=\d)/, "")))
|
||||
.catch(() => setVersion(""));
|
||||
}, []);
|
||||
|
||||
if (isFullBleed(pathname)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={cn(
|
||||
"flex h-12 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12",
|
||||
"[html[data-navbar-style=sticky]_&]:sticky [html[data-navbar-style=sticky]_&]:top-0 [html[data-navbar-style=sticky]_&]:z-50 [html[data-navbar-style=sticky]_&]:overflow-hidden [html[data-navbar-style=sticky]_&]:rounded-t-[inherit] [html[data-navbar-style=sticky]_&]:bg-background/50 [html[data-navbar-style=sticky]_&]:backdrop-blur-md",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between px-4 lg:px-6">
|
||||
<div className="flex items-center gap-1 lg:gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-2 data-[orientation=vertical]:h-4 data-[orientation=vertical]:self-center"
|
||||
/>
|
||||
<SearchDialog />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{version && (
|
||||
<span className="font-medium text-muted-foreground text-xs tabular-nums">
|
||||
{t("version", { version })}
|
||||
</span>
|
||||
)}
|
||||
<UpdateBadge />
|
||||
<LayoutControls />
|
||||
<ThemeSwitcher />
|
||||
<AccountSwitcher users={[currentUser]} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden p-4 has-data-[content-padding=false]:p-0 md:p-6 md:has-data-[content-padding=false]:p-0">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Check, KeyRound, LogOut } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cn, getInitials } from "@/lib/utils";
|
||||
|
||||
import { ChangePasswordDialog } from "./change-password-dialog";
|
||||
|
||||
export function AccountSwitcher({
|
||||
users,
|
||||
}: {
|
||||
readonly users: ReadonlyArray<{
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly avatar: string;
|
||||
readonly role: string;
|
||||
}>;
|
||||
}) {
|
||||
const t = useTranslations("userMenu");
|
||||
const [activeUser, setActiveUser] = useState(users[0]);
|
||||
const [pwOpen, setPwOpen] = useState(false);
|
||||
|
||||
function handleLogout() {
|
||||
auth.clearToken();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
if (!activeUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarImage src={activeUser.avatar || undefined} alt={activeUser.name} />
|
||||
<AvatarFallback>{getInitials(activeUser.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-56 space-y-1 rounded-lg" side="bottom" align="end" sideOffset={4}>
|
||||
{users.map((user) => (
|
||||
<DropdownMenuItem
|
||||
key={user.email}
|
||||
className={cn("p-0", user.id === activeUser.id && "bg-accent/50")}
|
||||
aria-current={user.id === activeUser.id ? "true" : undefined}
|
||||
onClick={() => setActiveUser(user)}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2 px-1 py-1.5">
|
||||
<Avatar className="size-9 rounded-lg">
|
||||
<AvatarImage src={user.avatar || undefined} alt={user.name} />
|
||||
<AvatarFallback>{getInitials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
<span className="truncate text-xs capitalize">{user.role}</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"mr-1 flex size-5 items-center justify-center rounded-full text-primary opacity-0",
|
||||
user.id === activeUser.id && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<Check aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setPwOpen(true)}>
|
||||
<KeyRound />
|
||||
{t("changePassword")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
|
||||
<LogOut />
|
||||
{t("logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ChangePasswordDialog open={pwOpen} onOpenChange={setPwOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { APP_CONFIG } from "@/config/app-config";
|
||||
import { useCurrentUser } from "@/hooks/use-current-user";
|
||||
import { sidebarItems } from "@/navigation/sidebar/sidebar-items";
|
||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
import { NavMain } from "./nav-main";
|
||||
import { NavUser } from "./nav-user";
|
||||
import { SidebarSupportCard } from "./sidebar-support-card";
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const currentUser = useCurrentUser();
|
||||
const { sidebarVariant, sidebarCollapsible, isSynced } = usePreferencesStore(
|
||||
useShallow((s) => ({
|
||||
sidebarVariant: s.sidebarVariant,
|
||||
sidebarCollapsible: s.sidebarCollapsible,
|
||||
isSynced: s.isSynced,
|
||||
})),
|
||||
);
|
||||
|
||||
const variant = isSynced ? sidebarVariant : props.variant;
|
||||
const collapsible = isSynced ? sidebarCollapsible : props.collapsible;
|
||||
|
||||
return (
|
||||
<Sidebar {...props} variant={variant} collapsible={collapsible}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link prefetch={false} href="/function/tasks">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src="/logo.png" alt="ARTEX" width={40} height={40} className="shrink-0" />
|
||||
<span className="font-semibold text-base">{APP_CONFIG.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={sidebarItems} />
|
||||
{/* <NavDocuments items={data.documents} /> */}
|
||||
{/* <NavSecondary items={data.navSecondary} className="mt-auto" /> */}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={currentUser} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export function ChangePasswordDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
readonly open: boolean;
|
||||
readonly onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations("changePasswordDialog");
|
||||
const [oldPassword, setOldPassword] = React.useState("");
|
||||
const [newPassword, setNewPassword] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
// reset fields whenever the dialog closes
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!oldPassword || !newPassword) {
|
||||
toast.error(t("errorRequired"));
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error(t("errorMismatch"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
api
|
||||
.changePassword(oldPassword, newPassword)
|
||||
.then(() => {
|
||||
toast.success(t("success"));
|
||||
onOpenChange(false);
|
||||
})
|
||||
.catch((err) => toast.error(t("failed", { msg: (err as Error).message })))
|
||||
.finally(() => setSaving(false));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={submit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t.rich("description", { b: (chunks) => <b>{chunks}</b> })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cp-old">{t("currentPassword")}</Label>
|
||||
<Input
|
||||
id="cp-old"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={oldPassword}
|
||||
disabled={saving}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cp-new">{t("newPassword")}</Label>
|
||||
<Input
|
||||
id="cp-new"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newPassword}
|
||||
disabled={saving}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cp-confirm">{t("confirmPassword")}</Label>
|
||||
<Input
|
||||
id="cp-confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
disabled={saving}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { type FontKey, fontOptions } from "@/lib/fonts/registry";
|
||||
import type { ContentLayout, NavbarStyle, SidebarCollapsible, SidebarVariant } from "@/lib/preferences/layout";
|
||||
import {
|
||||
applyContentLayout,
|
||||
applyFont,
|
||||
applyNavbarStyle,
|
||||
applySidebarCollapsible,
|
||||
applySidebarVariant,
|
||||
} from "@/lib/preferences/layout-utils";
|
||||
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
|
||||
import { persistPreference } from "@/lib/preferences/preferences-storage";
|
||||
import { THEME_PRESET_OPTIONS, type ThemeMode, type ThemePreset } from "@/lib/preferences/theme";
|
||||
import { applyThemePreset } from "@/lib/preferences/theme-utils";
|
||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
export function LayoutControls() {
|
||||
const themeMode = usePreferencesStore((s) => s.themeMode);
|
||||
const resolvedThemeMode = usePreferencesStore((s) => s.resolvedThemeMode);
|
||||
const setThemeMode = usePreferencesStore((s) => s.setThemeMode);
|
||||
const themePreset = usePreferencesStore((s) => s.themePreset);
|
||||
const setThemePreset = usePreferencesStore((s) => s.setThemePreset);
|
||||
const contentLayout = usePreferencesStore((s) => s.contentLayout);
|
||||
const setContentLayout = usePreferencesStore((s) => s.setContentLayout);
|
||||
const navbarStyle = usePreferencesStore((s) => s.navbarStyle);
|
||||
const setNavbarStyle = usePreferencesStore((s) => s.setNavbarStyle);
|
||||
const variant = usePreferencesStore((s) => s.sidebarVariant);
|
||||
const setSidebarVariant = usePreferencesStore((s) => s.setSidebarVariant);
|
||||
const collapsible = usePreferencesStore((s) => s.sidebarCollapsible);
|
||||
const setSidebarCollapsible = usePreferencesStore((s) => s.setSidebarCollapsible);
|
||||
const font = usePreferencesStore((s) => s.font);
|
||||
const setFont = usePreferencesStore((s) => s.setFont);
|
||||
|
||||
const onThemePresetChange = (preset: ThemePreset) => {
|
||||
applyThemePreset(preset);
|
||||
setThemePreset(preset);
|
||||
void persistPreference("theme_preset", preset);
|
||||
};
|
||||
|
||||
const onThemeModeChange = (mode: ThemeMode | "") => {
|
||||
if (!mode) return;
|
||||
setThemeMode(mode);
|
||||
void persistPreference("theme_mode", mode);
|
||||
};
|
||||
|
||||
const onContentLayoutChange = (layout: ContentLayout | "") => {
|
||||
if (!layout) return;
|
||||
applyContentLayout(layout);
|
||||
setContentLayout(layout);
|
||||
void persistPreference("content_layout", layout);
|
||||
};
|
||||
|
||||
const onNavbarStyleChange = (style: NavbarStyle | "") => {
|
||||
if (!style) return;
|
||||
applyNavbarStyle(style);
|
||||
setNavbarStyle(style);
|
||||
void persistPreference("navbar_style", style);
|
||||
};
|
||||
|
||||
const onSidebarStyleChange = (value: SidebarVariant | "") => {
|
||||
if (!value) return;
|
||||
setSidebarVariant(value);
|
||||
applySidebarVariant(value);
|
||||
void persistPreference("sidebar_variant", value);
|
||||
};
|
||||
|
||||
const onSidebarCollapseModeChange = (value: SidebarCollapsible | "") => {
|
||||
if (!value) return;
|
||||
setSidebarCollapsible(value);
|
||||
applySidebarCollapsible(value);
|
||||
void persistPreference("sidebar_collapsible", value);
|
||||
};
|
||||
|
||||
const onFontChange = (value: FontKey | "") => {
|
||||
if (!value) return;
|
||||
applyFont(value);
|
||||
setFont(value);
|
||||
void persistPreference("font", value);
|
||||
};
|
||||
|
||||
const handleRestore = () => {
|
||||
onThemePresetChange(PREFERENCE_DEFAULTS.theme_preset);
|
||||
onThemeModeChange(PREFERENCE_DEFAULTS.theme_mode);
|
||||
onContentLayoutChange(PREFERENCE_DEFAULTS.content_layout);
|
||||
onNavbarStyleChange(PREFERENCE_DEFAULTS.navbar_style);
|
||||
onSidebarStyleChange(PREFERENCE_DEFAULTS.sidebar_variant);
|
||||
onSidebarCollapseModeChange(PREFERENCE_DEFAULTS.sidebar_collapsible);
|
||||
onFontChange(PREFERENCE_DEFAULTS.font);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon">
|
||||
<Settings />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="font-medium text-sm leading-none">Preferences</h4>
|
||||
<p className="text-muted-foreground text-xs">Customize your dashboard layout preferences.</p>
|
||||
</div>
|
||||
<div className="space-y-3 **:data-[slot=toggle-group]:w-full **:data-[slot=toggle-group-item]:flex-1 **:data-[slot=toggle-group-item]:text-xs">
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Theme Preset</Label>
|
||||
<Select value={themePreset} onValueChange={onThemePresetChange}>
|
||||
<SelectTrigger size="sm" className="w-full text-xs">
|
||||
<SelectValue placeholder="Preset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{THEME_PRESET_OPTIONS.map((preset) => (
|
||||
<SelectItem key={preset.value} className="text-xs" value={preset.value}>
|
||||
<span
|
||||
className="size-2.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
(resolvedThemeMode ?? "light") === "dark" ? preset.primary.dark : preset.primary.light,
|
||||
}}
|
||||
/>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Fonts</Label>
|
||||
<Select value={font} onValueChange={onFontChange}>
|
||||
<SelectTrigger size="sm" className="w-full text-xs">
|
||||
<SelectValue placeholder="Select font" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{fontOptions.map((font) => (
|
||||
<SelectItem key={font.key} className="text-xs" value={font.key}>
|
||||
{font.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Theme Mode</Label>
|
||||
<ToggleGroup
|
||||
size="sm"
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={themeMode}
|
||||
onValueChange={onThemeModeChange}
|
||||
>
|
||||
<ToggleGroupItem value="light" aria-label="Toggle light">
|
||||
Light
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" aria-label="Toggle dark">
|
||||
Dark
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="system" aria-label="Toggle system">
|
||||
System
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Page Layout</Label>
|
||||
<ToggleGroup
|
||||
size="sm"
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={contentLayout}
|
||||
onValueChange={onContentLayoutChange}
|
||||
>
|
||||
<ToggleGroupItem value="centered" aria-label="Toggle centered">
|
||||
Centered
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="full-width" aria-label="Toggle full-width">
|
||||
Full Width
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Navbar Behavior</Label>
|
||||
<ToggleGroup
|
||||
size="sm"
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={navbarStyle}
|
||||
onValueChange={onNavbarStyleChange}
|
||||
>
|
||||
<ToggleGroupItem value="sticky" aria-label="Toggle sticky">
|
||||
Sticky
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="scroll" aria-label="Toggle scroll">
|
||||
Scroll
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Sidebar Style</Label>
|
||||
<ToggleGroup
|
||||
size="sm"
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={variant}
|
||||
onValueChange={onSidebarStyleChange}
|
||||
>
|
||||
<ToggleGroupItem value="inset" aria-label="Toggle inset">
|
||||
Inset
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="sidebar" aria-label="Toggle sidebar">
|
||||
Sidebar
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="floating" aria-label="Toggle floating">
|
||||
Floating
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium text-xs">Sidebar Collapse Mode</Label>
|
||||
<ToggleGroup
|
||||
size="sm"
|
||||
spacing={0}
|
||||
variant="outline"
|
||||
type="single"
|
||||
value={collapsible}
|
||||
onValueChange={onSidebarCollapseModeChange}
|
||||
>
|
||||
<ToggleGroupItem value="icon" aria-label="Toggle icon">
|
||||
Icon
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="offcanvas" aria-label="Toggle offcanvas">
|
||||
OffCanvas
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<Button type="button" size="sm" variant="outline" className="w-full text-xs" onClick={handleRestore}>
|
||||
Restore Defaults
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { Ellipsis, Folder, Forward, type LucideIcon, Trash2 } from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavDocuments({
|
||||
items,
|
||||
}: {
|
||||
readonly items: readonly {
|
||||
readonly name: string;
|
||||
readonly url: string;
|
||||
readonly icon: LucideIcon;
|
||||
}[];
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||
<SidebarGroupLabel>Documents</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.name}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.name}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover className="rounded-sm data-[state=open]:bg-accent">
|
||||
<Ellipsis />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-24 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align={isMobile ? "end" : "start"}
|
||||
>
|
||||
<DropdownMenuItem>
|
||||
<Folder />
|
||||
<span>Open</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Forward />
|
||||
<span>Share</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<Trash2 />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton className="text-sidebar-foreground/70">
|
||||
<Ellipsis className="text-sidebar-foreground/70" />
|
||||
<span>More</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { ChevronRight, MailIcon, PlusCircleIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
NavBadge,
|
||||
NavGroup,
|
||||
NavMainItem,
|
||||
NavMainLinkItem,
|
||||
NavMainParentItem,
|
||||
} from "@/navigation/sidebar/sidebar-items";
|
||||
|
||||
interface NavMainProps {
|
||||
readonly items: readonly NavGroup[];
|
||||
}
|
||||
interface NavItemProps {
|
||||
readonly item: NavMainItem;
|
||||
readonly isItemActive: (item: NavMainItem) => boolean;
|
||||
readonly isSubItemActive: (url: string) => boolean;
|
||||
readonly isSubmenuOpen: (item: NavMainParentItem) => boolean;
|
||||
}
|
||||
|
||||
interface NavLinkItemProps {
|
||||
readonly item: NavMainLinkItem;
|
||||
readonly isActive: boolean;
|
||||
readonly showIconFallback: boolean;
|
||||
}
|
||||
|
||||
interface NavLinkIconProps {
|
||||
readonly item: NavMainLinkItem;
|
||||
readonly title: string;
|
||||
readonly showFallback: boolean;
|
||||
}
|
||||
|
||||
interface NavDropdownItemProps {
|
||||
readonly item: NavMainParentItem;
|
||||
readonly isActive: boolean;
|
||||
readonly isSubItemActive: (url: string) => boolean;
|
||||
}
|
||||
|
||||
interface NavCollapsibleItemProps {
|
||||
readonly item: NavMainParentItem;
|
||||
readonly isActive: boolean;
|
||||
readonly defaultOpen: boolean;
|
||||
readonly isSubItemActive: (url: string) => boolean;
|
||||
}
|
||||
|
||||
function CollapsedIconFallback({ title }: { title: string }) {
|
||||
return (
|
||||
<span className="flex size-4 shrink-0 items-center justify-center rounded-xs font-medium text-[10px] outline">
|
||||
{title.slice(0, 1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function hasSubItems(item: NavMainItem): item is NavMainParentItem {
|
||||
return Boolean(item.subItems?.length);
|
||||
}
|
||||
|
||||
// 그룹 번호 → nav.group.* 메시지 키. 사이드바 라벨은 데이터(sidebar-items.ts)가 아니라
|
||||
// 렌더 시점에 useTranslations 로 번역한다. 매핑이 없는 그룹은 원문 label 로 떨어진다.
|
||||
const GROUP_MESSAGE_KEY: Record<number, string> = { 1: "function", 2: "system" };
|
||||
|
||||
export function NavMain({ items }: NavMainProps) {
|
||||
const t = useTranslations("nav");
|
||||
const rawPath = usePathname();
|
||||
// 路由段含中文,pathname 可能是百分号编码,解码后再与导航 url 比较,保证高亮命中。
|
||||
const path = (() => {
|
||||
try {
|
||||
return decodeURIComponent(rawPath);
|
||||
} catch {
|
||||
return rawPath;
|
||||
}
|
||||
})();
|
||||
|
||||
const isItemActive = (item: NavMainItem) => {
|
||||
if (hasSubItems(item)) {
|
||||
return item.subItems.some((sub) => path.startsWith(sub.url));
|
||||
}
|
||||
|
||||
return path === item.url;
|
||||
};
|
||||
|
||||
const isSubItemActive = (url: string) => {
|
||||
return path === url;
|
||||
};
|
||||
|
||||
const isSubmenuOpen = (item: NavMainParentItem) => {
|
||||
return item.subItems.some((sub) => path.startsWith(sub.url));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((group) => (
|
||||
<SidebarGroup key={group.id}>
|
||||
{group.label && (
|
||||
<SidebarGroupLabel className="group-data-[collapsible=icon]:pointer-events-none">
|
||||
{GROUP_MESSAGE_KEY[group.id] ? t(`group.${GROUP_MESSAGE_KEY[group.id]}`) : group.label}
|
||||
</SidebarGroupLabel>
|
||||
)}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => (
|
||||
<NavItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isItemActive={isItemActive}
|
||||
isSubItemActive={isSubItemActive}
|
||||
isSubmenuOpen={isSubmenuOpen}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ item, isItemActive, isSubItemActive, isSubmenuOpen }: NavItemProps) {
|
||||
const { state, isMobile } = useSidebar();
|
||||
const isCollapsedDesktop = state === "collapsed" && !isMobile;
|
||||
|
||||
if (!hasSubItems(item)) {
|
||||
return <NavLinkItem item={item} isActive={isItemActive(item)} showIconFallback={isCollapsedDesktop} />;
|
||||
}
|
||||
|
||||
if (isCollapsedDesktop) {
|
||||
return <NavDropdownItem item={item} isActive={isItemActive(item)} isSubItemActive={isSubItemActive} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavCollapsibleItem
|
||||
item={item}
|
||||
isActive={isItemActive(item)}
|
||||
defaultOpen={isSubmenuOpen(item)}
|
||||
isSubItemActive={isSubItemActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// InterceptPendingBadge polls /api/intercept/pending every 5 s and shows a red
|
||||
// count badge when there are unresolved approval requests.
|
||||
function InterceptPendingBadge() {
|
||||
const [count, setCount] = React.useState(0);
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
async function poll() {
|
||||
try {
|
||||
const list = await api.interceptPending();
|
||||
if (live) setCount(list.length);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
void poll();
|
||||
const t = setInterval(poll, 5000);
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, []);
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<SidebarMenuBadge className="flex h-4.5 min-w-[18px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white shadow-sm ring-1 ring-amber-400/40">
|
||||
{count > 99 ? "99+" : count}
|
||||
</SidebarMenuBadge>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinkItem({ item, isActive, showIconFallback }: NavLinkItemProps) {
|
||||
const t = useTranslations("nav");
|
||||
const title = t(`item.${item.id}`);
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild aria-disabled={item.disabled} tooltip={title} isActive={isActive}>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={item.url}
|
||||
target={item.newTab ? "_blank" : undefined}
|
||||
rel={item.newTab ? "noreferrer" : undefined}
|
||||
>
|
||||
<NavLinkIcon item={item} title={title} showFallback={showIconFallback} />
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{item.id === "approvals" ? <InterceptPendingBadge /> : <NavItemBadge badge={item.badge} />}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinkIcon({ item, title, showFallback }: NavLinkIconProps) {
|
||||
const Icon = item.icon;
|
||||
|
||||
if (Icon) {
|
||||
return <Icon />;
|
||||
}
|
||||
|
||||
if (showFallback) {
|
||||
return <CollapsedIconFallback title={title} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function NavDropdownItem({ item, isActive, isSubItemActive }: NavDropdownItemProps) {
|
||||
const t = useTranslations("nav");
|
||||
const Icon = item.icon;
|
||||
const title = t(`item.${item.id}`);
|
||||
const subTitle = (id: string, fallback: string) => (t.has(`item.${id}`) ? t(`item.${id}`) : fallback);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton tooltip={title} isActive={isActive} disabled={item.disabled}>
|
||||
{Icon ? <Icon /> : <CollapsedIconFallback title={title} />}
|
||||
<span>{title}</span>
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent side="right" align="start" sideOffset={12} className="w-48">
|
||||
<DropdownMenuGroup>
|
||||
{item.subItems.map((subItem) => {
|
||||
const SubIcon = subItem.icon;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem key={subItem.id} asChild disabled={subItem.disabled}>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={subItem.url}
|
||||
target={subItem.newTab ? "_blank" : undefined}
|
||||
rel={subItem.newTab ? "noreferrer" : undefined}
|
||||
aria-current={isSubItemActive(subItem.url) ? "page" : undefined}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{SubIcon && <SubIcon />}
|
||||
<span>{subTitle(subItem.id, subItem.title)}</span>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function NavCollapsibleItem({ item, isActive, defaultOpen, isSubItemActive }: NavCollapsibleItemProps) {
|
||||
const t = useTranslations("nav");
|
||||
const Icon = item.icon;
|
||||
const title = t(`item.${item.id}`);
|
||||
const subTitle = (id: string, fallback: string) => (t.has(`item.${id}`) ? t(`item.${id}`) : fallback);
|
||||
|
||||
return (
|
||||
<Collapsible asChild defaultOpen={defaultOpen} className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={title} isActive={isActive} disabled={item.disabled}>
|
||||
{Icon && <Icon />}
|
||||
<span>{title}</span>
|
||||
<ChevronRight className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<NavItemBadge badge={item.badge} />
|
||||
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.subItems.map((subItem) => {
|
||||
const SubIcon = subItem.icon;
|
||||
|
||||
return (
|
||||
<SidebarMenuSubItem key={subItem.id}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
aria-disabled={subItem.disabled}
|
||||
isActive={isSubItemActive(subItem.url)}
|
||||
>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={subItem.url}
|
||||
target={subItem.newTab ? "_blank" : undefined}
|
||||
rel={subItem.newTab ? "noreferrer" : undefined}
|
||||
>
|
||||
{SubIcon && <SubIcon />}
|
||||
<span>{subTitle(subItem.id, subItem.title)}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItemBadge({ badge }: { badge?: NavBadge }) {
|
||||
if (!badge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuBadge
|
||||
className={cn(
|
||||
"rounded-sm border capitalize",
|
||||
badge === "new" &&
|
||||
"border-green-600 text-green-600 peer-hover/menu-button:text-green-600 peer-data-active/menu-button:text-green-600",
|
||||
badge === "soon" && "border-muted-foreground text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</SidebarMenuBadge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
|
||||
export function NavSecondary({
|
||||
items,
|
||||
...props
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
}[];
|
||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||
return (
|
||||
<SidebarGroup {...props}>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { EllipsisVertical, KeyRound, LogOut } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getInitials } from "@/lib/utils";
|
||||
|
||||
import { ChangePasswordDialog } from "./change-password-dialog";
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
readonly user: {
|
||||
readonly name: string;
|
||||
readonly email: string;
|
||||
readonly avatar: string;
|
||||
};
|
||||
}) {
|
||||
const { isMobile } = useSidebar();
|
||||
const t = useTranslations("userMenu");
|
||||
const [pwOpen, setPwOpen] = React.useState(false);
|
||||
|
||||
function handleLogout() {
|
||||
auth.clearToken();
|
||||
// 硬跳转:让浏览器用已清除的 cookie 发起全新请求,
|
||||
// middleware 才能正确读到空 token 并放行 /login
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
||||
<AvatarImage src={user.avatar || undefined} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">{getInitials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
|
||||
</div>
|
||||
<EllipsisVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
<AvatarImage src={user.avatar || undefined} alt={user.name} />
|
||||
<AvatarFallback className="rounded-lg">{getInitials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-muted-foreground text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setPwOpen(true)}>
|
||||
<KeyRound />
|
||||
{t("changePassword")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout} className="text-destructive focus:text-destructive">
|
||||
<LogOut />
|
||||
{t("logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<ChangePasswordDialog open={pwOpen} onOpenChange={setPwOpen} />
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import type { NavMainItem } from "@/navigation/sidebar/sidebar-items";
|
||||
import { sidebarItems } from "@/navigation/sidebar/sidebar-items";
|
||||
|
||||
// 표시 문자열은 데이터에 담지 않고 nav 네임스페이스 키만 담아 둔다. 실제 라벨·헤딩은
|
||||
// 컴포넌트에서 useTranslations("nav") 로 렌더 시점에 번역한다(사이드바와 같은 메시지 원천).
|
||||
type SearchItem = {
|
||||
id: string;
|
||||
headingKey: string; // nav 기준 상대 키 (그룹 헤딩). 예) "group.function"
|
||||
labelKey: string; // nav 기준 상대 키 (항목 라벨). 예) "item.dashboard"
|
||||
fallbackLabel: string; // 메시지에 키가 없을 때 쓸 원문
|
||||
url: string;
|
||||
icon?: NavMainItem["icon"];
|
||||
disabled?: boolean;
|
||||
newTab?: boolean;
|
||||
};
|
||||
|
||||
// 그룹 번호 → nav.group.* 키. nav-main 과 같은 매핑을 쓴다.
|
||||
const GROUP_MESSAGE_KEY: Record<number, string> = { 1: "function", 2: "system" };
|
||||
function groupHeadingKey(groupId: number): string {
|
||||
const k = GROUP_MESSAGE_KEY[groupId];
|
||||
return k ? `group.${k}` : `group.${groupId}`;
|
||||
}
|
||||
|
||||
const searchItems: SearchItem[] = sidebarItems.flatMap((group) => {
|
||||
const headingKey = groupHeadingKey(group.id);
|
||||
return group.items.flatMap((item) => {
|
||||
if (item.subItems) {
|
||||
return item.subItems.map((sub) => ({
|
||||
id: sub.id,
|
||||
headingKey,
|
||||
labelKey: `item.${sub.id}`,
|
||||
fallbackLabel: sub.title,
|
||||
url: sub.url,
|
||||
icon: item.icon,
|
||||
disabled: sub.disabled,
|
||||
newTab: sub.newTab,
|
||||
}));
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
headingKey,
|
||||
labelKey: `item.${item.id}`,
|
||||
fallbackLabel: item.title,
|
||||
url: item.url,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
newTab: item.newTab,
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
|
||||
function getAvailableItems(items: SearchItem[]) {
|
||||
return items.filter((item) => !item.disabled && !item.url.includes("coming-soon"));
|
||||
}
|
||||
|
||||
const recommendations = getAvailableItems(searchItems);
|
||||
|
||||
function groupBy(items: SearchItem[]) {
|
||||
const headings = [...new Set(items.map((item) => item.headingKey))];
|
||||
return headings.map((headingKey) => ({
|
||||
headingKey,
|
||||
items: items.filter((item) => item.headingKey === headingKey),
|
||||
}));
|
||||
}
|
||||
|
||||
export function SearchDialog() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const router = useRouter();
|
||||
const t = useTranslations("search");
|
||||
const tNav = useTranslations("nav");
|
||||
const labelOf = (item: SearchItem) => (tNav.has(item.labelKey) ? tNav(item.labelKey) : item.fallbackLabel);
|
||||
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "j" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", down);
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
setOpen(value);
|
||||
if (!value) setQuery("");
|
||||
};
|
||||
|
||||
const handleSelect = (item: SearchItem) => {
|
||||
if (item.disabled) return;
|
||||
handleOpenChange(false);
|
||||
if (item.newTab) {
|
||||
window.open(item.url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
router.push(item.url);
|
||||
}
|
||||
};
|
||||
|
||||
const renderGroups = (items: SearchItem[]) =>
|
||||
groupBy(items).map(({ headingKey, items: groupItems }, index) => {
|
||||
const heading = tNav(headingKey);
|
||||
return (
|
||||
<React.Fragment key={headingKey}>
|
||||
{index > 0 && <CommandSeparator />}
|
||||
<CommandGroup heading={heading}>
|
||||
{groupItems.map((item) => (
|
||||
<CommandItem
|
||||
disabled={item.disabled}
|
||||
key={`${headingKey}-${item.id}`}
|
||||
value={`${heading} ${labelOf(item)}`}
|
||||
onSelect={() => handleSelect(item)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{item.icon && <item.icon />}
|
||||
<span className="truncate">{labelOf(item)}</span>
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleOpenChange(true)}
|
||||
variant="link"
|
||||
className="px-0! font-normal text-muted-foreground hover:no-underline"
|
||||
>
|
||||
<Search data-icon="inline-start" />
|
||||
{t("button")}
|
||||
<kbd className="inline-flex h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-medium text-[10px]">
|
||||
<span className="text-xs">⌘</span>J
|
||||
</kbd>
|
||||
</Button>
|
||||
<CommandDialog open={open} onOpenChange={handleOpenChange}>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("placeholder")} value={query} onValueChange={setQuery} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("empty")}</CommandEmpty>
|
||||
{query ? renderGroups(searchItems) : renderGroups(recommendations)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { siX } from "simple-icons";
|
||||
|
||||
import { SimpleIcon } from "@/components/simple-icon";
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
export function SidebarSupportCard() {
|
||||
return (
|
||||
<Card size="sm" className="overflow-hidden shadow-none group-data-[collapsible=icon]:hidden">
|
||||
<CardHeader className="min-w-0 px-4">
|
||||
<CardTitle className="truncate text-sm">Looking for something more?</CardTitle>
|
||||
<CardDescription className="line-clamp-2">
|
||||
Open an issue or do reach out to me on
|
||||
<Link
|
||||
href="https://x.com/arhamkhnz"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Reach out on X"
|
||||
className="inline-flex items-center text-foreground"
|
||||
>
|
||||
<SimpleIcon icon={siX} aria-hidden className="size-3 fill-current" />
|
||||
</Link>
|
||||
.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { persistPreference } from "@/lib/preferences/preferences-storage";
|
||||
import { usePreferencesStore } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
const THEME_CYCLE = ["light", "dark", "system"] as const;
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const themeMode = usePreferencesStore((s) => s.themeMode);
|
||||
const setThemeMode = usePreferencesStore((s) => s.setThemeMode);
|
||||
|
||||
const cycleTheme = () => {
|
||||
const currentIndex = THEME_CYCLE.indexOf(themeMode);
|
||||
const nextTheme = THEME_CYCLE[(currentIndex + 1) % THEME_CYCLE.length];
|
||||
|
||||
setThemeMode(nextTheme);
|
||||
void persistPreference("theme_mode", nextTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button size="icon" onClick={cycleTheme} aria-label={`Current theme: ${themeMode}. Click to cycle themes`}>
|
||||
{/* SYSTEM */}
|
||||
<Monitor className="hidden [html[data-theme-mode=system]_&]:block" />
|
||||
|
||||
{/* DARK (resolved) */}
|
||||
<Sun className="hidden dark:block [html[data-theme-mode=system]_&]:hidden" />
|
||||
|
||||
{/* LIGHT (resolved) */}
|
||||
<Moon className="block dark:hidden [html[data-theme-mode=system]_&]:hidden" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowUpCircleIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* 顶栏的"有新版本"提示:整页加载时查一次,有更新就在版本号旁边亮出来,
|
||||
* 点击直达系统配置页的「版本与更新」卡片。
|
||||
*
|
||||
* 后端对 GitHub 的查询结果有 30 分钟缓存,所以这里每次挂载都查一次是安全的
|
||||
* ——未认证的 GitHub API 只有 60 次/小时/IP,没有那层缓存的话,多开几个标签页
|
||||
* 就会把配额耗光,之后真想更新反而查不动。
|
||||
*
|
||||
* 查询失败一律静默:顶栏不是报错的地方,用户进设置页点「检查更新」会看到原因。
|
||||
*/
|
||||
export function UpdateBadge() {
|
||||
const t = useTranslations("header");
|
||||
const [latest, setLatest] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.checkUpdate()
|
||||
.then((r) => {
|
||||
// has_update 已经包含了"版本号可比较"的判断,开发构建不会亮这个提示。
|
||||
if (alive && r.has_update && r.latest) setLatest(r.latest.replace(/^v(?=\d)/, ""));
|
||||
})
|
||||
.catch(() => {
|
||||
// 静默:没网 / GitHub 限流都不该在顶栏弹错误。
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!latest) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/system/settings"
|
||||
title={t("updateTitle", { latest })}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-primary px-2.5 py-1 font-medium text-primary-foreground text-xs transition-opacity hover:opacity-90"
|
||||
>
|
||||
{/* 呼吸点:顶栏元素很多,纯文字容易被忽略,动效让它一眼可见。 */}
|
||||
<span className="relative flex size-1.5">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary-foreground opacity-75" />
|
||||
<span className="relative inline-flex size-1.5 rounded-full bg-primary-foreground" />
|
||||
</span>
|
||||
<ArrowUpCircleIcon className="size-3.5" />
|
||||
<span className="hidden sm:inline">{t("updateBadge", { latest })}</span>
|
||||
<span className="sm:hidden">{t("updateBadgeShort")}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
BuildingIcon,
|
||||
ChevronRightIcon,
|
||||
CircleDashedIcon,
|
||||
GlobeIcon,
|
||||
LayoutTemplateIcon,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
NetworkIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
SmartphoneIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
|
||||
import type { FindingAssetKind, FindingAssetNode } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 图标沿用资产页的类型映射,同一种资产在两处长得一样。
|
||||
const KIND_ICON: Record<FindingAssetKind, LucideIcon> = {
|
||||
company: BuildingIcon,
|
||||
root_domain: GlobeIcon,
|
||||
subdomain: GlobeIcon,
|
||||
ip: NetworkIcon,
|
||||
app: SmartphoneIcon,
|
||||
service: LayoutTemplateIcon,
|
||||
endpoint: LinkIcon,
|
||||
none: CircleDashedIcon,
|
||||
};
|
||||
|
||||
// TreeNode 是节点数组组装出来的树。后端已按「同父下发现多的在前」排好序,
|
||||
// 这里只需按数组顺序挂载。
|
||||
interface TreeNode extends FindingAssetNode {
|
||||
children: TreeNode[];
|
||||
depth: number;
|
||||
/** 树上真正渲染的文字;完整 label 仍保留在 label 里(悬停提示与面包屑用)。 */
|
||||
display: string;
|
||||
}
|
||||
|
||||
function stripBrackets(host: string) {
|
||||
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
||||
}
|
||||
|
||||
function parseAssetURL(raw: string): URL | null {
|
||||
try {
|
||||
return new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// hostOf 取一个节点代表的宿主:URL 取 hostname,「host:port」取 host,其余就是
|
||||
// 标签本身(根域名 / 子域名 / IP)。
|
||||
function hostOf(label: string): string {
|
||||
const url = parseAssetURL(label);
|
||||
if (url) return stripBrackets(url.hostname);
|
||||
const hostPort = label.match(/^(.+):(\d+)$/);
|
||||
return stripBrackets(hostPort ? hostPort[1] : label);
|
||||
}
|
||||
|
||||
// shortLabel 去掉与父节点重复的前缀。service / endpoint 的 label 是完整 URL,而
|
||||
// 宿主域名/IP 上一行已经写过了 —— 深层节点本来就窄,再把 host 重复一遍,真正有
|
||||
// 信息量的端口和路径就全被截断掉了。完整值仍在 title 与面包屑里。
|
||||
function shortLabel(node: FindingAssetNode, parent?: FindingAssetNode): string {
|
||||
if (!parent) return node.label;
|
||||
|
||||
// 子域名挂在根域名下:去掉根域名后缀,只留自己那一段。
|
||||
if (node.kind === "subdomain" && node.label.endsWith(`.${parent.label}`)) {
|
||||
return node.label.slice(0, -(parent.label.length + 1)) || node.label;
|
||||
}
|
||||
if (node.kind !== "service" && node.kind !== "endpoint") return node.label;
|
||||
|
||||
// 父标签正好是自己的前缀(接口挂在同 URL 的服务下、服务挂在同 IP 下):直接砍掉。
|
||||
if (node.label.startsWith(parent.label)) {
|
||||
return node.label.slice(parent.label.length) || node.label;
|
||||
}
|
||||
|
||||
// 否则只有父节点确实就是这个 URL 的宿主时才简写,不然会丢掉辨识信息
|
||||
// (比如服务因为缺子域名资产行而直接挂在根域名下,那就得显示完整 URL)。
|
||||
if (hostOf(node.label) !== hostOf(parent.label)) return node.label;
|
||||
|
||||
const url = parseAssetURL(node.label);
|
||||
if (!url) return node.label;
|
||||
if (node.kind === "endpoint") return `${url.pathname}${url.search}` || "/";
|
||||
const scheme = url.protocol.replace(":", "");
|
||||
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
||||
return `${scheme} :${port}`;
|
||||
}
|
||||
|
||||
export function buildAssetTree(nodes: FindingAssetNode[]): TreeNode[] {
|
||||
const byKey = new Map<string, TreeNode>();
|
||||
for (const node of nodes) {
|
||||
byKey.set(node.key, { ...node, children: [], depth: 0, display: node.label });
|
||||
}
|
||||
const roots: TreeNode[] = [];
|
||||
for (const node of nodes) {
|
||||
const current = byKey.get(node.key);
|
||||
if (!current) continue;
|
||||
const parent = node.parent ? byKey.get(node.parent) : undefined;
|
||||
// 父节点缺失(被截断层级丢掉)时上提为顶层,不让子树整个消失。
|
||||
if (parent) {
|
||||
parent.children.push(current);
|
||||
current.display = shortLabel(node, parent);
|
||||
} else {
|
||||
roots.push(current);
|
||||
}
|
||||
}
|
||||
const setDepth = (node: TreeNode, depth: number) => {
|
||||
node.depth = depth;
|
||||
for (const child of node.children) setDepth(child, depth + 1);
|
||||
};
|
||||
for (const root of roots) setDepth(root, 0);
|
||||
return roots;
|
||||
}
|
||||
|
||||
// assetPathOf 返回从顶层到该节点的路径,用于右侧面包屑。每一级同样只显示相对
|
||||
// 上一级的增量(display),完整值留在 label 里。
|
||||
export function assetPathOf(nodes: FindingAssetNode[], key: string | null): (FindingAssetNode & { display: string })[] {
|
||||
if (!key) return [];
|
||||
const byKey = new Map(nodes.map((n) => [n.key, n]));
|
||||
const path: FindingAssetNode[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = byKey.get(key);
|
||||
while (current && !seen.has(current.key)) {
|
||||
seen.add(current.key);
|
||||
path.unshift(current);
|
||||
current = current.parent ? byKey.get(current.parent) : undefined;
|
||||
}
|
||||
return path.map((node, index) => ({ ...node, display: shortLabel(node, path[index - 1]) }));
|
||||
}
|
||||
|
||||
// filterTree 按关键词过滤:命中的节点保留,并保留其整条祖先链(祖先自身可以不命中)。
|
||||
// 命中节点的子孙一并保留,便于继续下钻。
|
||||
function filterTree(nodes: TreeNode[], keyword: string): TreeNode[] {
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
if (!kw) return nodes;
|
||||
const walk = (node: TreeNode): TreeNode | null => {
|
||||
const hit = node.label.toLowerCase().includes(kw);
|
||||
if (hit) return node;
|
||||
const children = node.children.map(walk).filter((c): c is TreeNode => c !== null);
|
||||
if (children.length === 0) return null;
|
||||
return { ...node, children };
|
||||
};
|
||||
return nodes.map(walk).filter((n): n is TreeNode => n !== null);
|
||||
}
|
||||
|
||||
// collectKeys 收集一棵(子)树里的全部 key,用于「展开全部匹配项」。
|
||||
function collectKeys(nodes: TreeNode[], out: Set<string> = new Set()): Set<string> {
|
||||
for (const node of nodes) {
|
||||
out.add(node.key);
|
||||
collectKeys(node.children, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
interface AssetTreeProps {
|
||||
nodes: FindingAssetNode[];
|
||||
selected: string | null;
|
||||
onSelect: (key: string | null) => void;
|
||||
loading?: boolean;
|
||||
truncated?: boolean;
|
||||
droppedKinds?: string[];
|
||||
/** 未选中任何资产时右侧展示的发现总数,用于「全部资产」那一行。 */
|
||||
findingTotal: number;
|
||||
/** 资产视图不轮询,树的计数靠这个按钮或页面内的增删改来刷新。 */
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function AssetTree({
|
||||
nodes,
|
||||
selected,
|
||||
onSelect,
|
||||
loading,
|
||||
truncated,
|
||||
droppedKinds,
|
||||
findingTotal,
|
||||
onRefresh,
|
||||
}: AssetTreeProps) {
|
||||
const t = useTranslations("findings");
|
||||
const kindLabel = (kind: string) => (t.has(`assetTree.kind.${kind}`) ? t(`assetTree.kind.${kind}`) : kind);
|
||||
const [keyword, setKeyword] = React.useState("");
|
||||
const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set());
|
||||
// 记住用户手动折叠过的节点,免得「默认展开顶层」在每次刷新后又把它们撑开。
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set());
|
||||
|
||||
const roots = React.useMemo(() => buildAssetTree(nodes), [nodes]);
|
||||
const visible = React.useMemo(() => filterTree(roots, keyword), [roots, keyword]);
|
||||
|
||||
// 搜索时把匹配到的分支全部展开,否则命中项藏在折叠节点里等于没搜。
|
||||
const searching = keyword.trim() !== "";
|
||||
const searchKeys = React.useMemo(() => (searching ? collectKeys(visible) : null), [searching, visible]);
|
||||
|
||||
const isExpanded = React.useCallback(
|
||||
(node: TreeNode) => {
|
||||
if (searchKeys) return searchKeys.has(node.key);
|
||||
if (expanded.has(node.key)) return true;
|
||||
// 顶层默认展开一层:再深的层级要用户自己点开,免得一次铺开上千行。
|
||||
return node.depth === 0 && !collapsed.has(node.key);
|
||||
},
|
||||
[collapsed, expanded, searchKeys],
|
||||
);
|
||||
|
||||
const toggle = React.useCallback(
|
||||
(node: TreeNode) => {
|
||||
const open = isExpanded(node);
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) next.delete(node.key);
|
||||
else next.add(node.key);
|
||||
return next;
|
||||
});
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (open) next.add(node.key);
|
||||
else next.delete(node.key);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[isExpanded],
|
||||
);
|
||||
|
||||
let emptyHint = t("assetTree.emptyNoAsset");
|
||||
if (loading) emptyHint = t("common.loading");
|
||||
else if (searching) emptyHint = t("assetTree.emptyNoMatch");
|
||||
|
||||
const rows: React.ReactNode[] = [];
|
||||
const pushRows = (list: TreeNode[]) => {
|
||||
for (const node of list) {
|
||||
const open = isExpanded(node);
|
||||
rows.push(
|
||||
<AssetTreeRow
|
||||
key={node.key}
|
||||
node={node}
|
||||
open={open}
|
||||
selected={selected === node.key}
|
||||
onToggle={() => toggle(node)}
|
||||
onSelect={() => onSelect(selected === node.key ? null : node.key)}
|
||||
/>,
|
||||
);
|
||||
if (open && node.children.length > 0) pushRows(node.children);
|
||||
}
|
||||
};
|
||||
pushRows(visible);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<InputGroup className="flex-1">
|
||||
<InputGroupInput
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder={t("assetTree.filterPlaceholder")}
|
||||
aria-label={t("assetTree.filterPlaceholder")}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8 shrink-0 text-muted-foreground"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
aria-label={t("assetTree.refresh")}
|
||||
title={t("assetTree.refresh")}
|
||||
>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(null)}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-sm",
|
||||
selected === null ? "bg-accent font-medium" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<span>{t("asset.all")}</span>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{findingTotal}</span>
|
||||
</button>
|
||||
|
||||
<div className="max-h-[24rem] min-h-0 flex-1 overflow-y-auto pr-2 lg:max-h-[calc(100vh-16rem)]">
|
||||
<div className="flex flex-col">
|
||||
{rows}
|
||||
{rows.length === 0 && <p className="px-2 py-8 text-center text-xs text-muted-foreground">{emptyHint}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{truncated && (
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
{t("assetTree.truncated", {
|
||||
kinds: (droppedKinds ?? []).map((k) => kindLabel(k)).join(" / "),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetTreeRow({
|
||||
node,
|
||||
open,
|
||||
selected,
|
||||
onToggle,
|
||||
onSelect,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
open: boolean;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const t = useTranslations("findings");
|
||||
const kindLabel = t.has(`assetTree.kind.${node.kind}`) ? t(`assetTree.kind.${node.kind}`) : node.kind;
|
||||
const Icon = KIND_ICON[node.kind] ?? GlobeIcon;
|
||||
const hasChildren = node.children.length > 0;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-md pr-1 text-sm",
|
||||
selected ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
style={{ paddingLeft: `${node.depth * 10}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
||||
aria-label={open ? t("assetTree.collapse") : t("assetTree.expand")}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<ChevronRightIcon className={cn("size-3.5 transition-transform", open && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="size-5 shrink-0" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 py-1 text-left"
|
||||
title={`${kindLabel} · ${node.label}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className={cn("min-w-0 truncate", selected && "font-medium")}>{node.display}</span>
|
||||
</button>
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs tabular-nums">
|
||||
{node.critical > 0 && (
|
||||
<span className="text-rose-600" title={t("assetTree.criticalCount", { count: node.critical })}>
|
||||
{node.critical}
|
||||
</span>
|
||||
)}
|
||||
{node.high > 0 && (
|
||||
<span className="text-red-500" title={t("assetTree.highCount", { count: node.high })}>
|
||||
{node.high}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-muted-foreground" title={t("assetTree.totalCount", { count: node.total })}>
|
||||
{node.total}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
ChevronRightIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
RotateCcwIcon,
|
||||
ShieldAlertIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import type { ActiveFindingRetest, Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
|
||||
|
||||
export const FINDING_STATUSES: FindingStatus[] = [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"fixed",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
"risk_accepted",
|
||||
];
|
||||
|
||||
export const UNASSIGNED_TASK = "__unassigned__";
|
||||
|
||||
// 行内编辑缓冲:当前展开行的名称/类别/严重等级。
|
||||
export interface FindingEdit {
|
||||
name: string;
|
||||
vulnclass: string;
|
||||
severity: Severity;
|
||||
}
|
||||
|
||||
export type FindingReport = { status: "loading" | "done" | "error"; text: string };
|
||||
|
||||
// Exploration node ids are only unique inside a task. Prefer the persisted
|
||||
// finding id and otherwise namespace the node id by task so editing one group
|
||||
// cannot update a similarly-named node in another expanded group.
|
||||
export function findingRowKey(finding: Finding): string {
|
||||
if (finding.finding_id) return `finding:${finding.finding_id}`;
|
||||
return `node:${finding.task_id ?? UNASSIGNED_TASK}:${finding.id}`;
|
||||
}
|
||||
|
||||
export function isSameFinding(left: Finding, right: Finding): boolean {
|
||||
return findingRowKey(left) === findingRowKey(right);
|
||||
}
|
||||
|
||||
export function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const COLUMN_COUNT = 9;
|
||||
|
||||
interface FindingsTableProps {
|
||||
items: Finding[];
|
||||
selectedIds: Set<string>;
|
||||
onToggleSelected: (id: string, checked: boolean) => void;
|
||||
onToggleSelectedPage: (ids: string[], checked: boolean) => void;
|
||||
/** 当前展开行的 findingRowKey;null = 全部收起。 */
|
||||
expandedKey: string | null;
|
||||
onToggleRow: (finding: Finding) => void;
|
||||
reports: Record<string, FindingReport>;
|
||||
edit: FindingEdit | null;
|
||||
onEditChange: React.Dispatch<React.SetStateAction<FindingEdit | null>>;
|
||||
saving: boolean;
|
||||
onSave: (finding: Finding) => void;
|
||||
onStatusChange: (finding: Finding, next: FindingStatus) => void;
|
||||
onRetest: (finding: Finding) => void;
|
||||
activeRetests: Record<string, ActiveFindingRetest>;
|
||||
onDeepen: (finding: Finding) => void;
|
||||
onDelete: (finding: Finding) => void;
|
||||
/** 全选框的无障碍标签,平铺视图与分组视图措辞不同。 */
|
||||
selectAllLabel?: string;
|
||||
}
|
||||
|
||||
// FindingsTable 是发现列表的表格主体,平铺视图与按任务分组视图共用同一份行渲染
|
||||
// (勾选 / 行内展开 / 行内改名与改状态 / 复测 / 深入 / 删除),差异只在外层容器与分页。
|
||||
export function FindingsTable({
|
||||
items,
|
||||
selectedIds,
|
||||
onToggleSelected,
|
||||
onToggleSelectedPage,
|
||||
expandedKey,
|
||||
onToggleRow,
|
||||
reports,
|
||||
edit,
|
||||
onEditChange,
|
||||
saving,
|
||||
onSave,
|
||||
onStatusChange,
|
||||
onRetest,
|
||||
activeRetests,
|
||||
onDeepen,
|
||||
onDelete,
|
||||
selectAllLabel,
|
||||
}: FindingsTableProps) {
|
||||
const t = useTranslations("findings");
|
||||
const tStatus = useTranslations("status");
|
||||
const resolvedSelectAllLabel = selectAllLabel ?? t("selectAllPage");
|
||||
const selectableIds = items.map((finding) => finding.finding_id).filter((id): id is string => Boolean(id));
|
||||
const selectedCount = selectableIds.filter((id) => selectedIds.has(id)).length;
|
||||
let headerChecked: boolean | "indeterminate" = false;
|
||||
if (selectableIds.length > 0 && selectedCount === selectableIds.length) {
|
||||
headerChecked = true;
|
||||
} else if (selectedCount > 0) {
|
||||
headerChecked = "indeterminate";
|
||||
}
|
||||
|
||||
return (
|
||||
/* 固定列宽保证展开内容不撑开表格;窄屏只在表格内部横向滚动。 */
|
||||
<Table className="min-w-[60rem] table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<Checkbox
|
||||
checked={headerChecked}
|
||||
onCheckedChange={(checked) => onToggleSelectedPage(selectableIds, checked === true)}
|
||||
aria-label={resolvedSelectAllLabel}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead className="w-20">{t("table.colSeverity")}</TableHead>
|
||||
<TableHead>{t("table.colName")}</TableHead>
|
||||
<TableHead className="w-44">{t("table.colAsset")}</TableHead>
|
||||
<TableHead className="w-28">{t("table.colStatus")}</TableHead>
|
||||
<TableHead className="w-32">{t("table.colTask")}</TableHead>
|
||||
<TableHead className="w-24">{t("table.colTime")}</TableHead>
|
||||
<TableHead className="w-48">{t("table.colActions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((f) => {
|
||||
const rowKey = findingRowKey(f);
|
||||
const open = expandedKey === rowKey;
|
||||
const retest = f.finding_id ? activeRetests[f.finding_id] : undefined;
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow
|
||||
className="cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={open}
|
||||
onClick={() => onToggleRow(f)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) return;
|
||||
event.preventDefault();
|
||||
onToggleRow(f);
|
||||
}}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{f.finding_id && (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(f.finding_id)}
|
||||
onCheckedChange={(c) => onToggleSelected(f.finding_id as string, c === true)}
|
||||
aria-label={t("table.selectOne")}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 text-muted-foreground transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge domain="severity" value={f.severity} dot />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-md">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
{f.finding_id ? (
|
||||
<Link
|
||||
href={`/function/findings/detail?id=${f.finding_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="truncate font-medium hover:text-primary hover:underline"
|
||||
title={t("table.viewDetail")}
|
||||
>
|
||||
{f.name || f.vulnclass || t("table.unclassified")}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate font-medium">{f.name || f.vulnclass || t("table.unclassified")}</span>
|
||||
)}
|
||||
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
|
||||
<Badge variant="outline">{t("table.trafficEvidence", { count: f.traffic_count ?? 0 })}</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{f.assets && f.assets.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{f.assets.slice(0, 3).map((a) => (
|
||||
<code
|
||||
key={a.id}
|
||||
className="max-w-full truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||
title={`${a.type} · ${a.label}`}
|
||||
>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
{f.assets.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">+{f.assets.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
{f.finding_id ? (
|
||||
<Select value={f.status} onValueChange={(v) => onStatusChange(f, v as FindingStatus)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-full border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="finding" value={f.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
{FINDING_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{tStatus(`finding.${st}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<StatusBadge domain="finding" value={f.status} dot />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{f.task_id ? (
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${f.task_id}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="inline-flex max-w-full items-center gap-1 text-primary hover:underline"
|
||||
title={f.task_description}
|
||||
>
|
||||
<span className="truncate">{f.task_description}</span>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(f.ts)}</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
{retest ? (
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link href={`/chat?c=${retest.conversation_id}`} title={t("table.retestingTitle")}>
|
||||
<Spinner data-icon="inline-start" />
|
||||
{t("table.retesting")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{!retest && f.finding_id && !f.inherited ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => onRetest(f)} title={t("table.retestTitle")}>
|
||||
<RotateCcwIcon data-icon="inline-start" />
|
||||
{t("table.retest")}
|
||||
</Button>
|
||||
) : null}
|
||||
{f.finding_id && f.task_id && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onDeepen(f)}>
|
||||
<FlaskConicalIcon data-icon="inline-start" />
|
||||
{t("table.deepen")}
|
||||
</Button>
|
||||
)}
|
||||
{f.finding_id && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 text-muted-foreground hover:text-destructive"
|
||||
aria-label={t("table.deleteAria")}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("table.deleteConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="break-words">
|
||||
「
|
||||
<span className="break-all">
|
||||
{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}
|
||||
</span>
|
||||
」 {t("table.deleteConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => onDelete(f)}>{t("common.delete")}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{open && (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
|
||||
被强制单行、直接溢出单元格。 */}
|
||||
<TableCell colSpan={COLUMN_COUNT} className="bg-muted/30 whitespace-normal">
|
||||
<div className="flex flex-col gap-2 px-2 py-1">
|
||||
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
|
||||
{f.finding_id && edit && (
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-md border bg-background px-3 py-2.5">
|
||||
<div className="flex min-w-[12rem] flex-1 flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("table.editName")}</Label>
|
||||
<Input
|
||||
value={edit.name}
|
||||
onChange={(e) => onEditChange((s) => (s ? { ...s, name: e.target.value } : s))}
|
||||
placeholder={t("table.editNamePlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-[10rem] flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("table.editCategory")}</Label>
|
||||
<Input
|
||||
value={edit.vulnclass}
|
||||
onChange={(e) => onEditChange((s) => (s ? { ...s, vulnclass: e.target.value } : s))}
|
||||
placeholder={t("table.editCategoryPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{t("table.editSeverity")}</Label>
|
||||
<Select
|
||||
value={edit.severity}
|
||||
onValueChange={(v) => onEditChange((s) => (s ? { ...s, severity: v as Severity } : s))}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITIES.map((sv) => (
|
||||
<SelectItem key={sv} value={sv}>
|
||||
{tStatus(`severity.${sv}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button size="sm" disabled={saving} onClick={() => onSave(f)}>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<ShieldAlertIcon className="size-3.5" />
|
||||
{t("table.evidence")}
|
||||
{f.vulnclass && (
|
||||
<span>
|
||||
{t("table.typeInline")}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono">{f.vulnclass}</code>
|
||||
</span>
|
||||
)}
|
||||
{f.param_id && <code className="rounded bg-muted px-1.5 py-0.5 font-mono">{f.param_id}</code>}
|
||||
{f.assets && f.assets.length > 0 && (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{t("table.assetInline")}
|
||||
{f.assets.map((a) => (
|
||||
<code key={a.id} className="rounded bg-muted px-1.5 py-0.5 font-mono" title={a.type}>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{f.evidence}
|
||||
</pre>
|
||||
|
||||
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
|
||||
{f.finding_id && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
<FileTextIcon className="size-3.5" />
|
||||
{t("table.detailReport")}
|
||||
</span>
|
||||
{reports[rowKey]?.status === "done" && reports[rowKey]?.text.trim() && (
|
||||
<CopyButton
|
||||
text={reports[rowKey]?.text}
|
||||
successMessage={t("table.reportCopied")}
|
||||
variant="ghost"
|
||||
className="h-6 px-2 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const rep = reports[rowKey];
|
||||
if (!rep || rep.status === "loading")
|
||||
return <p className="text-xs text-muted-foreground">{t("common.loading")}</p>;
|
||||
if (rep.status === "error")
|
||||
return <p className="text-xs text-muted-foreground">{t("table.reportLoadFailed")}</p>;
|
||||
if (!rep.text.trim())
|
||||
return <p className="text-xs text-muted-foreground">{t("table.reportEmpty")}</p>;
|
||||
return (
|
||||
// break-words 会继承到段落/列表,pre 另加
|
||||
// whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL
|
||||
// 会撑宽 colSpan 单元格,把整张表挤出横向滚动条。
|
||||
<div className="min-w-0 break-words rounded-md border bg-background px-3 py-2 [&_pre]:whitespace-pre-wrap">
|
||||
<Markdown text={rep.text} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={COLUMN_COUNT} className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { ExplorationGraph } from "@/components/exploration-graph";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Edge, TaskNode } from "@/lib/types";
|
||||
|
||||
// FindingLineageView renders the exploration sub-graph from the task's initial
|
||||
// node down to this finding's node — the same 攻击链路图 canvas as the task graph,
|
||||
// scoped to just this finding's lineage.
|
||||
export function FindingLineageView({ findingId }: { findingId: string }) {
|
||||
const t = useTranslations("explorationGraph");
|
||||
const [nodes, setNodes] = React.useState<TaskNode[]>([]);
|
||||
const [edges, setEdges] = React.useState<Edge[]>([]);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.findingLineage(findingId)
|
||||
.then((g) => {
|
||||
if (!alive) return;
|
||||
setNodes(g.nodes ?? []);
|
||||
setEdges(g.edges ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 조회 실패는 무시한다 (finally 에서 로딩 상태 해제) */
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [findingId]);
|
||||
|
||||
if (loaded && nodes.length === 0) {
|
||||
return <p className="text-muted-foreground p-6 text-sm">{t("lineage.empty")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ExplorationGraph
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
className="h-[68vh]"
|
||||
emptyHint={loaded ? t("lineage.emptyHint") : t("lineage.loading")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { FindingRetestPanel } from "@/components/finding-retest-panel";
|
||||
import { FindingTrafficPanel } from "@/components/finding-traffic-panel";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
|
||||
import { FindingLineageView } from "./lineage";
|
||||
|
||||
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
|
||||
const FINDING_STATUSES: FindingStatus[] = [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"fixed",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
"risk_accepted",
|
||||
];
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("ko-KR");
|
||||
}
|
||||
|
||||
// FieldRow is one label/value line in the right-hand status panel.
|
||||
function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 py-2.5">
|
||||
<span className="shrink-0 pt-0.5 text-xs text-muted-foreground">{label}</span>
|
||||
<div className="flex min-w-0 flex-col items-end gap-1 text-right text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FindingDetailInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const id = searchParams.get("id") ?? "";
|
||||
const contextTaskId = searchParams.get("context_task") ?? "";
|
||||
const [finding, setFinding] = React.useState<Finding | null>(null);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [tab, setTab] = React.useState("overview");
|
||||
const t = useTranslations("findings");
|
||||
const tStatus = useTranslations("status");
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
if (!id) {
|
||||
setLoaded(true);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.getFinding(id, contextTaskId || undefined)
|
||||
.then((f) => setFinding(f))
|
||||
.catch(() => setFinding(null))
|
||||
.finally(() => setLoaded(true));
|
||||
}, [contextTaskId, id]);
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const changeSeverity = React.useCallback(
|
||||
async (next: Severity) => {
|
||||
if (!finding || finding.inherited || next === finding.severity) return;
|
||||
const prev = finding.severity;
|
||||
setFinding({ ...finding, severity: next });
|
||||
try {
|
||||
const updated = await api.setFindingSeverity(id, next);
|
||||
setFinding(updated);
|
||||
toast.success(t("detail.severityChanged", { label: tStatus(`severity.${next}`) }));
|
||||
} catch (e) {
|
||||
setFinding((cur) => (cur ? { ...cur, severity: prev } : cur));
|
||||
toast.error(t("toast.updateFailed", { error: (e as Error).message }));
|
||||
}
|
||||
},
|
||||
[finding, id, t, tStatus],
|
||||
);
|
||||
|
||||
const changeStatus = React.useCallback(
|
||||
async (next: FindingStatus) => {
|
||||
if (!finding || finding.inherited || next === finding.status) return;
|
||||
const prev = finding.status;
|
||||
setFinding({ ...finding, status: next });
|
||||
try {
|
||||
const updated = await api.setFindingStatus(id, next);
|
||||
setFinding(updated);
|
||||
toast.success(t("detail.statusChanged", { label: tStatus(`finding.${next}`) }));
|
||||
} catch (e) {
|
||||
setFinding((cur) => (cur ? { ...cur, status: prev } : cur));
|
||||
toast.error(t("toast.updateFailed", { error: (e as Error).message }));
|
||||
}
|
||||
},
|
||||
[finding, id, t, tStatus],
|
||||
);
|
||||
|
||||
if (!finding) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
|
||||
<p className="text-muted-foreground">{loaded ? t("detail.notFound", { id }) : t("common.loading")}</p>
|
||||
{loaded && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/function/findings">
|
||||
<ArrowLeftIcon /> {t("detail.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const title = finding.name || finding.vulnclass || t("table.unclassified");
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
|
||||
{/* Sticky header */}
|
||||
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Button asChild variant="ghost" size="icon" className="size-7">
|
||||
<Link href="/function/findings">
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
<ShieldAlertIcon className="size-4 text-muted-foreground" />
|
||||
<h1 className="max-w-md truncate text-sm font-semibold" title={title}>
|
||||
{title}
|
||||
</h1>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">#{finding.id}</code>
|
||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
{finding.inherited && finding.source_task_id && (
|
||||
<Badge variant="outline">{t("detail.sourceTask", { id: finding.source_task_id })}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">{t("detail.tab.overview")}</TabsTrigger>
|
||||
<TabsTrigger value="lineage">{t("detail.tab.lineage")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</header>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 p-4 lg:p-6">
|
||||
{/* 概览:左(摘要 + 证据)/ 右(状态区) */}
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{/* 左栏 */}
|
||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("detail.summary")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{finding.summary || t("detail.summaryEmpty")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingRetestPanel key={id} findingId={id} readOnly={finding.inherited} onCompleted={load} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("detail.evidence")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.evidence ? (
|
||||
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
|
||||
{finding.evidence}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("detail.evidenceEmpty")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingTrafficPanel
|
||||
key={id}
|
||||
findingId={id}
|
||||
contextTask={contextTaskId || undefined}
|
||||
readOnly={finding.inherited}
|
||||
onChanged={load}
|
||||
/>
|
||||
{/* 证据下方:详细报告(Markdown 渲染) */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm">{t("table.detailReport")}</CardTitle>
|
||||
{finding.report && <CopyButton text={finding.report} successMessage={t("table.reportCopied")} />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.report_stale ? (
|
||||
<Alert>
|
||||
<AlertDescription>{t("detail.reportStale")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{finding.report ? (
|
||||
<Markdown text={finding.report} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{t("table.reportEmpty")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右栏:状态区 */}
|
||||
<Card className="h-fit lg:sticky lg:top-24">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t("detail.statusTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
{/* 漏洞 ID */}
|
||||
<FieldRow label={t("detail.fieldId")}>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
#{finding.id}
|
||||
</code>
|
||||
</FieldRow>
|
||||
|
||||
{/* 严重等级 */}
|
||||
<FieldRow label={t("detail.fieldSeverity")}>
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
) : (
|
||||
<Select value={finding.severity} onValueChange={(v) => changeSeverity(v as Severity)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{SEVERITIES.map((sv) => (
|
||||
<SelectItem key={sv} value={sv}>
|
||||
{tStatus(`severity.${sv}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 处理状态 */}
|
||||
<FieldRow label={t("detail.fieldStatus")}>
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
) : (
|
||||
<Select value={finding.status} onValueChange={(v) => changeStatus(v as FindingStatus)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{FINDING_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{tStatus(`finding.${st}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 漏洞类型 */}
|
||||
<FieldRow label={t("detail.fieldType")}>
|
||||
{finding.vulnclass ? (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{finding.vulnclass}</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 涉及资产 */}
|
||||
<FieldRow label={t("detail.fieldAssets")}>
|
||||
{finding.assets && finding.assets.length > 0 ? (
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{finding.assets.map((a) => (
|
||||
<code
|
||||
key={a.id}
|
||||
className="max-w-[16rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||
title={`${a.type} · ${a.label}`}
|
||||
>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 所属任务 */}
|
||||
<FieldRow label={t("detail.fieldTask")}>
|
||||
{finding.task_id ? (
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${finding.task_id}`}
|
||||
className="inline-flex max-w-[16rem] items-center gap-1 text-primary hover:underline"
|
||||
title={finding.task_description}
|
||||
>
|
||||
<span className="truncate">{finding.task_description || `#${finding.task_id}`}</span>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t("detail.taskDeleted")}</span>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
{/* 发现时间 */}
|
||||
<FieldRow label={t("detail.fieldTime")}>
|
||||
<span className="tabular-nums">{fmtTime(finding.ts)}</span>
|
||||
</FieldRow>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 链路图:从任务初始节点回溯到本漏洞节点的攻击链路 */}
|
||||
<TabsContent value="lineage" className="mt-0">
|
||||
<FindingLineageView findingId={finding.id} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// useSearchParams must sit under a Suspense boundary for static export.
|
||||
export default function FindingDetailPage() {
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<FindingDetailInner />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,602 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
Loader2Icon,
|
||||
RadioIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { LLMRecordDetail, LLMRecordItem, LLMTask } 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",
|
||||
});
|
||||
}
|
||||
|
||||
function fmtLatency(ms: number) {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function fmtTokens(n: number) {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function tryFormatJSON(s: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 상자 안의 텍스트를 복사하는 작은 버튼. 복사에 성공하면 잠시 체크 표시를 보여 준다. text 가 비어 있거나 자리표시자뿐이면 비활성화한다.
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const t = useTranslations("llmRecords");
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const disabled = !text;
|
||||
const copy = async () => {
|
||||
if (disabled) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// navigator.clipboard 는 안전하지 않은 컨텍스트(예: http 사설망)에서는 쓸 수 없어 execCommand 로 폴백한다.
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch {
|
||||
/* 무시: 지원하지 않으면 조용히 넘어간다 */
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 shrink-0"
|
||||
disabled={disabled}
|
||||
title={copied ? t("copyCopied") : t("copyTooltip")}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? <CheckIcon className="size-3 text-emerald-600" /> : <CopyIcon className="size-3" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export default function LLMRecordsPage() {
|
||||
const t = useTranslations("llmRecords");
|
||||
const tp = useTranslations("pagination");
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [size, setSize] = React.useState(50);
|
||||
const [session, setSession] = React.useState("");
|
||||
const [sessionQ, setSessionQ] = React.useState("");
|
||||
const [model, setModel] = React.useState("");
|
||||
|
||||
const [records, setRecords] = React.useState<LLMRecordItem[]>([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
// Recording on/off toggle (settings.llm_record; default off). When off the
|
||||
// backend records nothing.
|
||||
const [recEnabled, setRecEnabled] = React.useState(false);
|
||||
const [recBusy, setRecBusy] = React.useState(false);
|
||||
|
||||
// Inline detail panel (Burp-style split, not a dialog)
|
||||
const [selected, setSelected] = React.useState<LLMRecordItem | null>(null);
|
||||
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = React.useState(false);
|
||||
// 정규화 뷰 / HTTP 원문 뷰. 원문은 provider 쪽 문제를 진단하는 유일한 근거다: 정규화 뷰는
|
||||
// 도구 schema 를 담지 않고, 응답에도 tool_use 블록이 없다.
|
||||
const [rawView, setRawView] = React.useState(false);
|
||||
|
||||
const hasRaw = !!(detail?.raw_request || detail?.raw_response);
|
||||
// 토글은 사용자 선택을 유지하되, 원문이 없는 옛 기록으로 전환하면 빈 화면 대신 자동으로 파싱 뷰로 되돌린다.
|
||||
const showRaw = rawView && hasRaw;
|
||||
// 원문 요청 본문은 JSON 이라 pretty-print 는 배치만 바꾸고 의미는 바꾸지 않아 읽기 편하다. 원문 응답은 SSE
|
||||
// 프레임이고, tryFormatJSON 은 파싱에 실패하면 원본을 그대로 돌려주므로 양쪽이 한 함수를 함께 쓰면 된다.
|
||||
const reqText = showRaw
|
||||
? detail?.raw_request && tryFormatJSON(detail.raw_request)
|
||||
: detail?.request_body && tryFormatJSON(detail.request_body);
|
||||
const respText = showRaw ? detail?.raw_response : detail?.response_body && tryFormatJSON(detail.response_body);
|
||||
|
||||
// Per-task delete (task picker + confirm dialog)
|
||||
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
|
||||
const [pickedTask, setPickedTask] = React.useState("");
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger
|
||||
|
||||
// Load recording toggle state on mount.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
api
|
||||
.settings()
|
||||
.then((s) => {
|
||||
if (alive) setRecEnabled(!!s.llm_record);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 조회 실패는 무시하고 기본 상태를 유지한다 */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleRecording = async (on: boolean) => {
|
||||
setRecBusy(true);
|
||||
setRecEnabled(on); // optimistic
|
||||
try {
|
||||
const s = await api.setSettings({ llm_record: on });
|
||||
setRecEnabled(!!s.llm_record);
|
||||
} catch {
|
||||
setRecEnabled(!on); // revert on failure
|
||||
} finally {
|
||||
setRecBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce session filter.
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setSessionQ(session.trim()), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [session]);
|
||||
|
||||
// Reset page on filter change.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset.
|
||||
React.useEffect(() => {
|
||||
setPage(0);
|
||||
}, [sessionQ, model, size, pickedTask]);
|
||||
|
||||
// Load list.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
api
|
||||
.llmRecords({
|
||||
model: model || undefined,
|
||||
session: sessionQ || undefined,
|
||||
task: pickedTask || undefined,
|
||||
page,
|
||||
size,
|
||||
})
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
setRecords(r.records ?? []);
|
||||
setTotal(r.total ?? 0);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 조회 실패는 무시한다 (finally 에서 로딩 상태 해제) */
|
||||
})
|
||||
.finally(() => alive && setLoading(false));
|
||||
api
|
||||
.llmTasks()
|
||||
.then((r) => {
|
||||
if (alive) setTasks(r.tasks ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 조회 실패는 무시한다 */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [page, size, sessionQ, model, pickedTask, reloadTick]);
|
||||
|
||||
// Delete every LLM record for the picked task, then refetch.
|
||||
const confirmDelete = () => {
|
||||
setDeleting(true);
|
||||
api
|
||||
.llmRecordsDeleteTask(pickedTask)
|
||||
.then(() => {
|
||||
setDeleteOpen(false);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
setPickedTask("");
|
||||
setPage(0);
|
||||
setReloadTick((n) => n + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 삭제 후 재조회 실패는 무시한다 (finally 에서 삭제 상태 해제) */
|
||||
})
|
||||
.finally(() => setDeleting(false));
|
||||
};
|
||||
|
||||
// Lazy-load full request/response when a row is selected.
|
||||
React.useEffect(() => {
|
||||
if (!selected) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
api
|
||||
.llmRecordDetail(selected.id)
|
||||
.then((d) => {
|
||||
if (alive) setDetail(d);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 상세 조회 실패는 무시한다 (finally 에서 로딩 상태 해제) */
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setDetailLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [selected]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / size));
|
||||
const rangeStart = total === 0 ? 0 : page * size + 1;
|
||||
const rangeEnd = page * size + records.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">
|
||||
<RadioIcon 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={session}
|
||||
onChange={(e) => setSession(e.target.value)}
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Input placeholder="Model" className="h-8 w-48" value={model} onChange={(e) => setModel(e.target.value)} />
|
||||
<Select value={pickedTask} onValueChange={setPickedTask}>
|
||||
<SelectTrigger size="sm" className="w-56">
|
||||
<SelectValue placeholder={t("taskPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tasks.length === 0 ? (
|
||||
<SelectItem value="__none__" disabled>
|
||||
{t("taskEmpty")}
|
||||
</SelectItem>
|
||||
) : (
|
||||
tasks.map((task) => (
|
||||
<SelectItem key={task.task_id} value={task.task_id}>
|
||||
<span className="font-mono">#{task.task_id}</span>
|
||||
<span className="ml-2 text-muted-foreground">({task.count})</span>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!pickedTask || deleting}
|
||||
title={pickedTask ? undefined : t("selectTaskFirst")}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
{t("deleteTaskConversations")}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
{/* Recording on/off — off means no LLM calls are recorded */}
|
||||
<div className="flex items-center gap-2 rounded-md border px-2.5 py-1">
|
||||
<Switch
|
||||
id="llm-rec-toggle"
|
||||
size="sm"
|
||||
checked={recEnabled}
|
||||
onCheckedChange={toggleRecording}
|
||||
disabled={recBusy}
|
||||
/>
|
||||
<label
|
||||
htmlFor="llm-rec-toggle"
|
||||
className={cn(
|
||||
"cursor-pointer text-xs font-medium select-none",
|
||||
recEnabled ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{recEnabled ? t("recording") : t("recordingOff")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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 + inline detail (Burp-style split) */}
|
||||
<div className="flex h-[calc(100vh-13rem)] min-h-0 flex-col gap-3">
|
||||
<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-[100px]">Profile</TableHead>
|
||||
<TableHead className="w-[140px]">Model</TableHead>
|
||||
<TableHead className="w-[70px]">{t("colLatency")}</TableHead>
|
||||
<TableHead className="w-[90px]">Tokens</TableHead>
|
||||
<TableHead className="w-[60px]">{t("colStatus")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && records.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-12 text-center">
|
||||
<Loader2Icon className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : records.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
records.map((rec) => (
|
||||
<TableRow
|
||||
key={rec.id}
|
||||
className={cn("cursor-pointer", selected?.id === rec.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(rec)}
|
||||
>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(rec.ts)}</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground">
|
||||
{rec.task_id ? `#${rec.task_id}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{rec.worker || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs">{rec.profile_name || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs font-mono">{rec.model || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={cn("text-xs", rec.latency_ms > 30000 && "text-amber-500")}>
|
||||
{fmtLatency(rec.latency_ms)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs">
|
||||
{fmtTokens(rec.input_tokens)} / {fmtTokens(rec.output_tokens)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{rec.status === "ok" ? (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Inline detail panel */}
|
||||
{selected && (
|
||||
<Card className="flex h-[42%] min-h-0 flex-col overflow-hidden py-0">
|
||||
{/* Detail header */}
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
#{selected.id}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{selected.profile_name || "-"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{selected.model || "-"}
|
||||
</Badge>
|
||||
{selected.task_id && (
|
||||
<Badge variant="outline" className="text-xs font-mono">
|
||||
{t("taskBadge", { id: selected.task_id })}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{fmtTime(selected.ts)}</span>
|
||||
<span className={cn("text-xs", selected.latency_ms > 30000 && "text-amber-500")}>
|
||||
{fmtLatency(selected.latency_ms)}
|
||||
</span>
|
||||
{selected.status === "ok" ? (
|
||||
<Badge variant="secondary" className="text-xs text-emerald-600">
|
||||
OK
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Error
|
||||
</Badge>
|
||||
)}
|
||||
{/* 원문 뷰 토글. 옛 기록은 원문이 없어 이때는 조용히 폴백하지 않고 비활성화해, 「원문과 파싱이 같다」처럼
|
||||
보이는 것을 막는다. */}
|
||||
<Button
|
||||
variant={showRaw ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="ml-auto h-7 shrink-0 text-xs"
|
||||
disabled={!hasRaw}
|
||||
title={hasRaw ? t("rawTooltipHas") : t("rawTooltipNone")}
|
||||
onClick={() => setRawView((v) => !v)}
|
||||
>
|
||||
{t("raw")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-7 shrink-0" onClick={() => setSelected(null)}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Request / Response split */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
|
||||
<span>Request{showRaw && ` · ${t("raw")}`}</span>
|
||||
<CopyButton text={reqText || ""} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
{t("loading")}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{reqText || t("emptyContent")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
|
||||
<span>Response{showRaw && ` · ${t("raw")}(SSE)`}</span>
|
||||
<CopyButton text={respText || ""} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-3 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
{t("loading")}
|
||||
</div>
|
||||
) : (
|
||||
<pre
|
||||
className={cn(
|
||||
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.status !== "ok" && "text-red-600 dark:text-red-400",
|
||||
)}
|
||||
>
|
||||
{respText || t("emptyContent")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteTitle", { taskId: pickedTask })}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("deleteDesc")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmDelete();
|
||||
}}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? t("deleting") : t("confirmDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { AlertCircleIcon, CheckCircle2Icon, DownloadIcon, PlugZapIcon, RefreshCwIcon, SearchIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
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 { SSProject, SSTask } from "@/lib/types";
|
||||
|
||||
type SSStatus = {
|
||||
exists: boolean;
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
reachable: boolean;
|
||||
url?: string;
|
||||
tools: string[];
|
||||
};
|
||||
|
||||
type Dimension = "project" | "task";
|
||||
|
||||
const ASSET_TYPES: string[] = ["subdomain", "service", "app"];
|
||||
|
||||
export default function AssetSyncPage() {
|
||||
const t = useTranslations("assetSync");
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="mb-4">
|
||||
<h1 className="font-semibold text-xl">{t("title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
|
||||
</div>
|
||||
<Tabs defaultValue="scopesentry">
|
||||
<TabsList>
|
||||
<TabsTrigger value="scopesentry">ScopeSentry</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="scopesentry" className="mt-4">
|
||||
<ScopeSentryPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeSentryPanel() {
|
||||
const t = useTranslations("assetSync");
|
||||
const [status, setStatus] = React.useState<SSStatus | null>(null);
|
||||
const [loadingStatus, setLoadingStatus] = React.useState(true);
|
||||
|
||||
const loadStatus = React.useCallback(() => {
|
||||
setLoadingStatus(true);
|
||||
api
|
||||
.ssStatus()
|
||||
.then(setStatus)
|
||||
.catch((e) => toast.error(t("statusLoadFailed", { msg: e.message })))
|
||||
.finally(() => setLoadingStatus(false));
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
const ready = !!status && status.exists && status.configured && status.enabled;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DataSourceCard status={status} loading={loadingStatus} onChanged={loadStatus} />
|
||||
{ready ? (
|
||||
<SyncWorkbench />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-muted-foreground text-sm">{t("notReady")}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 데이터 소스 상태 카드 ─────────────────────────────────────────────────────
|
||||
|
||||
function DataSourceCard({
|
||||
status,
|
||||
loading,
|
||||
onChanged,
|
||||
}: {
|
||||
status: SSStatus | null;
|
||||
loading: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const t = useTranslations("assetSync");
|
||||
const [url, setUrl] = React.useState("");
|
||||
const [apiKey, setApiKey] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (status?.url) setUrl(status.url);
|
||||
}, [status?.url]);
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.ssDatasource({});
|
||||
toast.success(t("created"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(t("createFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!url.trim()) return toast.error(t("urlRequired"));
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.ssDatasource({ url: url.trim(), api_key: apiKey.trim() });
|
||||
toast.success(r.enabled ? t("savedEnabled") : t("savedPending"));
|
||||
setApiKey("");
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(t("saveFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<PlugZapIcon className="size-4" /> {t("dsTitle")}
|
||||
<StatusBadge status={status} loading={loading} />
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={onChanged} disabled={loading}>
|
||||
<RefreshCwIcon className={loading ? "size-4 animate-spin" : "size-4"} /> {t("refresh")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!status?.exists ? (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-muted-foreground text-sm">{t("notCreatedDesc")}</p>
|
||||
<Button onClick={create} disabled={busy}>
|
||||
{t("createDs")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!status.configured && <p className="text-amber-600 text-sm dark:text-amber-500">{t("notConfigured")}</p>}
|
||||
{status.configured && !status.enabled && (
|
||||
<p className="text-amber-600 text-sm dark:text-amber-500">{t("notEnabled")}</p>
|
||||
)}
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("mcpUrl")}</Label>
|
||||
<Input placeholder={t("mcpUrlPlaceholder")} value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("apiKeyLabel")}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="ssk_..."
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={save} disabled={busy}>
|
||||
{t("saveEnable")}
|
||||
</Button>
|
||||
{status.enabled && status.tools.length > 0 && (
|
||||
<span className="text-muted-foreground text-xs">{t("toolsFound", { count: status.tools.length })}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, loading }: { status: SSStatus | null; loading: boolean }) {
|
||||
const t = useTranslations("assetSync");
|
||||
if (loading || !status) return <Badge variant="secondary">{t("badgeChecking")}</Badge>;
|
||||
if (!status.exists) return <Badge variant="destructive">{t("badgeNotCreated")}</Badge>;
|
||||
if (!status.configured) return <Badge variant="outline">{t("badgeNotConfigured")}</Badge>;
|
||||
if (!status.enabled) return <Badge variant="outline">{t("badgeNotEnabled")}</Badge>;
|
||||
if (status.reachable)
|
||||
return (
|
||||
<Badge className="bg-emerald-600 hover:bg-emerald-600">
|
||||
<CheckCircle2Icon className="mr-1 size-3" /> {t("badgeConnected")}
|
||||
</Badge>
|
||||
);
|
||||
return (
|
||||
<Badge variant="destructive">
|
||||
<AlertCircleIcon className="mr-1 size-3" /> {t("badgeUnreachable")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 동기화 작업 영역(프로젝트/작업 기준) ──────────────────────────────────────
|
||||
|
||||
function SyncWorkbench() {
|
||||
const t = useTranslations("assetSync");
|
||||
const tp = useTranslations("pagination");
|
||||
const [dimension, setDimension] = React.useState<Dimension>("project");
|
||||
const [assetTypes, setAssetTypes] = React.useState<Record<string, boolean>>({
|
||||
subdomain: true,
|
||||
service: true,
|
||||
app: true,
|
||||
});
|
||||
const [createCompany, setCreateCompany] = React.useState(true);
|
||||
|
||||
const [projects, setProjects] = React.useState<SSProject[]>([]);
|
||||
const [tasks, setTasks] = React.useState<SSTask[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [selected, setSelected] = React.useState<Set<string>>(new Set());
|
||||
|
||||
const [syncing, setSyncing] = React.useState(false);
|
||||
const [result, setResult] = React.useState<Awaited<ReturnType<typeof api.ssSync>> | null>(null);
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
setSelected(new Set());
|
||||
const fn =
|
||||
dimension === "project"
|
||||
? api.ssProjects(page, 50, search).then((r) => setProjects(r.projects))
|
||||
: api.ssTasks(page, 50, search).then(setTasks);
|
||||
fn.catch((e) => toast.error(t("listLoadFailed", { msg: e.message }))).finally(() => setLoading(false));
|
||||
}, [dimension, page, search, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const rows = dimension === "project" ? projects : tasks;
|
||||
const idOf = (row: SSProject | SSTask) => (dimension === "project" ? (row as SSProject).id : (row as SSTask).name);
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const toggleAll = () => {
|
||||
setSelected((prev) => (prev.size === rows.length ? new Set() : new Set(rows.map(idOf))));
|
||||
};
|
||||
|
||||
const chosenTypes = ASSET_TYPES.filter((key) => assetTypes[key]);
|
||||
|
||||
const runSync = async () => {
|
||||
if (selected.size === 0) return toast.error(t("selectAtLeastOneTarget", { dim: dimension }));
|
||||
if (chosenTypes.length === 0) return toast.error(t("selectAtLeastOneType"));
|
||||
setSyncing(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await api.ssSync({
|
||||
dimension,
|
||||
targets: [...selected],
|
||||
asset_types: chosenTypes,
|
||||
create_company: dimension === "project" ? createCompany : false,
|
||||
});
|
||||
setResult(r);
|
||||
const total = Object.values(r.synced ?? {}).reduce((a, b) => a + b, 0);
|
||||
toast.success(t("syncDone", { total }));
|
||||
} catch (e) {
|
||||
toast.error(t("syncFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderRows = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground text-sm">
|
||||
{t("loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-8 text-center text-muted-foreground text-sm">
|
||||
{t("noData")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
if (dimension === "project") {
|
||||
return projects.map((p) => (
|
||||
<TableRow key={p.id} className="cursor-pointer" onClick={() => toggle(p.id)}>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selected.has(p.id)} onCheckedChange={() => toggle(p.id)} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell>{p.tag ? <Badge variant="secondary">{p.tag}</Badge> : "—"}</TableCell>
|
||||
<TableCell className="text-right">{p.AssetCount ?? 0}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
return tasks.map((task) => (
|
||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => toggle(task.name)}>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={selected.has(task.name)} onCheckedChange={() => toggle(task.name)} />
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{task.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={task.progress === 100 ? "secondary" : "outline"}>
|
||||
{task.progress != null ? `${task.progress}%` : "—"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{task.endTime || task.creatTime || "—"}</TableCell>
|
||||
</TableRow>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("workbenchTitle")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기준 전환 */}
|
||||
<Tabs
|
||||
value={dimension}
|
||||
onValueChange={(v) => {
|
||||
setDimension(v as Dimension);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="project">{t("dimProject")}</TabsTrigger>
|
||||
<TabsTrigger value="task">{t("dimTask")}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* 자산 유형 + 옵션 */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className="font-medium text-sm">{t("syncAssetsLabel")}</span>
|
||||
{ASSET_TYPES.map((key) => (
|
||||
<label key={key} htmlFor={`at-${key}`} className="flex items-center gap-1.5 text-sm">
|
||||
<Checkbox
|
||||
id={`at-${key}`}
|
||||
checked={!!assetTypes[key]}
|
||||
onCheckedChange={(c) => setAssetTypes((prev) => ({ ...prev, [key]: !!c }))}
|
||||
/>
|
||||
{t(`assetType.${key}`)}
|
||||
</label>
|
||||
))}
|
||||
{dimension === "project" && (
|
||||
<label htmlFor="create-company" className="flex items-center gap-1.5 text-sm">
|
||||
<Checkbox id="create-company" checked={createCompany} onCheckedChange={(c) => setCreateCompany(!!c)} />
|
||||
{t("createCompany")}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검색 + 동작 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative max-w-xs flex-1">
|
||||
<SearchIcon className="absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8"
|
||||
placeholder={dimension === "project" ? t("searchProject") : t("searchTask")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
setPage(1);
|
||||
load();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCwIcon className={loading ? "size-4 animate-spin" : "size-4"} />
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<span className="text-muted-foreground text-xs">{t("selectedCount", { count: selected.size })}</span>
|
||||
<Button onClick={runSync} disabled={syncing || selected.size === 0}>
|
||||
<DownloadIcon className={syncing ? "size-4 animate-pulse" : "size-4"} /> {t("syncSelected")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox checked={rows.length > 0 && selected.size === rows.length} onCheckedChange={toggleAll} />
|
||||
</TableHead>
|
||||
<TableHead>{dimension === "project" ? t("colProjectName") : t("colTaskName")}</TableHead>
|
||||
{dimension === "project" ? (
|
||||
<>
|
||||
<TableHead>{t("colTag")}</TableHead>
|
||||
<TableHead className="text-right">{t("colAssetCount")}</TableHead>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableHead>{t("colStatus")}</TableHead>
|
||||
<TableHead>{t("colTime")}</TableHead>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{renderRows()}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지 나눔 */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1 || loading} onClick={() => setPage((p) => p - 1)}>
|
||||
{tp("prev")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">{t("pageNo", { page })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={rows.length < 50 || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{tp("next")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 결과 */}
|
||||
{result && <SyncResult result={result} />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncResult({ result }: { result: Awaited<ReturnType<typeof api.ssSync>> }) {
|
||||
const t = useTranslations("assetSync");
|
||||
const synced = result.synced ?? {};
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border bg-muted/40 p-3 text-sm">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{Object.entries(synced).map(([k, v]) => (
|
||||
<Badge key={k} variant="secondary">
|
||||
{t.has(`assetType.${k}`) ? t(`assetType.${k}`) : k}: {v}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{result.companies && result.companies.length > 0 && (
|
||||
<p className="text-muted-foreground">{t("resultCompanies", { names: result.companies.join(", ") })}</p>
|
||||
)}
|
||||
{result.warnings && result.warnings.length > 0 && (
|
||||
<ul className="list-inside list-disc text-amber-600 dark:text-amber-500">
|
||||
{result.warnings.map((wm) => (
|
||||
<li key={wm}>{wm}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<ul className="list-inside list-disc text-destructive">
|
||||
{result.errors.slice(0, 20).map((em) => (
|
||||
<li key={em}>{em}</li>
|
||||
))}
|
||||
{result.errors.length > 20 && <li>{t("moreErrors", { count: result.errors.length })}</li>}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
GlobeIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutTemplateIcon,
|
||||
LinkIcon,
|
||||
type LucideIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
SmartphoneIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AssetDslSearch } from "@/components/asset-dsl-search";
|
||||
import { ScopeTextEditor } from "@/components/scope-text-editor";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api } from "@/lib/api";
|
||||
import { parseCompanyScopeText } from "@/lib/company-scope";
|
||||
import type { Asset, NewAssetType } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZES = [25, 50, 100, 200];
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
DELETE: "bg-red-100 text-red-700",
|
||||
GET: "bg-emerald-100 text-emerald-700",
|
||||
HEAD: "bg-purple-100 text-purple-700",
|
||||
OPTIONS: "bg-slate-100 text-slate-600",
|
||||
PATCH: "bg-orange-100 text-orange-700",
|
||||
POST: "bg-blue-100 text-blue-700",
|
||||
PUT: "bg-amber-100 text-amber-700",
|
||||
};
|
||||
|
||||
// Node-type labels are translated at render via `t(`kind.${key}`)` because this
|
||||
// is a module constant (no hook access here).
|
||||
const TABS: { key: NewAssetType; icon: LucideIcon }[] = [
|
||||
{ key: "root_domain", icon: GlobeIcon },
|
||||
{ key: "ip", icon: NetworkIcon },
|
||||
{ key: "subdomain", icon: GlobeIcon },
|
||||
{ key: "app", icon: SmartphoneIcon },
|
||||
{ key: "service", icon: LayoutTemplateIcon },
|
||||
{ key: "endpoint", icon: LinkIcon },
|
||||
];
|
||||
|
||||
function firstText(values: Array<string | undefined>, fallback: string): string {
|
||||
return values.find((value) => value?.trim()) ?? fallback;
|
||||
}
|
||||
|
||||
function assetLabel(asset: Asset): string {
|
||||
switch (asset.type) {
|
||||
case "root_domain":
|
||||
case "subdomain":
|
||||
return firstText([asset.domain], `#${asset.id}`);
|
||||
case "ip":
|
||||
return firstText([asset.ip], `#${asset.id}`);
|
||||
case "app":
|
||||
return firstText([asset.app_name, asset.url], `#${asset.id}`);
|
||||
case "service": {
|
||||
const host = firstText([asset.ip, asset.domain], "");
|
||||
const address = [host, asset.port].filter((value) => value !== undefined && value !== "").join(":");
|
||||
return firstText([asset.url, address, asset.service_name], `#${asset.id}`);
|
||||
}
|
||||
case "endpoint":
|
||||
return firstText([[asset.method, asset.url].filter(Boolean).join(" ")], `#${asset.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function MethodBadge({ method }: { method: string }) {
|
||||
const normalized = method.toUpperCase();
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block rounded px-1.5 py-0.5 font-mono font-semibold text-[10px] leading-none",
|
||||
METHOD_COLOR[normalized] ?? "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{normalized || "—"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function statusTone(code: number) {
|
||||
if (code >= 500) return "text-red-500";
|
||||
if (code >= 400) return "text-amber-500";
|
||||
if (code >= 300) return "text-blue-500";
|
||||
if (code >= 200) return "text-emerald-500";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function fmtBytes(value?: number | null) {
|
||||
if (!value || !Number.isFinite(value) || value <= 0) return "—";
|
||||
const units = ["B", "KB", "MB"];
|
||||
const index = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
return `${index === 0 ? value : (value / 1024 ** index).toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function Chips({ items, mono }: { items: string[]; mono?: boolean }) {
|
||||
const clean = items.filter(Boolean);
|
||||
if (clean.length === 0) return <span className="text-muted-foreground text-xs">—</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{clean.map((item) => (
|
||||
<Badge key={item} variant="outline" className={cn(mono && "font-mono")}>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceCell({ asset }: { asset: Asset }) {
|
||||
const t = useTranslations("taskDetail.assetsTab");
|
||||
const source = firstText([asset.task_source], "legacy");
|
||||
const sourceLabel = t.has(`source.label.${source}`) ? t(`source.label.${source}`) : source;
|
||||
const summary = firstText([asset.task_source_summary], t("source.legacySummary"));
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="outline" className="max-w-28 shrink-0 font-normal">
|
||||
<span className="truncate">{sourceLabel}</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" align="start" className="max-w-sm">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="font-medium">{sourceLabel}</span>
|
||||
<span className="[overflow-wrap:anywhere]">{summary}</span>
|
||||
{asset.task_source_node_id ? (
|
||||
<span className="font-mono opacity-80">{t("source.node", { id: asset.task_source_node_id })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({
|
||||
children,
|
||||
cols,
|
||||
loaded,
|
||||
onPage,
|
||||
onSize,
|
||||
page,
|
||||
size,
|
||||
total,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cols: string[];
|
||||
loaded: boolean;
|
||||
onPage: (page: number) => void;
|
||||
onSize: (size: number) => void;
|
||||
page: number;
|
||||
size: number;
|
||||
total: number;
|
||||
}) {
|
||||
const t = useTranslations("taskDetail.assetsTab");
|
||||
const tp = useTranslations("pagination");
|
||||
const rows = React.Children.toArray(children);
|
||||
const pageCount = Math.max(1, Math.ceil(total / size));
|
||||
const start = total === 0 ? 0 : page * size + 1;
|
||||
const end = Math.min(total, page * size + rows.length);
|
||||
let tableRows: React.ReactNode;
|
||||
if (!loaded) {
|
||||
tableRows = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={cols.length} className="py-14 text-center">
|
||||
<Spinner className="mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
} else if (rows.length > 0) {
|
||||
tableRows = rows;
|
||||
} else {
|
||||
tableRows = (
|
||||
<TableRow>
|
||||
<TableCell colSpan={cols.length} className="py-10 text-center text-muted-foreground text-sm">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Card className="flex min-h-0 flex-1 flex-col py-0">
|
||||
<CardContent className="scrollbar-thin scrollbar-track-transparent min-h-0 flex-1 overflow-auto p-0">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
{cols.map((column) => (
|
||||
<TableHead key={column}>{column}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{tableRows}</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
{total > 0 ? (
|
||||
<CardFooter className="gap-2 px-3 py-1.5 text-muted-foreground text-xs">
|
||||
<Select
|
||||
value={String(size)}
|
||||
onValueChange={(value) => {
|
||||
onSize(Number(value));
|
||||
onPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{PAGE_SIZES.map((value) => (
|
||||
<SelectItem key={value} value={String(value)}>
|
||||
{value}
|
||||
{tp("perPage")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="tabular-nums">{tp("range", { from: start, to: end, total })}</span>
|
||||
{pageCount > 1 ? (
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page <= 0}
|
||||
onClick={() => onPage(Math.max(0, page - 1))}
|
||||
aria-label={tp("prev")}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="tabular-nums">
|
||||
{page + 1} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page + 1 >= pageCount}
|
||||
onClick={() => onPage(Math.min(pageCount - 1, page + 1))}
|
||||
aria-label={tp("next")}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function AddTaskAssetsSheet({
|
||||
onAttached,
|
||||
onOpenChange,
|
||||
open,
|
||||
taskId,
|
||||
}: {
|
||||
onAttached: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
taskId: string;
|
||||
}) {
|
||||
const t = useTranslations("taskDetail.assetsTab");
|
||||
const [scopeText, setScopeText] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const parsedScope = React.useMemo(() => parseCompanyScopeText(scopeText), [scopeText]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setScopeText("");
|
||||
}, [open]);
|
||||
|
||||
const attach = async () => {
|
||||
if (parsedScope.rules.length === 0 || parsedScope.errors.length > 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await api.registerTaskAssetScopes(taskId, parsedScope.rules);
|
||||
const assetSummary = result.assets_linked + result.assets_existing;
|
||||
toast.success(t("addSheet.success", { requested: result.requested, assetSummary }));
|
||||
onAttached();
|
||||
onOpenChange(false);
|
||||
} catch (reason) {
|
||||
toast.error(t("addSheet.error", { message: String((reason as Error)?.message ?? reason) }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("addSheet.title")}</SheetTitle>
|
||||
<SheetDescription>{t("addSheet.description")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<ScopeTextEditor
|
||||
id="task-asset-scope"
|
||||
value={scopeText}
|
||||
onValueChange={setScopeText}
|
||||
parsed={parsedScope}
|
||||
label={t("addSheet.editorLabel")}
|
||||
description={t("addSheet.editorDescription")}
|
||||
/>
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
{t("addSheet.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void attach()}
|
||||
disabled={saving || parsedScope.rules.length === 0 || parsedScope.errors.length > 0}
|
||||
>
|
||||
{saving ? <Spinner data-icon="inline-start" /> : <PlusIcon data-icon="inline-start" />}
|
||||
{parsedScope.rules.length > 0
|
||||
? t("addSheet.submitN", { count: parsedScope.rules.length })
|
||||
: t("addSheet.submit")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetsTab({ taskId }: { taskId: string }) {
|
||||
const t = useTranslations("taskDetail.assetsTab");
|
||||
const [rows, setRows] = React.useState<Asset[]>([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [counts, setCounts] = React.useState<Record<string, number>>({});
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [tab, setTab] = React.useState<NewAssetType>("root_domain");
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [size, setSize] = React.useState(50);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [dslError, setDslError] = React.useState("");
|
||||
const [refreshKey, setRefreshKey] = React.useState(0);
|
||||
const [addOpen, setAddOpen] = React.useState(false);
|
||||
const [removeTarget, setRemoveTarget] = React.useState<Asset | null>(null);
|
||||
const [removing, setRemoving] = React.useState(false);
|
||||
const assetsRequestRef = React.useRef(0);
|
||||
const dslMode = query.trim() !== "";
|
||||
|
||||
React.useEffect(() => {
|
||||
setPage(0);
|
||||
setRows([]);
|
||||
setLoaded(false);
|
||||
}, []);
|
||||
|
||||
// Switching type tabs resets the search (DSL fields differ per type; matches
|
||||
// the global asset view).
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: tab changes intentionally reset tab-local controls.
|
||||
React.useEffect(() => {
|
||||
setQuery("");
|
||||
setDslError("");
|
||||
}, [tab]);
|
||||
|
||||
// Query / tab / page-size changes restart server pagination.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: these controls intentionally reset server pagination.
|
||||
React.useEffect(() => setPage(0), [tab, size, query]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an explicit manual-reload trigger.
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
const dsl = query.trim();
|
||||
const request = ++assetsRequestRef.current;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// The search always stays within this task's assets: taskAssets and
|
||||
// searchTaskAssets both pin task_id server-side.
|
||||
const [current, nextCounts] = await Promise.all([
|
||||
dsl
|
||||
? api.searchTaskAssets(taskId, dsl, tab, size, page * size)
|
||||
: api.taskAssets(taskId, tab, size, page * size),
|
||||
api.assetCounts(taskId),
|
||||
]);
|
||||
if (!active || assetsRequestRef.current !== request) return;
|
||||
setRows(current.assets);
|
||||
setTotal(current.total);
|
||||
setCounts(nextCounts ?? {});
|
||||
setDslError("");
|
||||
} catch (reason) {
|
||||
if (!active || assetsRequestRef.current !== request) return;
|
||||
const message = String((reason as Error)?.message ?? reason);
|
||||
if (dsl) {
|
||||
setDslError(message);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
} else {
|
||||
toast.error(t("loadError", { message }));
|
||||
}
|
||||
} finally {
|
||||
if (active && assetsRequestRef.current === request) {
|
||||
setLoading(false);
|
||||
setLoaded(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Debounce DSL keystrokes; plain (re)loads and polling run immediately.
|
||||
const debounce = setTimeout(() => void load(), dsl ? 400 : 0);
|
||||
const timer = setInterval(() => void load(), 10_000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(debounce);
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [page, query, refreshKey, size, tab, taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
|
||||
if (page > maxPage) setPage(maxPage);
|
||||
}, [page, size, total]);
|
||||
|
||||
const refresh = React.useCallback(() => {
|
||||
setLoaded(false);
|
||||
setRefreshKey((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
const remove = async () => {
|
||||
if (!removeTarget) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
await api.detachTaskAsset(taskId, removeTarget.id);
|
||||
toast.success(t("remove.success", { label: assetLabel(removeTarget) }));
|
||||
setRemoveTarget(null);
|
||||
refresh();
|
||||
} catch (reason) {
|
||||
toast.error(t("remove.error", { message: String((reason as Error)?.message ?? reason) }));
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeButton = (asset: Asset) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setRemoveTarget(asset)}
|
||||
aria-label={t("remove.aria", { label: assetLabel(asset) })}
|
||||
title={t("remove.title")}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const commonCardProps = { loaded, onPage: setPage, onSize: setSize, page, size, total };
|
||||
const totalAll = TABS.reduce((sum, item) => sum + (counts[item.key] ?? 0), 0);
|
||||
|
||||
const searchBox = (
|
||||
<AssetDslSearch
|
||||
query={query}
|
||||
onChange={setQuery}
|
||||
loading={loading}
|
||||
error={dslError}
|
||||
count={dslMode ? total : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-medium text-sm">{t("title")}</h2>
|
||||
<p className="text-muted-foreground text-xs">{t("totalAll", { count: totalAll })}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setAddOpen(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value as NewAssetType)}
|
||||
className="flex min-h-0 flex-1 flex-col gap-2"
|
||||
>
|
||||
<div className="overflow-x-auto overflow-y-hidden">
|
||||
<TabsList className="w-max">
|
||||
{TABS.map((item) => (
|
||||
<TabsTrigger key={item.key} value={item.key}>
|
||||
<item.icon data-icon="inline-start" />
|
||||
{t(`kind.${item.key}`)}
|
||||
<span className="text-muted-foreground tabular-nums">{counts[item.key] ?? 0}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{searchBox}
|
||||
|
||||
<TabsContent value="root_domain" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard cols={[t("col.domain"), t("col.icp"), t("col.source"), t("col.action")]} {...commonCardProps}>
|
||||
{rows.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="font-medium font-mono text-xs">{asset.domain}</TableCell>
|
||||
<TableCell className="text-xs">{asset.icp || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ip" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard
|
||||
cols={[
|
||||
"IP",
|
||||
t("col.cSegment"),
|
||||
t("col.boundDomains"),
|
||||
t("col.openPorts"),
|
||||
t("col.source"),
|
||||
t("col.action"),
|
||||
]}
|
||||
{...commonCardProps}
|
||||
>
|
||||
{rows.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="font-medium font-mono text-xs">{asset.ip}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{asset.c_segment || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Chips items={asset.bound_domains ?? []} mono />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chips
|
||||
items={(asset.open_ports ?? []).map((item) =>
|
||||
item.service ? `${item.port}/${item.service}` : String(item.port),
|
||||
)}
|
||||
mono
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subdomain" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard
|
||||
cols={[
|
||||
t("col.domain"),
|
||||
t("col.rootDomain"),
|
||||
t("col.recordType"),
|
||||
t("col.recordValue"),
|
||||
t("col.source"),
|
||||
t("col.action"),
|
||||
]}
|
||||
{...commonCardProps}
|
||||
>
|
||||
{rows.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="font-medium font-mono text-xs">{asset.domain}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{asset.root_domain || "—"}</TableCell>
|
||||
<TableCell className="text-xs">{asset.record_type || "—"}</TableCell>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs">
|
||||
{(Array.isArray(asset.record_value) ? asset.record_value.join(", ") : asset.record_value) || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="app" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard
|
||||
cols={[
|
||||
t("col.app"),
|
||||
t("col.address"),
|
||||
t("col.category"),
|
||||
t("col.title"),
|
||||
t("col.fingerprint"),
|
||||
t("col.source"),
|
||||
t("col.action"),
|
||||
]}
|
||||
{...commonCardProps}
|
||||
>
|
||||
{rows.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="max-w-48 truncate font-medium text-xs">{asset.app_name || "—"}</TableCell>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs" title={asset.url}>
|
||||
{asset.url || "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{asset.category || "—"}</TableCell>
|
||||
<TableCell className="max-w-48 truncate text-xs">{asset.page_title || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Chips items={asset.technologies ?? []} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="service" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard
|
||||
cols={[
|
||||
t("col.addressService"),
|
||||
t("col.statusCode"),
|
||||
t("col.title"),
|
||||
t("col.responseLength"),
|
||||
t("col.fingerprint"),
|
||||
t("col.auth"),
|
||||
t("col.source"),
|
||||
t("col.action"),
|
||||
]}
|
||||
{...commonCardProps}
|
||||
>
|
||||
{rows.map((asset) => {
|
||||
const isHttp = asset.service_type === "http";
|
||||
const address = isHttp
|
||||
? asset.url || ""
|
||||
: asset.service_name || [asset.ip || asset.domain, asset.port].filter(Boolean).join(":");
|
||||
return (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="max-w-xs truncate font-mono text-xs" title={address}>
|
||||
{address || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{asset.status_code != null ? (
|
||||
<span
|
||||
className={cn("font-mono font-semibold text-xs tabular-nums", statusTone(asset.status_code))}
|
||||
>
|
||||
{asset.status_code}
|
||||
</span>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-48 truncate text-xs">{asset.page_title || "—"}</TableCell>
|
||||
<TableCell className="text-xs tabular-nums">{fmtBytes(asset.content_length)}</TableCell>
|
||||
<TableCell>
|
||||
<Chips items={asset.technologies ?? []} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(asset.auth ?? []).length === 0 ? (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
) : (
|
||||
(asset.auth ?? []).map((authItem) => {
|
||||
const item = authItem as Record<string, string>;
|
||||
return (
|
||||
<span
|
||||
key={`${asset.id}-${item.type ?? ""}-${item.username ?? ""}-${JSON.stringify(item)}`}
|
||||
className="inline-flex items-center gap-1 text-[11px]"
|
||||
>
|
||||
<KeyRoundIcon className="size-3 text-muted-foreground" />
|
||||
<span className="font-mono">{item.type || item.username || t("col.auth")}</span>
|
||||
</span>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="endpoint" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<AssetCard
|
||||
cols={[t("col.method"), t("col.fullAddress"), t("col.params"), t("col.source"), t("col.action")]}
|
||||
{...commonCardProps}
|
||||
>
|
||||
{rows.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell className="w-16">
|
||||
<MethodBadge method={asset.method || ""} />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-sm truncate font-mono text-xs" title={asset.url}>
|
||||
{asset.url || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chips
|
||||
items={(asset.params ?? []).map((param) => {
|
||||
const item = param as Record<string, string>;
|
||||
return item.name ? `${item.name}(${item.location || item.in || "?"})` : "?";
|
||||
})}
|
||||
mono
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SourceCell asset={asset} />
|
||||
</TableCell>
|
||||
<TableCell className="w-16">{removeButton(asset)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</AssetCard>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<AddTaskAssetsSheet onAttached={refresh} onOpenChange={setAddOpen} open={addOpen} taskId={taskId} />
|
||||
|
||||
<AlertDialog open={Boolean(removeTarget)} onOpenChange={(open) => !open && !removing && setRemoveTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("remove.confirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="[overflow-wrap:anywhere]">
|
||||
{removeTarget ? t("remove.confirmBody", { label: assetLabel(removeTarget) }) : ""}
|
||||
{t("remove.keep")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={removing}>{t("remove.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={removing}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void remove();
|
||||
}}
|
||||
>
|
||||
{removing ? <Spinner data-icon="inline-start" /> : <Trash2Icon data-icon="inline-start" />}
|
||||
{removing ? t("remove.removing") : t("remove.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowUpToLineIcon,
|
||||
BugIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CompassIcon,
|
||||
FlagIcon,
|
||||
FlaskConicalIcon,
|
||||
LayersIcon,
|
||||
LightbulbIcon,
|
||||
type LucideIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
SearchIcon,
|
||||
TargetIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/lib/api";
|
||||
import { type Tone, toneClasses, toneDot } from "@/lib/status";
|
||||
import type { Edge, ExploreKind, FindingAsset, TaskNode } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZES = [20, 50, 100];
|
||||
const POLL_MS = 8000;
|
||||
|
||||
type KindMeta = { icon: LucideIcon; dot: string; chip: string };
|
||||
|
||||
// 播报板自己的展示元数据。刻意不复用探索链路图那份:图是拓扑视角(节点卡片、连线配色),
|
||||
// 播报是流水视角(时间轴行),两边的信息密度和配色需求不同,各自演进更省事。
|
||||
// 文案走 i18n(taskDetail.broadcastTab.kind),这里只留图标与配色。
|
||||
const KIND_META: Record<string, KindMeta> = {
|
||||
begin: {
|
||||
icon: FlagIcon,
|
||||
dot: "bg-slate-500",
|
||||
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
|
||||
},
|
||||
task: {
|
||||
icon: FlagIcon,
|
||||
dot: "bg-slate-500",
|
||||
chip: "bg-slate-500/15 text-slate-600 dark:text-slate-300",
|
||||
},
|
||||
goal: {
|
||||
icon: TargetIcon,
|
||||
dot: "bg-emerald-500",
|
||||
chip: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
|
||||
},
|
||||
intent: {
|
||||
icon: CompassIcon,
|
||||
dot: "bg-blue-500",
|
||||
chip: "bg-blue-500/15 text-blue-600 dark:text-blue-400",
|
||||
},
|
||||
fact: {
|
||||
icon: FlaskConicalIcon,
|
||||
dot: "bg-amber-500",
|
||||
chip: "bg-amber-500/15 text-amber-600 dark:text-amber-400",
|
||||
},
|
||||
finding: {
|
||||
icon: BugIcon,
|
||||
dot: "bg-rose-500",
|
||||
chip: "bg-rose-500/15 text-rose-600 dark:text-rose-400",
|
||||
},
|
||||
hint: {
|
||||
icon: LightbulbIcon,
|
||||
dot: "bg-violet-500",
|
||||
chip: "bg-violet-500/15 text-violet-600 dark:text-violet-400",
|
||||
},
|
||||
digest: {
|
||||
icon: LayersIcon,
|
||||
dot: "bg-teal-500",
|
||||
chip: "bg-teal-500/15 text-teal-600 dark:text-teal-400",
|
||||
},
|
||||
};
|
||||
|
||||
function kindKeyOf(kind: string): string {
|
||||
return kind in KIND_META ? kind : "fact";
|
||||
}
|
||||
|
||||
// 可筛选的类型。起点(fact/state=origin)不单独列,它跟着「事实」一起过滤。
|
||||
const FILTER_KINDS: ExploreKind[] = ["goal", "intent", "fact", "finding", "hint", "digest"];
|
||||
|
||||
// goal / intent 的状态语义由全局 status 表提供(StatusBadge);其余类型的状态只在
|
||||
// 图和播报里出现,这里补一份(文案走 i18n,这里只留配色)。
|
||||
const STATE_TONE: Record<string, Record<string, Tone>> = {
|
||||
fact: {
|
||||
origin: "slate",
|
||||
confirmed: "green",
|
||||
dismissed: "slate",
|
||||
},
|
||||
finding: {
|
||||
confirmed: "red",
|
||||
dismissed: "slate",
|
||||
},
|
||||
hint: {
|
||||
active: "violet",
|
||||
consumed: "slate",
|
||||
},
|
||||
digest: {
|
||||
active: "green",
|
||||
superseded: "slate",
|
||||
},
|
||||
};
|
||||
|
||||
// 任务根是 state=origin 的 fact,播报里读作「起点」。
|
||||
function viewKind(n: TaskNode): string {
|
||||
return n.type === "fact" && n.state === "origin" ? "begin" : n.type;
|
||||
}
|
||||
|
||||
const SUMMARY_FIELDS: Record<string, string[]> = {
|
||||
begin: ["summary", "description"],
|
||||
task: ["summary", "description"],
|
||||
goal: ["text"],
|
||||
intent: ["summary"],
|
||||
fact: ["summary"],
|
||||
finding: ["name", "summary"],
|
||||
hint: ["text", "summary"],
|
||||
digest: ["body", "summary"],
|
||||
};
|
||||
|
||||
function summaryOf(n: TaskNode): string {
|
||||
const raw = n.payload ?? "";
|
||||
if (!raw.trim()) return "";
|
||||
for (const field of SUMMARY_FIELDS[viewKind(n)] ?? []) {
|
||||
try {
|
||||
const obj: unknown = JSON.parse(raw);
|
||||
if (obj && typeof obj === "object") {
|
||||
const v = (obj as Record<string, unknown>)[field];
|
||||
if (typeof v === "string" && v.trim()) return v;
|
||||
}
|
||||
} catch {
|
||||
return raw; // 非 JSON payload:原样播报
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function prettyPayload(raw: string | undefined, emptyLabel: string): string {
|
||||
if (!raw?.trim()) return emptyLabel;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
const dayFmt = new Intl.DateTimeFormat("ko-KR", { month: "long", day: "numeric", weekday: "short" });
|
||||
const clockFmt = new Intl.DateTimeFormat("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
function NodeStateBadge({ node }: { node: TaskNode }) {
|
||||
const t = useTranslations("taskDetail.broadcastTab");
|
||||
if (node.type === "goal") return <StatusBadge domain="goal" value={node.state} dot />;
|
||||
if (node.type === "intent") return <StatusBadge domain="intent" value={node.state} dot />;
|
||||
const tone = STATE_TONE[node.type]?.[node.state];
|
||||
if (!tone) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap",
|
||||
toneClasses[tone],
|
||||
)}
|
||||
>
|
||||
<span className={cn("size-1.5 rounded-full", toneDot[tone])} />
|
||||
{t(`state.${node.type}.${node.state}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function KindChip({ kind }: { kind: string }) {
|
||||
const t = useTranslations("taskDetail.broadcastTab");
|
||||
const key = kindKeyOf(kind);
|
||||
const meta = KIND_META[key];
|
||||
return (
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium whitespace-nowrap", meta.chip)}>
|
||||
{t(`kind.${key}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 节点锚定的资产:类型标签 + 可辨识文本。数据随播报页一起下发(node id → 资产),
|
||||
// 展开时直接展示,不额外请求。类型文案走 i18n(assetType),未覆盖的类型退回原始字符串。
|
||||
function AssetList({ assets, dense = false }: { assets: FindingAsset[]; dense?: boolean }) {
|
||||
const t = useTranslations("taskDetail.broadcastTab");
|
||||
if (assets.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs font-medium text-muted-foreground">{t("assets", { count: assets.length })}</div>
|
||||
<ul className="flex flex-wrap gap-1.5">
|
||||
{assets.map((a) => {
|
||||
const typeLabel = t.has(`assetType.${a.type}`) ? t(`assetType.${a.type}`) : a.type;
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className={cn(
|
||||
"inline-flex max-w-full items-center gap-1.5 rounded-md border bg-background px-2 py-0.5",
|
||||
dense && "text-xs",
|
||||
)}
|
||||
title={`${typeLabel} · ${a.label}`}
|
||||
>
|
||||
<span className="shrink-0 rounded bg-muted px-1 text-[10px] text-muted-foreground">{typeLabel}</span>
|
||||
<code className="truncate font-mono text-xs">{a.label}</code>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 悬停在上下游条目上时弹出的节点名片:类型/状态/来源/时间 + 摘要 + payload 片段 + 涉及资产。
|
||||
// 数据来自本页已经拿到的 refs,不额外发请求——播报接口已经把邻居节点整份带回来了。
|
||||
function RelatedNodeCard({ node, assets }: { node: TaskNode; assets: FindingAsset[] }) {
|
||||
const t = useTranslations("taskDetail.broadcastTab");
|
||||
const kind = viewKind(node);
|
||||
const ts = Date.parse(node.ts);
|
||||
const summary = summaryOf(node);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<KindChip kind={kind} />
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">#{node.id}</code>
|
||||
<NodeStateBadge node={node} />
|
||||
{node.priority > 0 && (kind === "goal" || kind === "intent") && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">P{node.priority}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
|
||||
<span>{t("fieldType", { label: t(`kind.${kindKeyOf(kind)}`) })}</span>
|
||||
<span>{t("fieldSource", { origin: node.origin || "system" })}</span>
|
||||
<span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("ko-KR")}</span>
|
||||
</div>
|
||||
<p className="line-clamp-4 text-xs break-words">{summary || t("noSummary")}</p>
|
||||
<AssetList assets={assets} dense />
|
||||
<pre className="max-h-40 overflow-auto rounded border bg-muted/40 p-2 font-mono text-[11px] whitespace-pre-wrap">
|
||||
{prettyPayload(node.payload, t("noPayload"))}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 一条播报涉及的上下游:上游 = 指向本节点的边,下游 = 本节点指出去的边。
|
||||
function RelatedList({
|
||||
title,
|
||||
rows,
|
||||
refs,
|
||||
assets,
|
||||
}: {
|
||||
title: string;
|
||||
rows: Array<{ rel: string; id: string }>;
|
||||
refs: Record<string, TaskNode>;
|
||||
assets: Record<string, FindingAsset[]>;
|
||||
}) {
|
||||
const t = useTranslations("taskDetail.broadcastTab");
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1.5 text-xs font-medium text-muted-foreground">{title}</div>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{rows.map((row) => {
|
||||
const node = refs[row.id];
|
||||
return (
|
||||
<li key={`${row.rel}-${row.id}`} className="flex min-w-0 items-center gap-2 text-xs">
|
||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-muted-foreground">
|
||||
{t.has(`rel.${row.rel}`) ? t(`rel.${row.rel}`) : row.rel}
|
||||
</span>
|
||||
{node ? (
|
||||
<HoverCard openDelay={150} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 cursor-help items-center gap-2 text-left hover:underline"
|
||||
>
|
||||
<KindChip kind={viewKind(node)} />
|
||||
<span className="truncate">{summaryOf(node) || t("nodeRef", { id: node.id })}</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="start" className="w-96">
|
||||
<RelatedNodeCard node={node} assets={assets[node.id] ?? []} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t("nodeRef", { id: row.id })}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BroadcastRow({
|
||||
node,
|
||||
edges,
|
||||
refs,
|
||||
assets,
|
||||
now,
|
||||
fresh,
|
||||
}: {
|
||||
node: TaskNode;
|
||||
edges: Edge[];
|
||||
refs: Record<string, TaskNode>;
|
||||
assets: Record<string, FindingAsset[]>;
|
||||
now: number;
|
||||
fresh: boolean;
|
||||
}) {
|
||||
const t = useTranslations("taskDetail.broadcastTab");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const kind = viewKind(node);
|
||||
const meta = KIND_META[kindKeyOf(kind)];
|
||||
const Icon = meta.icon;
|
||||
const ts = Date.parse(node.ts);
|
||||
const summary = summaryOf(node);
|
||||
const upstream = edges.filter((e) => e.dst === node.id).map((e) => ({ rel: e.rel, id: e.src }));
|
||||
const downstream = edges.filter((e) => e.src === node.id).map((e) => ({ rel: e.rel, id: e.dst }));
|
||||
|
||||
// 相对时间:阈值切档,文案与单位走 i18n。ts 解析失败(NaN)时返回空串,不显示。
|
||||
const relTime = (tsMs: number, nowMs: number): string => {
|
||||
if (!nowMs || !tsMs) return "";
|
||||
const sec = Math.max(0, (nowMs - tsMs) / 1000);
|
||||
if (sec < 60) return t("relTime.now");
|
||||
if (sec < 3600) return t("relTime.minutes", { count: Math.floor(sec / 60) });
|
||||
if (sec < 86400) return t("relTime.hours", { count: Math.floor(sec / 3600) });
|
||||
return t("relTime.days", { count: Math.floor(sec / 86400) });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative grid grid-cols-[4.5rem_1.75rem_1fr] gap-x-2", fresh && "bg-primary/5")}>
|
||||
{/* 时间列 */}
|
||||
<div className="py-3 text-right text-xs text-muted-foreground tabular-nums">
|
||||
<div>{Number.isNaN(ts) ? "--:--:--" : clockFmt.format(ts)}</div>
|
||||
<div className="text-[11px] opacity-70">{relTime(ts, now)}</div>
|
||||
</div>
|
||||
|
||||
{/* 时间轴:竖线 + 类型圆点 */}
|
||||
<div className="relative flex justify-center">
|
||||
<span className="absolute inset-y-0 w-px bg-border" />
|
||||
<span
|
||||
className={cn(
|
||||
"relative mt-3.5 flex size-6 items-center justify-center rounded-full text-white ring-4 ring-background",
|
||||
meta.dot,
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 内容列 */}
|
||||
<div className="min-w-0 border-b py-3 pr-1 last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full min-w-0 items-start gap-2 text-left"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn("mt-0.5 size-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<KindChip kind={kind} />
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">#{node.id}</code>
|
||||
<NodeStateBadge node={node} />
|
||||
{node.priority > 0 && (kind === "goal" || kind === "intent") && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">P{node.priority}</span>
|
||||
)}
|
||||
{fresh && (
|
||||
<span className="rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
||||
{t("fresh")}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-xs text-muted-foreground">{node.origin || "system"}</span>
|
||||
</div>
|
||||
<p className={cn("mt-1 text-sm", !open && "line-clamp-2")}>{summary || t("nodeRef", { id: node.id })}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-2 ml-5 flex flex-col gap-3 rounded-md border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("node")} <code className="font-mono">#{node.id}</code>
|
||||
</span>
|
||||
<span>{t("fieldType", { label: t(`kind.${kindKeyOf(kind)}`) })}</span>
|
||||
<span>{t("fieldSource", { origin: node.origin || "system" })}</span>
|
||||
<span>{Number.isNaN(ts) ? node.ts : new Date(ts).toLocaleString("ko-KR")}</span>
|
||||
</div>
|
||||
{node.state === "deleted" && node.delete_reason && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs">
|
||||
<span className="font-medium text-destructive">{t("deleteReason")}</span>
|
||||
<span className="ml-2 break-words text-muted-foreground">{node.delete_reason}</span>
|
||||
</div>
|
||||
)}
|
||||
<AssetList assets={assets[node.id] ?? []} />
|
||||
{(upstream.length > 0 || downstream.length > 0) && (
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<RelatedList title={t("upstream")} rows={upstream} refs={refs} assets={assets} />
|
||||
<RelatedList title={t("downstream")} rows={downstream} refs={refs} assets={assets} />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="mb-1.5 text-xs font-medium text-muted-foreground">payload</div>
|
||||
<pre className="max-h-64 overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{prettyPayload(node.payload, t("noPayload"))}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BroadcastTab({ taskId }: { taskId: string }) {
|
||||
const t = useTranslations("taskDetail.broadcastTab");
|
||||
const tp = useTranslations("pagination");
|
||||
const [kinds, setKinds] = React.useState<ExploreKind[]>([]);
|
||||
const [queryInput, setQueryInput] = React.useState("");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [order, setOrder] = React.useState<"asc" | "desc">("desc");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [size, setSize] = React.useState(20);
|
||||
const [live, setLive] = React.useState(true);
|
||||
|
||||
const [items, setItems] = React.useState<TaskNode[]>([]);
|
||||
const [edges, setEdges] = React.useState<Edge[]>([]);
|
||||
const [refs, setRefs] = React.useState<Record<string, TaskNode>>({});
|
||||
const [assets, setAssets] = React.useState<Record<string, FindingAsset[]>>({});
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [freshIDs, setFreshIDs] = React.useState<Set<string>>(new Set());
|
||||
const [pending, setPending] = React.useState(0);
|
||||
const [now, setNow] = React.useState(0);
|
||||
|
||||
const seenRef = React.useRef<Set<string>>(new Set());
|
||||
const baselineRef = React.useRef<number | null>(null);
|
||||
const streamRef = React.useRef("");
|
||||
// 只有「最新在前的第 1 页」才是真正的直播位;其余位置轮询只更新未读计数,
|
||||
// 不动列表,免得翻页/展开时内容在脚下变。
|
||||
const atLive = page === 1 && order === "desc";
|
||||
|
||||
React.useEffect(() => {
|
||||
setNow(Date.now());
|
||||
const t = setInterval(() => setNow(Date.now()), 30000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
// 输入防抖:打字停 300ms 才真正查询,并回到第一页。
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
setQuery(queryInput);
|
||||
setPage(1);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [queryInput]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
let rendered = false; // 本次查询是否已经把内容渲染出来过
|
||||
// 换任务/筛选/排序 = 换了一条播报流:清掉「新」标记和未读基线。翻页不算换流,
|
||||
// 否则回到最新时就没有未读计数可算了。
|
||||
const stream = `${taskId}|${kinds.join(",")}|${query}|${order}`;
|
||||
if (streamRef.current !== stream) {
|
||||
streamRef.current = stream;
|
||||
seenRef.current = new Set();
|
||||
baselineRef.current = null;
|
||||
setPending(0);
|
||||
}
|
||||
const load = () =>
|
||||
api
|
||||
.explorationNodes(taskId, { page, size, kinds, q: query, order })
|
||||
.then((r) => {
|
||||
if (!alive) return;
|
||||
// 直播位每轮都刷新;其它位置只渲染第一次,之后轮询仅更新未读计数。
|
||||
if (atLive || !rendered) {
|
||||
rendered = true;
|
||||
setItems(r.items);
|
||||
setEdges(r.edges);
|
||||
setRefs(r.refs);
|
||||
setAssets(r.assets);
|
||||
}
|
||||
setTotal(r.total);
|
||||
if (atLive) {
|
||||
const seen = seenRef.current;
|
||||
setFreshIDs(seen.size === 0 ? new Set() : new Set(r.items.filter((n) => !seen.has(n.id)).map((n) => n.id)));
|
||||
seenRef.current = new Set(r.items.map((n) => n.id));
|
||||
baselineRef.current = r.total;
|
||||
setPending(0);
|
||||
} else {
|
||||
const base = baselineRef.current;
|
||||
setPending(base === null ? 0 : Math.max(0, r.total - base));
|
||||
}
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// 轮询是尽力而为:保留上一次成功的播报内容,下一轮自动重试。
|
||||
});
|
||||
void load();
|
||||
if (!live) {
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}
|
||||
const timer = setInterval(load, POLL_MS);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [taskId, page, size, kinds, query, order, live, atLive]);
|
||||
|
||||
const toggleKind = (kind: ExploreKind) => {
|
||||
setKinds((cur) => (cur.includes(kind) ? cur.filter((k) => k !== kind) : [...cur, kind]));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const backToLive = () => {
|
||||
setPage(1);
|
||||
setOrder("desc");
|
||||
setPending(0);
|
||||
};
|
||||
|
||||
// 服务端的 refs 只补「不在本页的邻居」,同页节点之间的引用要靠 items 自己兜底,
|
||||
// 否则相邻两条播报互相引用时会退化成光秃秃的「节点 #id」。
|
||||
const nodeIndex = React.useMemo(() => {
|
||||
const idx: Record<string, TaskNode> = { ...refs };
|
||||
for (const n of items) idx[n.id] = n;
|
||||
return idx;
|
||||
}, [refs, items]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / size));
|
||||
const start = total === 0 ? 0 : (page - 1) * size + 1;
|
||||
const end = (page - 1) * size + items.length;
|
||||
|
||||
// 换任务或筛选后条数变少时,把越界的页码收回来。
|
||||
React.useEffect(() => {
|
||||
if (page > pageCount) setPage(pageCount);
|
||||
}, [page, pageCount]);
|
||||
|
||||
// 按天分组:播报流按日期断行,长任务翻页时还能认出「这是哪天的事」。
|
||||
const groups: Array<{ day: string; rows: TaskNode[] }> = [];
|
||||
for (const node of items) {
|
||||
const ts = Date.parse(node.ts);
|
||||
const day = Number.isNaN(ts) ? t("unknownDay") : dayFmt.format(ts);
|
||||
const last = groups[groups.length - 1];
|
||||
if (last && last.day === day) last.rows.push(node);
|
||||
else groups.push({ day, rows: [node] });
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden py-0">
|
||||
{/* 工具条 */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-4 py-2.5">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<SearchIcon className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={queryInput}
|
||||
onChange={(e) => setQueryInput(e.target.value)}
|
||||
placeholder={t("toolbar.searchPlaceholder")}
|
||||
className="h-8 pl-8"
|
||||
aria-label={t("toolbar.searchAria")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{FILTER_KINDS.map((kind) => {
|
||||
const meta = KIND_META[kind];
|
||||
const active = kinds.includes(kind);
|
||||
return (
|
||||
<button
|
||||
key={kind}
|
||||
type="button"
|
||||
onClick={() => toggleKind(kind)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"rounded-md border px-2 py-0.5 text-xs font-medium transition-colors",
|
||||
active ? meta.chip : "border-transparent text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
>
|
||||
{t(`kind.${kind}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{kinds.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setKinds([]);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("toolbar.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => {
|
||||
setOrder((o) => (o === "desc" ? "asc" : "desc"));
|
||||
setPage(1);
|
||||
}}
|
||||
aria-label={order === "desc" ? t("toolbar.orderDescAria") : t("toolbar.orderAscAria")}
|
||||
>
|
||||
{order === "desc" ? <ArrowDownIcon /> : <ArrowUpIcon />}
|
||||
{order === "desc" ? t("toolbar.newest") : t("toolbar.oldest")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={live ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setLive((v) => !v)}
|
||||
aria-label={live ? t("toolbar.pauseAria") : t("toolbar.resumeAria")}
|
||||
>
|
||||
{live ? <PauseIcon /> : <PlayIcon />}
|
||||
{live ? t("toolbar.live") : t("toolbar.paused")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 离开直播位时的未读提示 */}
|
||||
{!atLive && pending > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={backToLive}
|
||||
className="flex w-full items-center justify-center gap-1.5 border-b bg-primary/10 py-1.5 text-xs font-medium text-primary hover:bg-primary/15"
|
||||
>
|
||||
<ArrowUpToLineIcon className="size-3.5" />
|
||||
{t("pending", { count: pending > 99 ? "99+" : pending })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<CardContent className="px-4 py-0">
|
||||
{!loaded ? (
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
{query || kinds.length > 0 ? t("emptyFiltered") : t("emptyNoNodes")}
|
||||
</p>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<div key={group.day}>
|
||||
<div className="py-2 pl-[6.25rem] text-xs font-medium text-muted-foreground">{group.day}</div>
|
||||
{group.rows.map((node) => (
|
||||
<BroadcastRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
edges={edges}
|
||||
refs={nodeIndex}
|
||||
assets={assets}
|
||||
now={now}
|
||||
fresh={freshIDs.has(node.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex flex-wrap items-center gap-2 border-t px-4 py-2.5 text-xs text-muted-foreground">
|
||||
<Select
|
||||
value={String(size)}
|
||||
onValueChange={(v) => {
|
||||
setSize(Number(v));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-7 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n}
|
||||
{tp("perPage")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="tabular-nums">{tp("range", { from: start, to: end, total })}</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
aria-label={tp("prev")}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="tabular-nums">
|
||||
{page} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page >= pageCount}
|
||||
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
|
||||
aria-label={tp("next")}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,733 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import type { Graph as G6Graph } from "@antv/g6";
|
||||
import { AppWindow, Building2, Globe, Link2, type LucideIcon, Radio, RefreshCw, Server, Waypoints } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。
|
||||
const FOLD_LIMIT = 20;
|
||||
const FOLD_STEP = 20;
|
||||
|
||||
type Kind = CoverageGraphNode["kind"];
|
||||
|
||||
// label 은 더 이상 여기 두지 않는다. 노드 유형 이름은 화면에 노출되므로
|
||||
// `taskDetail.coverageGraph.kind.*` 에서 번역해 가져온다(캔버스 라벨 포함).
|
||||
type KindMeta = { icon: LucideIcon; iconBg: string; hex: string; size: number };
|
||||
|
||||
const kindMeta: Record<Kind, KindMeta> = {
|
||||
company: { icon: Building2, iconBg: "bg-slate-500", hex: "#64748b", size: 46 },
|
||||
root_domain: { icon: Globe, iconBg: "bg-indigo-500", hex: "#6366f1", size: 38 },
|
||||
subdomain: { icon: Waypoints, iconBg: "bg-blue-500", hex: "#3b82f6", size: 30 },
|
||||
ip: { icon: Server, iconBg: "bg-cyan-600", hex: "#0891b2", size: 28 },
|
||||
service: { icon: Radio, iconBg: "bg-amber-500", hex: "#f59e0b", size: 26 },
|
||||
app: { icon: AppWindow, iconBg: "bg-fuchsia-500", hex: "#d946ef", size: 26 },
|
||||
endpoint: { icon: Link2, iconBg: "bg-rose-500", hex: "#f43f5e", size: 20 },
|
||||
};
|
||||
|
||||
// G6 节点图标用平台一致的 lucide 图标:把 lucide 的 SVG 路径(v1.22)渲染成白色描边的
|
||||
// data URI,作为节点 iconSrc(白色在实色/灰色底上都清晰)。手写内嵌,避免 react-dom/server
|
||||
// 在 React19/Next 客户端打包的问题。
|
||||
function svgUri(inner: string, filled = false): string {
|
||||
const attrs = filled
|
||||
? 'fill="#fff" stroke="none"'
|
||||
: 'fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" ${attrs}>${inner}</svg>`;
|
||||
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
const KIND_ICON: Record<Kind, string> = {
|
||||
company: svgUri(
|
||||
'<path d="M10 12h4"/><path d="M10 8h4"/><path d="M14 21v-3a2 2 0 0 0-4 0v3"/><path d="M6 10H4a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-2"/><path d="M6 21V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v16"/>',
|
||||
),
|
||||
root_domain: svgUri(
|
||||
'<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
),
|
||||
subdomain: svgUri(
|
||||
'<path d="m10.586 5.414-5.172 5.172"/><path d="m18.586 13.414-5.172 5.172"/><path d="M6 12h12"/><circle cx="12" cy="20" r="2"/><circle cx="12" cy="4" r="2"/><circle cx="20" cy="12" r="2"/><circle cx="4" cy="12" r="2"/>',
|
||||
),
|
||||
ip: svgUri(
|
||||
'<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/><rect width="20" height="8" x="2" y="14" rx="2" ry="2"/><line x1="6" x2="6.01" y1="6" y2="6"/><line x1="6" x2="6.01" y1="18" y2="18"/>',
|
||||
),
|
||||
service: svgUri(
|
||||
'<path d="M16.247 7.761a6 6 0 0 1 0 8.478"/><path d="M19.075 4.933a10 10 0 0 1 0 14.134"/><path d="M4.925 19.067a10 10 0 0 1 0-14.134"/><path d="M7.753 16.239a6 6 0 0 1 0-8.478"/><circle cx="12" cy="12" r="2"/>',
|
||||
),
|
||||
app: svgUri(
|
||||
'<rect x="2" y="4" width="20" height="16" rx="2"/><path d="M10 4v4"/><path d="M2 8h20"/><path d="M6 4v4"/>',
|
||||
),
|
||||
endpoint: svgUri(
|
||||
'<path d="M9 17H7A5 5 0 0 1 7 7h2"/><path d="M15 7h2a5 5 0 1 1 0 10h-2"/><line x1="8" x2="16" y1="12" y2="12"/>',
|
||||
),
|
||||
};
|
||||
|
||||
const FOLD_ICON = svgUri(
|
||||
'<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
|
||||
true,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Folding: full graph → currently-visible node/edge set (自顶向下级联折叠)。
|
||||
// ---------------------------------------------------------------------------
|
||||
type FoldNode = {
|
||||
fold: true;
|
||||
key: string;
|
||||
groupId: string;
|
||||
parentKey: string;
|
||||
kind: Kind;
|
||||
hidden: CoverageGraphNode[];
|
||||
};
|
||||
type AssetRenderNode = { fold: false; key: string; kind: Kind; node: CoverageGraphNode };
|
||||
type RenderNode = AssetRenderNode | FoldNode;
|
||||
|
||||
function sortChildren(a: CoverageGraphNode, b: CoverageGraphNode): number {
|
||||
if (a.tested !== b.tested) return a.tested ? -1 : 1;
|
||||
return (a.label || "").localeCompare(b.label || "");
|
||||
}
|
||||
|
||||
function computeVisible(
|
||||
nodes: CoverageGraphNode[],
|
||||
edges: CoverageGraphEdge[],
|
||||
expanded: Map<string, number>,
|
||||
): { renderNodes: RenderNode[]; renderEdges: CoverageGraphEdge[] } {
|
||||
const byKey = new Map(nodes.map((n) => [n.key, n]));
|
||||
const parentOf = new Map<string, string>();
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const e of edges) {
|
||||
if (!byKey.has(e.src) || !byKey.has(e.dst)) continue;
|
||||
if (!parentOf.has(e.src)) parentOf.set(e.src, e.dst);
|
||||
const arr = childrenOf.get(e.dst);
|
||||
if (arr) arr.push(e.src);
|
||||
else childrenOf.set(e.dst, [e.src]);
|
||||
}
|
||||
|
||||
const renderNodes: RenderNode[] = [];
|
||||
const visible = new Set<string>();
|
||||
const foldNodes: FoldNode[] = [];
|
||||
|
||||
const queue: string[] = [];
|
||||
for (const n of nodes) {
|
||||
if (!parentOf.has(n.key)) {
|
||||
queue.push(n.key);
|
||||
visible.add(n.key);
|
||||
}
|
||||
}
|
||||
|
||||
for (let qi = 0; qi < queue.length; qi++) {
|
||||
const parent = queue[qi];
|
||||
const kids = childrenOf.get(parent) ?? [];
|
||||
if (kids.length === 0) continue;
|
||||
const groups = new Map<Kind, CoverageGraphNode[]>();
|
||||
for (const ck of kids) {
|
||||
const cn = byKey.get(ck);
|
||||
if (!cn) continue;
|
||||
const g = groups.get(cn.kind);
|
||||
if (g) g.push(cn);
|
||||
else groups.set(cn.kind, [cn]);
|
||||
}
|
||||
for (const [kind, arr] of groups) {
|
||||
arr.sort(sortChildren);
|
||||
const groupId = `${parent}::${kind}`;
|
||||
const shown = expanded.get(groupId) ?? FOLD_LIMIT;
|
||||
const visibleChildren = arr.slice(0, shown);
|
||||
const hidden = arr.slice(shown);
|
||||
for (const c of visibleChildren) {
|
||||
if (!visible.has(c.key)) {
|
||||
visible.add(c.key);
|
||||
queue.push(c.key);
|
||||
}
|
||||
}
|
||||
if (hidden.length > 0) {
|
||||
foldNodes.push({ fold: true, key: `fold:${groupId}`, groupId, parentKey: parent, kind, hidden });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of nodes) {
|
||||
if (visible.has(n.key)) renderNodes.push({ fold: false, key: n.key, kind: n.kind, node: n });
|
||||
}
|
||||
const renderEdges: CoverageGraphEdge[] = edges.filter((e) => visible.has(e.src) && visible.has(e.dst));
|
||||
for (const f of foldNodes) {
|
||||
renderNodes.push(f);
|
||||
renderEdges.push({ src: f.key, dst: f.parentKey });
|
||||
}
|
||||
return { renderNodes, renderEdges };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// G6 数据映射。自定义字段放节点顶层(G6 v5 官方 force 示例约定:style/layout 回调直接读 d.<field>)。
|
||||
// ---------------------------------------------------------------------------
|
||||
type G6NodeDatum = {
|
||||
id: string;
|
||||
kind: Kind;
|
||||
fold: boolean;
|
||||
tested: boolean;
|
||||
inScope: boolean;
|
||||
lbl: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
function trunc(s: string, n = 26): string {
|
||||
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
||||
}
|
||||
|
||||
// 图里的节点文案:服务不显示完整 URL,只显示 端口·标题·状态码;端点只显示 path。
|
||||
// 其余类型沿用后端给的 label。
|
||||
function graphLabel(n: CoverageGraphNode): string {
|
||||
if (n.kind === "service") {
|
||||
const parts: string[] = [];
|
||||
if (n.port) parts.push(`:${n.port}`);
|
||||
if (n.page_title) parts.push(n.page_title);
|
||||
if (n.status_code) parts.push(String(n.status_code));
|
||||
// biome-ignore lint/nursery/useNullishCoalescing: 표시용 폴백 — 빈 문자열도 다음 후보(ip·label)로 넘겨야 하므로 || 가 의도된 동작이다(?? 는 빈 문자열을 그대로 노출한다)
|
||||
return parts.length ? parts.join(" · ") : n.domain || n.ip || n.label;
|
||||
}
|
||||
if (n.kind === "endpoint" && n.url) {
|
||||
try {
|
||||
return new URL(n.url).pathname || "/";
|
||||
} catch {
|
||||
/* 非法 URL:回退到完整 label */
|
||||
}
|
||||
}
|
||||
return n.label;
|
||||
}
|
||||
|
||||
function toG6Nodes(
|
||||
renderNodes: RenderNode[],
|
||||
kindLabel: (k: Kind) => string,
|
||||
moreLabel: (count: number, k: Kind) => string,
|
||||
): G6NodeDatum[] {
|
||||
return renderNodes.map((rn) => {
|
||||
if (rn.fold) {
|
||||
return {
|
||||
id: rn.key,
|
||||
kind: rn.kind,
|
||||
fold: true,
|
||||
tested: false,
|
||||
inScope: false,
|
||||
lbl: moreLabel(rn.hidden.length, rn.kind),
|
||||
size: 24,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: rn.key,
|
||||
kind: rn.kind,
|
||||
fold: false,
|
||||
tested: rn.node.tested,
|
||||
inScope: rn.node.in_scope,
|
||||
lbl: trunc(graphLabel(rn.node) || kindLabel(rn.kind)),
|
||||
size: kindMeta[rn.kind].size,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// G6 的类型把自定义字段归在 data 下,但官方 force 示例(及运行时)按顶层读 d.<field>。
|
||||
// 回调形参用 unknown 满足 G6 签名,内部用 nd() 强转回我们的顶层结构。
|
||||
const nd = (d: unknown) => d as G6NodeDatum;
|
||||
|
||||
// 已测=实色高亮;范围内未测=灰;范围外/折叠=更淡的灰 + 虚线描边。
|
||||
function nodeFill(d: G6NodeDatum): string {
|
||||
if (d.fold) return "#f1f5f9";
|
||||
if (!d.inScope) return "#e2e8f0";
|
||||
if (d.tested) return kindMeta[d.kind].hex;
|
||||
return "#94a3b8";
|
||||
}
|
||||
function nodeStroke(d: G6NodeDatum): string {
|
||||
if (d.fold || !d.inScope) return "#94a3b8";
|
||||
if (d.tested) return "#0f766e";
|
||||
return "#64748b";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 抽屉:资产节点看详情;折叠节点看隐藏列表 + "展示更多"。
|
||||
// ---------------------------------------------------------------------------
|
||||
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
if (children === undefined || children === null || children === "") return null;
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-1 text-sm">
|
||||
<span className="text-muted-foreground w-16 shrink-0">{label}</span>
|
||||
<span className="text-foreground min-w-0 flex-1 break-words">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefList({ title, items }: { title: string; items: CoverageAssetRef[] }) {
|
||||
const t = useTranslations("taskDetail.coverageGraph");
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
{title} ({items.length})
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.map((r) => (
|
||||
<div
|
||||
key={`${r.kind}-${r.id}`}
|
||||
className="flex flex-wrap items-start gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<span className="shrink-0 font-mono text-muted-foreground">#{r.id}</span>
|
||||
{r.state && <span className="shrink-0 text-muted-foreground">{r.state}</span>}
|
||||
<span className="min-w-32 flex-1 break-words text-foreground">{r.summary || "—"}</span>
|
||||
{r.inherited && r.source_task_id && (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t("sheet.refSource", { id: r.source_task_id })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetSheet({
|
||||
node,
|
||||
taskId,
|
||||
onOpenChange,
|
||||
}: {
|
||||
node: CoverageGraphNode | null;
|
||||
taskId: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations("taskDetail.coverageGraph");
|
||||
const meta = node ? kindMeta[node.kind] : null;
|
||||
const Icon = meta?.icon;
|
||||
const kindLabel = node ? t(`kind.${node.kind}`) : "";
|
||||
const raw = node ? JSON.stringify(node, null, 2) : "";
|
||||
const [refs, setRefs] = React.useState<CoverageAssetRefs | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setRefs(null);
|
||||
if (!node?.asset_id) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.taskAssetRefs(taskId, node.asset_id)
|
||||
.then((r) => {
|
||||
if (!cancelled) setRefs(r);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 无关联或出错:不展示该区块 */
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [node, taskId]);
|
||||
return (
|
||||
<Sheet open={node !== null} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-md">
|
||||
{node && meta && Icon && (
|
||||
<>
|
||||
<SheetHeader className="border-b p-4">
|
||||
<div className="flex items-center gap-2.5 pr-8">
|
||||
<span
|
||||
className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg shadow-sm", meta.iconBg)}
|
||||
>
|
||||
<Icon className="size-4 text-white" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<SheetTitle className="leading-tight">{kindLabel}</SheetTitle>
|
||||
<span className="text-muted-foreground truncate font-mono text-xs" title={node.label}>
|
||||
{node.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex w-full min-w-0 flex-col gap-4 p-4">
|
||||
<section>
|
||||
<h4 className="text-muted-foreground mb-1 text-xs font-medium">{t("sheet.attributes")}</h4>
|
||||
<DetailRow label={t("sheet.attrType")}>{kindLabel}</DetailRow>
|
||||
<DetailRow label={t("sheet.attrTestState")}>
|
||||
{node.in_scope ? (
|
||||
node.tested ? (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{t("sheet.tested")}</span>
|
||||
) : (
|
||||
<span className="text-neutral-500">{t("sheet.untested")}</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-neutral-400">{t("sheet.outOfScope")}</span>
|
||||
)}
|
||||
</DetailRow>
|
||||
<DetailRow label={t("sheet.attrDomain")}>{node.domain}</DetailRow>
|
||||
<DetailRow label={t("sheet.attrRootDomain")}>{node.root_domain}</DetailRow>
|
||||
<DetailRow label="IP">{node.ip}</DetailRow>
|
||||
<DetailRow label={t("sheet.attrPort")}>{node.port ? node.port : undefined}</DetailRow>
|
||||
<DetailRow label="URL">
|
||||
{node.url ? <span className="font-mono text-xs break-all">{node.url}</span> : undefined}
|
||||
</DetailRow>
|
||||
<DetailRow label={t("sheet.attrTitle")}>{node.page_title}</DetailRow>
|
||||
<DetailRow label={t("sheet.attrStatusCode")}>
|
||||
{node.status_code ? node.status_code : undefined}
|
||||
</DetailRow>
|
||||
<DetailRow label={t("sheet.attrApp")}>{node.app_name}</DetailRow>
|
||||
<DetailRow label={t("sheet.attrAssetId")}>
|
||||
{node.asset_id ? <span className="font-mono text-xs">{node.asset_id}</span> : undefined}
|
||||
</DetailRow>
|
||||
</section>
|
||||
{refs && (refs.intents.length > 0 || refs.facts.length > 0 || refs.findings.length > 0) && (
|
||||
<section className="flex flex-col gap-3 border-t pt-3">
|
||||
<RefList title={t("sheet.refIntents")} items={refs.intents} />
|
||||
<RefList title={t("sheet.refFacts")} items={refs.facts} />
|
||||
<RefList title={t("sheet.refFindings")} items={refs.findings} />
|
||||
</section>
|
||||
)}
|
||||
<section className="border-t pt-3">
|
||||
<h4 className="text-muted-foreground mb-1.5 text-xs font-medium">{t("sheet.rawData")}</h4>
|
||||
<pre className="bg-muted/50 text-foreground max-w-full overflow-hidden rounded-md border p-3 font-mono text-xs leading-relaxed break-all whitespace-pre-wrap">
|
||||
{raw}
|
||||
</pre>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function FoldSheet({
|
||||
fold,
|
||||
onOpenChange,
|
||||
onShowMore,
|
||||
onPick,
|
||||
}: {
|
||||
fold: FoldNode | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onShowMore: (groupId: string) => void;
|
||||
onPick: (n: CoverageGraphNode) => void;
|
||||
}) {
|
||||
const t = useTranslations("taskDetail.coverageGraph");
|
||||
const meta = fold ? kindMeta[fold.kind] : null;
|
||||
const Icon = meta?.icon;
|
||||
const kindLabel = fold ? t(`kind.${fold.kind}`) : "";
|
||||
return (
|
||||
<Sheet open={fold !== null} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 data-[side=right]:sm:max-w-md">
|
||||
{fold && meta && Icon && (
|
||||
<>
|
||||
<SheetHeader className="border-b p-4">
|
||||
<SheetTitle className="text-base">
|
||||
{t("sheet.foldTitle", { kind: kindLabel, count: fold.hidden.length })}
|
||||
</SheetTitle>
|
||||
<p className="text-muted-foreground text-xs">{t("sheet.foldHint")}</p>
|
||||
</SheetHeader>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="flex flex-col gap-1 p-3">
|
||||
{fold.hidden.map((n) => (
|
||||
<button
|
||||
key={n.key}
|
||||
type="button"
|
||||
onClick={() => onPick(n)}
|
||||
className="hover:bg-accent flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-5 shrink-0 items-center justify-center rounded",
|
||||
n.tested ? meta.iconBg : "bg-neutral-400 dark:bg-neutral-600",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3 text-white" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={n.label}>
|
||||
{n.label}
|
||||
</span>
|
||||
{n.tested && <span className="size-1.5 shrink-0 rounded-full bg-emerald-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="border-t p-3">
|
||||
<Button className="w-full" variant="outline" onClick={() => onShowMore(fold.groupId)}>
|
||||
{t("sheet.showMore", { step: FOLD_STEP })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; coverageEnabled?: boolean }) {
|
||||
const [data, setData] = React.useState<{
|
||||
nodes: CoverageGraphNode[];
|
||||
edges: CoverageGraphEdge[];
|
||||
} | null>(null);
|
||||
const [expanded, setExpanded] = React.useState<Map<string, number>>(new Map());
|
||||
const [selectedAsset, setSelectedAsset] = React.useState<CoverageGraphNode | null>(null);
|
||||
const [selectedFoldId, setSelectedFoldId] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
const t = useTranslations("taskDetail.coverageGraph");
|
||||
const kindLabel = React.useCallback((k: Kind) => t(`kind.${k}`), [t]);
|
||||
const moreLabel = React.useCallback(
|
||||
(count: number, k: Kind) => t("graph.moreCount", { kind: t(`kind.${k}`), count }),
|
||||
[t],
|
||||
);
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const graphRef = React.useRef<G6Graph | null>(null);
|
||||
// click 处理需要最新的 key→RenderNode 映射(G6 事件回调闭包外读 ref)。
|
||||
const renderMapRef = React.useRef<Map<string, RenderNode>>(new Map());
|
||||
const gDataRef = React.useRef<{ nodes: G6NodeDatum[]; edges: { source: string; target: string }[] }>({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
const fetchGraph = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.taskCoverageGraph(taskId)
|
||||
.then((g) => {
|
||||
// 资产覆盖度功能关闭(B1):仍出图(范围内资产/company 关联依旧可见),但抹平
|
||||
// tested 状态——不显示测试进度、不做已测高亮。
|
||||
const nodes = coverageEnabled ? (g.nodes ?? []) : (g.nodes ?? []).map((n) => ({ ...n, tested: false }));
|
||||
setData({ nodes, edges: g.edges ?? [] });
|
||||
})
|
||||
.catch(() => {
|
||||
/* 保留上一次数据 */
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [taskId, coverageEnabled]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchGraph();
|
||||
}, [fetchGraph]);
|
||||
|
||||
const { renderNodes, renderEdges } = React.useMemo(
|
||||
() =>
|
||||
data
|
||||
? computeVisible(data.nodes, data.edges, expanded)
|
||||
: { renderNodes: [] as RenderNode[], renderEdges: [] as CoverageGraphEdge[] },
|
||||
[data, expanded],
|
||||
);
|
||||
|
||||
// 结构签名:只在可见节点/边集合变化时重建图 + 重跑布局,避免无谓抖动。
|
||||
const sig = React.useMemo(
|
||||
() =>
|
||||
`${renderNodes
|
||||
.map((n) => `${n.key}:${n.fold ? "f" : n.node.tested ? "t" : "u"}`)
|
||||
.sort()
|
||||
.join(",")}|${renderEdges.length}`,
|
||||
[renderNodes, renderEdges],
|
||||
);
|
||||
|
||||
// 维护 gDataRef + renderMapRef(供事件与图数据应用读取)。
|
||||
gDataRef.current = {
|
||||
nodes: toG6Nodes(renderNodes, kindLabel, moreLabel),
|
||||
edges: renderEdges.map((e) => ({ source: e.src, target: e.dst })),
|
||||
};
|
||||
const rmap = new Map<string, RenderNode>();
|
||||
for (const rn of renderNodes) rmap.set(rn.key, rn);
|
||||
renderMapRef.current = rmap;
|
||||
|
||||
const applyData = React.useCallback(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || graph.destroyed) return;
|
||||
graph.setData(gDataRef.current);
|
||||
// render() 异步跑 d3-force 布局;若组件在布局落地前被卸载/销毁,g6 会在已清空的
|
||||
// context 上访问 transform 抛错(见 runtime/layout transformDataAfterLayout)。这是纯
|
||||
// teardown 竞态,吞掉它,不影响功能;真正的渲染错误(图未销毁)仍打日志。
|
||||
void graph.render().catch((err) => {
|
||||
if (!graph.destroyed) console.error("[coverage-graph] render:", err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 建图(一次)。动态 import 避开 SSR/静态导出期的 window 依赖。
|
||||
React.useEffect(() => {
|
||||
let destroyed = false;
|
||||
let graph: G6Graph | null = null;
|
||||
void (async () => {
|
||||
const { Graph } = await import("@antv/g6");
|
||||
if (destroyed || !containerRef.current) return;
|
||||
graph = new Graph({
|
||||
container: containerRef.current,
|
||||
autoResize: true,
|
||||
autoFit: "view",
|
||||
background: "#f0f2f7",
|
||||
node: {
|
||||
style: {
|
||||
size: (d: unknown) => nd(d).size,
|
||||
fill: (d: unknown) => nodeFill(nd(d)),
|
||||
stroke: (d: unknown) => nodeStroke(nd(d)),
|
||||
lineWidth: (d: unknown) => (nd(d).fold || !nd(d).inScope ? 1 : 1.5),
|
||||
lineDash: (d: unknown) => (nd(d).fold || !nd(d).inScope ? [3, 3] : [0]),
|
||||
iconSrc: (d: unknown) => (nd(d).fold ? FOLD_ICON : KIND_ICON[nd(d).kind]),
|
||||
iconWidth: (d: unknown) => Math.max(12, nd(d).size * 0.55),
|
||||
iconHeight: (d: unknown) => Math.max(12, nd(d).size * 0.55),
|
||||
labelText: (d: unknown) => nd(d).lbl,
|
||||
labelFontSize: 10,
|
||||
labelPlacement: "bottom",
|
||||
labelFill: "#475569",
|
||||
labelBackground: true,
|
||||
labelBackgroundFill: "rgba(255,255,255,0.75)",
|
||||
labelBackgroundRadius: 3,
|
||||
labelPadding: [1, 3],
|
||||
},
|
||||
},
|
||||
edge: {
|
||||
style: { stroke: "#cbd5e1", lineWidth: 1, endArrow: false },
|
||||
},
|
||||
layout: {
|
||||
type: "d3-force",
|
||||
collide: { radius: (d: unknown) => (nd(d).size ? nd(d).size : 20) + 8 },
|
||||
link: {
|
||||
distance: (edge: unknown) => {
|
||||
// 顶层(公司/根域名)离子节点远一点,叶子近一点。edge.source 可能是 id 或已解析节点。
|
||||
const s = (edge as { source: string | { id?: string } }).source;
|
||||
const srcId = typeof s === "string" ? s : (s?.id ?? "");
|
||||
const src = renderMapRef.current.get(srcId);
|
||||
const k = src && !src.fold ? src.kind : "endpoint";
|
||||
return k === "company" || k === "root_domain" ? 120 : 60;
|
||||
},
|
||||
},
|
||||
manyBody: {
|
||||
strength: (d: unknown) => (nd(d).kind === "endpoint" || nd(d).fold ? -60 : -200),
|
||||
},
|
||||
},
|
||||
behaviors: ["drag-element-force", "drag-canvas", "zoom-canvas"],
|
||||
});
|
||||
graph.on("node:click", (evt: unknown) => {
|
||||
const id = (evt as { target?: { id?: string } }).target?.id;
|
||||
if (!id) return;
|
||||
const rn = renderMapRef.current.get(id);
|
||||
if (!rn) return;
|
||||
if (rn.fold) setSelectedFoldId(rn.key);
|
||||
else setSelectedAsset(rn.node);
|
||||
});
|
||||
graphRef.current = graph;
|
||||
applyData();
|
||||
})();
|
||||
return () => {
|
||||
destroyed = true;
|
||||
// 先停布局再销毁:尽量缩短"布局在飞、context 被清空"的竞态窗口。
|
||||
try {
|
||||
graph?.stopLayout();
|
||||
} catch {
|
||||
/* 图可能尚未建成或已无布局上下文 */
|
||||
}
|
||||
graph?.destroy();
|
||||
graphRef.current = null;
|
||||
};
|
||||
}, [applyData]);
|
||||
|
||||
// 可见集合变化 → 重新灌数据 + 布局。sig 只作为重排触发器(applyData 读 gDataRef)。
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: sig 是刻意的重排触发依赖
|
||||
React.useEffect(() => {
|
||||
applyData();
|
||||
}, [sig, applyData]);
|
||||
|
||||
const showMore = React.useCallback((groupId: string) => {
|
||||
setExpanded((prev) => {
|
||||
const m = new Map(prev);
|
||||
m.set(groupId, (m.get(groupId) ?? FOLD_LIMIT) + FOLD_STEP);
|
||||
return m;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectedFold =
|
||||
selectedFoldId != null
|
||||
? ((renderNodes.find((n) => n.fold && n.key === selectedFoldId) as FoldNode | undefined) ?? null)
|
||||
: null;
|
||||
React.useEffect(() => {
|
||||
if (selectedFoldId != null && !selectedFold) setSelectedFoldId(null);
|
||||
}, [selectedFoldId, selectedFold]);
|
||||
|
||||
const total = data?.nodes.length ?? 0;
|
||||
const tested = data?.nodes.filter((n) => n.in_scope && n.tested).length ?? 0;
|
||||
const inScope = data?.nodes.filter((n) => n.in_scope).length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
|
||||
{/* 图例 + 统计 + 刷新(叠加层) */}
|
||||
<div className="bg-card/95 pointer-events-auto absolute top-3 left-3 flex max-w-[340px] flex-col gap-2.5 rounded-lg border p-3 text-xs shadow-sm backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{total > 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{t("graph.scopeIn")} <span className="text-foreground font-semibold tabular-nums">{inScope}</span>
|
||||
{coverageEnabled && (
|
||||
<>
|
||||
{" "}
|
||||
· {t("graph.tested")}{" "}
|
||||
<span className="font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">{tested}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{loading ? t("graph.loading") : t("graph.empty")}</span>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="size-6" onClick={fetchGraph} title={t("graph.refresh")}>
|
||||
<RefreshCw className={cn("size-3.5", loading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1.5">
|
||||
{(Object.keys(kindMeta) as Kind[]).map((k) => {
|
||||
const m = kindMeta[k];
|
||||
const Icon = m.icon;
|
||||
return (
|
||||
<span key={k} className="text-foreground inline-flex items-center gap-1.5">
|
||||
<span className={cn("flex size-4 items-center justify-center rounded", m.iconBg)}>
|
||||
<Icon className="size-2.5 text-white" />
|
||||
</span>
|
||||
{t(`kind.${k}`)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
|
||||
{coverageEnabled && (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-3 rounded-full bg-emerald-500" /> {t("graph.legendTested")}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-3 rounded-full bg-neutral-400" /> {t("graph.legendUntested")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="size-3 rounded-full border border-dashed border-neutral-400 bg-neutral-200" />{" "}
|
||||
{t("graph.legendOutOfScope")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground/80 border-border/60 border-t pt-2 leading-relaxed">{t("graph.hint")}</p>
|
||||
</div>
|
||||
|
||||
<AssetSheet node={selectedAsset} taskId={taskId} onOpenChange={(o) => !o && setSelectedAsset(null)} />
|
||||
<FoldSheet
|
||||
fold={selectedFold}
|
||||
onOpenChange={(o) => !o && setSelectedFoldId(null)}
|
||||
onShowMore={showMore}
|
||||
onPick={(n) => {
|
||||
setSelectedFoldId(null);
|
||||
setSelectedAsset(n);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoverageGraphTab({ taskId, coverageEnabled = true }: { taskId: string; coverageEnabled?: boolean }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="h-[72vh] w-full overflow-hidden rounded-xl">
|
||||
<GraphInner taskId={taskId} coverageEnabled={coverageEnabled} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { api } from "@/lib/api";
|
||||
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
import type { Finding, FindingStatus } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FindingSortField = "time";
|
||||
|
||||
const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time"];
|
||||
const FINDING_SORT_PREFERENCE_KEY = "artex_task_findings_sort";
|
||||
|
||||
function findingLabel(finding: Finding, unclassified: string): string {
|
||||
if (finding.name?.trim()) return finding.name;
|
||||
if (finding.vulnclass?.trim()) return finding.vulnclass;
|
||||
return unclassified;
|
||||
}
|
||||
|
||||
const FINDING_STATUSES: FindingStatus[] = [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"fixed",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
"risk_accepted",
|
||||
];
|
||||
|
||||
function Row({
|
||||
f,
|
||||
contextTaskId,
|
||||
onStatus,
|
||||
}: {
|
||||
f: Finding;
|
||||
contextTaskId: string;
|
||||
onStatus: (f: Finding, next: FindingStatus) => void;
|
||||
}) {
|
||||
const t = useTranslations("taskDetail.findings");
|
||||
const tStatus = useTranslations("status");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<div className="border-b last:border-b-0">
|
||||
<div className="flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
<StatusBadge domain="severity" value={f.severity} dot />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{findingLabel(f, t("unclassified"))}</span>
|
||||
{f.inherited && f.source_task_id && (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t("sourceReadonly", { id: f.source_task_id })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
|
||||
</div>
|
||||
</button>
|
||||
<Badge variant="outline">{t("trafficEvidence", { count: f.traffic_count ?? 0 })}</Badge>
|
||||
{f.assets && f.assets.length > 0 && (
|
||||
<div className="hidden shrink-0 flex-wrap justify-end gap-1 sm:flex">
|
||||
{f.assets.slice(0, 2).map((a) => (
|
||||
<code
|
||||
key={a.id}
|
||||
className="max-w-[10rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||
title={`${a.type} · ${a.label}`}
|
||||
>
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
{f.assets.length > 2 && <span className="text-xs text-muted-foreground">+{f.assets.length - 2}</span>}
|
||||
</div>
|
||||
)}
|
||||
{f.finding_id && !f.inherited ? (
|
||||
<Select value={f.status} onValueChange={(v) => onStatus(f, v as FindingStatus)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-28 shrink-0 border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="finding" value={f.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
<SelectGroup>
|
||||
{FINDING_STATUSES.map((st) => (
|
||||
<SelectItem key={st} value={st}>
|
||||
{tStatus(`finding.${st}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<StatusBadge domain="finding" value={f.status} dot />
|
||||
)}
|
||||
<span className="hidden shrink-0 text-xs text-muted-foreground md:block">
|
||||
{new Date(f.ts).toLocaleString("ko-KR")}
|
||||
</span>
|
||||
{f.finding_id && (
|
||||
<Link
|
||||
href={
|
||||
f.inherited
|
||||
? `/function/findings/detail?id=${f.finding_id}&context_task=${contextTaskId}`
|
||||
: `/function/findings/detail?id=${f.finding_id}`
|
||||
}
|
||||
className="text-muted-foreground hover:text-primary inline-flex shrink-0 items-center gap-0.5 text-xs"
|
||||
title={t("viewDetail")}
|
||||
>
|
||||
{t("detail")}
|
||||
<ArrowUpRightIcon className="size-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
<div className="bg-muted/30 px-4 pb-4 pl-11">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">{t("evidencePoc")}</div>
|
||||
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{f.evidence}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
const t = useTranslations("taskDetail.findings");
|
||||
const tStatus = useTranslations("status");
|
||||
const [findings, setFindings] = React.useState<Finding[]>([]);
|
||||
const [sortPreference, setSortPreference] = useStoredSortPreference(
|
||||
FINDING_SORT_PREFERENCE_KEY,
|
||||
FINDING_SORT_FIELDS,
|
||||
"time",
|
||||
"desc",
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
const load = () => {
|
||||
api
|
||||
.findings(taskId)
|
||||
.then((fs) => {
|
||||
if (active) setFindings(fs);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the last successful snapshot during transient refresh failures.
|
||||
});
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, 3000);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const onStatus = React.useCallback(
|
||||
async (f: Finding, next: FindingStatus) => {
|
||||
if (f.inherited || !f.finding_id || next === f.status) return;
|
||||
const prev = f.status;
|
||||
setFindings((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)));
|
||||
try {
|
||||
await api.setFindingStatus(f.finding_id, next);
|
||||
toast.success(t("markedAs", { label: tStatus(`finding.${next}`) }));
|
||||
} catch (e) {
|
||||
setFindings((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: prev } : x)));
|
||||
toast.error(t("updateFailed", { message: (e as Error).message }));
|
||||
}
|
||||
},
|
||||
[t, tStatus],
|
||||
);
|
||||
|
||||
const items = findings
|
||||
.filter((f) => f.task_id === taskId || f.inherited)
|
||||
.sort((a, b) => {
|
||||
const compared = Date.parse(a.ts) - Date.parse(b.ts);
|
||||
if (compared !== 0) return sortPreference.direction === "asc" ? compared : -compared;
|
||||
return b.id.localeCompare(a.id, undefined, { numeric: true });
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden py-0">
|
||||
<CardContent className="px-0">
|
||||
<div className="flex items-center border-b px-4 py-2 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 flex-1">{t("columnFinding")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 outline-none focus-visible:underline"
|
||||
aria-label={t("sortAria", {
|
||||
order: sortPreference.direction === "asc" ? t("orderAsc") : t("orderDesc"),
|
||||
})}
|
||||
onClick={() =>
|
||||
setSortPreference((current) => ({
|
||||
field: "time",
|
||||
direction: current.direction === "asc" ? "desc" : "asc",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span>{t("sortTime")}</span>
|
||||
{sortPreference.direction === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{items.map((f) => (
|
||||
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
||||
))}
|
||||
{items.length === 0 && <p className="px-4 py-8 text-center text-sm text-muted-foreground">{t("empty")}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { ExplorationGraph } from "@/components/exploration-graph";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Edge, TaskNode } from "@/lib/types";
|
||||
|
||||
export function GraphTab({ taskId }: { taskId: string }) {
|
||||
const [nodes, setNodes] = React.useState<TaskNode[]>([]);
|
||||
const [edges, setEdges] = React.useState<Edge[]>([]);
|
||||
// 上一次图数据的签名:轮询拿到相同数据时跳过 setState,避免整图无谓重建(拖动时
|
||||
// 才不会被 20s 轮询打断而顿挫)。只取影响渲染的字段。
|
||||
const sigRef = React.useRef("");
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
sigRef.current = ""; // 换任务:强制下一次刷新
|
||||
const load = () => {
|
||||
api
|
||||
.explorationGraph(taskId)
|
||||
.then((g) => {
|
||||
if (cancelled) return;
|
||||
const ns = g.nodes ?? [];
|
||||
const es = g.edges ?? [];
|
||||
const sig = JSON.stringify([
|
||||
ns.map((n) => [n.id, n.type, n.state, n.priority, n.payload]),
|
||||
es.map((e) => [e.src, e.dst, e.rel]),
|
||||
]);
|
||||
if (sig === sigRef.current) return; // 无变化 → 不重建
|
||||
sigRef.current = sig;
|
||||
setNodes(ns);
|
||||
setEdges(es);
|
||||
})
|
||||
.catch(() => {
|
||||
/* keep last good data */
|
||||
});
|
||||
};
|
||||
load();
|
||||
const timer = setInterval(load, 20000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<ExplorationGraph nodes={nodes} edges={edges} className="h-[72vh]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ApprovalRecords } from "@/components/approval-records";
|
||||
|
||||
export function InterceptTab({ taskId }: { taskId: string }) {
|
||||
return <ApprovalRecords key={taskId} taskId={taskId} />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { CheckIcon, CopyIcon, FileTextIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api } from "@/lib/api";
|
||||
import { copyText } from "@/lib/utils";
|
||||
|
||||
export function ReportTab({ taskId }: { taskId: string }) {
|
||||
const t = useTranslations("taskDetail.report");
|
||||
const [report, setReport] = React.useState<string>("");
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
api
|
||||
.report(taskId)
|
||||
.then((text) => {
|
||||
if (active) setReport(text);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setReport("");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
async function copy() {
|
||||
if (!report) return;
|
||||
const ok = await copyText(report);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
toast.success(t("copySuccess"));
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error(t("copyError"));
|
||||
}
|
||||
}
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (loading) {
|
||||
content = (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-muted-foreground text-sm">
|
||||
<FileTextIcon className="size-8 opacity-40" />
|
||||
{t("loading")}
|
||||
</div>
|
||||
);
|
||||
} else if (report) {
|
||||
content = (
|
||||
<div className="max-h-[60vh] overflow-auto rounded-md border bg-muted/20 p-4">
|
||||
<Markdown text={report} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-muted-foreground text-sm">
|
||||
<FileTextIcon className="size-8 opacity-40" />
|
||||
{t("empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
<FileTextIcon className="size-4" /> {t("title")}
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
{report && (
|
||||
<Button size="sm" variant="outline" onClick={copy}>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />} {t("copy")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>{content}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowUpRightIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { FindingRetestPanel } from "@/components/finding-retest-panel";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding, FindingsPage } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function findingLabel(finding: Finding, unclassified: string) {
|
||||
return finding.name?.trim() || finding.vulnclass.trim() || unclassified;
|
||||
}
|
||||
|
||||
export function RetestsTab({ taskId }: { taskId: string }) {
|
||||
const t = useTranslations("taskDetail");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [data, setData] = React.useState<FindingsPage | null>(null);
|
||||
const [selectedId, setSelectedId] = React.useState("");
|
||||
const [error, setError] = React.useState("");
|
||||
const [refreshVersion, setRefreshVersion] = React.useState(0);
|
||||
const refresh = React.useCallback(() => setRefreshVersion((version) => version + 1), []);
|
||||
|
||||
// Only this task's own findings, including those without graph nodes.
|
||||
// Fetch history only for the selection, and wait for each poll to finish.
|
||||
React.useEffect(() => {
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function load() {
|
||||
try {
|
||||
const result = await api.findingsPage({ task: taskId, page, pageSize: PAGE_SIZE, sort: "time" });
|
||||
if (disposed) return;
|
||||
const lastPage = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
|
||||
if (page > lastPage) {
|
||||
setPage(lastPage);
|
||||
return;
|
||||
}
|
||||
setData(result);
|
||||
setSelectedId((current) =>
|
||||
result.items.some((finding) => finding.id === current) ? current : (result.items[0]?.id ?? ""),
|
||||
);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
if (!disposed) setError((e as Error).message);
|
||||
} finally {
|
||||
if (!disposed) timer = setTimeout(() => void load(), 3000);
|
||||
}
|
||||
}
|
||||
void refreshVersion; // Refresh triage immediately after a retest completes.
|
||||
void load();
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [taskId, page, refreshVersion]);
|
||||
|
||||
const loaded = data?.page === page;
|
||||
const findings = loaded ? data.items : [];
|
||||
const selected = findings.find((finding) => finding.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("retests.loadError", { error })}
|
||||
<Button variant="outline" size="sm" onClick={refresh}>
|
||||
{t("retests.retry")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[minmax(16rem,22rem)_minmax(0,1fr)]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("retests.selectTitle")}
|
||||
{data ? ` · ${data.total}` : ""}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("retests.selectDesc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex max-h-[32rem] flex-col overflow-y-auto">
|
||||
{!loaded && !error ? <Skeleton className="h-24 w-full" /> : null}
|
||||
{findings.map((finding, index) => (
|
||||
<React.Fragment key={finding.id}>
|
||||
{index > 0 ? <Separator className="my-1" /> : null}
|
||||
<Button
|
||||
variant={finding.id === selectedId ? "secondary" : "ghost"}
|
||||
className="h-auto w-full shrink-0 flex-col items-start gap-2 whitespace-normal py-3 text-left"
|
||||
aria-label={t("retests.selectAria", { name: findingLabel(finding, t("findings.unclassified")) })}
|
||||
aria-pressed={finding.id === selectedId}
|
||||
onClick={() => setSelectedId(finding.id)}
|
||||
>
|
||||
<span className="line-clamp-2 break-words">{findingLabel(finding, t("findings.unclassified"))}</span>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
</span>
|
||||
</Button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{loaded && findings.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{t("retests.emptyTitle")}</EmptyTitle>
|
||||
<EmptyDescription>{t("retests.emptyDesc")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : null}
|
||||
</CardContent>
|
||||
{data && data.total > PAGE_SIZE ? (
|
||||
<CardFooter className="justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={t("retests.prevPage")}
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("retests.pageIndicator", { page, total: Math.ceil(data.total / PAGE_SIZE) })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={t("retests.nextPage")}
|
||||
disabled={page * PAGE_SIZE >= data.total}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
{selected ? (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<h2 className="min-w-0 flex-1 break-words font-medium">
|
||||
{findingLabel(selected, t("findings.unclassified"))}
|
||||
</h2>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/function/findings/detail?id=${selected.finding_id || selected.id}`}>
|
||||
{t("retests.findingDetail")} <ArrowUpRightIcon data-icon="inline-end" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="line-clamp-3 break-words text-muted-foreground text-sm">{selected.summary}</p>
|
||||
</div>
|
||||
<FindingRetestPanel
|
||||
key={selected.id}
|
||||
findingId={selected.finding_id || selected.id}
|
||||
findingName={findingLabel(selected, t("findings.unclassified"))}
|
||||
onCompleted={refresh}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,462 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { ArchiveIcon, ArrowLeftIcon, BrainIcon, CheckIcon, CircleAlertIcon, PauseIcon, PlayIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api } from "@/lib/api";
|
||||
import type { LLMProfile, Task } from "@/lib/types";
|
||||
|
||||
import { AssetsTab } from "./_tabs/assets-tab";
|
||||
import { BroadcastTab } from "./_tabs/broadcast-tab";
|
||||
import { CoverageGraphTab } from "./_tabs/coverage-graph-tab";
|
||||
import { FindingsTab } from "./_tabs/findings-tab";
|
||||
import { GraphTab } from "./_tabs/graph-tab";
|
||||
import { InterceptTab } from "./_tabs/intercept-tab";
|
||||
import { OverviewTab } from "./_tabs/overview-tab";
|
||||
import { ReportTab } from "./_tabs/report-tab";
|
||||
import { RetestsTab } from "./_tabs/retests-tab";
|
||||
import { SessionsTab } from "./_tabs/sessions-tab";
|
||||
|
||||
const TAB_VALUES = [
|
||||
"sessions",
|
||||
"overview",
|
||||
"graph",
|
||||
"broadcast",
|
||||
"findings",
|
||||
"retests",
|
||||
"assets",
|
||||
"coverage",
|
||||
"intercept",
|
||||
"report",
|
||||
] as const;
|
||||
|
||||
function taskProfileIDs(task: Task): string[] {
|
||||
if (task.llm_profile_ids && task.llm_profile_ids.length > 0) {
|
||||
return task.llm_profile_ids.map(String);
|
||||
}
|
||||
return task.llm_profile_id ? [String(task.llm_profile_id)] : [];
|
||||
}
|
||||
|
||||
function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: LLMProfile[]; onUpdated: () => void }) {
|
||||
const t = useTranslations("taskDetail.llm");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [profileIDs, setProfileIDs] = React.useState<string[]>(() => taskProfileIDs(task));
|
||||
const [activeProfileID, setActiveProfileID] = React.useState(
|
||||
task.active_llm_profile_id ? String(task.active_llm_profile_id) : (taskProfileIDs(task)[0] ?? ""),
|
||||
);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const popoverContentRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const chain = taskProfileIDs(task);
|
||||
const exhausted = task.llm_failover_state === "chain_exhausted";
|
||||
// 任何状态都可以改链,终态也不例外:任务结束后主 Agent 对话仍走这条链,
|
||||
// 链上模型出问题时必须能换掉,否则已完成任务就没法继续交互。
|
||||
const terminal = ["done", "failed", "timeout"].includes(task.status);
|
||||
// A null active profile on an exhausted, non-empty chain is a persisted end
|
||||
// cursor. Keep the status display honest; choosing the first profile is only
|
||||
// the editor's reset draft and does not mean it is currently active.
|
||||
let activeID = chain[0] ?? "";
|
||||
if (exhausted) activeID = "";
|
||||
if (task.active_llm_profile_id) activeID = String(task.active_llm_profile_id);
|
||||
const activeProfile = profiles.find((profile) => profile.id === activeID);
|
||||
const currentLabel = exhausted
|
||||
? t("exhausted")
|
||||
: (activeProfile?.name ?? (activeID ? t("profileRef", { id: activeID }) : t("followDefault")));
|
||||
const activeIndex = chain.indexOf(activeID);
|
||||
const backupCount = activeIndex >= 0 ? Math.max(0, chain.length - activeIndex - 1) : 0;
|
||||
const currentTitle = [
|
||||
currentLabel,
|
||||
activeProfile?.model,
|
||||
backupCount > 0 ? t("backupCount", { count: backupCount }) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
let editorDescription = t("editorDescription");
|
||||
if (terminal) editorDescription = t("editorDescriptionTerminal");
|
||||
let saveLabel = t("save");
|
||||
if (exhausted) saveLabel = t("saveReset");
|
||||
if (saving) saveLabel = t("saving");
|
||||
|
||||
const syncDraft = React.useCallback(() => {
|
||||
const next = taskProfileIDs(task);
|
||||
setProfileIDs(next);
|
||||
setActiveProfileID(task.active_llm_profile_id ? String(task.active_llm_profile_id) : (next[0] ?? ""));
|
||||
}, [task]);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next) syncDraft();
|
||||
};
|
||||
|
||||
const handleProfileIDsChange = (next: string[]) => {
|
||||
setProfileIDs(next);
|
||||
setActiveProfileID((current) => (next.includes(current) ? current : (next[0] ?? "")));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await api.updateTaskLLMProfiles(
|
||||
task.id,
|
||||
profileIDs.map(Number),
|
||||
activeProfileID ? Number(activeProfileID) : undefined,
|
||||
);
|
||||
if (result.switch_event) {
|
||||
toast.info(result.switch_event.summary, { id: `task-${task.id}-llm-${result.switch_event.seq}` });
|
||||
} else {
|
||||
toast.success(
|
||||
result.reopened_intents > 0 ? t("updatedWithReopened", { count: result.reopened_intents }) : t("updated"),
|
||||
);
|
||||
}
|
||||
setOpen(false);
|
||||
onUpdated();
|
||||
} catch (error) {
|
||||
toast.error(t("updateFailed", { message: (error as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={exhausted ? "destructive" : "outline"}
|
||||
aria-label={t("controlAria")}
|
||||
title={currentTitle}
|
||||
>
|
||||
<BrainIcon data-icon="inline-start" />
|
||||
<span className="hidden max-w-36 truncate lg:inline">{currentLabel}</span>
|
||||
{backupCount > 0 && <span className="hidden text-muted-foreground xl:inline">+{backupCount}</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent ref={popoverContentRef} align="start" className="w-[min(28rem,calc(100vw-2rem))] gap-4 p-4">
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>{t("chainTitle")}</PopoverTitle>
|
||||
<PopoverDescription>{editorDescription}</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
|
||||
{exhausted && (
|
||||
<Alert variant="destructive">
|
||||
<CircleAlertIcon />
|
||||
<AlertTitle>{t("exhaustedTitle")}</AlertTitle>
|
||||
<AlertDescription>{task.llm_failover_reason ?? t("exhaustedDesc")}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<TaskLLMProfileChain
|
||||
profiles={profiles}
|
||||
value={profileIDs}
|
||||
onValueChange={handleProfileIDsChange}
|
||||
activeProfileId={activeProfileID}
|
||||
onActiveProfileChange={setActiveProfileID}
|
||||
inputId="task-llm-profiles"
|
||||
disabled={saving}
|
||||
portalContainer={popoverContentRef}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={save} disabled={saving}>
|
||||
{saving && <Spinner data-icon="inline-start" />}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetailInner() {
|
||||
const t = useTranslations("taskDetail");
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const id = searchParams.get("id") ?? "";
|
||||
const [task, setTask] = React.useState<Task | null>(null);
|
||||
const [paused, setPaused] = React.useState(false);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [tab, setTab] = React.useState("sessions");
|
||||
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [archiving, setArchiving] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
api
|
||||
.llmProfiles()
|
||||
.then(setProfiles)
|
||||
.catch(() => setProfiles([]));
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () =>
|
||||
api
|
||||
.interceptTask(id)
|
||||
.then((rows) => {
|
||||
if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length);
|
||||
})
|
||||
.catch(() => {
|
||||
// Polling is best-effort; the next interval retries automatically.
|
||||
});
|
||||
void load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
const taskLoadInFlight = React.useRef<string | null>(null);
|
||||
const load = React.useCallback(() => {
|
||||
if (taskLoadInFlight.current === id) return;
|
||||
taskLoadInFlight.current = id;
|
||||
Promise.all([api.task(id), api.stats(id).catch(() => null)])
|
||||
.then(([task, s]) => {
|
||||
if (taskLoadInFlight.current !== id) return;
|
||||
const base = { ...task };
|
||||
const at = s?.active_task;
|
||||
if (at) {
|
||||
base.in_flight = at.in_flight;
|
||||
base.goals_total = at.goals_total;
|
||||
base.goals_met = at.goals_met;
|
||||
base.engine_mode = s?.engine_mode ?? at.engine_mode;
|
||||
base.paused = at.paused;
|
||||
}
|
||||
setTask(base);
|
||||
setPaused(at?.paused ?? base.paused ?? false);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the last rendered task state during a transient poll failure.
|
||||
})
|
||||
.finally(() => {
|
||||
if (taskLoadInFlight.current === id) {
|
||||
taskLoadInFlight.current = null;
|
||||
setLoaded(true);
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
const timer = setInterval(load, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
async function togglePause() {
|
||||
if (task && ["done", "failed", "timeout"].includes(task.status)) return;
|
||||
const next = !paused;
|
||||
try {
|
||||
await api.controlTask(id, next ? "pause" : "resume");
|
||||
setPaused(next);
|
||||
toast.success(next ? t("control.paused") : t("control.resumed"));
|
||||
} catch (e) {
|
||||
toast.error(t("control.failed", { message: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveTask() {
|
||||
if (!task || archiving) return;
|
||||
setArchiving(true);
|
||||
try {
|
||||
await api.archiveTask(task.id);
|
||||
toast.success(t("archive.queued"));
|
||||
router.push("/function/tasks");
|
||||
} catch (error) {
|
||||
toast.error(t("archive.failed", { message: (error as Error).message }));
|
||||
setArchiving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
|
||||
<p className="text-muted-foreground">{loaded ? t("header.notFound", { id }) : t("header.loading")}</p>
|
||||
{loaded && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/function/tasks">
|
||||
<ArrowLeftIcon /> {t("header.backToList")}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const completed = task.status === "done";
|
||||
const terminal = ["done", "failed", "timeout"].includes(task.status);
|
||||
const archiveLifecycleEligible = terminal || paused || task.status === "paused";
|
||||
const canArchive = archiveLifecycleEligible && !task.archive_blocked_by_task_id;
|
||||
let archiveDisabledReason = task.queued ? t("archive.disabledQueued") : t("archive.disabledRunning");
|
||||
if (archiveLifecycleEligible && task.archive_blocked_by_task_id) {
|
||||
archiveDisabledReason = t("archive.disabledInherited", { id: task.archive_blocked_by_task_id });
|
||||
}
|
||||
const engineMode = paused ? "paused" : (task.engine_mode ?? "idle");
|
||||
let controlVariant: "default" | "secondary" | "outline" = "outline";
|
||||
let controlIcon = <PauseIcon data-icon="inline-start" />;
|
||||
let controlLabel = t("control.pause");
|
||||
if (terminal) {
|
||||
controlVariant = "secondary";
|
||||
controlIcon = <CheckIcon data-icon="inline-start" />;
|
||||
controlLabel = completed ? t("control.completed") : t("control.ended");
|
||||
} else if (paused) {
|
||||
controlVariant = "default";
|
||||
controlIcon = <PlayIcon data-icon="inline-start" />;
|
||||
controlLabel = t("control.resume");
|
||||
}
|
||||
const archiveTrigger = (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={!canArchive || archiving}
|
||||
aria-label={canArchive ? t("archive.triggerAria") : archiveDisabledReason}
|
||||
>
|
||||
{archiving ? <Spinner /> : <ArchiveIcon />}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
|
||||
{/* Top fixed area */}
|
||||
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Button asChild variant="ghost" size="icon" className="size-7">
|
||||
<Link href="/function/tasks">
|
||||
<ArrowLeftIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold" title={task.name || task.description}>
|
||||
{task.name || task.description}
|
||||
</h1>
|
||||
<code className="hidden rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground sm:inline">
|
||||
{task.id}
|
||||
</code>
|
||||
<Separator orientation="vertical" className="mx-1 hidden h-4 sm:block" />
|
||||
<TaskLLMControl task={task} profiles={profiles} onUpdated={load} />
|
||||
<StatusBadge domain={terminal ? "task" : "engine"} value={terminal ? task.status : engineMode} dot />
|
||||
{canArchive ? (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>{archiveTrigger}</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("archive.dialogTitle", { id: task.id })}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("archive.dialogDesc")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("archive.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => void archiveTask()}>{t("archive.confirm")}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">{archiveTrigger}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{archiveDisabledReason}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button size="sm" variant={controlVariant} onClick={togglePause} disabled={terminal}>
|
||||
{controlIcon}
|
||||
{controlLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">{task.goal}</p>
|
||||
{/* Tabs */}
|
||||
<div className="no-scrollbar min-w-0 overflow-x-auto">
|
||||
<TabsList variant="default" className="min-w-max">
|
||||
{TAB_VALUES.map((value) => (
|
||||
<TabsTrigger key={value} value={value}>
|
||||
{t(`tabs.${value}`)}
|
||||
{value === "intercept" && interceptPendingCount > 0 && (
|
||||
<span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white">
|
||||
{interceptPendingCount > 99 ? "99+" : interceptPendingCount}
|
||||
</span>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 p-4 lg:p-6">
|
||||
<TabsContent value="sessions" className="mt-0">
|
||||
<SessionsTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
<OverviewTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="graph" className="mt-0">
|
||||
<GraphTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="broadcast" className="mt-0">
|
||||
<BroadcastTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="findings" className="mt-0">
|
||||
<FindingsTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="retests" className="mt-0">
|
||||
<RetestsTab key={id} taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="assets" className="mt-0">
|
||||
<AssetsTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="coverage" className="mt-0">
|
||||
<CoverageGraphTab taskId={id} coverageEnabled={task?.coverage_enabled !== false} />
|
||||
</TabsContent>
|
||||
<TabsContent value="intercept" className="mt-0">
|
||||
<InterceptTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="report" className="mt-0">
|
||||
<ReportTab taskId={id} />
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// useSearchParams must sit under a Suspense boundary for static export.
|
||||
export default function TaskDetailPage() {
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<TaskDetailInner />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,836 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
ArrowDownWideNarrowIcon,
|
||||
ArrowUpNarrowWideIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
EraserIcon,
|
||||
FilterXIcon,
|
||||
ListChecksIcon,
|
||||
Loader2Icon,
|
||||
RadioTowerIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { LinkTrafficDialog } from "@/components/link-traffic-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
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 { SortableHead } from "@/components/ui/sortable-head";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api } from "@/lib/api";
|
||||
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } 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",
|
||||
});
|
||||
}
|
||||
|
||||
function fmtBytes(n: number) {
|
||||
if (n <= 0) return "0 B";
|
||||
// GB matters for the reclaimed-space figure a full purge reports; a capture-heavy
|
||||
// instance can hand back several.
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
|
||||
const v = n / 1024 ** i;
|
||||
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function statusTone(status: number) {
|
||||
if (status >= 500) return "text-red-500";
|
||||
if (status >= 400) return "text-amber-500";
|
||||
if (status >= 300) return "text-blue-500";
|
||||
if (status >= 200) return "text-emerald-500";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function MethodBadge({ method }: { method: string }) {
|
||||
return <Badge className="shrink-0 font-mono">{method}</Badge>;
|
||||
}
|
||||
|
||||
// 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)}`;
|
||||
}
|
||||
|
||||
// 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"];
|
||||
const PAGE_SIZES = [25, 50, 100, 200];
|
||||
type HostCountSortDirection = "asc" | "desc";
|
||||
|
||||
// Server-sortable columns. The list is sent to the backend verbatim as `sort`,
|
||||
// which whitelists these same names, so keep them in sync with traffic.Page.
|
||||
const SORT_FIELDS = ["ts", "status", "resp_len"] as const;
|
||||
type SortField = (typeof SORT_FIELDS)[number];
|
||||
const SORT_STORAGE_KEY = "traffic-sort";
|
||||
|
||||
// Status-class buckets for the filter dropdown; the value is sent as `status`,
|
||||
// which the backend reads as either an exact code or an "Nxx" class band.
|
||||
const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"];
|
||||
|
||||
export default function TrafficPage() {
|
||||
const t = useTranslations("traffic");
|
||||
const tp = useTranslations("pagination");
|
||||
const [selectedFlows, setSelectedFlows] = React.useState<Set<string>>(() => new Set());
|
||||
const [linking, setLinking] = React.useState(false);
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [size, setSize] = React.useState(50);
|
||||
const [host, setHost] = React.useState(""); // raw host input
|
||||
const [hostQ, setHostQ] = React.useState(""); // debounced → server
|
||||
const [query, setQuery] = React.useState(""); // raw free-text input
|
||||
const [queryQ, setQueryQ] = React.useState(""); // debounced → server
|
||||
const [method, setMethod] = React.useState("all");
|
||||
|
||||
// Advanced filters (issue #177): response-body content, path, status class and
|
||||
// response-size range. Text inputs are debounced like host/query; the status
|
||||
// select applies immediately.
|
||||
const [body, setBody] = React.useState("");
|
||||
const [bodyQ, setBodyQ] = React.useState("");
|
||||
const [path, setPath] = React.useState("");
|
||||
const [pathQ, setPathQ] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [respMin, setRespMin] = React.useState("");
|
||||
const [respMinQ, setRespMinQ] = React.useState("");
|
||||
const [respMax, setRespMax] = React.useState("");
|
||||
const [respMaxQ, setRespMaxQ] = React.useState("");
|
||||
const [sort, setSort] = useStoredSortPreference<SortField>(SORT_STORAGE_KEY, SORT_FIELDS, "ts", "desc");
|
||||
|
||||
const [traffic, setTraffic] = React.useState<TrafficResp | null>(null);
|
||||
const [selected, setSelected] = React.useState<TrafficExchange | null>(null);
|
||||
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = React.useState(false);
|
||||
|
||||
const [hosts, setHosts] = React.useState<TrafficHost[]>([]); // target picker
|
||||
const [selectedHosts, setSelectedHosts] = React.useState<string[]>([]); // checked in picker
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
const [hostCountSortDirection, setHostCountSortDirection] = React.useState<HostCountSortDirection>("desc");
|
||||
|
||||
const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | "all" | null>(null); // null = dialog closed
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger
|
||||
|
||||
// Debounce both filters so we don't refetch on every keystroke.
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setHostQ(host.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [host]);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setQueryQ(query.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [query]);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setBodyQ(body.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [body]);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setPathQ(path.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [path]);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setRespMinQ(respMin.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [respMin]);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setRespMaxQ(respMax.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [respMax]);
|
||||
|
||||
const hasAdvancedFilter = Boolean(bodyQ || pathQ || respMinQ || respMaxQ) || statusFilter !== "all";
|
||||
const resetAdvancedFilters = () => {
|
||||
setBody("");
|
||||
setBodyQ("");
|
||||
setPath("");
|
||||
setPathQ("");
|
||||
setStatusFilter("all");
|
||||
setRespMin("");
|
||||
setRespMinQ("");
|
||||
setRespMax("");
|
||||
setRespMaxQ("");
|
||||
};
|
||||
|
||||
// Any filter/size/sort 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, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort]);
|
||||
|
||||
// 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 = () => {
|
||||
api
|
||||
.traffic(page, size, hostQ, method, queryQ, {
|
||||
body: bodyQ,
|
||||
path: pathQ,
|
||||
status: statusFilter,
|
||||
respMin: respMinQ,
|
||||
respMax: respMaxQ,
|
||||
sort: sort.field,
|
||||
order: sort.direction,
|
||||
})
|
||||
.then((r) => {
|
||||
if (alive) setTraffic(r);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the last successful snapshot during transient refresh failures.
|
||||
});
|
||||
api
|
||||
.trafficHosts()
|
||||
.then((r) => {
|
||||
if (alive) setHosts(r.hosts ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the last successful host list during transient refresh failures.
|
||||
});
|
||||
};
|
||||
load();
|
||||
const t = setInterval(() => {
|
||||
if (page !== 0) return; // only auto-refresh the newest page
|
||||
load();
|
||||
}, 5000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [page, size, hostQ, method, queryQ, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick]);
|
||||
|
||||
// Delete traffic for the current host filter (substring) or the checked
|
||||
// hosts (exact batch), then refetch.
|
||||
const allSelected = hosts.length > 0 && hosts.every((h) => selectedHosts.includes(h.host));
|
||||
const sortedHosts = React.useMemo(
|
||||
() =>
|
||||
[...hosts].sort((a, b) => {
|
||||
const countOrder = hostCountSortDirection === "asc" ? a.count - b.count : b.count - a.count;
|
||||
return countOrder || a.host.localeCompare(b.host);
|
||||
}),
|
||||
[hosts, hostCountSortDirection],
|
||||
);
|
||||
|
||||
// 필터 없는 전체 삭제는 "비우기", 대상 한정 삭제는 "삭제"로 쓴다. 다이얼로그 제목과
|
||||
// 확인 버튼 문구가 모두 어느 쪽인지에 따라 갈린다.
|
||||
const deleteTitle = deleteMode
|
||||
? {
|
||||
all: t("dialog.titlePurge"),
|
||||
selected: t("dialog.titleSelected", { count: selectedHosts.length }),
|
||||
filter: t("dialog.titleFilter"),
|
||||
}[deleteMode]
|
||||
: "";
|
||||
|
||||
// `reclaimed` only comes back from the full purge; the host-scoped deletions
|
||||
// report the row count alone.
|
||||
const requestDelete = (mode: "filter" | "selected" | "all"): Promise<{ deleted: number; reclaimed?: number }> => {
|
||||
if (mode === "all") return api.trafficDeleteAll();
|
||||
if (mode === "selected") return api.trafficDeleteHosts(selectedHosts);
|
||||
return api.trafficDeleteHost(hostQ);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteMode) return;
|
||||
setDeleting(true);
|
||||
const mode = deleteMode;
|
||||
requestDelete(mode)
|
||||
.then((r) => {
|
||||
setDeleteMode(null);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
if (mode !== "filter") {
|
||||
setSelectedHosts([]);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
if (mode === "all") {
|
||||
// Reclaimed space is the whole point of compacting an emptied index, so say so.
|
||||
const reclaimed = r.reclaimed ?? 0;
|
||||
toast.success(
|
||||
reclaimed > 0
|
||||
? t("toast.purgedWithSpace", { deleted: r.deleted, space: fmtBytes(reclaimed) })
|
||||
: t("toast.purged", { deleted: r.deleted }),
|
||||
);
|
||||
}
|
||||
setPage(0);
|
||||
setReloadTick((t) => t + 1);
|
||||
})
|
||||
.catch((e) => {
|
||||
// Keep the confirmation open so the user can retry a failed deletion.
|
||||
if (mode === "all") toast.error(t("toast.purgeFailed", { msg: (e as Error).message }));
|
||||
})
|
||||
.finally(() => setDeleting(false));
|
||||
};
|
||||
|
||||
// Lazy-load the raw request/response for the selected exchange.
|
||||
React.useEffect(() => {
|
||||
if (!selected) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
api
|
||||
.trafficExchange(selected.id)
|
||||
.then((d) => {
|
||||
if (alive) setDetail(d);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setDetail({ req: t("detail.loadFailed"), resp: "" });
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setDetailLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [selected, t]);
|
||||
|
||||
// Toggle direction when re-clicking the active column, else sort the new column
|
||||
// newest/largest-first.
|
||||
const toggleSort = (field: SortField) =>
|
||||
setSort((prev) =>
|
||||
prev.field === field
|
||||
? { field, direction: prev.direction === "asc" ? "desc" : "asc" }
|
||||
: { field, direction: "desc" },
|
||||
);
|
||||
|
||||
const exchanges = React.useMemo(() => traffic?.exchanges ?? [], [traffic]);
|
||||
const total = traffic?.total ?? exchanges.length;
|
||||
const pageCount = Math.max(1, Math.ceil(total / size));
|
||||
const rangeStart = total === 0 ? 0 : page * size + 1;
|
||||
const rangeEnd = page * size + exchanges.length;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
traffic?.enabled
|
||||
? "border-emerald-500/20 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||
: "border-transparent bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<RadioTowerIcon className="size-3.5" />
|
||||
{traffic?.enabled ? t("recording") : t("stopped")}
|
||||
</span>
|
||||
{traffic?.proxy && <span className="font-mono text-xs text-muted-foreground">{traffic.proxy}</span>}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.rich("total", {
|
||||
n: () => <span className="tabular-nums">{traffic?.count ?? 0}</span>,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
<ListChecksIcon className="size-3.5" />
|
||||
{selectedHosts.length > 0
|
||||
? t("picker.selectTargetCount", { count: selectedHosts.length })
|
||||
: t("picker.selectTarget")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[calc(100vw-2rem)] p-0 data-open:animate-none data-closed:animate-none sm:w-80"
|
||||
align="start"
|
||||
collisionPadding={16}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">{t("picker.batchByTarget")}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{hosts.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setHostCountSortDirection((current) => (current === "desc" ? "asc" : "desc"))}
|
||||
aria-label={
|
||||
hostCountSortDirection === "desc" ? t("picker.sortAriaDesc") : t("picker.sortAriaAsc")
|
||||
}
|
||||
>
|
||||
{hostCountSortDirection === "desc" ? <ArrowDownWideNarrowIcon /> : <ArrowUpNarrowWideIcon />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hostCountSortDirection === "desc" ? t("picker.sortTipDesc") : t("picker.sortTipAsc")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hosts.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setSelectedHosts(allSelected ? [] : hosts.map((h) => h.host))}
|
||||
>
|
||||
{allSelected ? t("picker.deselectAll") : t("picker.selectAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{hosts.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">{t("picker.empty")}</div>
|
||||
) : (
|
||||
sortedHosts.map((h, index) => (
|
||||
<label
|
||||
key={h.host}
|
||||
htmlFor={`traffic-host-${index}`}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
id={`traffic-host-${index}`}
|
||||
checked={selectedHosts.includes(h.host)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedHosts((prev) =>
|
||||
prev.includes(h.host) ? prev.filter((x) => x !== h.host) : [...prev, h.host],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="truncate font-mono">{h.host}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">{h.count}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t p-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={selectedHosts.length === 0}
|
||||
onClick={() => {
|
||||
setDeleteMode("selected");
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("picker.deleteSelected", { count: selectedHosts.length })}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="relative w-48">
|
||||
<Input placeholder="host…" value={host} onChange={(e) => setHost(e.target.value)} className="h-8" />
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!hostQ || deleting}
|
||||
title={hostQ ? undefined : t("toolbar.hostHint")}
|
||||
onClick={() => setDeleteMode("filter")}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
{t("toolbar.deleteHost")}
|
||||
</Button>
|
||||
{/* 두 번째 파괴적 버튼이 아니라 아웃라인으로 둔다. 이 버튼은 모든 필터를 무시하므로
|
||||
"이 대상 삭제" 버튼 바로 옆에서 한 번의 오클릭처럼 보이면 안 된다. */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={!traffic?.count || deleting}
|
||||
title={traffic?.count ? t("toolbar.purgeTitle") : t("toolbar.purgeEmptyTitle")}
|
||||
onClick={() => setDeleteMode("all")}
|
||||
>
|
||||
<EraserIcon className="size-3.5" />
|
||||
{t("toolbar.purgeAll")}
|
||||
</Button>
|
||||
<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("toolbar.search")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue placeholder={t("toolbar.method")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("toolbar.allMethods")}</SelectItem>
|
||||
{METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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>
|
||||
|
||||
<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} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
disabled={page + 1 >= pageCount}
|
||||
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced filters (issue #177): narrow 660k+ exchanges down to the one packet. */}
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 px-2 py-1.5">
|
||||
<span className="pl-1 text-xs font-medium text-muted-foreground">{t("filters.advanced")}</span>
|
||||
<div className="relative w-56">
|
||||
<Input
|
||||
placeholder={t("filters.body")}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-52">
|
||||
<Input
|
||||
placeholder={t("filters.path")}
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger size="sm" className="w-28">
|
||||
<SelectValue placeholder={t("filters.status")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.allStatus")}</SelectItem>
|
||||
{STATUS_BUCKETS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>{t("filters.respLen")}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("filters.min")}
|
||||
value={respMin}
|
||||
onChange={(e) => setRespMin(e.target.value)}
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
<span>–</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={t("filters.max")}
|
||||
value={respMax}
|
||||
onChange={(e) => setRespMax(e.target.value)}
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
</div>
|
||||
{hasAdvancedFilter ? (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={resetAdvancedFilters}>
|
||||
<FilterXIcon className="size-3.5" />
|
||||
{t("filters.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t("selection.selected", { count: selectedFlows.size })}</span>
|
||||
<Button variant="outline" size="sm" disabled={selectedFlows.size === 0} onClick={() => setLinking(true)}>
|
||||
{t("selection.linkToVuln")}
|
||||
</Button>
|
||||
{selectedFlows.size > 0 ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedFlows(new Set())}>
|
||||
{t("selection.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{/* History table */}
|
||||
<div className="flex h-[calc(100vh-15rem)] 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-10">
|
||||
<Checkbox
|
||||
aria-label={t("table.selectPage")}
|
||||
checked={exchanges.length > 0 && exchanges.every((e) => selectedFlows.has(e.id))}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedFlows((previous) => {
|
||||
const next = new Set(previous);
|
||||
for (const e of exchanges) {
|
||||
if (checked === true) next.add(e.id);
|
||||
else next.delete(e.id);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
field="ts"
|
||||
label={t("table.time")}
|
||||
activeField={sort.field}
|
||||
direction={sort.direction}
|
||||
onSort={toggleSort}
|
||||
className="w-36"
|
||||
/>
|
||||
<TableHead className="w-44">host</TableHead>
|
||||
<TableHead className="w-20">{t("table.method")}</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<SortableHead
|
||||
field="status"
|
||||
label={t("table.status")}
|
||||
activeField={sort.field}
|
||||
direction={sort.direction}
|
||||
onSort={toggleSort}
|
||||
className="w-20"
|
||||
/>
|
||||
<TableHead className="w-36">content-type</TableHead>
|
||||
<SortableHead
|
||||
field="resp_len"
|
||||
label={t("table.respLen")}
|
||||
activeField={sort.field}
|
||||
direction={sort.direction}
|
||||
onSort={toggleSort}
|
||||
align="right"
|
||||
className="w-24 text-right"
|
||||
/>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{exchanges.map((e) => (
|
||||
<TableRow
|
||||
key={e.id}
|
||||
className={cn("cursor-pointer", selected?.id === e.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(e)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
aria-label={t("table.selectRow", { id: e.id })}
|
||||
checked={selectedFlows.has(e.id)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedFlows((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (checked === true) next.add(e.id);
|
||||
else next.delete(e.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.host}</TableCell>
|
||||
<TableCell>
|
||||
<MethodBadge method={e.method} />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0">
|
||||
<span className="block truncate font-mono text-xs">{e.url}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(e.status))}>
|
||||
{e.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{e.content_type}</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">{fmtBytes(e.resp_len)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{exchanges.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
|
||||
{traffic === null ? t("table.loading") : t("table.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{linking ? (
|
||||
<LinkTrafficDialog
|
||||
trafficIds={[...selectedFlows]}
|
||||
onClose={() => setLinking(false)}
|
||||
onBound={() => setSelectedFlows(new Set())}
|
||||
/>
|
||||
) : null}
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
{selected && (
|
||||
<>
|
||||
<SheetHeader className="border-b px-5 py-4">
|
||||
<div className="flex items-center gap-2 pr-8">
|
||||
<MethodBadge method={selected.method} />
|
||||
<Badge variant="secondary" className={cn("font-mono tabular-nums", statusTone(selected.status))}>
|
||||
{selected.status}
|
||||
</Badge>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{fmtTime(selected.ts)}</span>
|
||||
</div>
|
||||
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
|
||||
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Tabs defaultValue="request" className="min-h-0 flex-1 gap-0">
|
||||
<TabsList className="mx-5 mt-4 grid w-auto grid-cols-2">
|
||||
<TabsTrigger value="request">{t("detail.request")}</TabsTrigger>
|
||||
<TabsTrigger value="response">{t("detail.response")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
{t("detail.loadingMsg")}
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={requestWithHost(detail?.req ?? "", selected)} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="response" className="min-h-0 overflow-auto">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center gap-2 p-5 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
{t("detail.loadingMsg")}
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={detail?.resp ?? ""} />
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteMode !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteMode(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{deleteTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteMode === "all" &&
|
||||
t.rich("dialog.descPurge", {
|
||||
n: () => <span className="font-semibold tabular-nums">{traffic?.count ?? 0}</span>,
|
||||
br: () => <br />,
|
||||
muted: (chunks) => <span className="text-muted-foreground">{chunks}</span>,
|
||||
})}
|
||||
{deleteMode === "selected" &&
|
||||
t.rich("dialog.descSelected", {
|
||||
n: () => <span className="font-semibold tabular-nums">{selectedHosts.length}</span>,
|
||||
hosts: () => (
|
||||
<span className="font-mono">
|
||||
{selectedHosts.slice(0, 3).join(", ")}
|
||||
{selectedHosts.length > 3 ? "…" : ""}
|
||||
</span>
|
||||
),
|
||||
})}
|
||||
{deleteMode === "filter" &&
|
||||
t.rich("dialog.descFilter", {
|
||||
host: () => <span className="font-mono font-semibold">{hostQ}</span>,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>{t("dialog.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmDelete();
|
||||
}}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting
|
||||
? t(deleteMode === "all" ? "dialog.purging" : "dialog.deleting")
|
||||
: t(deleteMode === "all" ? "dialog.confirmPurge" : "dialog.confirmDelete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
DownloadIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
FolderPlusIcon,
|
||||
HardDriveIcon,
|
||||
RefreshCwIcon,
|
||||
SaveIcon,
|
||||
Trash2Icon,
|
||||
UploadIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { WorkspaceEntry, WorkspaceFile } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmtSize(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
function fmtTime(ms: number): string {
|
||||
return new Date(ms).toLocaleString("ko-KR", {
|
||||
year: "2-digit",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
type EditState = {
|
||||
file: WorkspaceFile;
|
||||
content: string;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
};
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const t = useTranslations("workspace");
|
||||
const [path, setPath] = React.useState("");
|
||||
const [entries, setEntries] = React.useState<WorkspaceEntry[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [edit, setEdit] = React.useState<EditState | null>(null);
|
||||
const [mkdirOpen, setMkdirOpen] = React.useState(false);
|
||||
const [mkdirName, setMkdirName] = React.useState("");
|
||||
const uploadRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = React.useCallback(
|
||||
(p: string) => {
|
||||
setLoading(true);
|
||||
api
|
||||
.workspaceList(p)
|
||||
.then((r) => {
|
||||
setEntries(r.entries);
|
||||
setPath(r.path);
|
||||
})
|
||||
.catch((e) => toast.error(t("readDirFailed", { msg: (e as Error).message })))
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
load("");
|
||||
}, [load]);
|
||||
|
||||
const crumbs = React.useMemo(() => {
|
||||
const parts = path ? path.split("/") : [];
|
||||
const acc: { name: string; path: string }[] = [{ name: t("crumbRoot"), path: "" }];
|
||||
let cur = "";
|
||||
for (const part of parts) {
|
||||
cur = cur ? `${cur}/${part}` : part;
|
||||
acc.push({ name: part, path: cur });
|
||||
}
|
||||
return acc;
|
||||
}, [path, t]);
|
||||
|
||||
const openFile = (e: WorkspaceEntry) => {
|
||||
api
|
||||
.workspaceRead(e.path)
|
||||
.then((f) => setEdit({ file: f, content: f.content ?? "", dirty: false, saving: false }))
|
||||
.catch((err) => toast.error(t("openFileFailed", { msg: (err as Error).message })));
|
||||
};
|
||||
|
||||
const saveFile = () => {
|
||||
if (!edit) return;
|
||||
setEdit({ ...edit, saving: true });
|
||||
api
|
||||
.workspaceWrite(edit.file.path, edit.content)
|
||||
.then(() => {
|
||||
toast.success(t("saved"));
|
||||
setEdit((cur) => (cur ? { ...cur, dirty: false, saving: false } : cur));
|
||||
load(path);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t("saveFailed", { msg: (err as Error).message }));
|
||||
setEdit((cur) => (cur ? { ...cur, saving: false } : cur));
|
||||
});
|
||||
};
|
||||
|
||||
const del = (e: WorkspaceEntry) => {
|
||||
if (!window.confirm(t("confirmDelete", { kind: e.dir ? "dir" : "file", name: e.name }))) return;
|
||||
api
|
||||
.workspaceDelete(e.path)
|
||||
.then(() => {
|
||||
toast.success(t("deleted"));
|
||||
load(path);
|
||||
})
|
||||
.catch((err) => toast.error(t("deleteFailed", { msg: (err as Error).message })));
|
||||
};
|
||||
|
||||
const doUpload = (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
api
|
||||
.workspaceUpload(path, Array.from(files))
|
||||
.then((r) => {
|
||||
toast.success(t("uploaded", { n: r.uploaded }));
|
||||
load(path);
|
||||
})
|
||||
.catch((err) => toast.error(t("uploadFailed", { msg: (err as Error).message })))
|
||||
.finally(() => {
|
||||
if (uploadRef.current) uploadRef.current.value = "";
|
||||
});
|
||||
};
|
||||
|
||||
const doMkdir = () => {
|
||||
const name = mkdirName.trim();
|
||||
if (!name) return;
|
||||
const target = path ? `${path}/${name}` : name;
|
||||
api
|
||||
.workspaceMkdir(target)
|
||||
.then(() => {
|
||||
toast.success(t("folderCreated"));
|
||||
setMkdirOpen(false);
|
||||
setMkdirName("");
|
||||
load(path);
|
||||
})
|
||||
.catch((err) => toast.error(t("createFailed", { msg: (err as Error).message })));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* 헤더: 경로(브레드크럼) + 동작 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-1 text-sm">
|
||||
<HardDriveIcon className="text-muted-foreground mr-1 size-4 shrink-0" />
|
||||
{crumbs.map((c, i) => (
|
||||
<React.Fragment key={c.path}>
|
||||
{i > 0 && <span className="text-muted-foreground">/</span>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => load(c.path)}
|
||||
className={cn(
|
||||
"max-w-[160px] truncate rounded px-1.5 py-0.5 hover:bg-accent",
|
||||
i === crumbs.length - 1 ? "text-foreground font-medium" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setMkdirOpen(true)}>
|
||||
<FolderPlusIcon /> {t("newFolder")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => uploadRef.current?.click()}>
|
||||
<UploadIcon /> {t("upload")}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={() => load(path)} title={t("refresh")}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
</Button>
|
||||
<input ref={uploadRef} type="file" multiple className="hidden" onChange={(e) => doUpload(e.target.files)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("colName")}</TableHead>
|
||||
<TableHead className="w-28 text-right">{t("colSize")}</TableHead>
|
||||
<TableHead className="w-40">{t("colMtime")}</TableHead>
|
||||
<TableHead className="w-24 text-right">{t("colActions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground py-10 text-center text-sm">
|
||||
{loading ? t("loading") : t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{entries.map((e) => (
|
||||
<TableRow key={e.path}>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (e.dir ? load(e.path) : openFile(e))}
|
||||
className="flex items-center gap-2 text-left hover:underline"
|
||||
>
|
||||
{e.dir ? (
|
||||
<FolderIcon className="size-4 shrink-0 text-blue-500" />
|
||||
) : (
|
||||
<FileIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate font-mono text-sm">{e.name}</span>
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-right font-mono text-xs">
|
||||
{e.dir ? "—" : fmtSize(e.size)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{fmtTime(e.mtime)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{!e.dir && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
title={t("download")}
|
||||
onClick={() =>
|
||||
api
|
||||
.workspaceDownload(e.path)
|
||||
.catch((err) => toast.error(t("downloadFailed", { msg: (err as Error).message })))
|
||||
}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive size-7"
|
||||
title={t("delete")}
|
||||
onClick={() => del(e)}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 파일 보기 / 편집 */}
|
||||
<Sheet open={edit !== null} onOpenChange={(o) => !o && setEdit(null)}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
|
||||
{edit && (
|
||||
<>
|
||||
<SheetHeader className="border-b p-4">
|
||||
<SheetTitle className="flex items-center gap-2 truncate font-mono text-sm">
|
||||
<FileIcon className="size-4 shrink-0" />
|
||||
<span className="truncate" title={edit.file.path}>
|
||||
{edit.file.path}
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<span className="text-muted-foreground text-xs">{fmtSize(edit.file.size)}</span>
|
||||
</SheetHeader>
|
||||
|
||||
{edit.file.binary || edit.file.too_large ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">{edit.file.too_large ? t("tooLarge") : t("binary")}</p>
|
||||
<Button variant="outline" onClick={() => api.workspaceDownload(edit.file.path)}>
|
||||
<DownloadIcon /> {t("downloadFile")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="min-h-0 flex-1 p-3">
|
||||
<Textarea
|
||||
value={edit.content}
|
||||
onChange={(ev) => setEdit({ ...edit, content: ev.target.value, dirty: true })}
|
||||
spellCheck={false}
|
||||
className="h-full min-h-[50vh] resize-none font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
<SheetFooter className="flex-row items-center justify-between border-t p-3">
|
||||
<span className="text-muted-foreground text-xs">{edit.dirty ? t("dirty") : t("synced")}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => api.workspaceDownload(edit.file.path)}>
|
||||
<DownloadIcon /> {t("download")}
|
||||
</Button>
|
||||
<Button onClick={saveFile} disabled={!edit.dirty || edit.saving}>
|
||||
<SaveIcon /> {edit.saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* 새 폴더 */}
|
||||
<Dialog open={mkdirOpen} onOpenChange={setMkdirOpen}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("newFolder")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
value={mkdirName}
|
||||
onChange={(e) => setMkdirName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && doMkdir()}
|
||||
placeholder={t("folderNamePlaceholder")}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setMkdirOpen(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button onClick={doMkdir} disabled={!mkdirName.trim()}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as React from "react";
|
||||
|
||||
import { AppSidebar } from "@/app/(main)/_components/sidebar/app-sidebar";
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { getClientCookie } from "@/lib/cookie.client";
|
||||
import {
|
||||
SIDEBAR_COLLAPSIBLE_VALUES,
|
||||
SIDEBAR_VARIANT_VALUES,
|
||||
type SidebarCollapsible,
|
||||
type SidebarVariant,
|
||||
} from "@/lib/preferences/layout";
|
||||
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { MainContent } from "./_components/main-content";
|
||||
|
||||
// Reads a layout-critical preference from the browser cookie (falls back to the
|
||||
// default during static-export prerender where document is unavailable). Kept
|
||||
// fully client-side so the app can be statically exported — no Server Actions /
|
||||
// next/headers.
|
||||
function readPref<T extends string>(key: string, allowed: readonly T[], fallback: T): T {
|
||||
if (typeof document === "undefined") return fallback;
|
||||
const value = getClientCookie(key);
|
||||
return value && (allowed as readonly string[]).includes(value) ? (value as T) : fallback;
|
||||
}
|
||||
|
||||
export default function Layout({ children }: Readonly<{ children: ReactNode }>) {
|
||||
// Client-side auth gate — replaces the Next proxy/middleware that static export
|
||||
// disables. No token → bounce to /login; render nothing until confirmed so no
|
||||
// protected UI (or its API calls) flashes for a logged-out visitor.
|
||||
const [authed, setAuthed] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (auth.getToken()) {
|
||||
setAuthed(true);
|
||||
} else {
|
||||
// 客户端守卫认为未登录时,必须同时清掉 cookie:否则 proxy.ts 仅凭
|
||||
// “cookie 存在”就把我们从 /login 又重定向回主界面,与本守卫来回弹跳
|
||||
// 形成无限重定向 → 白屏(cookie 与 localStorage 不一致时触发)。
|
||||
auth.clearToken();
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const defaultOpen = typeof document === "undefined" ? true : getClientCookie("sidebar_state") !== "false";
|
||||
const variant = readPref<SidebarVariant>(
|
||||
"sidebar_variant",
|
||||
SIDEBAR_VARIANT_VALUES,
|
||||
PREFERENCE_DEFAULTS.sidebar_variant,
|
||||
);
|
||||
const collapsible = readPref<SidebarCollapsible>(
|
||||
"sidebar_collapsible",
|
||||
SIDEBAR_COLLAPSIBLE_VALUES,
|
||||
PREFERENCE_DEFAULTS.sidebar_collapsible,
|
||||
);
|
||||
|
||||
if (!authed) return null;
|
||||
|
||||
return (
|
||||
<SidebarProvider
|
||||
defaultOpen={defaultOpen}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": "calc(var(--spacing) * 68)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<AppSidebar variant={variant} collapsible={collapsible} />
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
"[html[data-content-layout=centered]_&>*]:mx-auto",
|
||||
"[html[data-content-layout=centered]_&>*]:w-full",
|
||||
"[html[data-content-layout=centered]_&>*]:max-w-screen-2xl",
|
||||
"peer-data-[variant=inset]:border",
|
||||
"[--dashboard-header-height:--spacing(12)]",
|
||||
"min-w-0 overflow-x-hidden",
|
||||
)}
|
||||
>
|
||||
<MainContent>{children}</MainContent>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
|
||||
import { AgentEditor } from "@/components/agent-editor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Deep-link editor page for one agent. The primary flow is the drawer on
|
||||
// /system/agents; this page reuses the same AgentEditor component full-width so a
|
||||
// direct URL (or an external link) still opens the editor.
|
||||
function AgentDetailInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const key = searchParams.get("key") ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost" size="icon-sm">
|
||||
<Link href="/system/agents">
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Agent</h1>
|
||||
<p className="text-muted-foreground font-mono text-xs">{key}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col rounded-lg border">
|
||||
<AgentEditor agentKey={key} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// useSearchParams must sit under a Suspense boundary for static export.
|
||||
export default function AgentDetailPage() {
|
||||
return (
|
||||
<React.Suspense fallback={null}>
|
||||
<AgentDetailInner />
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { Bot, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AgentEditor } from "@/components/agent-editor";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Agent } from "@/lib/types";
|
||||
|
||||
// AgentGridCard is one clickable tile opening the agent's editor drawer. Custom
|
||||
// (non-builtin) agents get a delete button.
|
||||
function AgentGridCard({ agent, onOpen, onDeleted }: { agent: Agent; onOpen: () => void; onDeleted: () => void }) {
|
||||
const t = useTranslations("agentsPage");
|
||||
async function del() {
|
||||
try {
|
||||
await api.deleteAgent(agent.key);
|
||||
toast.success(t("deleted", { name: agent.name }));
|
||||
onDeleted();
|
||||
} catch (e) {
|
||||
toast.error(t("deleteFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="hover:border-primary/50 group relative flex flex-col gap-2 rounded-lg border p-4 transition-colors">
|
||||
<button type="button" onClick={onOpen} className="flex flex-col gap-2 text-left">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Bot className="text-muted-foreground size-4" />
|
||||
<span className="text-sm font-medium">{agent.name}</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">{agent.key}</span>
|
||||
{agent.builtin ? (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
{t("badgeBuiltin")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||
{t("badgeCustom")}
|
||||
</Badge>
|
||||
)}
|
||||
{!agent.enabled && (
|
||||
<Badge variant="outline" className="text-destructive px-1.5 py-0 text-[10px]">
|
||||
{t("badgeDisabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* biome-ignore lint/nursery/useNullishCoalescing: 설명 폴백 — 빈 설명도 안내 문구로 대체해야 하므로 || 가 의도된 동작이다(?? 는 빈 문자열을 그대로 노출한다) */}
|
||||
<p className="text-muted-foreground line-clamp-2 min-h-8 text-xs">{agent.description || t("noDescription")}</p>
|
||||
<div className="text-muted-foreground flex flex-wrap gap-1.5 text-[10px]">
|
||||
<span className="rounded border px-1.5 py-0.5">MCP {agent.mcp_count ?? 0}</span>
|
||||
<span className="rounded border px-1.5 py-0.5">Skill {agent.skill_count ?? 0}</span>
|
||||
<span className="rounded border px-1.5 py-0.5">
|
||||
{t("toolLabel")} {agent.tool_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{!agent.builtin && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground hover:text-destructive absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("deleteTitle", { name: agent.name })}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("deleteDesc")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={del}>{t("delete")}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateAgentDialog({ onCreated }: { onCreated: (key: string) => void }) {
|
||||
const t = useTranslations("agentsPage");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [key, setKey] = React.useState("");
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
async function create() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const a = await api.createAgent(key.trim(), name.trim(), description.trim());
|
||||
toast.success(t("created", { name: a.name }));
|
||||
setOpen(false);
|
||||
setKey("");
|
||||
setName("");
|
||||
setDescription("");
|
||||
onCreated(a.key);
|
||||
} catch (e) {
|
||||
toast.error(t("createFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const keyOk = /^[a-z][a-z0-9_]*$/.test(key.trim());
|
||||
const canCreate = keyOk && name.trim().length > 0 && !busy;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<PlusIcon /> {t("new")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("createTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("createDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="agent-key">Key</Label>
|
||||
<Input
|
||||
id="agent-key"
|
||||
placeholder={t("keyPlaceholder")}
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
className="font-mono"
|
||||
/>
|
||||
{key.length > 0 && !keyOk && <span className="text-destructive text-xs">{t("keyError")}</span>}
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="agent-name">{t("nameLabel")}</Label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
placeholder={t("namePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="agent-desc">{t("descLabel")}</Label>
|
||||
<Textarea
|
||||
id="agent-desc"
|
||||
placeholder={t("descPlaceholder")}
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={create} disabled={!canCreate}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const t = useTranslations("agentsPage");
|
||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||
const [editKey, setEditKey] = React.useState<string | null>(null);
|
||||
|
||||
const reload = React.useCallback(() => {
|
||||
api
|
||||
.agents()
|
||||
.then(setAgents)
|
||||
.catch(() => setAgents([]));
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
const editing = agents.find((a) => a.key === editKey) ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
|
||||
</div>
|
||||
<CreateAgentDialog
|
||||
onCreated={(key) => {
|
||||
reload();
|
||||
setEditKey(key);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("listTitle")}</CardTitle>
|
||||
<CardDescription>{t("countTotal", { count: agents.length })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{agents.length === 0 ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">{t("empty")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{agents.map((a) => (
|
||||
<AgentGridCard key={a.key} agent={a} onOpen={() => setEditKey(a.key)} onDeleted={reload} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Sheet open={!!editing} onOpenChange={(o) => !o && setEditKey(null)}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw]"
|
||||
>
|
||||
{editing && (
|
||||
<>
|
||||
<SheetHeader className="px-4">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{editing.name}
|
||||
<span className="text-muted-foreground font-mono text-xs">{editing.key}</span>
|
||||
{!editing.builtin && (
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||
{t("badgeCustom")}
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>{editing.description || t("editorDescFallback")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<AgentEditor agentKey={editing.key} onSaved={reload} />
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApprovalRecords } from "@/components/approval-records";
|
||||
|
||||
export default function ApprovalsPage() {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col p-4 sm:p-6">
|
||||
<ApprovalRecords />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { BanIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { AssetInterceptKind, AssetInterceptRule } from "@/lib/types";
|
||||
|
||||
// ---- kind 메타 정보 ----
|
||||
// label 은 assetInterceptRules.kind.* 에서, group 헤더는 assetInterceptPage.group.* 에서 해석한다.
|
||||
|
||||
type KindGroup = "exact" | "fuzzy" | "cidr";
|
||||
|
||||
const KIND_OPTIONS: { value: AssetInterceptKind; group: KindGroup; placeholder: string }[] = [
|
||||
{ value: "exact_domain", group: "exact", placeholder: "example.gov.cn" },
|
||||
{ value: "exact_ip", group: "exact", placeholder: "203.0.113.10" },
|
||||
{ value: "exact_url", group: "exact", placeholder: "https://example.gov.cn/login" },
|
||||
{ value: "fuzzy_domain", group: "fuzzy", placeholder: ".gov.cn" },
|
||||
{ value: "fuzzy_ip", group: "fuzzy", placeholder: "203.0.113." },
|
||||
{ value: "fuzzy_url", group: "fuzzy", placeholder: "/admin" },
|
||||
{ value: "cidr", group: "cidr", placeholder: "192.168.0.0/16" },
|
||||
];
|
||||
|
||||
const KIND_GROUPS: KindGroup[] = ["exact", "fuzzy", "cidr"];
|
||||
|
||||
function KindBadge({ kind, label }: { kind: AssetInterceptKind; label: string }) {
|
||||
const fuzzy = kind.startsWith("fuzzy_");
|
||||
const cidr = kind === "cidr";
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
cidr
|
||||
? "border-sky-400 text-sky-600"
|
||||
: fuzzy
|
||||
? "border-amber-400 text-amber-600"
|
||||
: "border-emerald-400 text-emerald-600"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Label>
|
||||
{children}
|
||||
{hint && <p className="text-[11px] text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- form state ----
|
||||
|
||||
type RuleForm = {
|
||||
enabled: boolean;
|
||||
kind: AssetInterceptKind;
|
||||
pattern: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
const defaultForm = (): RuleForm => ({ enabled: true, kind: "fuzzy_domain", pattern: "", note: "" });
|
||||
|
||||
// 프런트 가벼운 검증(백엔드와 일치: exact_ip / cidr 만 형식을 검증하고 나머지는 백엔드에 맡긴다).
|
||||
// 오류 코드를 돌려주고, 화면에서 번역 메시지로 바꾼다.
|
||||
function frontValidate(form: RuleForm): "contentRequired" | "cidrInvalid" | null {
|
||||
const p = form.pattern.trim();
|
||||
if (!p) return "contentRequired";
|
||||
if (form.kind === "cidr" && !/^[0-9a-fA-F:.]+\/\d{1,3}$/.test(p)) {
|
||||
return "cidrInvalid";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
|
||||
export default function AssetInterceptPage() {
|
||||
const t = useTranslations("assetInterceptPage");
|
||||
const tr = useTranslations("assetInterceptRules");
|
||||
const [rules, setRules] = React.useState<AssetInterceptRule[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<AssetInterceptRule | null>(null);
|
||||
const [form, setForm] = React.useState<RuleForm>(defaultForm());
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setRules(await api.assetInterceptRules());
|
||||
} catch {
|
||||
toast.error(t("toast.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function set(patch: Partial<RuleForm>) {
|
||||
setForm((f) => ({ ...f, ...patch }));
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setForm(defaultForm());
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(rule: AssetInterceptRule) {
|
||||
setEditing(rule);
|
||||
setForm({ enabled: rule.enabled, kind: rule.kind, pattern: rule.pattern, note: rule.note });
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const err = frontValidate(form);
|
||||
if (err) {
|
||||
toast.error(t(`toast.${err}`));
|
||||
return;
|
||||
}
|
||||
const payload = { ...form, pattern: form.pattern.trim() };
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api.updateAssetInterceptRule(editing.id, payload);
|
||||
toast.success(t("toast.ruleUpdated"));
|
||||
} else {
|
||||
await api.createAssetInterceptRule(payload);
|
||||
toast.success(t("toast.ruleCreated"));
|
||||
}
|
||||
setOpen(false);
|
||||
void load();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(rule: AssetInterceptRule) {
|
||||
if (!window.confirm(t("deleteConfirm", { pattern: rule.pattern }))) return;
|
||||
try {
|
||||
await api.deleteAssetInterceptRule(rule.id);
|
||||
toast.success(t("toast.ruleDeleted"));
|
||||
void load();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(rule: AssetInterceptRule) {
|
||||
try {
|
||||
await api.toggleAssetInterceptRule(rule.id, !rule.enabled);
|
||||
void load();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const placeholder = KIND_OPTIONS.find((o) => o.value === form.kind)?.placeholder ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-5 p-6">
|
||||
{/* header */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<BanIcon className="h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">{t("intro")}</p>
|
||||
<Button onClick={openNew} size="sm" className="shrink-0">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-muted-foreground">{t("loading")}</p>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<BanIcon className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">{t("empty")}</p>
|
||||
<Button size="sm" variant="outline" onClick={openNew}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("addFirst")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[130px]">{t("colType")}</TableHead>
|
||||
<TableHead>{t("colPattern")}</TableHead>
|
||||
<TableHead>{t("colNote")}</TableHead>
|
||||
<TableHead className="w-[64px] text-center">{t("colEnabled")}</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map((rule) => (
|
||||
<TableRow key={rule.id} className={!rule.enabled ? "opacity-40" : ""}>
|
||||
<TableCell>
|
||||
<KindBadge kind={rule.kind} label={tr(`kind.${rule.kind}`)} />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px]">
|
||||
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
|
||||
{rule.pattern}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{rule.builtin && (
|
||||
<Badge variant="secondary" className="shrink-0 px-1 py-0 text-[10px]">
|
||||
{t("builtin")}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="truncate">{rule.note}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch checked={rule.enabled} onCheckedChange={() => handleToggle(rule)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(rule)}>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(rule)}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* editor sheet */}
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="flex flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b px-6 py-4">
|
||||
<SheetTitle>{editing ? t("editor.editTitle") : t("editor.newTitle")}</SheetTitle>
|
||||
<SheetDescription className="text-xs">{t("editor.desc")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
|
||||
<Field label={t("editor.matchType")}>
|
||||
<Select value={form.kind} onValueChange={(v) => set({ kind: v as AssetInterceptKind })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{KIND_GROUPS.map((g) => (
|
||||
<React.Fragment key={g}>
|
||||
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(`group.${g}`)}
|
||||
</div>
|
||||
{KIND_OPTIONS.filter((o) => o.group === g).map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{tr(`kind.${o.value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("editor.matchContent")}
|
||||
hint={
|
||||
form.kind === "cidr"
|
||||
? t("editor.hintCidr")
|
||||
: form.kind.startsWith("fuzzy_")
|
||||
? t("editor.hintFuzzy")
|
||||
: t("editor.hintExact")
|
||||
}
|
||||
>
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={form.pattern}
|
||||
onChange={(e) => set({ pattern: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("editor.note")}>
|
||||
<Textarea
|
||||
placeholder={t("editor.notePlaceholder")}
|
||||
value={form.note}
|
||||
onChange={(e) => set({ note: e.target.value })}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="asset-rule-enabled" checked={form.enabled} onCheckedChange={(v) => set({ enabled: v })} />
|
||||
<Label htmlFor="asset-rule-enabled" className="cursor-pointer">
|
||||
{t("editor.enableRule")}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t px-6 py-4 flex-row justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t("editor.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? t("editor.saving") : t("editor.save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,902 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { BotIcon, ListFilterIcon, PencilIcon, PlusIcon, ShieldAlertIcon, Trash2Icon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptAction, InterceptRule, JudgeConfig, LLMProfile, Tool } from "@/lib/types";
|
||||
|
||||
// ---- tool scope ----
|
||||
|
||||
// SDK tools are intentionally not seeded into the DB (they apply to every agent
|
||||
// and have no per-agent binding). We hardcode their keys here so they still
|
||||
// appear in the scope dialog; descriptions are resolved from i18n at render time.
|
||||
const SDK_EXEC_KEYS = [
|
||||
"Bash",
|
||||
"WebFetch",
|
||||
"web_search",
|
||||
"shell_open",
|
||||
"shell_send",
|
||||
"shell_read",
|
||||
"shell_close",
|
||||
"shell_list",
|
||||
] as const;
|
||||
const SDK_WRITE_KEYS = ["Write", "Edit", "MultiEdit"] as const;
|
||||
|
||||
const SDK_KEYS = new Set<string>([...SDK_EXEC_KEYS, ...SDK_WRITE_KEYS]);
|
||||
|
||||
// ---- form state ----
|
||||
|
||||
type RuleForm = {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
match_target: "tool_name" | "tool_input";
|
||||
match_type: "string" | "regex";
|
||||
pattern: string;
|
||||
action: InterceptAction;
|
||||
message: string;
|
||||
timeout_enabled: boolean;
|
||||
timeout_seconds: number;
|
||||
timeout_action: "deny" | "allow";
|
||||
};
|
||||
|
||||
const defaultForm = (): RuleForm => ({
|
||||
name: "",
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
match_target: "tool_name",
|
||||
match_type: "string",
|
||||
pattern: "",
|
||||
action: "deny",
|
||||
message: "",
|
||||
timeout_enabled: true,
|
||||
timeout_seconds: 60,
|
||||
timeout_action: "deny",
|
||||
});
|
||||
|
||||
// ---- small components ----
|
||||
|
||||
function ActionBadge({ action, label }: { action: InterceptAction; label: string }) {
|
||||
if (action === "allow") return <Badge variant="secondary">{label}</Badge>;
|
||||
if (action === "deny") return <Badge variant="destructive">{label}</Badge>;
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-400 text-amber-600">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{label}</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- LLM fallback judge card ----
|
||||
|
||||
const FOLLOW_ACTIVE = "0"; // profile_id 0 = 활성/기본 설정 따르기
|
||||
|
||||
const defaultJudge = (): JudgeConfig => ({
|
||||
enabled: false,
|
||||
profile_id: 0,
|
||||
prompt: "",
|
||||
timeout_seconds: 15,
|
||||
fail_action: "allow",
|
||||
ask_timeout_seconds: 300,
|
||||
ask_timeout_action: "deny",
|
||||
});
|
||||
|
||||
function JudgeCard() {
|
||||
const t = useTranslations("interceptPage");
|
||||
const [cfg, setCfg] = React.useState<JudgeConfig>(defaultJudge());
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [j, ps] = await Promise.all([api.interceptGetJudgeConfig(), api.llmProfiles()]);
|
||||
setCfg(j);
|
||||
setProfiles(ps);
|
||||
} catch (e) {
|
||||
toast.error(t("judge.loadConfigFailed", { error: (e as Error).message }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function patch(p: Partial<JudgeConfig>) {
|
||||
setCfg((c) => ({ ...c, ...p }));
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.interceptSetJudgeConfig(cfg);
|
||||
toast.success(t("judge.configSaved"));
|
||||
await load(); // 되읽기: 프롬프트를 비우면 내장 템플릿을 다시 채운다
|
||||
} catch (e) {
|
||||
toast.error(t("toast.saveFailed", { error: (e as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restorePrompt() {
|
||||
// 프롬프트를 비우고 저장하면 서버가 다음 응답에 내장 템플릿 전문을 돌려주고, 입력창에 다시 채워진다.
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.interceptSetJudgeConfig({ ...cfg, prompt: "" });
|
||||
const j = await api.interceptGetJudgeConfig();
|
||||
setCfg(j);
|
||||
toast.success(t("judge.restored"));
|
||||
} catch (e) {
|
||||
toast.error(t("judge.restoreFailed", { error: (e as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 사용 스위치 — 독립 강조 바 */}
|
||||
<div
|
||||
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-3 ${
|
||||
cfg.enabled ? "border-violet-400/50 bg-violet-50/40 dark:bg-violet-950/20" : "bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<BotIcon className={`h-5 w-5 shrink-0 ${cfg.enabled ? "text-violet-600" : "text-muted-foreground"}`} />
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">{t("judge.title")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{t.rich("judge.desc", {
|
||||
scope: (c) => <span className="font-medium text-foreground">{c}</span>,
|
||||
norule: (c) => <span className="font-medium text-foreground">{c}</span>,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cfg.enabled ? t("judge.enabled") : t("judge.disabled")}
|
||||
</span>
|
||||
<Switch checked={cfg.enabled} disabled={loading} onCheckedChange={(v) => patch({ enabled: v })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cfg.enabled && (
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
{/* 왼쪽: 프롬프트 편집기(바로 펼침, 주 영역) */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardContent className="flex h-full flex-col gap-2 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("judge.promptTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("judge.promptDesc")}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={restorePrompt} disabled={saving}>
|
||||
{t("judge.restoreTemplate")}
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-[22rem] flex-1 resize-none font-mono text-xs leading-relaxed"
|
||||
value={cfg.prompt}
|
||||
onChange={(e) => patch({ prompt: e.target.value })}
|
||||
placeholder={t("judge.promptPlaceholder")}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-right text-[11px] text-muted-foreground">
|
||||
{t("judge.charCount", { count: cfg.prompt.length })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 오른쪽: 판정 매개변수(설정 칸) */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardContent className="space-y-5 p-4">
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("judge.modelAndPolicy")}
|
||||
</p>
|
||||
<Field label={t("judge.approvalModel")}>
|
||||
<Select value={String(cfg.profile_id || 0)} onValueChange={(v) => patch({ profile_id: Number(v) })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={FOLLOW_ACTIVE}>{t("judge.followActive")}</SelectItem>
|
||||
{profiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}({p.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={t("judge.judgeTimeout")}>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={cfg.timeout_seconds}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (n > 0) patch({ timeout_seconds: n });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("judge.failAction")}>
|
||||
<Select
|
||||
value={cfg.fail_action}
|
||||
onValueChange={(v) => patch({ fail_action: v as JudgeConfig["fail_action"] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">{t("judge.failAllow")}</SelectItem>
|
||||
<SelectItem value="ask">{t("judge.failAsk")}</SelectItem>
|
||||
<SelectItem value="deny">{t("judge.failDeny")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("judge.manualApproval")}
|
||||
</p>
|
||||
<Field label={t("judge.askTimeout")}>
|
||||
<Input
|
||||
type="number"
|
||||
min={5}
|
||||
value={cfg.ask_timeout_seconds}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (n > 0) patch({ ask_timeout_seconds: n });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("judge.askTimeoutAction")}>
|
||||
<Select
|
||||
value={cfg.ask_timeout_action}
|
||||
onValueChange={(v) => patch({ ask_timeout_action: v as JudgeConfig["ask_timeout_action"] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">{t("judge.askTimeoutDeny")}</SelectItem>
|
||||
<SelectItem value="allow">{t("judge.askTimeoutAllow")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={save} disabled={saving || loading}>
|
||||
{saving ? t("judge.saving") : t("judge.saveConfig")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- page ----
|
||||
|
||||
export default function InterceptPage() {
|
||||
const t = useTranslations("interceptPage");
|
||||
const [rules, setRules] = React.useState<InterceptRule[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<InterceptRule | null>(null);
|
||||
const [form, setForm] = React.useState<RuleForm>(defaultForm());
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [regexErr, setRegexErr] = React.useState("");
|
||||
const [regexWarn, setRegexWarn] = React.useState(false); // true = JS 는 못 파싱하지만 Go 에서는 유효한 문법일 수 있음
|
||||
|
||||
// ---- tool scope dialog ----
|
||||
const [scopeOpen, setScopeOpen] = React.useState(false);
|
||||
const [allTools, setAllTools] = React.useState<Tool[]>([]);
|
||||
const [enabledTools, setEnabledTools] = React.useState<Set<string>>(new Set());
|
||||
const [scopeLoading, setScopeLoading] = React.useState(false);
|
||||
const [scopeSaving, setScopeSaving] = React.useState(false);
|
||||
const [scopeTools, setScopeTools] = React.useState<string[]>([]); // 페이지 머리 정보 바: 현재 가로채기에 들어간 도구
|
||||
|
||||
// ---- data ----
|
||||
|
||||
const loadScope = React.useCallback(async () => {
|
||||
try {
|
||||
const cfg = await api.interceptGetToolConfig();
|
||||
setScopeTools(cfg.enabled_tools);
|
||||
} catch {
|
||||
// 정보 바는 핵심이 아니라 실패하면 조용히 넘긴다
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
const r = await api.interceptRules();
|
||||
setRules(r);
|
||||
} catch {
|
||||
toast.error(t("toast.loadRulesFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
void loadScope();
|
||||
}, [load, loadScope]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (form.match_type !== "regex" || !form.pattern) {
|
||||
setRegexErr("");
|
||||
setRegexWarn(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new RegExp(form.pattern);
|
||||
setRegexErr("");
|
||||
setRegexWarn(false);
|
||||
} catch {
|
||||
// JS RegExp 는 Go RE2 확장 문법(예: (?i) 인라인 플래그)을 지원하지 않는다.
|
||||
// 여기서의 미리 보기 검증 실패가 Go 에서 무효하다는 뜻은 아니며, 최종 검증은 서버에 맡긴다.
|
||||
setRegexErr("");
|
||||
setRegexWarn(true);
|
||||
}
|
||||
}, [form.pattern, form.match_type]);
|
||||
|
||||
// ---- rule handlers ----
|
||||
|
||||
function set(patch: Partial<RuleForm>) {
|
||||
setForm((f) => ({ ...f, ...patch }));
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
setEditing(null);
|
||||
setForm(defaultForm());
|
||||
setRegexErr("");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(rule: InterceptRule) {
|
||||
setEditing(rule);
|
||||
setForm({
|
||||
name: rule.name,
|
||||
enabled: rule.enabled,
|
||||
priority: rule.priority,
|
||||
match_target: rule.match_target,
|
||||
match_type: rule.match_type,
|
||||
pattern: rule.pattern,
|
||||
action: rule.action,
|
||||
message: rule.message,
|
||||
timeout_enabled: rule.timeout_enabled,
|
||||
timeout_seconds: rule.timeout_seconds,
|
||||
timeout_action: rule.timeout_action,
|
||||
});
|
||||
setRegexErr("");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.name.trim()) {
|
||||
toast.error(t("toast.nameRequired"));
|
||||
return;
|
||||
}
|
||||
if (!form.pattern.trim()) {
|
||||
toast.error(t("toast.patternRequired"));
|
||||
return;
|
||||
}
|
||||
if (regexErr) {
|
||||
toast.error(t("toast.regexInvalid"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await api.updateInterceptRule(editing.id, form);
|
||||
toast.success(t("toast.ruleUpdated"));
|
||||
} else {
|
||||
await api.createInterceptRule(form);
|
||||
toast.success(t("toast.ruleCreated"));
|
||||
}
|
||||
setOpen(false);
|
||||
void load();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await api.deleteInterceptRule(id);
|
||||
toast.success(t("toast.ruleDeleted"));
|
||||
void load();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(rule: InterceptRule) {
|
||||
try {
|
||||
await api.toggleInterceptRule(rule.id, !rule.enabled);
|
||||
void load();
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- scope handlers ----
|
||||
|
||||
async function openScope() {
|
||||
setScopeOpen(true);
|
||||
setScopeLoading(true);
|
||||
try {
|
||||
const [tools, cfg] = await Promise.all([api.tools(), api.interceptGetToolConfig()]);
|
||||
setAllTools(tools);
|
||||
setEnabledTools(new Set(cfg.enabled_tools));
|
||||
} catch (e) {
|
||||
toast.error(t("toast.loadFailed", { error: (e as Error).message }));
|
||||
} finally {
|
||||
setScopeLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTool(key: string, val: boolean) {
|
||||
setEnabledTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (val) next.add(key);
|
||||
else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveScope() {
|
||||
setScopeSaving(true);
|
||||
try {
|
||||
await api.interceptSetToolConfig([...enabledTools]);
|
||||
toast.success(t("toast.scopeSaved"));
|
||||
setScopeTools([...enabledTools]);
|
||||
setScopeOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(t("toast.saveFailed", { error: (e as Error).message }));
|
||||
} finally {
|
||||
setScopeSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const toolGroups = React.useMemo(() => {
|
||||
const mk = (key: string): Tool => ({
|
||||
key,
|
||||
system: true,
|
||||
description: t(`toolDesc.${key}`),
|
||||
schema: {},
|
||||
agents: [],
|
||||
enabled: true,
|
||||
kind: "builtin",
|
||||
});
|
||||
const sdkExec = SDK_EXEC_KEYS.map(mk);
|
||||
const sdkWrite = SDK_WRITE_KEYS.map(mk);
|
||||
const sys: Tool[] = [],
|
||||
custom: Tool[] = [];
|
||||
for (const tool of allTools) {
|
||||
if (SDK_KEYS.has(tool.key)) continue; // 하드코딩 그룹에서 이미 다룸
|
||||
if (tool.system) sys.push(tool);
|
||||
else custom.push(tool);
|
||||
}
|
||||
return [
|
||||
{ label: t("toolGroup.exec"), tools: sdkExec },
|
||||
{ label: t("toolGroup.write"), tools: sdkWrite },
|
||||
{ label: t("toolGroup.system"), tools: sys },
|
||||
{ label: t("toolGroup.custom"), tools: custom },
|
||||
].filter((g) => g.tools.length > 0);
|
||||
}, [allTools, t]);
|
||||
|
||||
// ---- render ----
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-5 p-6">
|
||||
{/* ---- header ---- */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ShieldAlertIcon className="h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold leading-tight">{t("title")}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">{t("subtitle")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- 가로채기 범위 정보 바(규칙 대조와 모델 폴백 공용: 범위 밖 도구는 둘 다 개입 안 함) ---- */}
|
||||
<div
|
||||
className={`flex items-center justify-between gap-3 rounded-lg border px-4 py-2.5 ${
|
||||
scopeTools.length === 0 ? "border-amber-400/60 bg-amber-50/50 dark:bg-amber-950/20" : "bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<ListFilterIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="shrink-0 font-medium">{t("scope.label")}</span>
|
||||
{scopeTools.length === 0 ? (
|
||||
<span className="text-amber-700 dark:text-amber-500">{t("scope.empty")}</span>
|
||||
) : (
|
||||
<>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{t("scope.toolCount", { count: scopeTools.length })}
|
||||
</Badge>
|
||||
<span className="truncate text-muted-foreground" title={scopeTools.join(" · ")}>
|
||||
{scopeTools.join(" · ")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={scopeTools.length === 0 ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={openScope}
|
||||
>
|
||||
<ListFilterIcon className="h-4 w-4" />
|
||||
{t("scope.adjust")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="rules" className="flex-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="rules">{t("tab.rules")}</TabsTrigger>
|
||||
<TabsTrigger value="judge">{t("tab.model")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ---- tab: rules ---- */}
|
||||
<TabsContent value="rules" className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">{t("rules.priorityHint")}</p>
|
||||
<Button onClick={openNew} size="sm" className="shrink-0">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("rules.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-muted-foreground">{t("rules.loading")}</p>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
|
||||
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">{t("rules.empty")}</p>
|
||||
<Button size="sm" variant="outline" onClick={openNew}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("rules.addFirst")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[72px]">{t("rules.colPriority")}</TableHead>
|
||||
<TableHead>{t("rules.colName")}</TableHead>
|
||||
<TableHead className="w-[90px]">{t("rules.colTarget")}</TableHead>
|
||||
<TableHead className="w-[80px]">{t("rules.colType")}</TableHead>
|
||||
<TableHead>{t("rules.colPattern")}</TableHead>
|
||||
<TableHead className="w-[72px]">{t("rules.colAction")}</TableHead>
|
||||
<TableHead className="w-[64px] text-center">{t("rules.colEnabled")}</TableHead>
|
||||
<TableHead className="w-[80px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rules.map((rule) => (
|
||||
<TableRow key={rule.id} className={!rule.enabled ? "opacity-40" : ""}>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs tabular-nums">{rule.priority}</span>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-sm">{rule.name}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rule.match_target === "tool_name" ? t("target.tool_name") : t("target.tool_input")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rule.match_type === "regex" ? t("matchType.regex") : t("matchType.string")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[220px]">
|
||||
<code className="block truncate rounded bg-muted px-1.5 py-0.5 text-xs font-mono">
|
||||
{rule.pattern}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ActionBadge action={rule.action} label={t(`action.${rule.action}`)} />
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch checked={rule.enabled} onCheckedChange={() => handleToggle(rule)} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => openEdit(rule)}>
|
||||
<PencilIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(rule.id)}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* ---- tab: model ---- */}
|
||||
<TabsContent value="judge" className="mt-4">
|
||||
<JudgeCard />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* ---- editor sheet ---- */}
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="flex flex-col gap-0 p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b px-6 py-4">
|
||||
<SheetTitle>{editing ? t("editor.editTitle") : t("editor.newTitle")}</SheetTitle>
|
||||
<SheetDescription className="text-xs">{t("editor.desc")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
|
||||
<Field label={t("editor.name")}>
|
||||
<Input
|
||||
placeholder={t("editor.namePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("editor.priority")}>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.priority}
|
||||
onChange={(e) => set({ priority: parseInt(e.target.value) || 0 })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Field label={t("editor.matchTarget")}>
|
||||
<Select
|
||||
value={form.match_target}
|
||||
onValueChange={(v) => set({ match_target: v as RuleForm["match_target"] })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="tool_name">{t("editor.targetToolName")}</SelectItem>
|
||||
<SelectItem value="tool_input">{t("editor.targetToolInput")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label={t("editor.matchTypeLabel")}>
|
||||
<Select value={form.match_type} onValueChange={(v) => set({ match_type: v as RuleForm["match_type"] })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">{t("editor.typeStringContains")}</SelectItem>
|
||||
<SelectItem value="regex">{t("editor.typeRegex")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label={t("editor.pattern")}>
|
||||
<Input
|
||||
placeholder={form.match_type === "regex" ? "^Bash$" : "rm -rf"}
|
||||
value={form.pattern}
|
||||
onChange={(e) => set({ pattern: e.target.value })}
|
||||
className={regexErr ? "border-destructive focus-visible:ring-destructive" : ""}
|
||||
/>
|
||||
{regexErr && <p className="text-xs text-destructive mt-1">{regexErr}</p>}
|
||||
{regexWarn && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
{t.rich("editor.regexWarn", { code: (c) => <code className="font-mono">{c}</code> })}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Field label={t("editor.actionLabel")}>
|
||||
<Select value={form.action} onValueChange={(v) => set({ action: v as InterceptAction })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="allow">{t("editor.actionAllow")}</SelectItem>
|
||||
<SelectItem value="deny">{t("editor.actionDeny")}</SelectItem>
|
||||
<SelectItem value="ask">{t("editor.actionAsk")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{form.action !== "allow" && (
|
||||
<Field label={form.action === "deny" ? t("editor.denyMessage") : t("editor.askMessage")}>
|
||||
<Textarea
|
||||
placeholder={form.action === "deny" ? t("editor.denyMessagePlaceholder") : ""}
|
||||
value={form.message}
|
||||
onChange={(e) => set({ message: e.target.value })}
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{form.action === "ask" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("editor.enableTimeout")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("editor.enableTimeoutHint")}</p>
|
||||
</div>
|
||||
<Switch checked={form.timeout_enabled} onCheckedChange={(v) => set({ timeout_enabled: v })} />
|
||||
</div>
|
||||
{form.timeout_enabled && (
|
||||
<div className="flex items-end gap-3">
|
||||
<Field label={t("editor.timeoutSeconds")}>
|
||||
<Input
|
||||
type="number"
|
||||
min={5}
|
||||
className="w-28"
|
||||
value={form.timeout_seconds}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (n > 0) set({ timeout_seconds: n });
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("editor.timeoutAction")}>
|
||||
<Select
|
||||
value={form.timeout_action}
|
||||
onValueChange={(v) => set({ timeout_action: v as "deny" | "allow" })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deny">{t("editor.timeoutDeny")}</SelectItem>
|
||||
<SelectItem value="allow">{t("editor.timeoutAllow")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch id="rule-enabled" checked={form.enabled} onCheckedChange={(v) => set({ enabled: v })} />
|
||||
<Label htmlFor="rule-enabled" className="cursor-pointer">
|
||||
{t("editor.enableRule")}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t px-6 py-4 flex-row justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||
{t("editor.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !!regexErr}>
|
||||
{saving ? t("editor.saving") : t("editor.save")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* ---- scope dialog ---- */}
|
||||
<Dialog open={scopeOpen} onOpenChange={setScopeOpen}>
|
||||
<DialogContent
|
||||
className="sm:max-w-lg flex flex-col overflow-hidden p-0 gap-0"
|
||||
style={{ maxHeight: "min(80vh, 560px)" }}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ListFilterIcon className="h-4 w-4" />
|
||||
{t("scope.dialogTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">{t("scope.dialogDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-4 space-y-5">
|
||||
{scopeLoading ? (
|
||||
<p className="text-sm text-muted-foreground py-4">{t("rules.loading")}</p>
|
||||
) : (
|
||||
toolGroups.map((group, gi) => (
|
||||
<div key={group.label}>
|
||||
{gi > 0 && <Separator className="mb-5" />}
|
||||
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">
|
||||
{group.label}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{group.tools.map((tool) => (
|
||||
<div key={tool.key} className="flex items-center gap-3 rounded-md px-2 py-1.5 hover:bg-muted/50">
|
||||
<Switch
|
||||
id={`scope-${tool.key}`}
|
||||
checked={enabledTools.has(tool.key)}
|
||||
onCheckedChange={(v) => toggleTool(tool.key, v)}
|
||||
/>
|
||||
<label htmlFor={`scope-${tool.key}`} className="flex-1 min-w-0 cursor-pointer">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-sm">{tool.key}</span>
|
||||
{tool.kind && tool.kind !== "builtin" && (
|
||||
<Badge variant="outline" className="px-1 py-0 text-[10px]">
|
||||
{tool.kind}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{tool.description && (
|
||||
<p className="text-[11px] text-muted-foreground line-clamp-1">{tool.description}</p>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t px-6 py-3 flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setScopeOpen(false)}>
|
||||
{t("editor.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={saveScope} disabled={scopeSaving || scopeLoading}>
|
||||
{scopeSaving ? t("editor.saving") : t("editor.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
// LLM 重试配置的共用件:五层重试各自的「次数 + 间隔」。
|
||||
//
|
||||
// 五层从内到外:建连(SDK) → 空响应(SDK) → 同 provider 安全窗口 → 轮询熔断 → 意图重跑。
|
||||
// 前三层跟着端点走,所以每个模型配置都能覆盖全局默认;后两层是进程级的,只有全局一份。
|
||||
//
|
||||
// 所有输入都遵循同一套「留空 = 不配置」语义,与后端 db.RetryRule 一致:
|
||||
// 次数 空/0 = 用内置默认 | -1 = 关闭这层重试 | >0 = 用这个次数
|
||||
// 间隔 空/0 = 用这层原本的指数退避 | >0 = 改用这个固定毫秒间隔
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { Loader2Icon, SaveIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { api } from "@/lib/api";
|
||||
import type { LLMRetryOverride, LLMRetryPolicy, LLMRetryRule } from "@/lib/types";
|
||||
|
||||
export const ZERO_RULE: LLMRetryRule = { attempts: 0, interval_ms: 0 };
|
||||
export const ZERO_OVERRIDE: LLMRetryOverride = {
|
||||
connect: ZERO_RULE,
|
||||
empty: ZERO_RULE,
|
||||
stream: ZERO_RULE,
|
||||
};
|
||||
const ZERO_POLICY: LLMRetryPolicy = {
|
||||
...ZERO_OVERRIDE,
|
||||
breaker: ZERO_RULE,
|
||||
intent: ZERO_RULE,
|
||||
};
|
||||
|
||||
// 每层的「次数留空时的默认值」,仅用于输入框占位符。其余文案(标题 / 触发 / 说明等)
|
||||
// 走 i18n(llmPage.retryLayer.<层>.*),按运行时语言渲染。
|
||||
export const RETRY_LAYERS = {
|
||||
connect: { defAttempts: 3 },
|
||||
empty: { defAttempts: 2 },
|
||||
stream: { defAttempts: 2 },
|
||||
breaker: { defAttempts: 3 },
|
||||
intent: { defAttempts: 2 },
|
||||
} satisfies Record<string, { defAttempts: number }>;
|
||||
|
||||
type LayerKey = keyof typeof RETRY_LAYERS;
|
||||
|
||||
/** 毫秒的人话,只用于在输入框旁边回显,免得数零。 */
|
||||
function humanMs(ms: number) {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return "";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${Number((ms / 1000).toFixed(2))}s`;
|
||||
return `${Number((ms / 60_000).toFixed(2))}min`;
|
||||
}
|
||||
|
||||
/** 受控数字输入:空串 ↔ 0,中间态("-"、"1e")原样留在本地,不打扰父级。 */
|
||||
function NumField({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
min,
|
||||
}: {
|
||||
id: string;
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
placeholder: string;
|
||||
min: number;
|
||||
}) {
|
||||
const [text, setText] = React.useState(value === 0 ? "" : String(value));
|
||||
// 父级换了一整套值(读取到策略、切换配置)时跟上;自己敲字时不会走到这里,
|
||||
// 因为那时 value 已经等于本地文本 parse 后的结果。
|
||||
React.useEffect(() => {
|
||||
const incoming = value === 0 ? "" : String(value);
|
||||
setText((cur) => (Number(cur || 0) === value ? cur : incoming));
|
||||
}, [value]);
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min={min}
|
||||
className="w-28 shrink-0"
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
const n = Number(e.target.value);
|
||||
onChange(e.target.value.trim() === "" || !Number.isFinite(n) ? 0 : Math.trunc(n));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一层重试的两个旋钮。idPrefix 用来在同一页出现多次时保住 label 的 htmlFor。 */
|
||||
export function RetryRuleFields({
|
||||
layer,
|
||||
idPrefix,
|
||||
value,
|
||||
onChange,
|
||||
compact,
|
||||
}: {
|
||||
layer: LayerKey;
|
||||
idPrefix: string;
|
||||
value: LLMRetryRule;
|
||||
onChange: (r: LLMRetryRule) => void;
|
||||
/** true = 配置抽屉里的紧凑版:省掉展开说明,只留「什么错误会走到这层」这一句 */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("llmPage");
|
||||
const meta = RETRY_LAYERS[layer];
|
||||
const human = humanMs(value.interval_ms);
|
||||
return (
|
||||
<div className={compact ? "grid gap-2" : "grid gap-3 rounded-lg border p-3"}>
|
||||
<div className="grid gap-0.5">
|
||||
<div className="flex flex-wrap items-baseline gap-2">
|
||||
<Label className="text-sm">{t(`retryLayer.${layer}.title`)}</Label>
|
||||
<span className="text-muted-foreground text-xs">{t(`retryLayer.${layer}.where`)}</span>
|
||||
</div>
|
||||
{/* 哪些错误会走到这层,具体到状态码——填了旋钮却看不到效果,多半是错误压根不落在这层。 */}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium text-foreground">{t("retry.triggerLabel")}</span>:{" "}
|
||||
{t(`retryLayer.${layer}.trigger`)}
|
||||
</p>
|
||||
{!compact && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<span className="font-medium text-foreground">{t("retry.skipsLabel")}</span>:{" "}
|
||||
{t(`retryLayer.${layer}.skips`)}
|
||||
</p>
|
||||
)}
|
||||
{!compact && <p className="text-muted-foreground text-xs">{t(`retryLayer.${layer}.desc`)}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`${idPrefix}-${layer}-n`} className="text-muted-foreground text-xs">
|
||||
{t(`retryLayer.${layer}.attemptsLabel`)}
|
||||
</Label>
|
||||
<NumField
|
||||
id={`${idPrefix}-${layer}-n`}
|
||||
min={-1}
|
||||
value={value.attempts}
|
||||
placeholder={t("retry.defaultN", { n: meta.defAttempts })}
|
||||
onChange={(n) => onChange({ ...value, attempts: n })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`${idPrefix}-${layer}-ms`} className="text-muted-foreground text-xs">
|
||||
{t("retry.intervalMs")}
|
||||
</Label>
|
||||
<NumField
|
||||
id={`${idPrefix}-${layer}-ms`}
|
||||
min={0}
|
||||
value={value.interval_ms}
|
||||
placeholder={t("retry.defaultBackoff")}
|
||||
onChange={(n) => onChange({ ...value, interval_ms: n })}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{human ? t("retry.fixed", { human }) : t(`retryLayer.${layer}.defInterval`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!compact && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("retry.emptyLine", { hint: t(`retryLayer.${layer}.offHint`) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 模型配置抽屉里的三层覆盖(跟着端点走的那三层)。 */
|
||||
export function ProfileRetryFields({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: LLMRetryOverride;
|
||||
onChange: (o: LLMRetryOverride) => void;
|
||||
}) {
|
||||
const t = useTranslations("llmPage");
|
||||
return (
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">{t("retry.override.label")}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t("retry.override.hint")}</p>
|
||||
</div>
|
||||
{(["connect", "empty", "stream"] as const).map((k) => (
|
||||
<div key={k} className="border-t pt-3 first:border-t-0 first:pt-0">
|
||||
<RetryRuleFields
|
||||
compact
|
||||
layer={k}
|
||||
idPrefix="pf"
|
||||
value={value[k]}
|
||||
onChange={(r) => onChange({ ...value, [k]: r })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 「重试与退避」tab:五层的全局默认值。 */
|
||||
export function RetryPolicyPanel() {
|
||||
const t = useTranslations("llmPage");
|
||||
const [policy, setPolicy] = React.useState<LLMRetryPolicy>(ZERO_POLICY);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const p = await api.llmRetryPolicy();
|
||||
setPolicy({ ...ZERO_POLICY, ...p });
|
||||
} catch (e) {
|
||||
toast.error(t("retry.toast.loadFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function save() {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
// 后端会把越界值夹回区间并回传,直接用回传值刷新,所见即所存。
|
||||
const saved = await api.saveLLMRetryPolicy(policy);
|
||||
setPolicy({ ...ZERO_POLICY, ...saved });
|
||||
toast.success(t("retry.toast.saved"));
|
||||
} catch (e) {
|
||||
toast.error(t("retry.toast.saveFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const set = (k: LayerKey) => (r: LLMRetryRule) => setPolicy((p) => ({ ...p, [k]: r }));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-dashed p-10 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" /> {t("retry.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-muted-foreground text-xs leading-relaxed">
|
||||
{t.rich("retry.intro", {
|
||||
chain: (c) => <span className="text-foreground">{c}</span>,
|
||||
emph: (c) => <span className="text-foreground">{c}</span>,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{(Object.keys(RETRY_LAYERS) as LayerKey[]).map((k) => (
|
||||
<RetryRuleFields key={k} layer={k} idPrefix="gl" value={policy[k]} onChange={set(k)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? <Loader2Icon className="animate-spin" /> : <SaveIcon />}
|
||||
{t("retry.save")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setPolicy(ZERO_POLICY)} disabled={saving}>
|
||||
{t("retry.resetAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
Loader2Icon,
|
||||
PlugZapIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
RotateCcwIcon,
|
||||
SaveIcon,
|
||||
StarIcon,
|
||||
Trash2Icon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { LLMPoolMember, LLMPoolStatus, LLMProfile, LLMRetryOverride } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ProfileRetryFields, RetryPolicyPanel, ZERO_OVERRIDE } from "./_components/retry";
|
||||
|
||||
// 思考开关(thinking.type)与思考强度(reasoning_effort)是两个【互相独立】的字段,
|
||||
// 各自单独设置——有些接口没有 thinking 字段、只靠强度参数就能激活思考,故需解耦。
|
||||
// 存库空字符串 = 该字段【不发送】;Radix Select 不接受空 value,故 UI 用 "none"
|
||||
// 哨兵表示不发送,存取时与 "" 互转(NONE / fromStore / toStore)。
|
||||
const NONE = "none";
|
||||
const fromStore = (v?: string) => (v ? v : NONE);
|
||||
const toStore = (v: string) => (v === NONE ? "" : v);
|
||||
|
||||
function cooldownText(secs: number) {
|
||||
if (secs <= 0) return "";
|
||||
if (secs < 60) return `${secs}s`;
|
||||
return `${Math.ceil(secs / 60)}min`;
|
||||
}
|
||||
|
||||
// 一个配置在卡片上显示的「是否正常」。没填 Key 的配置根本发不出请求,比熔断更该先说;
|
||||
// 其余状态来自轮询的熔断记录(轮询关着时不会产生新记录,此时「正常」= 没有已知故障)。
|
||||
// 文案走 i18n(llmPage.health.*),所以把 t 传进来由调用方注入。
|
||||
type Health = { label: string; cls: string; hint?: string };
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 轮询配置抽屉
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function PoolSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
pool,
|
||||
onReload,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
pool: LLMPoolStatus | null;
|
||||
onReload: () => Promise<void>;
|
||||
}) {
|
||||
const t = useTranslations("llmPage");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
// 冷却倒计时是后端算出的剩余秒数——抽屉开着且有配置不正常时才定时拉,让它走起来。
|
||||
React.useEffect(() => {
|
||||
if (!open || !pool?.enabled || !pool.chain.some((m) => m.state !== "ok")) return;
|
||||
const t = setInterval(() => void onReload(), 10_000);
|
||||
return () => clearInterval(t);
|
||||
}, [open, pool, onReload]);
|
||||
|
||||
async function toggle(patch: { llm_pool_enabled?: boolean; llm_pool_bind_fallback?: boolean }) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.setSettings(patch);
|
||||
await onReload();
|
||||
if (patch.llm_pool_enabled !== undefined) {
|
||||
toast.success(patch.llm_pool_enabled ? t("pool.toast.enabled") : t("pool.toast.disabled"));
|
||||
} else {
|
||||
toast.success(t("pool.toast.fallbackUpdated"));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(t("pool.toast.setFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function recover(id?: string) {
|
||||
try {
|
||||
await api.resetLLMPool(id);
|
||||
await onReload();
|
||||
toast.success(id ? t("pool.toast.recoveredOne") : t("pool.toast.recoveredAll"));
|
||||
} catch (e) {
|
||||
toast.error(t("pool.toast.recoverFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = pool?.enabled ?? false;
|
||||
const chain = pool?.chain ?? [];
|
||||
// 参与轮询的成员(排除被标记「不参与轮询」的),顺序即后端实际的尝试顺序。
|
||||
const inChain = chain.filter((m) => m.active || !m.excluded);
|
||||
const tripped = chain.filter((m) => m.state === "tripped");
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex flex-col gap-0 p-0 data-[side=right]:sm:max-w-lg">
|
||||
<SheetHeader className="px-4">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<ZapIcon className="size-4" /> {t("pool.title")}
|
||||
</SheetTitle>
|
||||
<SheetDescription>{t.rich("pool.desc", { b: (c) => <b>{c}</b> })}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-6">
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">{t("pool.enable.label")}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t("pool.enable.hint")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
onCheckedChange={(v) => void toggle({ llm_pool_enabled: v })}
|
||||
aria-label={t("pool.enable.aria")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">{t("pool.bindFallback.label")}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t("pool.bindFallback.hint")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={pool?.bind_fallback ?? false}
|
||||
disabled={busy}
|
||||
onCheckedChange={(v) => void toggle({ llm_pool_bind_fallback: v })}
|
||||
aria-label={t("pool.bindFallback.aria")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm">{t("pool.order.label")}</Label>
|
||||
{tripped.length > 0 && (
|
||||
<Button size="sm" variant="ghost" onClick={() => void recover()}>
|
||||
<RotateCcwIcon /> {t("pool.recoverAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{inChain.length < 2 && (
|
||||
<p className="text-muted-foreground text-xs">{t("pool.order.tooFew", { n: inChain.length })}</p>
|
||||
)}
|
||||
{chain.map((m) => {
|
||||
const excluded = m.excluded && !m.active;
|
||||
const order = excluded ? null : inChain.findIndex((x) => x.profile_id === m.profile_id) + 1;
|
||||
return (
|
||||
<div
|
||||
key={m.profile_id}
|
||||
className={cn(
|
||||
"grid gap-1 rounded-lg border p-2.5 text-sm",
|
||||
excluded && "opacity-55",
|
||||
m.state === "tripped" && "border-destructive/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="w-5 shrink-0 text-center font-mono text-muted-foreground text-xs">
|
||||
{order ?? "—"}
|
||||
</span>
|
||||
<span className="font-medium">{m.name}</span>
|
||||
{m.active && (
|
||||
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
|
||||
{t("pool.badge.active")}
|
||||
</Badge>
|
||||
)}
|
||||
{excluded && <Badge variant="outline">{t("pool.badge.excluded")}</Badge>}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{m.state === "tripped" && m.cooldown_secs > 0 && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("pool.cooldown", { cooldown: cooldownText(m.cooldown_secs) })}
|
||||
</span>
|
||||
)}
|
||||
{m.state === "degraded" && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("pool.consecutiveFails", { fails: m.fails })}
|
||||
</span>
|
||||
)}
|
||||
{m.state !== "ok" && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label={t("pool.recoverNow.aria")}
|
||||
title={t("pool.recoverNow.title")}
|
||||
onClick={() => void recover(m.profile_id)}
|
||||
>
|
||||
<RotateCcwIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 pl-7 text-muted-foreground text-xs">
|
||||
<code className="truncate font-mono">{m.model}</code>
|
||||
{!m.active && <span>{t("pool.priority", { priority: m.priority })}</span>}
|
||||
</div>
|
||||
{m.last_error && (
|
||||
<p className="truncate pl-7 font-mono text-muted-foreground text-xs" title={m.last_error}>
|
||||
{m.last_error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{chain.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed p-4 text-center text-muted-foreground text-sm">
|
||||
{t("pool.empty")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed p-3 text-muted-foreground text-xs leading-relaxed">
|
||||
{t("pool.note")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 模型配置抽屉(新建 / 编辑共用同一套表单)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ProfileSheet({
|
||||
profile,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: {
|
||||
profile: LLMProfile | null; // null = 新建
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
onSaved: (id: string) => void;
|
||||
}) {
|
||||
const t = useTranslations("llmPage");
|
||||
const isNew = !profile;
|
||||
const [name, setName] = React.useState("");
|
||||
const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses">("anthropic");
|
||||
const [model, setModel] = React.useState("");
|
||||
const [baseUrl, setBaseUrl] = React.useState("");
|
||||
const [proxy, setProxy] = React.useState("");
|
||||
const [apiKey, setApiKey] = React.useState("");
|
||||
const [keyHint, setKeyHint] = React.useState("");
|
||||
const [rps, setRps] = React.useState("0");
|
||||
const [rpm, setRpm] = React.useState("0");
|
||||
const [cw, setCw] = React.useState("0"); // 上下文窗口(K tokens);0=默认200K
|
||||
const [thinkingType, setThinkingType] = React.useState(NONE);
|
||||
const [effort, setEffort] = React.useState(NONE);
|
||||
const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先
|
||||
const [poolExclude, setPoolExclude] = React.useState(false);
|
||||
const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式
|
||||
const [maxTokens, setMaxTokens] = React.useState("0"); // 单次回复输出上限;0=不发送
|
||||
const [maxTokensField, setMaxTokensField] = React.useState(NONE); // 上限用哪个字段名;NONE=max_tokens
|
||||
const [sessionHeaderKey, setSessionHeaderKey] = React.useState(""); // 自定义会话头名;空=不发送
|
||||
const [retry, setRetry] = React.useState<LLMRetryOverride>(ZERO_OVERRIDE); // 本配置的重试覆盖;全 0=跟随全局
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [models, setModels] = React.useState<string[]>([]);
|
||||
const [loadingModels, setLoadingModels] = React.useState(false);
|
||||
const [modelsOpen, setModelsOpen] = React.useState(false);
|
||||
|
||||
// 思考开关 / 上限字段名 / 思考强度三组选项的标签走 i18n,故在组件内按当前语言构造。
|
||||
const THINKING_TYPES: { value: string; label: string }[] = [
|
||||
{ value: NONE, label: t("profile.thinkingType.opt.none") },
|
||||
{ value: "disabled", label: t("profile.thinkingType.opt.disabled") },
|
||||
{ value: "enabled", label: t("profile.thinkingType.opt.enabled") },
|
||||
];
|
||||
const MAX_TOKENS_FIELDS: { value: string; label: string }[] = [
|
||||
{ value: NONE, label: t("profile.maxTokensField.opt.default") },
|
||||
{ value: "max_completion_tokens", label: "max_completion_tokens" },
|
||||
];
|
||||
const EFFORT_LEVELS: { value: string; label: string }[] = [
|
||||
{ value: NONE, label: t("profile.effort.opt.none") },
|
||||
{ value: "low", label: "low" },
|
||||
{ value: "medium", label: "medium" },
|
||||
{ value: "high", label: "high" },
|
||||
{ value: "xhigh", label: "xhigh" },
|
||||
{ value: "max", label: "max" },
|
||||
];
|
||||
|
||||
// 每次打开时从传入的 profile 灌一遍表单(新建则重置为默认值)。抽屉关掉再打开
|
||||
// 就是一次干净的开始,不会留下上一个配置的残影。
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(profile?.name ?? "");
|
||||
setFormat(profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic");
|
||||
setModel(profile?.model ?? "");
|
||||
setBaseUrl(profile?.base_url ?? "");
|
||||
setProxy(profile?.proxy ?? "");
|
||||
setRps(String(profile?.rate_per_second ?? 0));
|
||||
setRpm(String(profile?.rate_per_minute ?? 0));
|
||||
setCw(String(profile?.context_window_k ?? 0));
|
||||
setThinkingType(fromStore(profile?.thinking_type));
|
||||
setEffort(fromStore(profile?.reasoning_effort));
|
||||
setPriority(String(profile?.priority ?? 0));
|
||||
setPoolExclude(profile?.pool_exclude ?? false);
|
||||
setStreaming(profile?.streaming ?? true);
|
||||
setMaxTokens(String(profile?.max_tokens ?? 0));
|
||||
setMaxTokensField(fromStore(profile?.max_tokens_field));
|
||||
setSessionHeaderKey(profile?.session_header_key ?? "");
|
||||
setRetry(profile?.retry ?? ZERO_OVERRIDE);
|
||||
setApiKey("");
|
||||
setKeyHint(profile?.api_key_hint ?? "");
|
||||
setModels([]);
|
||||
setModelsOpen(false);
|
||||
}, [open, profile]);
|
||||
|
||||
const profileId = profile ? Number(profile.id) : undefined;
|
||||
|
||||
async function loadModels() {
|
||||
if (loadingModels) return;
|
||||
setLoadingModels(true);
|
||||
setModels([]);
|
||||
try {
|
||||
const r = await api.fetchLLMModels(format, baseUrl, apiKey, proxy, profileId);
|
||||
if (r.ok && r.models && r.models.length > 0) {
|
||||
setModels(r.models);
|
||||
setModelsOpen(true);
|
||||
toast.success(t("profile.toast.modelsLoaded", { n: r.models.length }));
|
||||
} else {
|
||||
toast.error(t("profile.toast.loadModelsFailed", { error: r.error ?? t("profile.toast.noModels") }));
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(t("profile.toast.loadModelsError", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (testing) return;
|
||||
setTesting(true);
|
||||
try {
|
||||
// 用配置实际会跑的思考参数来测,这样不支持该字段的模型在这里就失败,
|
||||
// 而不是等到跑任务时才炸。传 profile id:Key 输入框留空时用已存的 Key。
|
||||
const r = await api.testLLM(
|
||||
format,
|
||||
model,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
proxy,
|
||||
toStore(thinkingType),
|
||||
toStore(effort),
|
||||
profileId,
|
||||
streaming,
|
||||
sessionHeaderKey.trim(),
|
||||
);
|
||||
// 回复内容一并展示:看得见模型确实说了话,才算和会话里跑通是一回事。
|
||||
if (r.ok)
|
||||
toast.success(t("profile.toast.testOk", { latency: r.latency_ms ?? "?", model: r.model ?? model }), {
|
||||
description: r.reply ? t("profile.toast.testReply", { reply: r.reply }) : undefined,
|
||||
});
|
||||
else toast.error(t("profile.toast.testFailed", { error: r.error ?? t("profile.toast.unknown") }));
|
||||
} catch (e) {
|
||||
toast.error(t("profile.toast.testError", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!name.trim() || !model.trim()) {
|
||||
toast.error(t("profile.toast.nameModelRequired"));
|
||||
return;
|
||||
}
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const { id } = await api.saveLLMProfile({
|
||||
...(profile ? { id: Number(profile.id) } : {}),
|
||||
name: name.trim(),
|
||||
format,
|
||||
model: model.trim(),
|
||||
base_url: baseUrl.trim(),
|
||||
proxy: proxy.trim(),
|
||||
api_key: apiKey,
|
||||
rate_per_second: Number(rps) || 0,
|
||||
rate_per_minute: Number(rpm) || 0,
|
||||
context_window_k: Number(cw) || 0,
|
||||
thinking_type: toStore(thinkingType),
|
||||
reasoning_effort: toStore(effort),
|
||||
priority: Number(priority) || 0,
|
||||
pool_exclude: poolExclude,
|
||||
streaming,
|
||||
max_tokens: Math.max(0, Number(maxTokens) || 0),
|
||||
// 字段名开关只对 openai(Chat Completions) 有意义,其它格式一律回落到默认;
|
||||
// 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。
|
||||
max_tokens_field: format === "openai" ? toStore(maxTokensField) : "",
|
||||
session_header_key: sessionHeaderKey.trim(),
|
||||
retry,
|
||||
});
|
||||
if (isNew) toast.success(t("profile.toast.created", { name: name.trim() }));
|
||||
else toast.success(profile?.is_default ? t("profile.toast.savedActive") : t("profile.toast.saved"));
|
||||
onSaved(String(id));
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
toast.error(t("profile.toast.saveFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col gap-0 p-0 data-[side=right]:min-w-[420px] data-[side=right]:sm:max-w-xl"
|
||||
>
|
||||
<SheetHeader className="px-4">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{isNew ? t("profile.titleNew") : t("profile.titleEdit", { name: profile?.name ?? "" })}
|
||||
{profile?.is_default && (
|
||||
<Badge variant="outline" className="border-amber-400/50 text-amber-500">
|
||||
{t("profile.activeBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription>{isNew ? t("profile.descNew") : t("profile.descEdit")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-name">{t("profile.name.label")}</Label>
|
||||
<Input
|
||||
id="p-name"
|
||||
placeholder={t("profile.name.placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("profile.format.label")}</Label>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("profile.format.placeholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
<SelectItem value="openai">OpenAI (Chat Completions)</SelectItem>
|
||||
<SelectItem value="openai-responses">OpenAI (Responses API)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-model">{t("profile.model.label")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="p-model"
|
||||
className="font-mono"
|
||||
placeholder="claude-opus-4-8"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
{/* modal: 这个 Popover 的内容被 portal 到 <body>,在 Sheet 的滚动锁之外,
|
||||
不加 modal 时列表能渲染却滚不动。modal 让它自己持有最上层滚动锁。 */}
|
||||
<Popover open={modelsOpen} onOpenChange={setModelsOpen} modal>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
disabled={loadingModels}
|
||||
onClick={loadModels}
|
||||
title={t("profile.model.loadTitle")}
|
||||
>
|
||||
{loadingModels ? <Loader2Icon className="animate-spin" /> : <RefreshCwIcon />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
{models.length > 0 && (
|
||||
<PopoverContent className="max-h-72 w-72 gap-0 overflow-y-auto overscroll-contain p-1" align="end">
|
||||
{models.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
className="w-full shrink-0 rounded-md px-2 py-1.5 text-left font-mono text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
onClick={() => {
|
||||
setModel(m);
|
||||
setModelsOpen(false);
|
||||
}}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-base-url">{t("profile.baseUrl.label")}</Label>
|
||||
<Input
|
||||
id="p-base-url"
|
||||
className="font-mono"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-proxy">{t("profile.proxy.label")}</Label>
|
||||
<Input
|
||||
id="p-proxy"
|
||||
className="font-mono"
|
||||
placeholder="socks5://user:pass@127.0.0.1:1080 · http://127.0.0.1:8080"
|
||||
value={proxy}
|
||||
onChange={(e) => setProxy(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{t("profile.proxy.hint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-session-header">{t("profile.sessionHeader.label")}</Label>
|
||||
<Input
|
||||
id="p-session-header"
|
||||
className="font-mono"
|
||||
placeholder={t("profile.sessionHeader.placeholder")}
|
||||
value={sessionHeaderKey}
|
||||
onChange={(e) => setSessionHeaderKey(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t.rich("profile.sessionHeader.hint", { b: (c) => <b>{c}</b> })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-api-key">API Key</Label>
|
||||
<Input
|
||||
id="p-api-key"
|
||||
type="password"
|
||||
placeholder={keyHint ? t("profile.apiKey.placeholderSet", { hint: keyHint }) : "sk-…"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-rps">{t("profile.rps")}</Label>
|
||||
<Input id="p-rps" type="number" min={0} value={rps} onChange={(e) => setRps(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-rpm">{t("profile.rpm")}</Label>
|
||||
<Input id="p-rpm" type="number" min={0} value={rpm} onChange={(e) => setRpm(e.target.value)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="p-cw">{t("profile.cw")}</Label>
|
||||
<Input
|
||||
id="p-cw"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={cw}
|
||||
onChange={(e) => setCw(e.target.value)}
|
||||
placeholder="200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="-mt-2 text-muted-foreground text-xs">{t("profile.rateHint")}</p>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="grid gap-0.5">
|
||||
<Label htmlFor="p-priority" className="text-sm">
|
||||
{t("profile.priority.label")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{t("profile.priority.hint")}</p>
|
||||
</div>
|
||||
<Input
|
||||
id="p-priority"
|
||||
type="number"
|
||||
className="w-24 shrink-0"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">{t("profile.exclude.label")}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t("profile.exclude.hint")}</p>
|
||||
</div>
|
||||
<Switch checked={poolExclude} onCheckedChange={setPoolExclude} aria-label={t("profile.exclude.aria")} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">{t("profile.streaming.label")}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t("profile.streaming.hint")}</p>
|
||||
</div>
|
||||
<Switch checked={streaming} onCheckedChange={setStreaming} aria-label={t("profile.streaming.aria")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="grid gap-0.5">
|
||||
<Label htmlFor="p-max-tokens" className="text-sm">
|
||||
{t("profile.maxTokens.label")}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{t("profile.maxTokens.hint")}</p>
|
||||
</div>
|
||||
<Input
|
||||
id="p-max-tokens"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-28 shrink-0"
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">{t("profile.maxTokensField.label")}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t(`profile.maxTokensFieldHint.${format}`)}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={format === "openai" ? maxTokensField : NONE}
|
||||
onValueChange={setMaxTokensField}
|
||||
disabled={format !== "openai"}
|
||||
>
|
||||
<SelectTrigger className="w-56 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MAX_TOKENS_FIELDS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">{t("profile.thinkingType.label")}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t("profile.thinkingType.hint")}</p>
|
||||
</div>
|
||||
<Select value={thinkingType} onValueChange={setThinkingType}>
|
||||
<SelectTrigger className="w-32 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THINKING_TYPES.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">{t("profile.effort.label")}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t("profile.effort.hint")}</p>
|
||||
</div>
|
||||
<Select value={effort} onValueChange={setEffort}>
|
||||
<SelectTrigger className="w-32 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EFFORT_LEVELS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProfileRetryFields value={retry} onChange={setRetry} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 border-t px-4 py-3">
|
||||
<Button variant="outline" onClick={testConnection} disabled={testing}>
|
||||
{testing ? <Loader2Icon className="animate-spin" /> : <PlugZapIcon />}
|
||||
{testing ? t("profile.testing") : t("profile.testConnection")}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving} className="flex-1">
|
||||
{saving && <Loader2Icon className="animate-spin" />}
|
||||
{!saving && (isNew ? <PlusIcon /> : <SaveIcon />)}
|
||||
{isNew ? t("profile.create") : t("profile.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function LLMPage() {
|
||||
const t = useTranslations("llmPage");
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [pool, setPool] = React.useState<LLMPoolStatus | null>(null);
|
||||
const [poolOpen, setPoolOpen] = React.useState(false);
|
||||
// 抽屉的开关和内容分开存:关闭时 editing 保持不变,否则关闭动画期间标题会从
|
||||
// 「编辑 X」闪成「新建」。editing = null 表示新建。
|
||||
const [editOpen, setEditOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<LLMProfile | null>(null);
|
||||
const openEditor = React.useCallback((p: LLMProfile | null) => {
|
||||
setEditing(p);
|
||||
setEditOpen(true);
|
||||
}, []);
|
||||
|
||||
// 一个配置在卡片上显示的「是否正常」。没填 Key 的配置根本发不出请求,比熔断更该先说。
|
||||
const healthOf = React.useCallback(
|
||||
(p: LLMProfile, m?: LLMPoolMember): Health => {
|
||||
if (!p.api_key_hint) {
|
||||
return {
|
||||
label: t("health.noKey.label"),
|
||||
cls: "border-muted-foreground/40 text-muted-foreground",
|
||||
hint: t("health.noKey.hint"),
|
||||
};
|
||||
}
|
||||
if (m?.state === "tripped") {
|
||||
return {
|
||||
label:
|
||||
m.cooldown_secs > 0
|
||||
? t("health.trippedCooldown", { cooldown: cooldownText(m.cooldown_secs) })
|
||||
: t("health.tripped"),
|
||||
cls: "border-destructive/50 text-destructive",
|
||||
hint: m.last_error,
|
||||
};
|
||||
}
|
||||
if (m?.state === "degraded") {
|
||||
return {
|
||||
label: t("health.degraded", { fails: m.fails }),
|
||||
cls: "border-amber-500/50 text-amber-600 dark:text-amber-400",
|
||||
hint: m.last_error,
|
||||
};
|
||||
}
|
||||
return { label: t("health.ok"), cls: "border-emerald-500/50 text-emerald-600 dark:text-emerald-400" };
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const loadPool = React.useCallback(async () => {
|
||||
try {
|
||||
setPool(await api.llmPool());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setProfiles(await api.llmProfiles());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await loadPool();
|
||||
}, [loadPool]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// 卡片上的健康徽章按 profile id 取轮询状态。
|
||||
const health = React.useMemo(() => {
|
||||
const m = new Map<string, LLMPoolMember>();
|
||||
for (const c of pool?.chain ?? []) m.set(c.profile_id, c);
|
||||
return m;
|
||||
}, [pool]);
|
||||
|
||||
async function activate(id: string, name: string) {
|
||||
try {
|
||||
await api.activateLLMProfile(id);
|
||||
toast.success(t("page.toast.activated", { name }));
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(t("page.toast.activateFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(p: LLMProfile) {
|
||||
if (p.is_default) {
|
||||
toast.error(t("page.toast.cannotDeleteActive"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.deleteLLMProfile(p.id);
|
||||
toast.success(t("page.toast.deleted", { name: p.name }));
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(t("page.toast.deleteFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
const poolOn = pool?.enabled ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-semibold text-xl tracking-tight">LLM</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("page.subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setPoolOpen(true)}>
|
||||
<ZapIcon /> {t("page.poolConfig")}
|
||||
{poolOn && (
|
||||
<Badge variant="outline" className="ml-1 border-emerald-500/50 text-emerald-600 dark:text-emerald-400">
|
||||
{t("page.enabled")}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openEditor(null)}>
|
||||
<PlusIcon /> {t("page.new")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="profiles" className="flex-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="profiles">{t("page.tab.profiles")}</TabsTrigger>
|
||||
<TabsTrigger value="retry">{t("page.tab.retry")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profiles" className="mt-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{profiles.map((p) => {
|
||||
const h = healthOf(p, health.get(p.id));
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: 卡片内含自己的操作按钮,用原生 <button> 会造成按钮嵌套(非法 HTML)
|
||||
<Card
|
||||
key={p.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => openEditor(p)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
openEditor(p);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer gap-0 py-4 outline-none transition-colors hover:border-foreground/30",
|
||||
p.is_default && "border-amber-400/50 bg-amber-400/5",
|
||||
)}
|
||||
>
|
||||
<CardContent className="grid gap-2 px-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<StarIcon
|
||||
className={cn(
|
||||
"mt-0.5 size-4 shrink-0",
|
||||
p.is_default ? "fill-amber-400 text-amber-400" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate font-medium text-sm">{p.name}</span>
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{p.format}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={cn("ml-auto", h.cls)} title={h.hint}>
|
||||
{h.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<code className="mt-1 block truncate font-mono text-muted-foreground text-xs">{p.model}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 pl-6 text-muted-foreground text-xs">
|
||||
{p.api_key_hint && <span>{p.api_key_hint}</span>}
|
||||
<span>
|
||||
{p.rate_per_second}/s · {p.rate_per_minute}/min
|
||||
</span>
|
||||
{p.proxy && <span className="truncate">{t("card.proxy", { proxy: p.proxy })}</span>}
|
||||
{p.reasoning_effort && (
|
||||
<span>
|
||||
{t("card.thinking", {
|
||||
effort: p.reasoning_effort === "off" ? t("card.thinkingOff") : p.reasoning_effort,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{/* 轮询相关的两个字段只在轮询开着时才有意义,关着时不占版面 */}
|
||||
{poolOn &&
|
||||
!p.is_default &&
|
||||
(p.pool_exclude ? (
|
||||
<span>{t("card.excluded")}</span>
|
||||
) : (
|
||||
<span>{t("card.priority", { priority: p.priority ?? 0 })}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={p.is_default}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void activate(p.id, p.name);
|
||||
}}
|
||||
>
|
||||
{p.is_default ? t("card.active") : t("card.activate")}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label={t("card.deleteAria")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void remove(p);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{profiles.length === 0 && (
|
||||
<div className="col-span-full rounded-lg border border-dashed p-10 text-center text-muted-foreground text-sm">
|
||||
{t("page.empty")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="retry" className="mt-4">
|
||||
<RetryPolicyPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ProfileSheet profile={editing} open={editOpen} onOpenChange={setEditOpen} onSaved={() => void load()} />
|
||||
<PoolSheet open={poolOpen} onOpenChange={setPoolOpen} pool={pool} onReload={loadPool} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { sseUrl } from "@/lib/api";
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import type { LogLine } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Mock 데모: 백엔드 SSE 가 없으므로 예시 로그 몇 줄을 채워 둔다.
|
||||
const MOCK_LOGS: LogLine[] = [
|
||||
{
|
||||
seq: 1,
|
||||
ts: "2026-07-26T03:55:00Z",
|
||||
level: "info",
|
||||
tag: "engine",
|
||||
text: "ARTEX v0.1.0 backend listening on :8787 (workers=3)",
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
ts: "2026-07-26T03:55:01Z",
|
||||
level: "info",
|
||||
tag: "config",
|
||||
text: "LLM configured from DB: anthropic / claude-opus-4-8",
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
ts: "2026-07-26T03:56:10Z",
|
||||
level: "info",
|
||||
tag: "planner",
|
||||
text: "task t-acme-web: 계획 3라운드, 의도 i-4 생성",
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
ts: "2026-07-26T03:57:00Z",
|
||||
level: "warn",
|
||||
tag: "guard",
|
||||
text: "block bash: 범위 이탈, out.evil.example 는 scope 밖",
|
||||
},
|
||||
{
|
||||
seq: 5,
|
||||
ts: "2026-07-26T03:57:30Z",
|
||||
level: "info",
|
||||
tag: "work#1",
|
||||
text: "report_finding: Default Credentials (high) 저장 완료",
|
||||
},
|
||||
{
|
||||
seq: 6,
|
||||
ts: "2026-07-26T03:58:20Z",
|
||||
level: "error",
|
||||
tag: "work#3",
|
||||
text: "intercept: mysqldump 파괴적 규칙 적중, 사람 승인 대기",
|
||||
},
|
||||
];
|
||||
|
||||
const levelTone: Record<LogLine["level"], string> = {
|
||||
info: "text-foreground/80",
|
||||
warn: "text-amber-600 dark:text-amber-400",
|
||||
error: "text-red-600 dark:text-red-400",
|
||||
};
|
||||
const levelDot: Record<LogLine["level"], string> = {
|
||||
info: "bg-muted-foreground/40",
|
||||
warn: "bg-amber-500",
|
||||
error: "bg-red-500",
|
||||
};
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
const d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const t = useTranslations("logsPage");
|
||||
const [lines, setLines] = React.useState<LogLine[]>([]);
|
||||
const [q, setQ] = React.useState("");
|
||||
const [level, setLevel] = React.useState<"all" | LogLine["level"]>("all");
|
||||
const [paused, setPaused] = React.useState(false);
|
||||
const [loadingHistory, setLoadingHistory] = React.useState(false);
|
||||
const [hasMore, setHasMore] = React.useState(false);
|
||||
const bottom = React.useRef<HTMLDivElement>(null);
|
||||
const stick = React.useRef(true);
|
||||
const pausedRef = React.useRef(false);
|
||||
pausedRef.current = paused;
|
||||
|
||||
// Minimum db_id seen — used as the cursor for loading older history.
|
||||
const minDbId = React.useMemo(() => {
|
||||
let min = 0;
|
||||
for (const l of lines) {
|
||||
if (l.db_id && (min === 0 || l.db_id < min)) min = l.db_id;
|
||||
}
|
||||
return min;
|
||||
}, [lines]);
|
||||
|
||||
// Live tail via SSE. on connect, replays whatever is in the ring (includes
|
||||
// DB-restored history from the last 100 rows written before restart).
|
||||
React.useEffect(() => {
|
||||
if (MOCK) {
|
||||
setLines(MOCK_LOGS);
|
||||
return;
|
||||
}
|
||||
const es = new EventSource(sseUrl("/api/logs/stream?since=0"));
|
||||
es.onmessage = (e) => {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
const l = JSON.parse(e.data) as LogLine;
|
||||
setLines((prev) => (prev.some((x) => x.seq === l.seq) ? prev : [...prev, l].slice(-3000)));
|
||||
} catch {
|
||||
/* ignore malformed frame */
|
||||
}
|
||||
};
|
||||
return () => es.close();
|
||||
}, []);
|
||||
|
||||
// Mark "has more" once we have any db_id in view.
|
||||
React.useEffect(() => {
|
||||
if (minDbId > 1) setHasMore(true);
|
||||
}, [minDbId]);
|
||||
|
||||
async function loadOlderHistory() {
|
||||
if (loadingHistory) return;
|
||||
setLoadingHistory(true);
|
||||
try {
|
||||
const params = minDbId > 0 ? `?before=${minDbId}&limit=200` : `?limit=200`;
|
||||
const res = await fetch(`/api/logs/history${params}`);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { items: LogLine[]; has_more: boolean };
|
||||
if (data.items?.length) {
|
||||
// Assign synthetic seq numbers below current minimum to keep dedup working.
|
||||
setLines((prev) => {
|
||||
const minSeq = prev.reduce((m, l) => Math.min(m, l.seq ?? 0), 0);
|
||||
const older = data.items.map((l, i) => ({
|
||||
...l,
|
||||
seq: minSeq - data.items.length + i,
|
||||
}));
|
||||
// Prepend, dedup by db_id, cap at 5000.
|
||||
const merged = [...older, ...prev];
|
||||
const seen = new Set<number>();
|
||||
const deduped = merged.filter((l) => {
|
||||
if (!l.db_id) return true;
|
||||
if (seen.has(l.db_id)) return false;
|
||||
seen.add(l.db_id);
|
||||
return true;
|
||||
});
|
||||
return deduped.slice(-5000);
|
||||
});
|
||||
}
|
||||
setHasMore(data.has_more ?? false);
|
||||
} finally {
|
||||
setLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
const needle = q.trim().toLowerCase();
|
||||
return lines.filter(
|
||||
(l) =>
|
||||
(level === "all" || l.level === level) &&
|
||||
(!needle || l.text.toLowerCase().includes(needle) || l.tag.toLowerCase().includes(needle)),
|
||||
);
|
||||
}, [lines, q, level]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: filtered changes intentionally re-run the auto-scroll to the newest line.
|
||||
React.useEffect(() => {
|
||||
if (stick.current && !paused) bottom.current?.scrollIntoView();
|
||||
}, [filtered, paused]);
|
||||
|
||||
const counts = React.useMemo(() => {
|
||||
let warn = 0,
|
||||
error = 0;
|
||||
for (const l of lines) {
|
||||
if (l.level === "warn") warn++;
|
||||
else if (l.level === "error") error++;
|
||||
}
|
||||
return { warn, error, total: lines.length };
|
||||
}, [lines]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder={t("filterPlaceholder")}
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
className="h-8 max-w-xs"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{(["all", "info", "warn", "error"] as const).map((lv) => (
|
||||
<Button
|
||||
key={lv}
|
||||
size="sm"
|
||||
variant={level === lv ? "default" : "outline"}
|
||||
className="h-8"
|
||||
onClick={() => setLevel(lv)}
|
||||
>
|
||||
{lv === "all" ? t("all") : lv}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={paused ? "default" : "outline"}
|
||||
className="h-8"
|
||||
onClick={() => setPaused((p) => !p)}
|
||||
>
|
||||
{paused ? t("paused") : t("pause")}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8" onClick={() => setLines([])}>
|
||||
{t("clear")}
|
||||
</Button>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{t("totalLines", { count: counts.total })} ·{" "}
|
||||
<span className="text-amber-600 dark:text-amber-400">{t("warnCount", { count: counts.warn })}</span> ·{" "}
|
||||
<span className="text-red-600 dark:text-red-400">{t("errorCount", { count: counts.error })}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}}
|
||||
className="h-[calc(100vh-14rem)] overflow-auto rounded-lg border bg-card p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{hasMore && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
disabled={loadingHistory}
|
||||
onClick={loadOlderHistory}
|
||||
>
|
||||
{loadingHistory ? t("loading") : t("loadOlder")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 ? (
|
||||
<p className="py-10 text-center text-muted-foreground">{t("empty")}</p>
|
||||
) : (
|
||||
filtered.map((l) => {
|
||||
const body =
|
||||
l.tag && l.text.startsWith("[" + l.tag + "]") ? l.text.slice(l.tag.length + 2).trimStart() : l.text;
|
||||
return (
|
||||
<div key={l.seq} className="flex items-start gap-2 px-1 py-0.5 hover:bg-muted/40">
|
||||
<span className={cn("mt-1.5 size-1.5 shrink-0 rounded-full", levelDot[l.level])} />
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">{fmtTime(l.ts)}</span>
|
||||
{l.tag && (
|
||||
<span className="shrink-0 rounded bg-muted px-1 text-[10px] text-foreground/70">{l.tag}</span>
|
||||
)}
|
||||
<span className={cn("min-w-0 whitespace-pre-wrap break-words", levelTone[l.level])}>{body}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { PlusIcon, RefreshCwIcon, ServerIcon, Trash2Icon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Agent, MCPServer, MCPTool } from "@/lib/types";
|
||||
|
||||
type Transport = "stdio" | "http" | "sse";
|
||||
type FormState = {
|
||||
name: string;
|
||||
transport: Transport;
|
||||
command: string;
|
||||
args: string;
|
||||
url: string;
|
||||
env: string;
|
||||
insecure: boolean;
|
||||
};
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
transport: "stdio",
|
||||
command: "",
|
||||
args: "",
|
||||
url: "",
|
||||
env: "",
|
||||
insecure: false,
|
||||
};
|
||||
|
||||
export default function MCPPage() {
|
||||
const t = useTranslations("mcpPage");
|
||||
const [servers, setServers] = React.useState<MCPServer[]>([]);
|
||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||
const [visibility, setVisibility] = React.useState<Record<number, string[]>>({});
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<MCPServer | null>(null); // null = add mode
|
||||
const [tab, setTab] = React.useState<"config" | "tools">("config");
|
||||
const [form, setForm] = React.useState<FormState>(emptyForm);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [tools, setTools] = React.useState<MCPTool[]>([]);
|
||||
const [toolsLoading, setToolsLoading] = React.useState(false);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
api
|
||||
.agents()
|
||||
.then(setAgents)
|
||||
.catch(() => {
|
||||
/* 조회 실패는 무시하고 기본 상태를 유지한다 */
|
||||
});
|
||||
api
|
||||
.mcpServers()
|
||||
.then((ss) => {
|
||||
setServers(ss);
|
||||
ss.forEach((s) => {
|
||||
void api
|
||||
.resourceVisibility("mcp", s.id)
|
||||
.then((ids) => setVisibility((v) => ({ ...v, [s.id]: ids })))
|
||||
.catch(() => {
|
||||
/* 가시성 조회 실패는 무시한다 */
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* 조회 실패는 무시하고 기본 상태를 유지한다 */
|
||||
});
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function setF(patch: Partial<FormState>) {
|
||||
setForm((f) => ({ ...f, ...patch }));
|
||||
}
|
||||
|
||||
function parseEnv(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx > 0) {
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
if (key) out[key] = trimmed.slice(idx + 1).trim();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function envToText(env: Record<string, string> | undefined): string {
|
||||
return Object.entries(env ?? {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setTools([]);
|
||||
setTab("config");
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(s: MCPServer) {
|
||||
setEditing(s);
|
||||
setForm({
|
||||
name: s.name,
|
||||
transport: s.transport,
|
||||
command: s.command ?? "",
|
||||
args: (s.args ?? []).join(" "),
|
||||
url: s.url ?? "",
|
||||
env: envToText(s.env),
|
||||
insecure: s.insecure ?? false,
|
||||
});
|
||||
setTab("config");
|
||||
setOpen(true);
|
||||
void loadTools(s.id);
|
||||
}
|
||||
|
||||
async function loadTools(id: number) {
|
||||
setToolsLoading(true);
|
||||
try {
|
||||
setTools(await api.mcpTools(id));
|
||||
} catch {
|
||||
setTools([]);
|
||||
} finally {
|
||||
setToolsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
if (!form.name.trim()) {
|
||||
toast.error(t("errNoName"));
|
||||
return;
|
||||
}
|
||||
if (form.transport === "stdio" && !form.command.trim()) {
|
||||
toast.error(t("errNoCmd"));
|
||||
return;
|
||||
}
|
||||
if (form.transport !== "stdio" && !form.url.trim()) {
|
||||
toast.error(t("errNoUrl"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const base =
|
||||
form.transport !== "stdio"
|
||||
? {
|
||||
transport: form.transport,
|
||||
url: form.url.trim(),
|
||||
command: "",
|
||||
args: [] as string[],
|
||||
env: parseEnv(form.env), // 远程模式下 env 即请求头
|
||||
insecure: form.insecure,
|
||||
}
|
||||
: {
|
||||
transport: "stdio" as const,
|
||||
command: form.command.trim(),
|
||||
args: form.args.trim() ? form.args.trim().split(/\s+/) : [],
|
||||
env: parseEnv(form.env),
|
||||
insecure: false,
|
||||
};
|
||||
await api.saveMcpServer({
|
||||
...(editing ? { id: editing.id } : {}),
|
||||
name: form.name.trim(),
|
||||
enabled: editing ? editing.enabled : true,
|
||||
...base,
|
||||
});
|
||||
toast.success(editing ? t("saved") : t("added"));
|
||||
if (!editing) setOpen(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(t("saveFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTools() {
|
||||
if (!editing) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const found = await api.refreshMcpServer(editing.id);
|
||||
setTools(found);
|
||||
toast.success(t("foundTools", { count: found.length }));
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(t("refreshFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(s: MCPServer) {
|
||||
try {
|
||||
await api.deleteMcpServer(s.id);
|
||||
toast.success(t("removed", { name: s.name }));
|
||||
setOpen(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(t("deleteFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(s: MCPServer) {
|
||||
try {
|
||||
await api.saveMcpServer({ ...s, enabled: !s.enabled });
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(t("actionFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleVisibility(serverId: number, agentId: string, agentName: string) {
|
||||
const on = (visibility[serverId] ?? []).includes(agentId);
|
||||
try {
|
||||
await api.toggleVisibility(agentId, "mcp", serverId, !on);
|
||||
toast.success(on ? t("visibilityRevoked", { name: agentName }) : t("visibilityGranted", { name: agentName }));
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(t("actionFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
function renderForm() {
|
||||
return (
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("transport")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={form.transport === "stdio" ? "default" : "outline"}
|
||||
onClick={() => setF({ transport: "stdio" })}
|
||||
>
|
||||
{t("transportStdio")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={form.transport === "http" ? "default" : "outline"}
|
||||
onClick={() => setF({ transport: "http" })}
|
||||
>
|
||||
{t("transportHttp")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={form.transport === "sse" ? "default" : "outline"}
|
||||
onClick={() => setF({ transport: "sse" })}
|
||||
>
|
||||
{t("transportSse")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="m-name">{t("nameLabel")}</Label>
|
||||
<Input
|
||||
id="m-name"
|
||||
placeholder="filesystem"
|
||||
value={form.name}
|
||||
onChange={(e) => setF({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
{form.transport === "stdio" ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="m-cmd">{t("cmdLabel")}</Label>
|
||||
<Input
|
||||
id="m-cmd"
|
||||
className="font-mono"
|
||||
placeholder="npx"
|
||||
value={form.command}
|
||||
onChange={(e) => setF({ command: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="m-args">{t("argsLabel")}</Label>
|
||||
<Input
|
||||
id="m-args"
|
||||
className="font-mono"
|
||||
placeholder="-y @modelcontextprotocol/server-filesystem /data"
|
||||
value={form.args}
|
||||
onChange={(e) => setF({ args: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="m-url">{t("urlLabel")}</Label>
|
||||
<Input
|
||||
id="m-url"
|
||||
className="font-mono"
|
||||
placeholder="https://mcp.example.com/mcp"
|
||||
value={form.url}
|
||||
onChange={(e) => setF({ url: e.target.value })}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={form.insecure} onCheckedChange={(v) => setF({ insecure: v === true })} />
|
||||
{t("insecureLabel")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="m-env">{form.transport !== "stdio" ? t("headersLabel") : t("envLabel")}</Label>
|
||||
<Textarea
|
||||
id="m-env"
|
||||
className="font-mono"
|
||||
placeholder={form.transport !== "stdio" ? "Authorization=Bearer xxxx" : "API_KEY=xxxx\nFOO=bar"}
|
||||
value={form.env}
|
||||
onChange={(e) => setF({ env: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderTools() {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">{t("toolCount", { count: tools.length })}</span>
|
||||
<Button size="sm" variant="outline" disabled={refreshing} onClick={refreshTools}>
|
||||
<RefreshCwIcon className={refreshing ? "animate-spin" : ""} /> {t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{toolsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">{t("loading")}</p>
|
||||
) : tools.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t("toolsEmpty")}</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y">
|
||||
{tools.map((t) => (
|
||||
<div key={t.name} className="py-2.5">
|
||||
<code className="font-mono text-sm">{t.name}</code>
|
||||
{t.description && (
|
||||
<p className="text-muted-foreground mt-0.5 text-xs leading-relaxed">{t.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">MCP</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAdd}
|
||||
className="text-foreground/70 border-foreground/70 hover:bg-muted/60 hover:shadow-sm flex min-h-[116px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed transition"
|
||||
>
|
||||
<PlusIcon className="size-6" />
|
||||
<span className="text-sm">{t("addCard")}</span>
|
||||
</button>
|
||||
|
||||
{servers.map((s) => (
|
||||
<Card
|
||||
key={s.id}
|
||||
onClick={() => openEdit(s)}
|
||||
className="hover:border-primary/60 cursor-pointer gap-3 transition hover:shadow-sm"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<CardTitle className="truncate text-base">{s.name}</CardTitle>
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{s.transport}
|
||||
</Badge>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: 부모 카드로의 클릭 전파만 차단하는 컨테이너다. 이 div 자체는 상호작용 요소가 아니고, 안쪽 Switch·삭제 버튼은 각각 키보드로 접근된다 */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 위와 같이 클릭 전파 차단 전용이라 별도 키보드 핸들러가 필요 없다 */}
|
||||
<div className="ml-auto flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch checked={s.enabled} onCheckedChange={() => toggleEnabled(s)} aria-label={t("enableAria")} />
|
||||
<Button size="icon" variant="outline" aria-label={t("deleteAria")} onClick={() => removeServer(s)}>
|
||||
<Trash2Icon className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{s.tools && s.tools.length > 0 ? t("toolCount", { count: s.tools.length }) : t("noTools")}
|
||||
</p>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: 체크박스 영역의 클릭 전파만 차단하는 컨테이너다. 이 div 자체는 상호작용 요소가 아니고, 안쪽 체크박스는 각각 키보드로 접근된다 */}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 위와 같이 클릭 전파 차단 전용이라 별도 키보드 핸들러가 필요 없다 */}
|
||||
<div className="grid gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-muted-foreground text-xs">{t("visibilityLabel")}</span>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{agents.map((a) => (
|
||||
<label key={a.key} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={(visibility[s.id] ?? []).includes(a.id)}
|
||||
onCheckedChange={() => toggleVisibility(s.id, a.id, a.name)}
|
||||
/>
|
||||
{a.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="w-full data-[side=right]:sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{editing ? editing.name : t("addServer")}</SheetTitle>
|
||||
<SheetDescription>{t("sheetDesc")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{editing ? (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as "config" | "tools")}
|
||||
className="flex min-h-0 flex-1 flex-col px-4"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="config">{t("tabConfig")}</TabsTrigger>
|
||||
<TabsTrigger value="tools">
|
||||
{t("tabTools")}
|
||||
{tools.length ? ` (${tools.length})` : ""}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="config" className="min-h-0 flex-1 overflow-y-auto">
|
||||
{renderForm()}
|
||||
<div className="flex gap-2 pt-2 pb-6">
|
||||
<Button onClick={saveForm} disabled={saving}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="tools" className="min-h-0 flex-1 overflow-y-auto">
|
||||
{renderTools()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
{renderForm()}
|
||||
<div className="pt-2 pb-6">
|
||||
<Button onClick={saveForm} disabled={saving}>
|
||||
<PlusIcon /> {t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// 渠道字段表与配置值的解析工具。
|
||||
//
|
||||
// 与页面拆开是因为这一份是**数据**而不是视图:它描述每种渠道有哪些字段、
|
||||
// 各自该用什么控件,以及表单文本到配置值(JSON)的双向转换。
|
||||
// 单独放一个文件后,新增渠道只需要动这里,页面本身不必改。
|
||||
//
|
||||
// 本文件是纯数据模块(非 client component),不能调用 useTranslations。
|
||||
// 所以文案不写死在这里,而是存 i18n 键(notifyPage 命名空间),由渲染它们的
|
||||
// 组件(page.tsx / channel-form.tsx)用 t() 解析——键是 ASCII,上游中文原文
|
||||
// 保留在 messages/zh.json。渠道类型展示名改走 notifyPage.kind.<kind>。
|
||||
|
||||
// 各渠道的配置字段定义。
|
||||
//
|
||||
// 这里刻意保留一份前端字段表,而不是让后端下发 schema:后端只负责
|
||||
// Validate(必填/格式),UI 需要的是布局与控件类型,两者关注的不是同一件事。
|
||||
// 唯一的耦合点是 secret_keys —— 哪些字段该渲染成密码框由后端给出,
|
||||
// 因为只有渠道实现自己清楚哪些值算凭据(企业微信的整个 Webhook 就是凭据,
|
||||
// 而钉钉的只是其中一个 secret)。新增渠道时这里少一个条目只会让表单变空白,
|
||||
// 不会静默出错(下面的 hasFields 会提示)。
|
||||
//
|
||||
// label / help / options[].label 存的是 notifyPage 命名空间下的 i18n 键,
|
||||
// placeholder 保留字面值(URL、示例,不翻译)。
|
||||
export type FieldKind = "text" | "password" | "number" | "select" | "textarea" | "switch" | "kv" | "list";
|
||||
export interface FieldDef {
|
||||
key: string;
|
||||
/** notifyPage 命名空间下的 i18n 键 */
|
||||
label: string;
|
||||
kind: FieldKind;
|
||||
placeholder?: string;
|
||||
/** notifyPage 命名空间下的 i18n 键 */
|
||||
help?: string;
|
||||
/** options[].label 也是 i18n 键 */
|
||||
options?: { value: string; label: string }[];
|
||||
}
|
||||
export const CHANNEL_FIELDS: Record<string, FieldDef[]> = {
|
||||
dingtalk: [
|
||||
{
|
||||
key: "webhook",
|
||||
label: "field.dingtalk.webhook.label",
|
||||
kind: "text",
|
||||
placeholder: "https://oapi.dingtalk.com/robot/send?access_token=...",
|
||||
},
|
||||
{
|
||||
key: "secret",
|
||||
label: "field.dingtalk.secret.label",
|
||||
kind: "password",
|
||||
help: "field.dingtalk.secret.help",
|
||||
},
|
||||
],
|
||||
feishu: [
|
||||
{
|
||||
key: "webhook",
|
||||
label: "field.feishu.webhook.label",
|
||||
kind: "text",
|
||||
placeholder: "https://open.feishu.cn/open-apis/bot/v2/hook/...",
|
||||
},
|
||||
{ key: "secret", label: "field.feishu.secret.label", kind: "password", help: "field.feishu.secret.help" },
|
||||
],
|
||||
wecom: [
|
||||
{
|
||||
key: "webhook",
|
||||
label: "field.wecom.webhook.label",
|
||||
kind: "text",
|
||||
placeholder: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=...",
|
||||
},
|
||||
],
|
||||
webhook: [
|
||||
{
|
||||
key: "url",
|
||||
label: "field.webhook.url.label",
|
||||
kind: "text",
|
||||
placeholder: "https://your-endpoint.example.com/hook",
|
||||
},
|
||||
{
|
||||
key: "method",
|
||||
label: "field.webhook.method.label",
|
||||
kind: "select",
|
||||
options: [
|
||||
{ value: "POST", label: "field.webhook.method.opt.POST" },
|
||||
{ value: "PUT", label: "field.webhook.method.opt.PUT" },
|
||||
{ value: "PATCH", label: "field.webhook.method.opt.PATCH" },
|
||||
{ value: "GET", label: "field.webhook.method.opt.GET" },
|
||||
],
|
||||
},
|
||||
{ key: "headers", label: "field.webhook.headers.label", kind: "kv", help: "field.webhook.headers.help" },
|
||||
{
|
||||
key: "body_template",
|
||||
label: "field.webhook.body_template.label",
|
||||
kind: "textarea",
|
||||
help: "field.webhook.body_template.help",
|
||||
},
|
||||
],
|
||||
telegram: [
|
||||
{ key: "bot_token", label: "field.telegram.bot_token.label", kind: "password", placeholder: "123456:ABC-DEF..." },
|
||||
{ key: "chat_id", label: "field.telegram.chat_id.label", kind: "text", placeholder: "-1001234567890" },
|
||||
{
|
||||
key: "base_url",
|
||||
label: "field.telegram.base_url.label",
|
||||
kind: "text",
|
||||
placeholder: "https://api.telegram.org",
|
||||
help: "field.telegram.base_url.help",
|
||||
},
|
||||
],
|
||||
email: [
|
||||
{ key: "host", label: "field.email.host.label", kind: "text", placeholder: "smtp.example.com" },
|
||||
{
|
||||
key: "port",
|
||||
label: "field.email.port.label",
|
||||
kind: "number",
|
||||
placeholder: "587",
|
||||
help: "field.email.port.help",
|
||||
},
|
||||
{ key: "username", label: "field.email.username.label", kind: "text" },
|
||||
{ key: "password", label: "field.email.password.label", kind: "password" },
|
||||
{ key: "from", label: "field.email.from.label", kind: "text", placeholder: "artex@example.com" },
|
||||
{ key: "to", label: "field.email.to.label", kind: "list", help: "field.email.to.help" },
|
||||
{ key: "tls", label: "field.email.tls.label", kind: "switch", help: "field.email.tls.help" },
|
||||
],
|
||||
};
|
||||
|
||||
// value 是过滤用的 min_severity 值,label 是 notifyPage.severityOpt.* 的 i18n 键。
|
||||
export const SEVERITY_OPTIONS = [
|
||||
{ value: "", label: "severityOpt.all" },
|
||||
{ value: "low", label: "severityOpt.low" },
|
||||
{ value: "medium", label: "severityOpt.medium" },
|
||||
{ value: "high", label: "severityOpt.high" },
|
||||
{ value: "critical", label: "severityOpt.critical" },
|
||||
];
|
||||
|
||||
export type ChannelForm = {
|
||||
name: string;
|
||||
kind: string;
|
||||
mode: "realtime" | "digest";
|
||||
enabled: boolean;
|
||||
ratePerMin: string;
|
||||
config: Record<string, unknown>;
|
||||
minSeverity: string;
|
||||
includeText: string;
|
||||
excludeText: string;
|
||||
taskIDsText: string;
|
||||
assetIDsText: string;
|
||||
onStatusChange: boolean;
|
||||
};
|
||||
|
||||
export const emptyForm = (kind: string): ChannelForm => ({
|
||||
name: "",
|
||||
kind,
|
||||
mode: "realtime",
|
||||
enabled: true,
|
||||
ratePerMin: "",
|
||||
config: {},
|
||||
minSeverity: "",
|
||||
includeText: "",
|
||||
excludeText: "",
|
||||
taskIDsText: "",
|
||||
assetIDsText: "",
|
||||
onStatusChange: false,
|
||||
});
|
||||
|
||||
// parseKV 解析「每行 KEY=VALUE」的文本域。
|
||||
export function parseKV(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const line of text.split("\n")) {
|
||||
const t = line.trim();
|
||||
if (!t) continue;
|
||||
const i = t.indexOf("=");
|
||||
if (i > 0) out[t.slice(0, i).trim()] = t.slice(i + 1).trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// parseIDs 解析逗号/空白分隔的 id 列表。分隔符保留全角逗号「,」:
|
||||
// 用户可能从别处粘贴带全角逗号的列表,这是输入解析逻辑而非展示文案。
|
||||
export function parseIDs(text: string): number[] {
|
||||
return text
|
||||
.split(/[\s,,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => Number(s))
|
||||
.filter((n) => Number.isFinite(n) && n > 0);
|
||||
}
|
||||
// parseKeywords 解析行/逗号分隔的关键词列表(漏洞类型名可能含空格,所以按行或逗号切)。
|
||||
// 同样保留全角逗号「,」作为分隔符(输入解析,非展示)。
|
||||
export function parseKeywords(text: string): string[] {
|
||||
return text
|
||||
.split(/[\n,,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { NotificationFilter } from "@/lib/types";
|
||||
|
||||
// asText / inputType 是本文件内的取值辅助(与控件渲染强相关),不放在 channel-fields。
|
||||
import { type FieldDef, type FieldKind, SEVERITY_OPTIONS } from "./channel-fields";
|
||||
|
||||
// asText 把任意配置值渲染成输入框可用的字符串。
|
||||
// config 来自 JSON,值可能是 string / number / boolean / array / null,
|
||||
// 这里只关心「能不能塞进文本框」,具体序列化由 buildConfig 负责。
|
||||
function asText(v: unknown): string {
|
||||
if (typeof v === "string") return v;
|
||||
if (v === null || v === undefined) return "";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// inputType 把字段类型映射到 input 的 type 属性。
|
||||
function inputType(kind: FieldKind): "text" | "password" | "number" {
|
||||
if (kind === "password") return "password";
|
||||
if (kind === "number") return "number";
|
||||
return "text";
|
||||
}
|
||||
|
||||
// ConfigField 按字段定义渲染对应的控件。
|
||||
//
|
||||
// 掩码字段的处理是这里唯一的讲究:输入框**不显示**掩码值本身,只显示一行
|
||||
// 「已保存」提示。这样界面上就只有一个规则——框里有字就是用户填的,
|
||||
// 空框就是空值。若把 "__masked__:…abc123" 塞进输入框,用户会以为那是要自己
|
||||
// 删掉的占位文本,反而更容易误清凭据。
|
||||
//
|
||||
// def.label / def.help / def.options[].label 是 notifyPage 命名空间的 i18n 键,
|
||||
// 由这里用 t() 解析(见 channel-fields.ts)。
|
||||
export function ConfigField({
|
||||
def,
|
||||
value,
|
||||
isSecret,
|
||||
onChange,
|
||||
}: {
|
||||
def: FieldDef;
|
||||
value: unknown;
|
||||
isSecret: boolean;
|
||||
onChange: (v: unknown) => void;
|
||||
}) {
|
||||
const t = useTranslations("notifyPage");
|
||||
const id = `n-cfg-${def.key}`;
|
||||
const raw = asText(value);
|
||||
// 后端回显的掩码值:形如 "__masked__:…abc123",尾部是原值的可辨识片段。
|
||||
const masked = isSecret && raw.startsWith("__masked__");
|
||||
const maskedTail = masked ? (raw.split("…")[1] ?? "") : "";
|
||||
|
||||
if (def.kind === "switch") {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={value === true} onCheckedChange={onChange} aria-label={t(def.label)} />
|
||||
{t(def.label)}
|
||||
{def.help && <span className="text-muted-foreground">({t(def.help)})</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (def.kind === "select") {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>{t(def.label)}</Label>
|
||||
<Select value={raw || def.options?.[0]?.value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(def.options ?? []).map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 控件按字段类型分派。用 if 链而不是嵌套三元,是因为这里要区分四种控件,
|
||||
// 三层三元读起来已经要停下来数括号了。
|
||||
function control() {
|
||||
if (def.kind === "textarea" || def.kind === "kv") {
|
||||
return (
|
||||
<Textarea
|
||||
id={id}
|
||||
className="font-mono"
|
||||
placeholder={def.placeholder}
|
||||
value={masked ? "" : raw}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (def.kind === "list") {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
value={Array.isArray(value) ? (value as string[]).join(", ") : raw}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={def.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
className={def.kind === "text" ? "font-mono" : ""}
|
||||
type={inputType(def.kind)}
|
||||
placeholder={def.placeholder}
|
||||
value={masked ? "" : raw}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const hint = masked ? (
|
||||
<p className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<CheckIcon className="size-3" />
|
||||
{maskedTail ? t("masked.savedTail", { tail: maskedTail }) : t("masked.saved")}
|
||||
</p>
|
||||
) : (
|
||||
def.help && <p className="text-muted-foreground text-xs">{t(def.help)}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={id}>{t(def.label)}</Label>
|
||||
{control()}
|
||||
{hint}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// FilterSummary 把过滤条件摘要成一行,让卡片不用展开就能看出这个渠道推什么。
|
||||
export function FilterSummary({ filter }: { filter: NotificationFilter }) {
|
||||
const t = useTranslations("notifyPage");
|
||||
const parts: string[] = [];
|
||||
if (filter.min_severity) {
|
||||
const opt = SEVERITY_OPTIONS.find((o) => o.value === filter.min_severity);
|
||||
parts.push(opt ? t(opt.label) : filter.min_severity);
|
||||
}
|
||||
if (filter.vulnclass_include?.length) parts.push(t("summary.includeCount", { n: filter.vulnclass_include.length }));
|
||||
if (filter.vulnclass_exclude?.length) parts.push(t("summary.excludeCount", { n: filter.vulnclass_exclude.length }));
|
||||
if (filter.task_ids?.length) parts.push(t("summary.taskCount", { n: filter.task_ids.length }));
|
||||
if (filter.asset_ids?.length) parts.push(t("summary.assetCount", { n: filter.asset_ids.length }));
|
||||
if (filter.on_status_change) parts.push(t("summary.statusChange"));
|
||||
if (parts.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm">{t("summary.all")}</p>;
|
||||
}
|
||||
return <p className="text-muted-foreground text-sm">{parts.join(" · ")}</p>;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { RefreshCwIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { NotificationChannel, NotificationDelivery } from "@/lib/types";
|
||||
|
||||
// DeliveryList 是投递记录表:可按渠道与状态筛选,失败项可手动重发。
|
||||
export function DeliveryList({ channels }: { channels: NotificationChannel[] }) {
|
||||
const t = useTranslations("notifyPage");
|
||||
// 전송 상태·심각도 라벨은 전역 공유 `status` 네임스페이스에서 온다(StatusBadge 와 동일 출처).
|
||||
// lib/status.ts 의 중국어는 상류 대조용 죽은 폴백이라 직접 쓰지 않는다(턴 129 함정 참조).
|
||||
const ts = useTranslations("status");
|
||||
const tp = useTranslations("pagination");
|
||||
const [rows, setRows] = React.useState<NotificationDelivery[]>([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [channelID, setChannelID] = React.useState<number | undefined>(undefined);
|
||||
const [state, setState] = React.useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const pageSize = 50;
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.notifyDeliveries({ channelId: channelID, state, page, pageSize })
|
||||
.then((r) => {
|
||||
setRows(r.deliveries);
|
||||
setTotal(r.total);
|
||||
})
|
||||
.catch((e) => toast.error(t("delivery.loadFailed", { msg: (e as Error).message })))
|
||||
.finally(() => setLoading(false));
|
||||
// t 는 안정 참조라 deps 에 넣지 않아도 되지만, exhaustive-deps 를 만족시키려 포함.
|
||||
}, [channelID, state, page, t]);
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function retry(id: number) {
|
||||
try {
|
||||
await api.notifyRetryDelivery(id);
|
||||
toast.success(t("delivery.requeued"));
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(t("delivery.resendFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={channelID ? String(channelID) : "all"}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setChannelID(v === "all" ? undefined : Number(v));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-44">
|
||||
<SelectValue placeholder={t("delivery.allChannels")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("delivery.allChannels")}</SelectItem>
|
||||
{channels.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={state ?? "all"}
|
||||
onValueChange={(v) => {
|
||||
setPage(1);
|
||||
setState(v === "all" ? undefined : v);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue placeholder={t("delivery.allStates")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("delivery.allStates")}</SelectItem>
|
||||
{["pending", "sending", "sent", "failed", "skipped"].map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{ts(`delivery.${s}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={load} disabled={loading}>
|
||||
<RefreshCwIcon className={loading ? "animate-spin" : ""} /> {t("delivery.refresh")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground ml-auto text-xs">{t("delivery.totalCount", { total })}</span>
|
||||
</div>
|
||||
|
||||
<Card className="py-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-40">{t("delivery.colTime")}</TableHead>
|
||||
<TableHead>{t("delivery.colFinding")}</TableHead>
|
||||
<TableHead className="w-40">{t("delivery.colChannel")}</TableHead>
|
||||
<TableHead className="w-24">{t("delivery.colState")}</TableHead>
|
||||
<TableHead className="w-16">{t("delivery.colAttempts")}</TableHead>
|
||||
<TableHead>{t("delivery.colError")}</TableHead>
|
||||
<TableHead className="w-20" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-muted-foreground py-8 text-center">
|
||||
{loading ? t("delivery.loading") : t("delivery.empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatTime(d.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge domain="severity" value={d.severity} />
|
||||
<span className="truncate text-sm">{d.title || t("delivery.noTitle")}</span>
|
||||
{d.event_kind === "finding_status_changed" && (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t("delivery.statusChanged")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{d.channel_name}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge domain="delivery" value={d.state} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{d.attempts}</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-md text-xs break-all">{d.last_error}</TableCell>
|
||||
<TableCell>
|
||||
{/* 只有失败/跳过的才给重发入口:已送达的重发会造成重复推送。 */}
|
||||
{(d.state === "failed" || d.state === "skipped") && (
|
||||
<Button size="sm" variant="outline" onClick={() => retry(d.id)}>
|
||||
<RotateCcwIcon /> {t("delivery.resend")}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{maxPage > 1 && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{tp("prev")}
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{page} / {maxPage}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= maxPage} onClick={() => setPage((p) => p + 1)}>
|
||||
{tp("next")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString("ko-KR", { hour12: false });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { useTranslations } from "next-intl";
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
// 本文件被 client 页面引用,但自身是纯展示/纯函数,不直接调用 useTranslations。
|
||||
// formatBacklog 需要 i18n 单位文案,所以由调用方把 t 注入进来(tasks/approval-records 先例)。
|
||||
type Translator = ReturnType<typeof useTranslations>;
|
||||
|
||||
export function StatTile({ label, value, hint, tone }: { label: string; value: string; hint?: string; tone?: string }) {
|
||||
return (
|
||||
<Card size="sm" className="gap-1">
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-xs">{label}</p>
|
||||
<p className={`text-lg font-semibold ${tone === "red" ? "text-rose-600" : ""}`}>{value}</p>
|
||||
{hint && <p className={`text-xs ${tone === "red" ? "text-rose-600" : "text-muted-foreground"}`}>{hint}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// formatBacklog 把积压毫秒数渲染成人看得懂的量级。
|
||||
export function formatBacklog(ms: number, t: Translator): string {
|
||||
if (!ms) return "—";
|
||||
if (ms < 60_000) return t("unit.sec", { n: Math.round(ms / 1000) });
|
||||
if (ms < 3_600_000) return t("unit.min", { n: Math.round(ms / 60_000) });
|
||||
return t("unit.hour", { n: (ms / 3_600_000).toFixed(1) });
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { BellIcon, PlusIcon, SendIcon, Trash2Icon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { NotificationChannel, NotificationFilter, NotificationMeta } from "@/lib/types";
|
||||
|
||||
import {
|
||||
CHANNEL_FIELDS,
|
||||
type ChannelForm,
|
||||
emptyForm,
|
||||
parseIDs,
|
||||
parseKeywords,
|
||||
parseKV,
|
||||
SEVERITY_OPTIONS,
|
||||
} from "./_components/channel-fields";
|
||||
import { ConfigField, FilterSummary } from "./_components/channel-form";
|
||||
import { DeliveryList } from "./_components/delivery-list";
|
||||
import { formatBacklog, StatTile } from "./_components/stat-tile";
|
||||
|
||||
// 本页只负责编排:加载数据、维护表单状态、调用接口。
|
||||
// 字段定义与解析在 _components/channel-fields.ts,控件与过滤摘要在
|
||||
// _components/channel-form.tsx,投递记录在 _components/delivery-list.tsx——
|
||||
// 拆开是因为它们各自能被单独读懂,而挤在一个文件里时这个页面接近 1100 行。
|
||||
export default function NotifyPage() {
|
||||
const t = useTranslations("notifyPage");
|
||||
// 渠道类型展示名:已知的 6 种走 i18n(notifyPage.kind.<kind>),
|
||||
// 后端若回传未知类型则原样显示该 key(保留原来的 `?? ch.kind` 语义)。
|
||||
const kindLabel = (k: string) => (k in CHANNEL_FIELDS ? t(`kind.${k}`) : k);
|
||||
const [meta, setMeta] = React.useState<NotificationMeta | null>(null);
|
||||
const [channels, setChannels] = React.useState<NotificationChannel[]>([]);
|
||||
const [tab, setTab] = React.useState<"channels" | "deliveries">("channels");
|
||||
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [editing, setEditing] = React.useState<NotificationChannel | null>(null);
|
||||
const [form, setForm] = React.useState<ChannelForm>(emptyForm("dingtalk"));
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
|
||||
const [globalSaving, setGlobalSaving] = React.useState(false);
|
||||
const [baseURL, setBaseURL] = React.useState("");
|
||||
const [digestMin, setDigestMin] = React.useState("");
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
api
|
||||
.notifyMeta()
|
||||
.then((m) => {
|
||||
setMeta(m);
|
||||
setBaseURL(m.public_base_url);
|
||||
setDigestMin(m.digest_interval_min);
|
||||
})
|
||||
.catch((e) => toast.error(t("toast.loadConfigFailed", { msg: (e as Error).message })));
|
||||
// 渠道列表加载失败要报出来:静默失败会显示成「一个渠道都没有」,
|
||||
// 用户会以为配置丢了,比直接报错更让人慌。
|
||||
api
|
||||
.notifyChannels()
|
||||
.then(setChannels)
|
||||
.catch((e) => toast.error(t("toast.loadChannelsFailed", { msg: (e as Error).message })));
|
||||
}, [t]);
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function setF(patch: Partial<ChannelForm>) {
|
||||
setForm((f) => ({ ...f, ...patch }));
|
||||
}
|
||||
function setCfg(key: string, value: unknown) {
|
||||
setForm((f) => ({ ...f, config: { ...f.config, [key]: value } }));
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
setEditing(null);
|
||||
setForm(emptyForm(meta?.kinds[0]?.kind ?? "dingtalk"));
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(ch: NotificationChannel) {
|
||||
setEditing(ch);
|
||||
// filter 在后端是 Go 结构体,永远序列化成对象(不会是 null),所以不需要兜底。
|
||||
const f = ch.filter;
|
||||
setForm({
|
||||
name: ch.name,
|
||||
kind: ch.kind,
|
||||
mode: ch.mode,
|
||||
enabled: ch.enabled,
|
||||
ratePerMin: String(ch.rate_per_min),
|
||||
// 后端回显的 config 里凭据是掩码值;原样放进表单,提交时原样送回,
|
||||
// 后端据此保留库中原值。
|
||||
config: { ...ch.config },
|
||||
minSeverity: f.min_severity ?? "",
|
||||
includeText: (f.vulnclass_include ?? []).join("\n"),
|
||||
excludeText: (f.vulnclass_exclude ?? []).join("\n"),
|
||||
taskIDsText: (f.task_ids ?? []).join(","),
|
||||
assetIDsText: (f.asset_ids ?? []).join(","),
|
||||
onStatusChange: f.on_status_change ?? false,
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
// buildConfig 把表单状态转成渠道 config。
|
||||
//
|
||||
// 唯一的规则,两类值:
|
||||
// - 掩码值("__masked__...")原样送回 → 后端解读为「这个字段没改,保留库中原值」
|
||||
// - 其余一律按用户输入提交,空串即「清空该字段」
|
||||
//
|
||||
// 之所以不特殊照顾凭据字段(比如「凭据留空就跳过」),是因为那会让用户**无法清除**
|
||||
// 一个设错的密钥——界面上没有任何操作能表达「我要把它删掉」。现在的规则下,
|
||||
// 清空输入框就等于清空该字段,语义唯一且用户可控。
|
||||
// 掩码值不会出现在输入框里(见 ConfigField),所以「框里有字」永远等于
|
||||
// 「用户主动填的」。
|
||||
function buildConfig(): Record<string, unknown> {
|
||||
const defs = CHANNEL_FIELDS[form.kind] ?? [];
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const d of defs) {
|
||||
const raw = form.config[d.key];
|
||||
if (d.kind === "switch") {
|
||||
out[d.key] = raw === true;
|
||||
continue;
|
||||
}
|
||||
if (typeof raw === "string" && raw.startsWith("__masked__")) {
|
||||
out[d.key] = raw;
|
||||
continue;
|
||||
}
|
||||
if (d.kind === "number") {
|
||||
const n = Number(raw);
|
||||
out[d.key] = Number.isFinite(n) && n > 0 ? n : 0;
|
||||
continue;
|
||||
}
|
||||
if (d.kind === "kv") {
|
||||
out[d.key] = parseKV(String(raw ?? ""));
|
||||
continue;
|
||||
}
|
||||
if (d.kind === "list") {
|
||||
out[d.key] = String(raw ?? "")
|
||||
.split(/[\s,,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
out[d.key] = String(raw ?? "").trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildFilter(): NotificationFilter {
|
||||
return {
|
||||
min_severity: form.minSeverity || undefined,
|
||||
vulnclass_include: parseKeywords(form.includeText),
|
||||
vulnclass_exclude: parseKeywords(form.excludeText),
|
||||
task_ids: parseIDs(form.taskIDsText),
|
||||
asset_ids: parseIDs(form.assetIDsText),
|
||||
on_status_change: form.onStatusChange,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveForm() {
|
||||
if (!form.name.trim()) {
|
||||
toast.error(t("toast.nameRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
kind: form.kind,
|
||||
mode: form.mode,
|
||||
enabled: form.enabled,
|
||||
config: buildConfig(),
|
||||
filter: buildFilter(),
|
||||
rate_per_min: form.ratePerMin.trim() === "" ? undefined : Number(form.ratePerMin),
|
||||
};
|
||||
if (editing) {
|
||||
await api.notifyUpdateChannel(editing.id, payload);
|
||||
toast.success(t("toast.saved"));
|
||||
setOpen(false);
|
||||
} else {
|
||||
await api.notifyCreateChannel(payload);
|
||||
toast.success(t("toast.channelAdded"));
|
||||
setOpen(false);
|
||||
}
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(t("toast.saveFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testChannel() {
|
||||
if (!editing) return;
|
||||
setTesting(true);
|
||||
try {
|
||||
const r = await api.notifyTestChannel(editing.id);
|
||||
toast.success(t("toast.testSent", { ms: r.latency_ms }));
|
||||
} catch (e) {
|
||||
// 后端把渠道返回的原始错误如实回传,这是排查配置的唯一线索,原样展示。
|
||||
toast.error(t("toast.testFailed", { msg: (e as Error).message }), { duration: 12000 });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeChannel(ch: NotificationChannel) {
|
||||
try {
|
||||
await api.notifyDeleteChannel(ch.id);
|
||||
toast.success(t("toast.deleted", { name: ch.name }));
|
||||
setOpen(false);
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(t("toast.deleteFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(ch: NotificationChannel) {
|
||||
try {
|
||||
await api.notifyUpdateChannel(ch.id, { enabled: !ch.enabled });
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(t("toast.actionFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleGlobal(on: boolean) {
|
||||
setGlobalSaving(true);
|
||||
try {
|
||||
await api.setSettings({ notify_enabled: on });
|
||||
setMeta((m) => (m ? { ...m, enabled: on } : m));
|
||||
toast.success(on ? t("toast.globalOn") : t("toast.globalOff"));
|
||||
} catch (e) {
|
||||
toast.error(t("toast.actionFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setGlobalSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGlobal() {
|
||||
setGlobalSaving(true);
|
||||
try {
|
||||
const patch: Record<string, unknown> = { notify_public_base_url: baseURL.trim() };
|
||||
const n = Number(digestMin);
|
||||
if (Number.isFinite(n) && n > 0) patch.notify_digest_interval_min = n;
|
||||
await api.setSettings(patch);
|
||||
toast.success(t("toast.saved"));
|
||||
load();
|
||||
} catch (e) {
|
||||
toast.error(t("toast.saveFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setGlobalSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const fields = CHANNEL_FIELDS[form.kind] ?? [];
|
||||
const secretKeys = new Set(meta?.kinds.find((k) => k.kind === form.kind)?.secret_keys ?? []);
|
||||
const defaultRate = meta?.kinds.find((k) => k.kind === form.kind)?.default_rate_per_min ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
|
||||
</div>
|
||||
{meta && (
|
||||
// 用 div 而不是 label:Switch 自带 aria-label,外面再套一层 label
|
||||
// 既关联不到任何原生控件,又会让点击文字看起来应该能切换。
|
||||
<div className="flex shrink-0 items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t("globalSwitch")}</span>
|
||||
<Switch
|
||||
checked={meta.enabled}
|
||||
disabled={globalSaving}
|
||||
onCheckedChange={toggleGlobal}
|
||||
aria-label={t("aria.globalSwitch")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{meta && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<StatTile
|
||||
label={t("stats.channels")}
|
||||
value={`${meta.stats.channels_on} / ${meta.stats.channels}`}
|
||||
hint={t("stats.channelsHint")}
|
||||
/>
|
||||
<StatTile label={t("stats.sentToday")} value={String(meta.stats.sent_today)} />
|
||||
<StatTile label={t("stats.pending")} value={String(meta.stats.pending)} />
|
||||
<StatTile
|
||||
label={t("stats.failed")}
|
||||
value={String(meta.stats.failed)}
|
||||
tone={meta.stats.failed > 0 ? "red" : undefined}
|
||||
/>
|
||||
<StatTile
|
||||
label={t("stats.backlog")}
|
||||
value={formatBacklog(meta.stats.backlog_age_ms, t)}
|
||||
// 积压年龄比积压条数有用得多:积压 3 条可以是从 3 秒到 3 小时。
|
||||
hint={meta.stats.backlog_age_ms > 5 * 60_000 ? t("stats.backlogStuck") : undefined}
|
||||
tone={meta.stats.backlog_age_ms > 5 * 60_000 ? "red" : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="gap-3">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">{t("global.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-base">{t("global.baseUrl")}</Label>
|
||||
<Input
|
||||
id="n-base"
|
||||
placeholder="https://artex.example.com"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{t("global.baseUrlHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-digest">{t("global.digest")}</Label>
|
||||
<Input
|
||||
id="n-digest"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1440}
|
||||
placeholder="30"
|
||||
value={digestMin}
|
||||
onChange={(e) => setDigestMin(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{t("global.digestHint")}</p>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<Button onClick={saveGlobal} disabled={globalSaving}>
|
||||
{t("global.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "channels" | "deliveries")} className="flex flex-col gap-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="channels">{t("tab.channels")}</TabsTrigger>
|
||||
<TabsTrigger value="deliveries">{t("tab.deliveries")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="channels">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAdd}
|
||||
className="text-foreground/70 border-foreground/70 hover:bg-muted/60 hover:shadow-sm flex min-h-[130px] flex-col items-center justify-center gap-2 rounded-xl border border-dashed transition"
|
||||
>
|
||||
<PlusIcon className="size-6" />
|
||||
<span className="text-sm">{t("addChannel")}</span>
|
||||
</button>
|
||||
|
||||
{channels.map((ch) => (
|
||||
<Card
|
||||
key={ch.id}
|
||||
onClick={() => openEdit(ch)}
|
||||
className="hover:border-primary/60 cursor-pointer gap-3 transition hover:shadow-sm"
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BellIcon className="text-muted-foreground size-4 shrink-0" />
|
||||
<CardTitle className="truncate text-base">{ch.name}</CardTitle>
|
||||
{/* 卡片整体可点(进入编辑),所以这两个控件必须各自吞掉冒泡,
|
||||
否则开关/删除会顺带触发编辑。把 stopPropagation 挂在控件自己
|
||||
身上,而不是套一层 div:套 div 会造出一个「看起来可交互但没有
|
||||
角色」的静态元素,既触发 a11y 告警,语义上也说不通。 */}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Switch
|
||||
checked={ch.enabled}
|
||||
onCheckedChange={() => toggleEnabled(ch)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={t("aria.enable")}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label={t("aria.delete")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// void 显式丢弃 Promise:removeChannel 自己 catch 并 toast,
|
||||
// 这里不需要 await(onClick 不是 async)。
|
||||
void removeChannel(ch);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{kindLabel(ch.kind)}</Badge>
|
||||
<Badge variant="outline">{ch.mode === "digest" ? t("mode.digest") : t("mode.realtime")}</Badge>
|
||||
{!ch.enabled && <Badge variant="outline">{t("disabled")}</Badge>}
|
||||
</div>
|
||||
<FilterSummary filter={ch.filter} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="deliveries">
|
||||
<DeliveryList channels={channels} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent side="right" className="w-full data-[side=right]:sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{editing ? editing.name : t("sheet.addTitle")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{kindLabel(form.kind)}
|
||||
{defaultRate > 0 ? t("sheet.rateLimited", { rate: defaultRate }) : t("sheet.rateUnlimited")}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4">
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("sheet.kindLabel")}</Label>
|
||||
<Select
|
||||
value={form.kind}
|
||||
onValueChange={(v) => {
|
||||
// 换类型等于换一套凭据字段,不能把旧配置合并进来。
|
||||
setF({ kind: v, config: {} });
|
||||
}}
|
||||
disabled={!!editing}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(meta?.kinds ?? []).map((k) => (
|
||||
<SelectItem key={k.kind} value={k.kind}>
|
||||
{kindLabel(k.kind)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{editing && <p className="text-muted-foreground text-xs">{t("sheet.kindLocked")}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-name">{t("sheet.nameLabel")}</Label>
|
||||
<Input
|
||||
id="n-name"
|
||||
placeholder={t("sheet.namePlaceholder")}
|
||||
value={form.name}
|
||||
onChange={(e) => setF({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">{t("sheet.noFields")}</p>
|
||||
) : (
|
||||
fields.map((d) => (
|
||||
<ConfigField
|
||||
key={d.key}
|
||||
def={d}
|
||||
value={form.config[d.key]}
|
||||
isSecret={secretKeys.has(d.key)}
|
||||
onChange={(v) => setCfg(d.key, v)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("sheet.modeLabel")}</Label>
|
||||
<Select value={form.mode} onValueChange={(v) => setF({ mode: v as "realtime" | "digest" })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="realtime">{t("sheet.modeRealtime")}</SelectItem>
|
||||
<SelectItem value="digest">{t("sheet.modeDigest")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">{t("sheet.modeHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-rate">{t("sheet.rateLabel")}</Label>
|
||||
<Input
|
||||
id="n-rate"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder={defaultRate > 0 ? String(defaultRate) : t("sheet.ratePlaceholder")}
|
||||
value={form.ratePerMin}
|
||||
onChange={(e) => setF({ ratePerMin: e.target.value })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{t("sheet.rateHint")}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<p className="mb-3 text-sm font-medium">{t("filter.title")}</p>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("filter.minSeverity")}</Label>
|
||||
<Select
|
||||
value={form.minSeverity || "all"}
|
||||
onValueChange={(v) => setF({ minSeverity: v === "all" ? "" : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SEVERITY_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value || "all"} value={o.value || "all"}>
|
||||
{t(o.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-inc">{t("filter.includeLabel")}</Label>
|
||||
<Textarea
|
||||
id="n-inc"
|
||||
placeholder={t("filter.includePlaceholder")}
|
||||
value={form.includeText}
|
||||
onChange={(e) => setF({ includeText: e.target.value })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{t("filter.includeHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-exc">{t("filter.excludeLabel")}</Label>
|
||||
<Textarea
|
||||
id="n-exc"
|
||||
placeholder={t("filter.excludePlaceholder")}
|
||||
value={form.excludeText}
|
||||
onChange={(e) => setF({ excludeText: e.target.value })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{t("filter.excludeHint")}</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-tasks">{t("filter.taskIds")}</Label>
|
||||
<Input
|
||||
id="n-tasks"
|
||||
placeholder="1, 2, 3"
|
||||
value={form.taskIDsText}
|
||||
onChange={(e) => setF({ taskIDsText: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="n-assets">{t("filter.assetIds")}</Label>
|
||||
<Input
|
||||
id="n-assets"
|
||||
placeholder="10, 11"
|
||||
value={form.assetIDsText}
|
||||
onChange={(e) => setF({ assetIDsText: e.target.value })}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">{t("filter.idsHint")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={form.onStatusChange}
|
||||
onCheckedChange={(v) => setF({ onStatusChange: v })}
|
||||
aria-label={t("aria.statusChange")}
|
||||
/>
|
||||
{t("filter.onStatusChange")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(v) => setF({ enabled: v })}
|
||||
aria-label={t("aria.enable")}
|
||||
/>
|
||||
{t("sheet.enable")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2 pb-6">
|
||||
<Button onClick={saveForm} disabled={saving}>
|
||||
{editing ? t("sheet.save") : t("sheet.add")}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button variant="outline" onClick={testChannel} disabled={testing}>
|
||||
<SendIcon /> {t("sheet.sendTest")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
RefreshCwIcon,
|
||||
RotateCcwIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { api, sseUrl } from "@/lib/api";
|
||||
import type { UpdateCheck, UpdateProgress } from "@/lib/types";
|
||||
|
||||
/** 等待新版本上线的最长时间。一次升级要经过三次进程启动(暂存 → 换装 → 新版),
|
||||
* 每次都是秒级,三分钟足够覆盖慢磁盘和 Docker 容器重建。 */
|
||||
const RESTART_TIMEOUT_MS = 180_000;
|
||||
|
||||
function humanSize(n?: number): string {
|
||||
if (!n || n <= 0) return "";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let v = n;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
export function UpdateCard() {
|
||||
const t = useTranslations("settingsPage");
|
||||
const richTags = {
|
||||
code: (chunks: React.ReactNode) => <span className="font-mono">{chunks}</span>,
|
||||
};
|
||||
const [info, setInfo] = React.useState<UpdateCheck | null>(null);
|
||||
const [checking, setChecking] = React.useState(true);
|
||||
const [progress, setProgress] = React.useState<UpdateProgress | null>(null);
|
||||
// 与 progress 分开:暂存完成后进程就没了,SSE 会断,此时要切到轮询 /api/health。
|
||||
const [restarting, setRestarting] = React.useState(false);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
|
||||
// quiet 同时决定要不要绕过后端缓存:进页面时的自动检查用缓存(顶栏刚查过),
|
||||
// 用户手动点「检查更新」则强制回源,否则刚发布的版本要等缓存过期才看得到。
|
||||
const check = React.useCallback(
|
||||
(quiet = false) => {
|
||||
setChecking(true);
|
||||
api
|
||||
.checkUpdate(!quiet)
|
||||
.then((r) => {
|
||||
setInfo(r);
|
||||
if (!quiet) {
|
||||
if (r.error) toast.error(t("update.toast.checkFailed", { error: r.error }));
|
||||
else if (r.has_update) toast.success(t("update.toast.found", { latest: r.latest ?? "" }));
|
||||
else if (r.comparable) toast.success(t("update.toast.upToDate"));
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!quiet) toast.error(t("update.toast.checkFailed", { error: (e as Error).message }));
|
||||
})
|
||||
.finally(() => setChecking(false));
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
check(true);
|
||||
}, [check]);
|
||||
|
||||
// 轮询 /api/health 直到版本号变化。
|
||||
//
|
||||
// 判据必须是"版本变了"而不是"能连上了":换装过程中旧版本会短暂地重新起来一次
|
||||
// (那一次只负责把 artex.new 换上去然后立刻退出),只看连通性会误判成功。
|
||||
const waitForNewVersion = React.useCallback(
|
||||
async (fromVersion: string) => {
|
||||
setRestarting(true);
|
||||
const deadline = Date.now() + RESTART_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(2000);
|
||||
try {
|
||||
const r = await fetch("/api/health", { cache: "no-store" });
|
||||
if (r.ok) {
|
||||
const j = (await r.json()) as { version?: string };
|
||||
if (j.version && j.version !== fromVersion) {
|
||||
toast.success(t("update.toast.updated", { version: j.version }));
|
||||
await sleep(800);
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 重启窗口内连不上是预期的,继续轮询。
|
||||
}
|
||||
}
|
||||
setRestarting(false);
|
||||
toast.error(t("update.toast.restartTimeout"));
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// 订阅更新进度。SSE 不走 Next 的 /api 重写(那层会缓冲,事件推不出来)。
|
||||
const openStream = React.useCallback(
|
||||
(fromVersion: string) => {
|
||||
const es = new EventSource(sseUrl("/api/update/stream"));
|
||||
es.onmessage = (ev) => {
|
||||
let p: UpdateProgress;
|
||||
try {
|
||||
p = JSON.parse(ev.data) as UpdateProgress;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
setProgress(p);
|
||||
if (p.phase === "failed") {
|
||||
es.close();
|
||||
setBusy(false);
|
||||
toast.error(t("update.toast.updateFailed", { error: p.error || p.message }));
|
||||
return;
|
||||
}
|
||||
if (p.phase === "staged") {
|
||||
es.close();
|
||||
void waitForNewVersion(fromVersion);
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
// 进程退出时 SSE 必然断开。如果已经进入等待重启,这属于正常现象,
|
||||
// 交给 /api/health 轮询继续判定即可。
|
||||
es.close();
|
||||
};
|
||||
return es;
|
||||
},
|
||||
[waitForNewVersion, t],
|
||||
);
|
||||
|
||||
const doUpdate = () => {
|
||||
if (!info) return;
|
||||
const from = info.current;
|
||||
const ok = window.confirm(
|
||||
`${t("update.confirm.title", { latest: info.latest ?? "" })}\n\n` +
|
||||
t("update.confirm.restart") +
|
||||
(info.mode === "docker" ? `\n\n${t("update.confirm.docker")}` : ""),
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
setBusy(true);
|
||||
setProgress({ phase: "downloading", percent: 0, message: t("update.toast.preparing") });
|
||||
const es = openStream(from);
|
||||
api.applyUpdate().catch((e) => {
|
||||
es.close();
|
||||
setBusy(false);
|
||||
setProgress(null);
|
||||
toast.error(t("update.toast.startFailed", { error: (e as Error).message }));
|
||||
});
|
||||
};
|
||||
|
||||
const doRollback = () => {
|
||||
if (!info) return;
|
||||
if (!window.confirm(t("update.confirm.rollback"))) return;
|
||||
const from = info.current;
|
||||
setBusy(true);
|
||||
api
|
||||
.rollbackUpdate()
|
||||
.then(() => {
|
||||
toast.success(t("update.toast.rollbackSwitched"));
|
||||
void waitForNewVersion(from);
|
||||
})
|
||||
.catch((e) => {
|
||||
setBusy(false);
|
||||
toast.error(t("update.toast.rollbackFailed", { error: (e as Error).message }));
|
||||
});
|
||||
};
|
||||
|
||||
const phase = progress?.phase;
|
||||
const showProgress = busy || restarting;
|
||||
// 只有下载阶段拿得到真实百分比(按 Content-Length 算)。校验/解压/等待重启都是
|
||||
// 时长不可知的阶段,进度条填满并加个脉冲动画表示"在忙但说不准还要多久"。
|
||||
const downloading = !restarting && phase === "downloading";
|
||||
const pct = downloading ? Math.max(progress?.percent ?? 0, 0) : 100;
|
||||
|
||||
return (
|
||||
// 设置页是多列瀑布流布局,卡片自己负责行间距并禁止跨列断开(见 page.tsx 的注释)。
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<DownloadIcon className="size-4" />
|
||||
{t("update.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("update.desc")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t("update.currentVersion")}</span>
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{info?.current ?? "…"}
|
||||
</Badge>
|
||||
{info && (
|
||||
<>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{info.os}/{info.arch}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{info.mode === "docker" ? t("update.modeDocker") : t("update.modeStandalone")}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{info?.latest && (
|
||||
<>
|
||||
<span className="text-muted-foreground">{t("update.latestVersion")}</span>
|
||||
<Badge variant={info.has_update ? "default" : "secondary"} className="font-mono">
|
||||
{info.latest}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
{info?.html_url && (
|
||||
<a
|
||||
href={info.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("update.changelog")} <ExternalLinkIcon className="size-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{info?.boot_notice && (
|
||||
<p className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-700 dark:text-amber-400">
|
||||
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
|
||||
{info.boot_notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{info?.error && (
|
||||
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
|
||||
{t("update.connectError", { error: info.error })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{info && !info.comparable && info.reason && <p className="text-xs text-muted-foreground">{info.reason}</p>}
|
||||
|
||||
{info?.has_update && info.asset_available === false && (
|
||||
<p className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
<TriangleAlertIcon className="mt-0.5 size-3.5 shrink-0" />
|
||||
{t("update.noAsset", { latest: info.latest ?? "", os: info.os, arch: info.arch, asset: info.asset ?? "" })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{info?.has_update && info.asset_available !== false && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("update.willDownloadPrefix")}
|
||||
<span className="font-mono">{info.asset}</span>
|
||||
{info.size ? t("update.willDownloadSize", { size: humanSize(info.size) }) : ""}
|
||||
{t("update.willDownloadSuffix")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{info && !info.has_update && info.comparable && !info.error && (
|
||||
<p className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CheckCircle2Icon className="size-3.5 text-emerald-600" />
|
||||
{t("update.upToDate")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{info?.mode === "docker" && info.has_update && (
|
||||
<p className="text-xs text-muted-foreground">{t.rich("update.dockerNote", richTags)}</p>
|
||||
)}
|
||||
|
||||
{showProgress && (
|
||||
<div className="space-y-1.5">
|
||||
<Progress value={pct} className={downloading ? undefined : "animate-pulse"} />
|
||||
<p className="text-xs text-muted-foreground">{restarting ? t("update.restarting") : progress?.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => check(false)} disabled={checking || busy || restarting}>
|
||||
<RefreshCwIcon className={checking ? "size-4 animate-spin" : "size-4"} />
|
||||
{t("update.check")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={doUpdate}
|
||||
disabled={busy || restarting || !info?.has_update || info?.asset_available === false}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{info?.has_update ? t("update.updateToVersion", { latest: info.latest ?? "" }) : t("update.updateNow")}
|
||||
</Button>
|
||||
{info?.has_backup && (
|
||||
<Button variant="ghost" size="sm" onClick={doRollback} disabled={busy || restarting}>
|
||||
<RotateCcwIcon className="size-4" />
|
||||
{t("update.rollback")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t.rich("update.footer", richTags)}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,659 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { CpuIcon, FlaskConicalIcon, KeyboardIcon, RadioTowerIcon, SearchIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/lib/api";
|
||||
import { type ChatSendMode, setChatSendMode, useChatSendMode, useChatSendModeOptions } from "@/lib/chat-send-mode";
|
||||
import type { Settings } from "@/lib/types";
|
||||
|
||||
import { UpdateCard } from "./_components/update-card";
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
const t = useTranslations("settingsPage");
|
||||
const richTags = {
|
||||
b: (chunks: React.ReactNode) => <b>{chunks}</b>,
|
||||
br: () => <br />,
|
||||
code: (chunks: React.ReactNode) => <span className="font-mono">{chunks}</span>,
|
||||
};
|
||||
const [trafficCapture, setTrafficCapture] = React.useState(false);
|
||||
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
|
||||
const [webSearch, setWebSearch] = React.useState(false);
|
||||
const [backend, setBackend] = React.useState("ddgs");
|
||||
const [braveKeySet, setBraveKeySet] = React.useState(false);
|
||||
const [braveKeyInput, setBraveKeyInput] = React.useState("");
|
||||
const [tavilyKeySet, setTavilyKeySet] = React.useState(false);
|
||||
const [tavilyKeyInput, setTavilyKeyInput] = React.useState("");
|
||||
const [savingTavilyKey, setSavingTavilyKey] = React.useState(false);
|
||||
const [proxyInput, setProxyInput] = React.useState("");
|
||||
const [savingProxy, setSavingProxy] = React.useState(false);
|
||||
const [globalProxyInput, setGlobalProxyInput] = React.useState("");
|
||||
const [savingGlobalProxy, setSavingGlobalProxy] = React.useState(false);
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [loaded, setLoaded] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [savingKey, setSavingKey] = React.useState(false);
|
||||
const [pyInterp, setPyInterp] = React.useState("");
|
||||
const [workers, setWorkers] = React.useState("3");
|
||||
const [savingWorkers, setSavingWorkers] = React.useState(false);
|
||||
// 操作约束注入范围(默认都开)。
|
||||
const [injectPlanner, setInjectPlanner] = React.useState(true);
|
||||
const [injectWorker, setInjectWorker] = React.useState(true);
|
||||
// 实验功能:noa 上下文压缩(默认关)。
|
||||
const [noaCompaction, setNoaCompaction] = React.useState(false);
|
||||
// 纯前端偏好:不走 /api/settings,直接读写 localStorage。
|
||||
const sendMode = useChatSendMode();
|
||||
const sendModeOptions = useChatSendModeOptions();
|
||||
|
||||
const apply = React.useCallback((s: Settings) => {
|
||||
setTrafficCapture(!!s.traffic_capture);
|
||||
setAgentTrafficBinding(!!s.agent_traffic_binding);
|
||||
setWebSearch(!!s.web_search_enabled);
|
||||
setBackend(s.web_search_backend || "ddgs");
|
||||
setBraveKeySet(!!s.brave_key_set);
|
||||
setTavilyKeySet(!!s.tavily_key_set);
|
||||
setProxyInput(s.web_search_proxy ?? "");
|
||||
setGlobalProxyInput(s.global_proxy ?? "");
|
||||
setPyInterp(s.python_interpreter ?? "");
|
||||
setWorkers(String(s.workers ?? 3));
|
||||
setInjectPlanner(s.constraints_inject_planner !== false);
|
||||
setInjectWorker(s.constraints_inject_worker !== false);
|
||||
setNoaCompaction(!!s.noa_compaction);
|
||||
}, []);
|
||||
|
||||
const saveWorkers = () => {
|
||||
const n = Number(workers);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
toast.error(t("toast.workersInvalid"));
|
||||
return;
|
||||
}
|
||||
setSavingWorkers(true);
|
||||
api
|
||||
.setSettings({ workers: n })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(t("toast.workersSaved"));
|
||||
})
|
||||
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
|
||||
.finally(() => setSavingWorkers(false));
|
||||
};
|
||||
|
||||
const savePython = () => {
|
||||
setSaving(true);
|
||||
api
|
||||
.setSettings({ python_interpreter: pyInterp.trim() })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(t("toast.pythonSaved"));
|
||||
})
|
||||
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
const detectPython = () => {
|
||||
setSaving(true);
|
||||
api
|
||||
.detectPython()
|
||||
.then((r) => setPyInterp(r.python_interpreter))
|
||||
.catch(() => undefined)
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
api
|
||||
.settings()
|
||||
.then(apply)
|
||||
.catch(() => undefined)
|
||||
.finally(() => setLoaded(true));
|
||||
}, [apply]);
|
||||
|
||||
const toggleTraffic = (v: boolean) => {
|
||||
setTrafficCapture(v); // optimistic
|
||||
setSaving(true);
|
||||
api
|
||||
.setSettings({ traffic_capture: v })
|
||||
.then(apply)
|
||||
.catch(() => setTrafficCapture(!v)) // revert on failure
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const toggleInjectPlanner = (v: boolean) => {
|
||||
setInjectPlanner(v); // optimistic
|
||||
api
|
||||
.setSettings({ constraints_inject_planner: v })
|
||||
.then(apply)
|
||||
.catch(() => setInjectPlanner(!v)); // revert on failure
|
||||
};
|
||||
|
||||
const toggleAgentTrafficBinding = (v: boolean) => {
|
||||
setAgentTrafficBinding(v);
|
||||
setSaving(true);
|
||||
api
|
||||
.setSettings({ agent_traffic_binding: v })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(v ? t("toast.bindingOn") : t("toast.bindingOff"));
|
||||
})
|
||||
.catch((e) => {
|
||||
setAgentTrafficBinding(!v);
|
||||
toast.error(t("toast.saveFailed", { error: (e as Error).message }));
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const toggleInjectWorker = (v: boolean) => {
|
||||
setInjectWorker(v); // optimistic
|
||||
api
|
||||
.setSettings({ constraints_inject_worker: v })
|
||||
.then(apply)
|
||||
.catch(() => setInjectWorker(!v)); // revert on failure
|
||||
};
|
||||
|
||||
const toggleNoaCompaction = (v: boolean) => {
|
||||
setNoaCompaction(v); // optimistic
|
||||
api
|
||||
.setSettings({ noa_compaction: v })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(v ? t("toast.noaOn") : t("toast.noaOff"));
|
||||
})
|
||||
.catch((e) => {
|
||||
setNoaCompaction(!v); // revert on failure
|
||||
toast.error(t("toast.saveFailed", { error: (e as Error).message }));
|
||||
});
|
||||
};
|
||||
|
||||
// Persist a web-search patch (enable and/or backend). Optimistic with refetch.
|
||||
const saveWebSearch = (patch: Partial<Settings>) => {
|
||||
setSaving(true);
|
||||
api
|
||||
.setSettings(patch)
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(t("toast.webSearchSaved"));
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(t("toast.saveFailed", { error: (e as Error).message }));
|
||||
api
|
||||
.settings()
|
||||
.then(apply)
|
||||
.catch(() => undefined);
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const saveBraveKey = () => {
|
||||
setSavingKey(true);
|
||||
api
|
||||
.setSettings({ brave_search_api_key: braveKeyInput })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
setBraveKeyInput("");
|
||||
toast.success(t("toast.braveKeySaved"));
|
||||
})
|
||||
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
|
||||
.finally(() => setSavingKey(false));
|
||||
};
|
||||
|
||||
const saveTavilyKey = () => {
|
||||
setSavingTavilyKey(true);
|
||||
api
|
||||
.setSettings({ tavily_search_api_key: tavilyKeyInput })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
setTavilyKeyInput("");
|
||||
toast.success(t("toast.tavilyKeySaved"));
|
||||
})
|
||||
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
|
||||
.finally(() => setSavingTavilyKey(false));
|
||||
};
|
||||
|
||||
const saveProxy = () => {
|
||||
setSavingProxy(true);
|
||||
api
|
||||
.setSettings({ web_search_proxy: proxyInput.trim() })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(proxyInput.trim() ? t("toast.proxySaved") : t("toast.proxyCleared"));
|
||||
})
|
||||
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
|
||||
.finally(() => setSavingProxy(false));
|
||||
};
|
||||
|
||||
const saveGlobalProxy = () => {
|
||||
setSavingGlobalProxy(true);
|
||||
api
|
||||
.setSettings({ global_proxy: globalProxyInput.trim() })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(globalProxyInput.trim() ? t("toast.globalProxySaved") : t("toast.globalProxyCleared"));
|
||||
})
|
||||
.catch((e) => toast.error(t("toast.saveFailed", { error: (e as Error).message })))
|
||||
.finally(() => setSavingGlobalProxy(false));
|
||||
};
|
||||
|
||||
// Run a real "test" search ("test") against the CURRENT form values (backend +
|
||||
// proxy + entered key), falling back to saved values server-side. Toasts result.
|
||||
const runTest = () => {
|
||||
setTesting(true);
|
||||
api
|
||||
.testWebSearch({
|
||||
web_search_backend: backend,
|
||||
web_search_proxy: proxyInput.trim(),
|
||||
brave_search_api_key: braveKeyInput,
|
||||
tavily_search_api_key: tavilyKeyInput,
|
||||
})
|
||||
.then((r) => {
|
||||
if (r.ok) toast.success(t("toast.testSuccess", { backend: r.backend ?? "", count: r.count ?? 0 }));
|
||||
else toast.error(t("toast.testFailed", { error: r.error || t("toast.testUnknownError") }));
|
||||
})
|
||||
.catch((e) => toast.error(t("toast.testFailed", { error: (e as Error).message })))
|
||||
.finally(() => setTesting(false));
|
||||
};
|
||||
|
||||
// brave-free selected but no key stored and none being entered → tool stays off.
|
||||
const braveNeedsKey = webSearch && backend === "brave-free" && !braveKeySet;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* 多列而非 grid:网络搜索卡片比其余高数倍,且高度随所选后端变化(brave/tavily
|
||||
的 key 输入是条件渲染)。grid 会按最高的一张撑满整行、在旁边留下大片空白,
|
||||
多列则自动按内容高度平衡填充。卡片间距靠 mb 而非 gap——多列布局下
|
||||
column-gap 只管列间距,行间距要由子元素自己给。 */}
|
||||
<div className="columns-1 gap-4 md:gap-6 lg:columns-2">
|
||||
<UpdateCard />
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RadioTowerIcon className="size-4" />
|
||||
{t("trafficCapture.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t.rich("trafficCapture.desc", richTags)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="traffic-capture" className="text-sm font-normal text-muted-foreground">
|
||||
{trafficCapture ? t("trafficCapture.on") : t("trafficCapture.off")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="traffic-capture"
|
||||
checked={trafficCapture}
|
||||
disabled={!loaded || saving}
|
||||
onCheckedChange={toggleTraffic}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RadioTowerIcon className="size-4" />
|
||||
{t("agentBinding.title")}
|
||||
</CardTitle>
|
||||
<CardDescription id="agent-traffic-binding-description">
|
||||
{t.rich("agentBinding.desc", richTags)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="agent-traffic-binding" className="text-sm font-normal text-muted-foreground">
|
||||
{agentTrafficBinding ? t("agentBinding.on") : t("agentBinding.off")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="agent-traffic-binding"
|
||||
aria-describedby="agent-traffic-binding-description"
|
||||
checked={agentTrafficBinding}
|
||||
disabled={!loaded || saving}
|
||||
onCheckedChange={toggleAgentTrafficBinding}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RadioTowerIcon className="size-4" />
|
||||
{t("globalProxy.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t.rich("globalProxy.desc", richTags)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Label htmlFor="global-proxy" className="text-sm font-normal text-muted-foreground">
|
||||
{t("globalProxy.addrLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="global-proxy"
|
||||
autoComplete="off"
|
||||
placeholder={t("globalProxy.placeholder")}
|
||||
value={globalProxyInput}
|
||||
disabled={!loaded || savingGlobalProxy}
|
||||
onChange={(e) => setGlobalProxyInput(e.target.value)}
|
||||
/>
|
||||
<Button type="button" onClick={saveGlobalProxy} disabled={!loaded || savingGlobalProxy}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{globalProxyInput.trim() ? t("globalProxy.configured") : t("globalProxy.notConfigured")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShieldAlertIcon className="size-4" />
|
||||
{t("constraints.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t.rich("constraints.desc", richTags)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="inject-planner" className="text-sm font-normal text-muted-foreground">
|
||||
{t("constraints.injectPlanner")}
|
||||
{injectPlanner ? t("onSuffix") : t("offSuffix")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="inject-planner"
|
||||
checked={injectPlanner}
|
||||
disabled={!loaded}
|
||||
onCheckedChange={toggleInjectPlanner}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="inject-worker" className="text-sm font-normal text-muted-foreground">
|
||||
{t("constraints.injectWorker")}
|
||||
{injectWorker ? t("onSuffix") : t("offSuffix")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="inject-worker"
|
||||
checked={injectWorker}
|
||||
disabled={!loaded}
|
||||
onCheckedChange={toggleInjectWorker}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<FlaskConicalIcon className="size-4" />
|
||||
{t("experimental.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t.rich("experimental.desc", richTags)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="noa-compaction" className="text-sm font-normal text-muted-foreground">
|
||||
{t("experimental.noaLabel")}
|
||||
{noaCompaction ? t("onSuffix") : t("offSuffix")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="noa-compaction"
|
||||
checked={noaCompaction}
|
||||
disabled={!loaded}
|
||||
onCheckedChange={toggleNoaCompaction}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<SearchIcon className="size-4" />
|
||||
{t("webSearch.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t.rich("webSearch.desc", richTags)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="web-search" className="text-sm font-normal text-muted-foreground">
|
||||
{webSearch ? t("webSearch.on") : t("webSearch.off")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="web-search"
|
||||
checked={webSearch}
|
||||
disabled={!loaded || saving}
|
||||
onCheckedChange={(v) => {
|
||||
setWebSearch(v); // optimistic
|
||||
saveWebSearch({ web_search_enabled: v });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{webSearch && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label className="text-sm font-normal text-muted-foreground">{t("webSearch.sourceLabel")}</Label>
|
||||
<Select
|
||||
value={backend}
|
||||
disabled={!loaded || saving}
|
||||
onValueChange={(v) => {
|
||||
setBackend(v); // optimistic
|
||||
saveWebSearch({ web_search_backend: v });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48 shrink-0">
|
||||
<SelectValue placeholder={t("webSearch.sourcePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ddgs">{t("webSearch.sourceDdgs")}</SelectItem>
|
||||
<SelectItem value="brave-free">{t("webSearch.sourceBrave")}</SelectItem>
|
||||
<SelectItem value="tavily">{t("webSearch.sourceTavily")}</SelectItem>
|
||||
<SelectItem value="deepseek">{t("webSearch.sourceDeepseek")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && backend === "deepseek" && (
|
||||
<div className="border-border/60 bg-muted/30 flex flex-col gap-2 rounded-md border p-3">
|
||||
<p className="text-sm font-medium">{t("webSearch.deepseekTitle")}</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t.rich("webSearch.deepseekDesc1", richTags)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{t.rich("webSearch.deepseekDesc2", richTags)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">{t("webSearch.deepseekDesc3")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && backend === "brave-free" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="brave-key" className="text-sm font-normal text-muted-foreground">
|
||||
{t("webSearch.braveKeyLabel")}
|
||||
{braveKeySet && <span className="ml-2 text-xs text-emerald-500">{t("configured")}</span>}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="brave-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
braveKeySet ? t("webSearch.braveKeyPlaceholderSet") : t("webSearch.braveKeyPlaceholderUnset")
|
||||
}
|
||||
value={braveKeyInput}
|
||||
disabled={!loaded || savingKey}
|
||||
onChange={(e) => setBraveKeyInput(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveBraveKey}
|
||||
disabled={!loaded || savingKey || braveKeyInput.trim() === ""}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
{braveNeedsKey && <p className="text-xs text-amber-500">{t("webSearch.braveNeedsKey")}</p>}
|
||||
<p className="text-muted-foreground text-xs">{t("webSearch.braveQuota")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && backend === "tavily" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="tavily-key" className="text-sm font-normal text-muted-foreground">
|
||||
{t("webSearch.tavilyKeyLabel")}
|
||||
{tavilyKeySet && <span className="ml-2 text-xs text-emerald-500">{t("configured")}</span>}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="tavily-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
tavilyKeySet ? t("webSearch.tavilyKeyPlaceholderSet") : t("webSearch.tavilyKeyPlaceholderUnset")
|
||||
}
|
||||
value={tavilyKeyInput}
|
||||
disabled={!loaded || savingTavilyKey}
|
||||
onChange={(e) => setTavilyKeyInput(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={saveTavilyKey}
|
||||
disabled={!loaded || savingTavilyKey || tavilyKeyInput.trim() === ""}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
{webSearch && backend === "tavily" && !tavilyKeySet && (
|
||||
<p className="text-xs text-amber-500">{t("webSearch.tavilyNeedsKey")}</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">{t("webSearch.tavilyGetKey")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="ws-proxy" className="text-sm font-normal text-muted-foreground">
|
||||
{t("webSearch.proxyLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="ws-proxy"
|
||||
autoComplete="off"
|
||||
placeholder={t("webSearch.proxyPlaceholder")}
|
||||
value={proxyInput}
|
||||
disabled={!loaded || savingProxy}
|
||||
onChange={(e) => setProxyInput(e.target.value)}
|
||||
/>
|
||||
<Button type="button" onClick={saveProxy} disabled={!loaded || savingProxy}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{t("webSearch.proxyHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{webSearch && (
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-4">
|
||||
<p className="text-muted-foreground text-xs">{t("webSearch.testHint")}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={runTest}
|
||||
disabled={!loaded || testing}
|
||||
className="shrink-0"
|
||||
>
|
||||
{testing ? t("webSearch.testRunning") : t("webSearch.testButton")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RadioTowerIcon className="size-4" />
|
||||
{t("python.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t.rich("python.desc", richTags)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
className="font-mono text-sm"
|
||||
placeholder={t("python.placeholder")}
|
||||
value={pyInterp}
|
||||
disabled={!loaded || saving}
|
||||
onChange={(e) => setPyInterp(e.target.value)}
|
||||
/>
|
||||
<Button variant="outline" onClick={detectPython} disabled={!loaded || saving}>
|
||||
{t("python.detect")}
|
||||
</Button>
|
||||
<Button onClick={savePython} disabled={!loaded || saving}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CpuIcon className="size-4" />
|
||||
{t("workers.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t.rich("workers.desc", richTags)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-32 font-mono text-sm"
|
||||
placeholder="3"
|
||||
value={workers}
|
||||
disabled={!loaded || savingWorkers}
|
||||
onChange={(e) => setWorkers(e.target.value)}
|
||||
/>
|
||||
<Button onClick={saveWorkers} disabled={!loaded || savingWorkers}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<KeyboardIcon className="size-4" />
|
||||
{t("sendKey.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t.rich("sendKey.desc", richTags)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="chat-send-mode" className="text-sm font-normal text-muted-foreground">
|
||||
{t("sendKey.label")}
|
||||
</Label>
|
||||
<Select value={sendMode} onValueChange={(v) => setChatSendMode(v as ChatSendMode)}>
|
||||
<SelectTrigger id="chat-send-mode" className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sendModeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,956 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { PlayIcon, PlusIcon, RotateCcwIcon, SaveIcon, SearchIcon, Trash2Icon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Agent, Tool } from "@/lib/types";
|
||||
|
||||
// Traffic tools are host tools gated by the global 流量捕获 switch: bindable, but
|
||||
// only usable when capture is on. Keep in sync with traffic.SeedToolMetas.
|
||||
const TRAFFIC_TOOL_KEYS = new Set(["traffic_search", "traffic_get"]);
|
||||
|
||||
// paramRows flattens schema.properties into an ordered row list for editing.
|
||||
// name/type/required are read-only (welded to the Go handler); description/default
|
||||
// are editable. Non-scalar params (array/object) can't carry an editable default.
|
||||
// parentKey is set for rows that live inside an array param's items.properties.
|
||||
type ParamRow = {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
defaultStr: string; // "" = no default declared
|
||||
scalar: boolean;
|
||||
parentKey?: string; // set when this is a sub-field of an array param's items
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function toRows(schema: Record<string, any>): ParamRow[] {
|
||||
const props = (schema?.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = new Set<string>((schema?.required as string[]) ?? []);
|
||||
const rows: ParamRow[] = [];
|
||||
for (const [name, p] of Object.entries(props)) {
|
||||
const type = String(p?.type ?? "");
|
||||
const hasDefault = p != null && "default" in p && (p as Record<string, unknown>).default != null;
|
||||
rows.push({
|
||||
name,
|
||||
type,
|
||||
required: required.has(name),
|
||||
description: String(p?.description ?? ""),
|
||||
defaultStr: hasDefault ? String((p as Record<string, unknown>).default) : "",
|
||||
scalar: ["string", "integer", "number", "boolean"].includes(type),
|
||||
});
|
||||
// Expand items.properties for array params so sub-fields are editable.
|
||||
if (type === "array") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const itemProps = (p as any)?.items?.properties as Record<string, Record<string, unknown>> | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const itemRequired = new Set<string>(((p as any)?.items?.required as string[]) ?? []);
|
||||
if (itemProps) {
|
||||
for (const [subName, subP] of Object.entries(itemProps)) {
|
||||
const subType = String((subP as Record<string, unknown>)?.type ?? "");
|
||||
const subHasDefault = subP != null && "default" in subP && subP.default != null;
|
||||
rows.push({
|
||||
name: subName,
|
||||
type: subType,
|
||||
required: itemRequired.has(subName),
|
||||
description: String(subP?.description ?? ""),
|
||||
defaultStr: subHasDefault ? String(subP.default) : "",
|
||||
scalar: ["string", "integer", "number", "boolean"].includes(subType),
|
||||
parentKey: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// coerceDefault turns the edited default string back into a typed JSON value, or
|
||||
// undefined to drop the "default" key entirely.
|
||||
function coerceDefault(type: string, raw: string): unknown {
|
||||
const s = raw.trim();
|
||||
if (s === "") return undefined;
|
||||
if (type === "integer" || type === "number") {
|
||||
const n = Number(s);
|
||||
return Number.isNaN(n) ? undefined : n;
|
||||
}
|
||||
if (type === "boolean") return s === "true";
|
||||
return raw;
|
||||
}
|
||||
|
||||
// applyRows writes edited rows back into a deep-copied schema (structure untouched).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function applyRows(schema: Record<string, any>, rows: ParamRow[]): Record<string, any> {
|
||||
const next = structuredClone(schema ?? {});
|
||||
const props = (next.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
for (const r of rows) {
|
||||
if (r.parentKey) {
|
||||
// Sub-field of an array param's items.properties
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const parent = props[r.parentKey] as any;
|
||||
const subP = parent?.items?.properties?.[r.name] as Record<string, unknown> | undefined;
|
||||
if (!subP) continue;
|
||||
subP.description = r.description;
|
||||
if (r.scalar) {
|
||||
const dv = coerceDefault(r.type, r.defaultStr);
|
||||
if (dv === undefined) delete subP.default;
|
||||
else subP.default = dv;
|
||||
}
|
||||
} else {
|
||||
const p = props[r.name];
|
||||
if (!p) continue;
|
||||
p.description = r.description;
|
||||
if (r.scalar) {
|
||||
const dv = coerceDefault(r.type, r.defaultStr);
|
||||
if (dv === undefined) delete p.default;
|
||||
else p.default = dv;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// ToolEditor is the full edit form for one tool, rendered inside the drawer.
|
||||
function ToolEditor({
|
||||
tool,
|
||||
agents,
|
||||
captureOn,
|
||||
onSaved,
|
||||
onClose,
|
||||
}: {
|
||||
tool: Tool;
|
||||
agents: Agent[];
|
||||
captureOn: boolean;
|
||||
onSaved: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations("toolsPage");
|
||||
// traffic tools can't be bound/enabled until the global 流量捕获 switch is on.
|
||||
const trafficGated = TRAFFIC_TOOL_KEYS.has(tool.key) && !captureOn;
|
||||
const [description, setDescription] = React.useState(tool.description);
|
||||
const [bound, setBound] = React.useState<string[]>(tool.agents);
|
||||
const [enabled, setEnabled] = React.useState(tool.enabled);
|
||||
const [rows, setRows] = React.useState<ParamRow[]>(() => toRows(tool.schema));
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
|
||||
const setRow = (i: number, patch: Partial<ParamRow>) =>
|
||||
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...patch } : r)));
|
||||
const toggleAgent = (k: string) => setBound((b) => (b.includes(k) ? b.filter((x) => x !== k) : [...b, k]));
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveTool(tool.key, {
|
||||
description,
|
||||
schema: applyRows(tool.schema, rows),
|
||||
agents: bound,
|
||||
enabled,
|
||||
});
|
||||
toast.success(t("savedTool", { key: tool.key }));
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.error(t("saveFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
async function reset() {
|
||||
try {
|
||||
await api.resetTool(tool.key);
|
||||
toast.success(t("resetDone", { key: tool.key }));
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast.error(t("resetFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4">
|
||||
{trafficGated && (
|
||||
<div className="border-amber-500/40 bg-amber-500/10 text-muted-foreground rounded-md border px-3 py-2 text-xs">
|
||||
{t.rich("trafficGatedNote", { b: (c) => <b>{c}</b> })}
|
||||
</div>
|
||||
)}
|
||||
{/* binding + switch */}
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground text-xs">{t("bindAgentLabel")}</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{agents.map((ag) => (
|
||||
<label key={ag.key} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={bound.includes(ag.key)}
|
||||
disabled={trafficGated}
|
||||
onCheckedChange={() => toggleAgent(ag.key)}
|
||||
/>
|
||||
{ag.name}
|
||||
<span className="text-muted-foreground font-mono text-xs">{ag.key}</span>
|
||||
</label>
|
||||
))}
|
||||
{agents.length === 0 && <span className="text-muted-foreground text-xs">{t("noAgents")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} id={`en-${tool.key}`} />
|
||||
<Label htmlFor={`en-${tool.key}`} className="text-sm">
|
||||
{t("enabled")}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* description */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground text-xs">{t("descLabel")}</Label>
|
||||
<Textarea
|
||||
className="font-mono text-xs"
|
||||
rows={6}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* params */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-muted-foreground text-xs">{t("paramsLabel")}</Label>
|
||||
{rows.length === 0 && <span className="text-muted-foreground text-xs">{t("noParams")}</span>}
|
||||
{rows.map((r, i) => (
|
||||
<div
|
||||
key={(r.parentKey ?? "") + "." + r.name}
|
||||
className={
|
||||
r.parentKey ? "border-l-2 border-muted ml-3 pl-3 grid gap-2 py-2" : "grid gap-2 rounded-md border p-3"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{r.parentKey && <span className="text-muted-foreground font-mono text-[10px]">↳</span>}
|
||||
<span className="font-mono text-sm">{r.name}</span>
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
{r.type || "?"}
|
||||
</Badge>
|
||||
{r.required && (
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||
{t("required")}
|
||||
</Badge>
|
||||
)}
|
||||
{r.parentKey && <span className="text-muted-foreground text-[10px]">{t("itemsSubfield")}</span>}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground text-[11px]">{t("rowDesc")}</Label>
|
||||
<Input
|
||||
className="text-xs"
|
||||
value={r.description}
|
||||
onChange={(e) => setRow(i, { description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<Label className="text-muted-foreground text-[11px]">{t("rowDefault")}</Label>
|
||||
<Input
|
||||
className="text-xs"
|
||||
placeholder={r.scalar ? t("defaultEmptyHint") : t("scalarOnly")}
|
||||
disabled={!r.scalar}
|
||||
value={r.defaultStr}
|
||||
onChange={(e) => setRow(i, { defaultStr: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="mt-4" />
|
||||
<div className="flex flex-wrap gap-2 p-4">
|
||||
<Button size="sm" onClick={save} disabled={saving}>
|
||||
<SaveIcon /> {t("save")}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={reset}>
|
||||
<RotateCcwIcon /> {t("resetDefault")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ToolGridCard is one clickable tile in the catalog grid.
|
||||
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
|
||||
const t = useTranslations("toolsPage");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="hover:border-primary/50 hover:bg-muted/40 focus-visible:ring-ring flex flex-col gap-2 rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-sm font-medium">{tool.key}</span>
|
||||
{tool.system ? (
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
|
||||
{t("badgeSystem")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||
{t("badgeCustom")}·{tool.kind}
|
||||
</Badge>
|
||||
)}
|
||||
{tool.deferred && (
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||
deferred
|
||||
</Badge>
|
||||
)}
|
||||
{!tool.enabled && (
|
||||
<Badge variant="outline" className="text-destructive px-1.5 py-0 text-[10px]">
|
||||
{t("badgeDisabled")}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-auto px-1.5 py-0 text-[10px] tabular-nums"
|
||||
title={t("callsTitle", { count: tool.calls ?? 0 })}
|
||||
>
|
||||
{t("callsBadge", { count: tool.calls ?? 0 })}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">{tool.description || t("noDescription")}</p>
|
||||
<div className="mt-auto flex flex-wrap gap-1 pt-1">
|
||||
{tool.agents.length === 0 && <span className="text-muted-foreground text-[10px]">{t("noBoundAgents")}</span>}
|
||||
{tool.agents.map((a) => (
|
||||
<Badge key={a} variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||
{a}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ToolsPage() {
|
||||
const t = useTranslations("toolsPage");
|
||||
const [tools, setTools] = React.useState<Tool[]>([]);
|
||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||
const [captureOn, setCaptureOn] = React.useState(false);
|
||||
const [selectedKey, setSelectedKey] = React.useState<string | null>(null);
|
||||
const [customEdit, setCustomEdit] = React.useState<Tool | "new" | null>(null);
|
||||
|
||||
const reload = React.useCallback(() => {
|
||||
api
|
||||
.tools()
|
||||
.then(setTools)
|
||||
.catch(() => setTools([]));
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
reload();
|
||||
api
|
||||
.agents()
|
||||
.then(setAgents)
|
||||
.catch(() => {
|
||||
/* 조회 실패는 무시하고 기본 상태를 유지한다 */
|
||||
});
|
||||
api
|
||||
.settings()
|
||||
.then((s) => setCaptureOn(!!s.traffic_capture))
|
||||
.catch(() => {
|
||||
/* 조회 실패는 무시하고 기본 상태를 유지한다 */
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
const [query, setQuery] = React.useState("");
|
||||
|
||||
const selected = tools.find((t) => t.key === selectedKey) ?? null;
|
||||
const matchTool = React.useCallback(
|
||||
(t: Tool) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
t.key.toLowerCase().includes(q) ||
|
||||
t.description.toLowerCase().includes(q) ||
|
||||
t.agents.some((a) => a.toLowerCase().includes(q))
|
||||
);
|
||||
},
|
||||
[query],
|
||||
);
|
||||
const systemTools = tools.filter((t) => t.system && matchTool(t));
|
||||
const customTools = tools.filter((t) => !t.system && matchTool(t));
|
||||
const allSystemCount = tools.filter((t) => t.system).length;
|
||||
const allCustomCount = tools.filter((t) => !t.system).length;
|
||||
// clicking a built-in tool opens the binding drawer; a custom tool opens its full editor.
|
||||
const openTool = (tool: Tool) => (tool.system ? setSelectedKey(tool.key) : setCustomEdit(tool));
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 md:gap-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t("title")}</h1>
|
||||
<p className="text-muted-foreground text-sm">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="relative w-64">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
className="h-8 pl-8 text-sm"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="system">
|
||||
<TabsList>
|
||||
<TabsTrigger value="system">{t("tabSystem")}</TabsTrigger>
|
||||
<TabsTrigger value="custom">{t("tabCustom")}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="system">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("tabSystem")}</CardTitle>
|
||||
<CardDescription>
|
||||
{query.trim()
|
||||
? t("systemMatched", { shown: systemTools.length, total: allSystemCount })
|
||||
: t("systemTotal", { total: allSystemCount })}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{systemTools.length === 0 ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
{query.trim() ? t("systemNoMatch") : t("systemEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{systemTools.map((tool) => (
|
||||
<ToolGridCard key={tool.key} tool={tool} onClick={() => openTool(tool)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="custom">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>{t("tabCustom")}</CardTitle>
|
||||
<CardDescription>
|
||||
{query.trim()
|
||||
? t("customMatched", { shown: customTools.length, total: allCustomCount })
|
||||
: t("customTotal", { total: allCustomCount })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCustomEdit("new")}>
|
||||
<PlusIcon /> {t("newCustom")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{customTools.length === 0 ? (
|
||||
<p className="text-muted-foreground py-6 text-center text-sm">
|
||||
{query.trim() ? t("customNoMatch") : t("customEmpty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{customTools.map((tool) => (
|
||||
<ToolGridCard key={tool.key} tool={tool} onClick={() => openTool(tool)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<Sheet open={!!selected} onOpenChange={(o) => !o && setSelectedKey(null)}>
|
||||
<SheetContent side="right" className="gap-0 p-0 data-[side=right]:w-[30vw] data-[side=right]:sm:max-w-[30vw]">
|
||||
{selected && (
|
||||
<>
|
||||
<SheetHeader className="px-4">
|
||||
<SheetTitle className="font-mono">{selected.key}</SheetTitle>
|
||||
<SheetDescription>{t("editSheetDesc")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ToolEditor
|
||||
key={selected.key}
|
||||
tool={selected}
|
||||
agents={agents}
|
||||
captureOn={captureOn}
|
||||
onSaved={reload}
|
||||
onClose={() => setSelectedKey(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<CustomToolDialog
|
||||
edit={customEdit}
|
||||
agents={agents}
|
||||
onClose={() => setCustomEdit(null)}
|
||||
onSaved={() => {
|
||||
setCustomEdit(null);
|
||||
reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 自定义工具编辑器 ----
|
||||
|
||||
type ExecState = {
|
||||
command: string;
|
||||
code: string;
|
||||
method: string;
|
||||
url: string;
|
||||
headers: string;
|
||||
body: string;
|
||||
timeout_ms: string;
|
||||
proxy: string;
|
||||
use_recording_proxy: boolean;
|
||||
};
|
||||
|
||||
function CustomToolDialog({
|
||||
edit,
|
||||
agents,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
edit: Tool | "new" | null;
|
||||
agents: Agent[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const t = useTranslations("toolsPage");
|
||||
const isNew = edit === "new";
|
||||
const tool = edit && edit !== "new" ? edit : null;
|
||||
const [key, setKey] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [kind, setKind] = React.useState<"shell" | "command" | "script" | "http">("shell");
|
||||
const [ex, setEx] = React.useState<ExecState>({
|
||||
command: "",
|
||||
code: "",
|
||||
method: "GET",
|
||||
url: "",
|
||||
headers: "",
|
||||
body: "",
|
||||
timeout_ms: "",
|
||||
proxy: "",
|
||||
use_recording_proxy: false,
|
||||
});
|
||||
const [schemaText, setSchemaText] = React.useState("");
|
||||
const [bound, setBound] = React.useState<string[]>([]);
|
||||
const [deferred, setDeferred] = React.useState(false);
|
||||
const [enabled, setEnabled] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [paramsText, setParamsText] = React.useState("");
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [testResult, setTestResult] = React.useState<{ output: string; is_error: boolean } | null>(null);
|
||||
|
||||
// (re)load form state when opening.
|
||||
React.useEffect(() => {
|
||||
if (!edit) return;
|
||||
setParamsText("");
|
||||
setTestResult(null);
|
||||
if (edit === "new") {
|
||||
setKey("");
|
||||
setDescription("");
|
||||
setKind("shell");
|
||||
setEx({
|
||||
command: "",
|
||||
code: "",
|
||||
method: "GET",
|
||||
url: "",
|
||||
headers: "",
|
||||
body: "",
|
||||
timeout_ms: "",
|
||||
proxy: "",
|
||||
use_recording_proxy: false,
|
||||
});
|
||||
setSchemaText("");
|
||||
setBound([]);
|
||||
setDeferred(false);
|
||||
setEnabled(true);
|
||||
return;
|
||||
}
|
||||
const t = edit;
|
||||
const e = (t.exec ?? {}) as Record<string, unknown>;
|
||||
setKey(t.key);
|
||||
setDescription(t.description);
|
||||
setKind((t.kind as "shell" | "command" | "script" | "http") ?? "shell");
|
||||
setEx({
|
||||
command: String(e.command ?? ""),
|
||||
code: String(e.code ?? ""),
|
||||
method: String(e.method ?? "GET"),
|
||||
url: String(e.url ?? ""),
|
||||
headers: e.headers ? JSON.stringify(e.headers, null, 2) : "",
|
||||
body: String(e.body ?? ""),
|
||||
timeout_ms: e.timeout_ms ? String(e.timeout_ms) : "",
|
||||
proxy: String(e.proxy ?? ""),
|
||||
use_recording_proxy: !!e.use_recording_proxy,
|
||||
});
|
||||
setSchemaText(t.schema && Object.keys(t.schema).length ? JSON.stringify(t.schema, null, 2) : "");
|
||||
setBound(t.agents ?? []);
|
||||
setDeferred(!!t.deferred);
|
||||
setEnabled(t.enabled);
|
||||
}, [edit]);
|
||||
|
||||
const toggleAgent = (k: string) => setBound((b) => (b.includes(k) ? b.filter((x) => x !== k) : [...b, k]));
|
||||
|
||||
function buildExec(): Record<string, unknown> {
|
||||
if (kind === "shell") return {};
|
||||
const t = ex.timeout_ms ? Number(ex.timeout_ms) : undefined;
|
||||
if (kind === "command") return { command: ex.command, timeout_ms: t };
|
||||
if (kind === "script") return { code: ex.code, timeout_ms: t };
|
||||
let headers: Record<string, string> = {};
|
||||
if (ex.headers.trim()) {
|
||||
try {
|
||||
headers = JSON.parse(ex.headers);
|
||||
} catch {
|
||||
/* validated on save */
|
||||
}
|
||||
}
|
||||
return {
|
||||
method: ex.method,
|
||||
url: ex.url,
|
||||
headers,
|
||||
body: ex.body,
|
||||
timeout_ms: t,
|
||||
proxy: ex.proxy,
|
||||
use_recording_proxy: ex.use_recording_proxy,
|
||||
};
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (isNew && !/^[a-z][a-z0-9_]*$/.test(key.trim())) {
|
||||
toast.error(t("keyError"));
|
||||
return;
|
||||
}
|
||||
let schema: Record<string, unknown> = {};
|
||||
if (schemaText.trim()) {
|
||||
try {
|
||||
schema = JSON.parse(schemaText);
|
||||
} catch {
|
||||
toast.error(t("schemaError"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (kind === "http" && ex.headers.trim()) {
|
||||
try {
|
||||
JSON.parse(ex.headers);
|
||||
} catch {
|
||||
toast.error(t("headersError"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (kind === "http") {
|
||||
const props = (schema as { properties?: Record<string, unknown> }).properties;
|
||||
if (!props || Object.keys(props).length === 0) {
|
||||
toast.error(t("httpSchemaRequired"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
description,
|
||||
schema: kind === "shell" ? {} : schema,
|
||||
agents: bound,
|
||||
enabled,
|
||||
kind,
|
||||
exec: buildExec(),
|
||||
deferred: kind === "shell" ? false : deferred,
|
||||
};
|
||||
try {
|
||||
if (isNew) await api.createCustomTool({ key: key.trim(), ...payload });
|
||||
else await api.updateCustomTool(tool!.key, payload);
|
||||
toast.success(isNew ? t("createdCustom") : t("saved"));
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast.error(t("saveFailed", { msg: (e as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
async function del() {
|
||||
if (!tool) return;
|
||||
try {
|
||||
await api.deleteCustomTool(tool.key);
|
||||
toast.success(t("deleted"));
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast.error(t("deleteFailed", { msg: (e as Error).message }));
|
||||
}
|
||||
}
|
||||
// runTest dry-runs the CURRENT form (unsaved) with the sample params, so a
|
||||
// template/script/request can be debugged before saving.
|
||||
async function runTest() {
|
||||
let params: Record<string, unknown> = {};
|
||||
if (paramsText.trim()) {
|
||||
try {
|
||||
params = JSON.parse(paramsText);
|
||||
} catch {
|
||||
toast.error(t("testParamsError"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (kind === "http" && ex.headers.trim()) {
|
||||
try {
|
||||
JSON.parse(ex.headers);
|
||||
} catch {
|
||||
toast.error(t("headersError"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const r = await api.testCustomTool({ kind, exec: buildExec(), params });
|
||||
setTestResult(r);
|
||||
} catch (e) {
|
||||
setTestResult({ output: (e as Error).message, is_error: true });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
let sheetTitle = t("newCustom");
|
||||
if (!isNew && tool) sheetTitle = t("editCustom", { key: tool.key });
|
||||
|
||||
return (
|
||||
<Sheet open={!!edit} onOpenChange={(o) => !o && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col gap-0 p-0 data-[side=right]:w-[45vw] data-[side=right]:sm:max-w-[45vw] data-[side=right]:min-w-[480px]"
|
||||
>
|
||||
<SheetHeader className="px-4">
|
||||
<SheetTitle>{sheetTitle}</SheetTitle>
|
||||
<SheetDescription>{t("customSheetDesc")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">Key</Label>
|
||||
<Input
|
||||
className="font-mono"
|
||||
placeholder={t("keyPlaceholder")}
|
||||
value={key}
|
||||
disabled={!isNew}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("descLabelShort")}</Label>
|
||||
<Textarea rows={2} value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("kindLabel")}</Label>
|
||||
<Select value={kind} onValueChange={(v) => setKind(v as "shell" | "command" | "script" | "http")}>
|
||||
<SelectTrigger className="w-56">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="shell">{t("kindShell")}</SelectItem>
|
||||
<SelectItem value="command">{t("kindCommand")}</SelectItem>
|
||||
<SelectItem value="script">{t("kindScript")}</SelectItem>
|
||||
<SelectItem value="http">{t("kindHttp")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{kind === "shell" && <p className="text-muted-foreground text-xs">{t("shellHint")}</p>}
|
||||
</div>
|
||||
|
||||
{kind === "command" && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("cmdTemplateLabel")}</Label>
|
||||
<Textarea
|
||||
className="font-mono text-xs"
|
||||
rows={2}
|
||||
value={ex.command}
|
||||
onChange={(e) => setEx({ ...ex, command: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{kind === "script" && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("scriptLabel")}</Label>
|
||||
<Textarea
|
||||
className="font-mono text-xs"
|
||||
rows={10}
|
||||
value={ex.code}
|
||||
placeholder={"import json,sys\nargs=json.load(sys.stdin)\nprint(...)"}
|
||||
onChange={(e) => setEx({ ...ex, code: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{kind === "http" && (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">Method</Label>
|
||||
<Input
|
||||
className="w-24"
|
||||
value={ex.method}
|
||||
onChange={(e) => setEx({ ...ex, method: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid flex-1 gap-1.5">
|
||||
<Label className="text-xs">{t("httpUrlLabel")}</Label>
|
||||
<Input
|
||||
className="font-mono text-xs"
|
||||
value={ex.url}
|
||||
onChange={(e) => setEx({ ...ex, url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("httpHeadersLabel")}</Label>
|
||||
<Textarea
|
||||
className="font-mono text-xs"
|
||||
rows={2}
|
||||
value={ex.headers}
|
||||
placeholder={'{"Authorization": "Bearer {token}"}'}
|
||||
onChange={(e) => setEx({ ...ex, headers: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("httpBodyLabel")}</Label>
|
||||
<Textarea
|
||||
className="font-mono text-xs"
|
||||
rows={2}
|
||||
value={ex.body}
|
||||
onChange={(e) => setEx({ ...ex, body: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("proxyLabel")}</Label>
|
||||
<Input
|
||||
className="font-mono text-xs w-56"
|
||||
value={ex.proxy}
|
||||
onChange={(e) => setEx({ ...ex, proxy: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<label className="mt-4 flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={ex.use_recording_proxy}
|
||||
onCheckedChange={(v) => setEx({ ...ex, use_recording_proxy: !!v })}
|
||||
/>
|
||||
{t("useRecordingProxy")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind !== "shell" && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">{t("timeoutLabel")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-32"
|
||||
value={ex.timeout_ms}
|
||||
onChange={(e) => setEx({ ...ex, timeout_ms: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{kind !== "shell" && (
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-xs">
|
||||
{t("schemaLabel")}
|
||||
{kind === "http" ? t("schemaHttpSuffix") : t("schemaDefaultSuffix")}
|
||||
</Label>
|
||||
<Textarea
|
||||
className="font-mono text-xs"
|
||||
rows={4}
|
||||
value={schemaText}
|
||||
placeholder={'{"type":"object","properties":{"target":{"type":"string"}},"required":["target"]}'}
|
||||
onChange={(e) => setSchemaText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label className="text-muted-foreground text-xs">{t("bindAgentShort")}</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{agents.map((a) => (
|
||||
<label key={a.key} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={bound.includes(a.key)} onCheckedChange={() => toggleAgent(a.key)} />
|
||||
{a.name}
|
||||
<span className="text-muted-foreground font-mono text-xs">{a.key}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} /> {t("enabled")}
|
||||
</label>
|
||||
{kind !== "shell" && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Switch checked={deferred} onCheckedChange={setDeferred} /> {t("deferredLabel")}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{kind !== "shell" && (
|
||||
<div className="grid gap-1.5 rounded-md border p-3">
|
||||
<Label className="text-xs font-medium">{t("testSectionLabel")}</Label>
|
||||
<Textarea
|
||||
className="font-mono text-xs"
|
||||
rows={2}
|
||||
value={paramsText}
|
||||
placeholder={t("testParamsPlaceholder")}
|
||||
onChange={(e) => setParamsText(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<Button size="sm" variant="outline" onClick={runTest} disabled={testing}>
|
||||
<PlayIcon /> {testing ? t("testRunning") : t("testRun")}
|
||||
</Button>
|
||||
</div>
|
||||
{testResult && (
|
||||
<pre
|
||||
className={
|
||||
"max-h-64 overflow-auto whitespace-pre-wrap break-words rounded bg-muted p-2 font-mono text-xs " +
|
||||
(testResult.is_error ? "text-destructive" : "")
|
||||
}
|
||||
>
|
||||
{testResult.output || t("noOutput")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<Button size="sm" onClick={save} disabled={saving}>
|
||||
<SaveIcon /> {isNew ? t("create") : t("save")}
|
||||
</Button>
|
||||
{!isNew && (
|
||||
<Button size="sm" variant="outline" className="text-destructive" onClick={del}>
|
||||
<Trash2Icon /> {t("delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,284 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
/* Theme preset styles: these override CSS variables based on the selected data-theme-preset */
|
||||
@import "../styles/presets/brutalist.css";
|
||||
@import "../styles/presets/soft-pop.css";
|
||||
@import "../styles/presets/tangerine.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Default theme styles (used when no data-theme-preset is set or when 'default' is selected).
|
||||
These serve as the fallback; there is no separate default.css file. */
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
/* fonts */
|
||||
--font-sans: var(--font-geist);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xs {
|
||||
box-shadow: var(--shadow-2xs);
|
||||
}
|
||||
|
||||
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-xs {
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-sm {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
[data-theme-preset]:not([data-theme-preset="default"]) .shadow {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-md {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-lg {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-xl {
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
[data-theme-preset]:not([data-theme-preset="default"]) .shadow-2xl {
|
||||
box-shadow: var(--shadow-2xl);
|
||||
}
|
||||
|
||||
html[data-font="inter"] body {
|
||||
--font-sans: var(--font-inter);
|
||||
}
|
||||
html[data-font="notoSans"] body {
|
||||
--font-sans: var(--font-noto-sans);
|
||||
}
|
||||
html[data-font="nunitoSans"] body {
|
||||
--font-sans: var(--font-nunito-sans);
|
||||
}
|
||||
html[data-font="figtree"] body {
|
||||
--font-sans: var(--font-figtree);
|
||||
}
|
||||
html[data-font="roboto"] body {
|
||||
--font-sans: var(--font-roboto);
|
||||
}
|
||||
html[data-font="geist"] body {
|
||||
--font-sans: var(--font-geist);
|
||||
}
|
||||
html[data-font="raleway"] body {
|
||||
--font-sans: var(--font-raleway);
|
||||
}
|
||||
html[data-font="dmSans"] body {
|
||||
--font-sans: var(--font-dm-sans);
|
||||
}
|
||||
html[data-font="publicSans"] body {
|
||||
--font-sans: var(--font-public-sans);
|
||||
}
|
||||
html[data-font="outfit"] body {
|
||||
--font-sans: var(--font-outfit);
|
||||
}
|
||||
html[data-font="geistMono"] body {
|
||||
--font-sans: var(--font-geist-mono);
|
||||
}
|
||||
html[data-font="geistPixelSquare"] body {
|
||||
--font-sans: var(--font-geist-pixel-square);
|
||||
}
|
||||
html[data-font="jetBrainsMono"] body {
|
||||
--font-sans: var(--font-jetbrains-mono);
|
||||
}
|
||||
html[data-font="notoSerif"] body {
|
||||
--font-sans: var(--font-noto-serif);
|
||||
}
|
||||
html[data-font="robotoSlab"] body {
|
||||
--font-sans: var(--font-roboto-slab);
|
||||
}
|
||||
html[data-font="merriweather"] body {
|
||||
--font-sans: var(--font-merriweather);
|
||||
}
|
||||
html[data-font="lora"] body {
|
||||
--font-sans: var(--font-lora);
|
||||
}
|
||||
html[data-font="playfairDisplay"] body {
|
||||
--font-sans: var(--font-playfair-display);
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.disable-transitions * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
[data-print-root] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
@page {
|
||||
size: Letter;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 8.5in !important;
|
||||
height: 11in !important;
|
||||
margin: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
body > *:not([data-print-root]) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
[data-print-root] {
|
||||
display: block !important;
|
||||
width: 8.5in !important;
|
||||
height: 11in !important;
|
||||
margin: 0 !important;
|
||||
overflow: hidden !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
[data-print-root] [data-print-paper] {
|
||||
width: 8.5in !important;
|
||||
height: 11in !important;
|
||||
margin: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
[data-print-root] [data-print-paper],
|
||||
[data-print-root] [data-print-paper] * {
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 480 KiB |
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { Metadata } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { APP_CONFIG } from "@/config/app-config";
|
||||
import { resolveLocale } from "@/i18n/config";
|
||||
import { fontVars } from "@/lib/fonts/registry";
|
||||
import { PREFERENCE_DEFAULTS } from "@/lib/preferences/preferences-config";
|
||||
import { ThemeBootScript } from "@/scripts/theme-boot";
|
||||
import { PreferencesStoreProvider } from "@/stores/preferences/preferences-provider";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: APP_CONFIG.meta.title,
|
||||
description: APP_CONFIG.meta.description,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
|
||||
const { theme_mode, theme_preset, content_layout, navbar_style, sidebar_variant, sidebar_collapsible, font } =
|
||||
PREFERENCE_DEFAULTS;
|
||||
const locale = resolveLocale();
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
data-theme-mode={theme_mode}
|
||||
data-theme-preset={theme_preset}
|
||||
data-content-layout={content_layout}
|
||||
data-navbar-style={navbar_style}
|
||||
data-sidebar-variant={sidebar_variant}
|
||||
data-sidebar-collapsible={sidebar_collapsible}
|
||||
data-font={font}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
{/* Applies theme and layout preferences on load to avoid flicker and unnecessary server rerenders. */}
|
||||
<ThemeBootScript />
|
||||
</head>
|
||||
<body className={`${fontVars} min-h-screen antialiased`}>
|
||||
<NextIntlClientProvider>
|
||||
<TooltipProvider>
|
||||
<PreferencesStoreProvider
|
||||
themeMode={theme_mode}
|
||||
themePreset={theme_preset}
|
||||
contentLayout={content_layout}
|
||||
navbarStyle={navbar_style}
|
||||
font={font}
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</PreferencesStoreProvider>
|
||||
</TooltipProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function NotFound() {
|
||||
const t = useTranslations("notFound");
|
||||
return (
|
||||
<div className="flex h-dvh flex-col items-center justify-center space-y-2 text-center">
|
||||
<h1 className="font-semibold text-2xl">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("description")}</p>
|
||||
<Link prefetch={false} replace href="/function/tasks">
|
||||
<Button variant="outline">{t("back")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Activity, InterceptExecution } from "@/lib/types";
|
||||
|
||||
// Resolve one exact persisted call, independently of transcript pagination.
|
||||
export function useApprovalFocus({ taskId, conversationId }: { taskId?: string; conversationId?: number }) {
|
||||
const t = useTranslations("approvalFocus");
|
||||
const [state, setState] = React.useState<{
|
||||
id: number;
|
||||
source?: InterceptExecution;
|
||||
error?: string;
|
||||
loading: boolean;
|
||||
} | null>(null);
|
||||
const [revision, setRevision] = React.useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: revision is the explicit retry trigger.
|
||||
React.useEffect(() => {
|
||||
const q = new URLSearchParams(window.location.search);
|
||||
const id = Number(q.get("approval"));
|
||||
const matches = conversationId ? q.get("c") === String(conversationId) : q.get("id") === taskId;
|
||||
if (!matches || !Number.isSafeInteger(id) || id <= 0) {
|
||||
setState(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setState({ id, loading: true });
|
||||
api
|
||||
.interceptExecution(id, conversationId)
|
||||
.then((source) => {
|
||||
if (cancelled) return;
|
||||
if (
|
||||
conversationId
|
||||
? source.conversation_id !== conversationId
|
||||
: source.task_id !== taskId || source.conversation_id != null
|
||||
) {
|
||||
throw new Error(t("mismatch"));
|
||||
}
|
||||
setState({ id, source, loading: false });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setState({ id, error: (e as Error).message || t("noSource"), loading: false });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [taskId, conversationId, revision]);
|
||||
const close = React.useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("approval");
|
||||
window.history.replaceState(null, "", url);
|
||||
setState(null);
|
||||
}, []);
|
||||
return { state, close, retry: () => setRevision((v) => v + 1) };
|
||||
}
|
||||
|
||||
type HistoryPage = { items: Activity[]; hasMore: boolean };
|
||||
|
||||
// Fill the existing transcript continuously back to the call, preserving all
|
||||
// intervening messages. Never splice an isolated call into a paginated history.
|
||||
export function useApprovalHistory(
|
||||
source: InterceptExecution | undefined,
|
||||
loaded: boolean,
|
||||
items: Activity[],
|
||||
loadPage: (before: number) => Promise<HistoryPage>,
|
||||
mergePage: (page: HistoryPage) => void,
|
||||
) {
|
||||
const t = useTranslations("approvalFocus");
|
||||
const itemsRef = React.useRef(items);
|
||||
itemsRef.current = items;
|
||||
const [result, setResult] = React.useState<{ source: InterceptExecution; error?: string }>();
|
||||
React.useEffect(() => {
|
||||
if (!source || !loaded) return;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
let current = itemsRef.current;
|
||||
let before = current[0]?.seq ?? 0;
|
||||
while (!current.some((a) => a.seq === source.seq && a.kind === "tool_use")) {
|
||||
const page = await loadPage(before);
|
||||
if (cancelled) return;
|
||||
if (!page.items.length || (before > 0 && page.items[0].seq >= before)) {
|
||||
throw new Error(t("notFoundDeleted"));
|
||||
}
|
||||
mergePage(page);
|
||||
current = page.items;
|
||||
before = current[0].seq;
|
||||
if (!page.hasMore && !current.some((a) => a.seq === source.seq)) {
|
||||
throw new Error(t("notFound"));
|
||||
}
|
||||
}
|
||||
if (!cancelled) setResult({ source });
|
||||
})().catch((error) => {
|
||||
if (!cancelled) setResult({ source, error: (error as Error).message });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [source, loaded, loadPage, mergePage, t]);
|
||||
return {
|
||||
ready: !!source && result?.source === source && !result.error,
|
||||
error: result?.source === source ? result?.error : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function ApprovalExecutionFocus({
|
||||
focus,
|
||||
history,
|
||||
}: {
|
||||
focus: ReturnType<typeof useApprovalFocus>;
|
||||
history: ReturnType<typeof useApprovalHistory>;
|
||||
}) {
|
||||
const t = useTranslations("approvalFocus");
|
||||
const { state } = focus;
|
||||
if (!state) return null;
|
||||
const error = state.error || history.error;
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b px-4 py-2 text-xs"
|
||||
>
|
||||
<span className={error ? "text-destructive" : "text-muted-foreground"}>
|
||||
{error
|
||||
? t("locateFail", { error })
|
||||
: history.ready
|
||||
? t("expanded", { id: state.id })
|
||||
: t("loading", { id: state.id })}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{error ? (
|
||||
<Button size="sm" variant="outline" onClick={focus.retry}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="ghost" onClick={focus.close}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,944 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClipboardListIcon,
|
||||
CopyIcon,
|
||||
RefreshCwIcon,
|
||||
ShieldAlertIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
InterceptApprovalFilter,
|
||||
InterceptApprovalRow,
|
||||
InterceptDetail,
|
||||
InterceptPending,
|
||||
InterceptReviewInput,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Translator = ReturnType<typeof useTranslations>;
|
||||
|
||||
function fmtTime(value?: string) {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function source(row: InterceptApprovalRow) {
|
||||
if (row.decision_source) return row.decision_source;
|
||||
if (row.rule_id) return "rule";
|
||||
return row.reason?.startsWith("[模型]") ? "model" : "unknown";
|
||||
}
|
||||
|
||||
function originLabel(row: InterceptApprovalRow, t: Translator) {
|
||||
if (row.task_id) return row.task_id;
|
||||
if (row.conversation_id) return t("conversationRef", { id: row.conversation_id });
|
||||
return "—";
|
||||
}
|
||||
|
||||
function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) {
|
||||
const t = useTranslations("approval");
|
||||
const [locating, setLocating] = React.useState(false);
|
||||
const label = detail && row.task_id ? t("taskRef", { id: row.task_id }) : originLabel(row, t);
|
||||
const query = new URLSearchParams({ approval: String(row.id) });
|
||||
let href: string | undefined;
|
||||
if (row.conversation_id) {
|
||||
query.set("c", String(row.conversation_id));
|
||||
href = `/chat?${query}`;
|
||||
} else if (row.task_id) {
|
||||
query.set("id", row.task_id);
|
||||
href = `/function/tasks/detail?${query}`;
|
||||
}
|
||||
return href ? (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline-offset-4 hover:underline"
|
||||
aria-label={t("locateSourceAria", { id: row.id, label })}
|
||||
aria-busy={locating}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
if (locating) return;
|
||||
setLocating(true);
|
||||
try {
|
||||
await api.interceptExecution(row.id, row.conversation_id ?? undefined);
|
||||
window.location.assign(href);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || t("locateFailed"));
|
||||
setLocating(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
) : (
|
||||
<span>{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const t = useTranslations("approval");
|
||||
let variant: "default" | "destructive" | "secondary" | "outline" = "outline";
|
||||
if (status === "allowed") variant = "default";
|
||||
if (status === "denied") variant = "destructive";
|
||||
if (status === "pending") variant = "secondary";
|
||||
return <Badge variant={variant}>{t.has(`status.${status}`) ? t(`status.${status}`) : status}</Badge>;
|
||||
}
|
||||
|
||||
function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) {
|
||||
const t = useTranslations("approval");
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "");
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{source(row) === "model" ? (
|
||||
<Badge variant="outline">
|
||||
<BotIcon />
|
||||
{t("modelDecision")}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="truncate">{row.rule_name || t("ruleMissing")}</span>
|
||||
)}
|
||||
{showReason ? (
|
||||
<p className="truncate text-muted-foreground text-xs" title={reason}>
|
||||
{reason || t("noReason")}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ label, text, truncated = false }: { label: string; text: string; truncated?: boolean }) {
|
||||
const t = useTranslations("approval");
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(t("copied"));
|
||||
} catch {
|
||||
toast.error(t("copyFailed"));
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="flex min-w-0 flex-col gap-2" aria-label={label}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-muted-foreground text-xs">{label}</h3>
|
||||
{text ? (
|
||||
<Button variant="ghost" size="icon-xs" aria-label={t("copyAria", { label })} onClick={() => void copy()}>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="max-h-80 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-muted/60 p-3 font-mono text-xs leading-6 [overflow-wrap:anywhere]">
|
||||
{text || t("notRecorded")}
|
||||
</pre>
|
||||
{truncated ? <p className="text-muted-foreground text-xs">{t("truncatedNote")}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelReviewContext({ input }: { input: InterceptReviewInput }) {
|
||||
const t = useTranslations("approval");
|
||||
return (
|
||||
<section className="flex min-w-0 flex-col gap-4" aria-label={t("review.context")}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="font-medium text-sm">{t("review.context")}</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("review.snapshotNote")}
|
||||
{input.version < 4 ? t("review.legacyInput") : null}
|
||||
</p>
|
||||
</div>
|
||||
<CodeBlock
|
||||
label={t("review.currentCall")}
|
||||
text={JSON.stringify({ tool_name: input.tool_name, arguments: input.arguments }, null, 2)}
|
||||
/>
|
||||
{input.version >= 2 ? (
|
||||
input.background ? (
|
||||
<div className="flex min-w-0 flex-col gap-2">
|
||||
<CodeBlock
|
||||
label={input.background.source === "user_message" ? t("review.bgUser") : t("review.bgWorkerLegacy")}
|
||||
text={input.background.text}
|
||||
truncated={input.background.truncated}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{input.background.source === "user_message" ? t("review.bgUserNote") : t("review.bgWorkerNote")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">{t("review.noBg")}</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{input.turn_input ? (
|
||||
<CodeBlock
|
||||
label={t("review.turnInputLegacy")}
|
||||
text={input.turn_input}
|
||||
truncated={input.background_truncated}
|
||||
/>
|
||||
) : null}
|
||||
{input.task ? (
|
||||
<>
|
||||
<CodeBlock
|
||||
label={t("review.taskDescLegacy")}
|
||||
text={input.task.description}
|
||||
truncated={input.task.truncated}
|
||||
/>
|
||||
<CodeBlock label={t("review.taskGoalLegacy")} text={input.task.goal} truncated={input.task.truncated} />
|
||||
<CodeBlock
|
||||
label={t("review.taskConstraintsLegacy")}
|
||||
text={JSON.stringify(input.task.constraints, null, 2)}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{input.worker_intent ? (
|
||||
<CodeBlock
|
||||
label={t("review.workerIntentLegacy")}
|
||||
text={input.worker_intent}
|
||||
truncated={input.background_truncated}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{input.working_directory ? <CodeBlock label={t("review.workingDir")} text={input.working_directory} /> : null}
|
||||
{input.version >= 3 ? (
|
||||
<p className="text-muted-foreground text-xs">{t("review.noHistory")}</p>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<h4 className="font-medium text-muted-foreground text-xs">{t("review.historyTitleLegacy")}</h4>
|
||||
{input.history?.length ? (
|
||||
input.history.map((entry) => (
|
||||
<div key={entry.tool_use_id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
|
||||
<p className="break-words font-medium text-xs">
|
||||
{entry.tool} · {entry.status === "succeeded" ? t("review.histSucceeded") : t("review.histFailed")}
|
||||
</p>
|
||||
<CodeBlock label={t("review.histArgs")} text={entry.arguments_preview} truncated={entry.truncated} />
|
||||
<CodeBlock label={t("review.histResult")} text={entry.result} truncated={entry.truncated} />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">{t("review.noPairable")}</p>
|
||||
)}
|
||||
{input.history_truncated ? (
|
||||
<p className="text-muted-foreground text-xs">{t("review.histTruncated")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="self-start">
|
||||
<ChevronDownIcon data-icon="inline-start" />
|
||||
{t("review.viewFullJson")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<CodeBlock label={t("review.modelInput")} text={JSON.stringify(input, null, 2)} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type Decide = (id: number, decision: "allowed" | "denied") => Promise<void>;
|
||||
|
||||
function DecisionActions({ row, busy, decide }: { row: InterceptApprovalRow; busy: boolean; decide: Decide }) {
|
||||
const t = useTranslations("approval");
|
||||
if (row.status !== "pending") return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={busy} onClick={() => void decide(row.id, "allowed")}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
{t("allow")}
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" disabled={busy} onClick={() => void decide(row.id, "denied")}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
{t("deny")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalDetail({
|
||||
row,
|
||||
busy,
|
||||
decide,
|
||||
revision,
|
||||
readOnly = false,
|
||||
defaultExpanded = false,
|
||||
onResolved,
|
||||
}: {
|
||||
row: InterceptApprovalRow;
|
||||
busy: boolean;
|
||||
decide: Decide;
|
||||
revision: number;
|
||||
readOnly?: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
onResolved?: (status: "allowed" | "denied" | "timeout") => void;
|
||||
}) {
|
||||
const t = useTranslations("approval");
|
||||
const [detail, setDetail] = React.useState<InterceptDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [retry, setRetry] = React.useState(0);
|
||||
const [more, setMore] = React.useState(defaultExpanded);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Status, refresh and retry invalidate details without closing the panel.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
async function load() {
|
||||
try {
|
||||
const next = await api.interceptDetail(row.id);
|
||||
if (cancelled) return;
|
||||
setDetail(next);
|
||||
setError("");
|
||||
if (next.status !== "pending") onResolved?.(next.status);
|
||||
if (next.status === "pending" || next.audit?.execution_status === "awaiting_result")
|
||||
timer = setTimeout(() => void load(), 5000);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message || t("detailLoadFailed"));
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [row.id, row.status, revision, retry, onResolved, t]);
|
||||
|
||||
// Row updates are authoritative until the lazy detail has caught up.
|
||||
const current = detail?.status === row.status ? detail : row;
|
||||
const audit = detail?.audit;
|
||||
let execution = audit ? t(`execution.${audit.execution_status}`) : t("notRecorded");
|
||||
if (row.status === "pending") execution = t("execution.not_started");
|
||||
if (audit && audit.correlation !== "exact" && audit.effective_action === "allow")
|
||||
execution = t("execution.notCorrelated");
|
||||
const command = typeof row.tool_input?.command === "string" ? row.tool_input.command : undefined;
|
||||
let initialLabel = source(row) === "model" ? t("modelInitial") : t("ruleInitial");
|
||||
if (audit?.model_fallback) initialLabel = t("modelFallback");
|
||||
const agentName = current.agent_name || current.conv_agent_key || t("agentFallback");
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4 p-3 sm:p-5">
|
||||
<div className="grid min-w-0 gap-5 rounded-xl border bg-muted/20 p-4 lg:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<CodeBlock
|
||||
label={t("toolRequest", { agent: agentName })}
|
||||
text={JSON.stringify(row.tool_input ?? {}, null, 2)}
|
||||
/>
|
||||
{command ? (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronDownIcon data-icon="inline-start" />
|
||||
{t("viewCommand")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<CodeBlock label={t("commandContent")} text={command} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("executionResult")}: <span className="text-foreground">{detail ? execution : t("loading")}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-4 lg:border-l lg:pl-5">
|
||||
<h3 className="font-medium text-muted-foreground text-xs">
|
||||
{current.status === "pending" ? t("reviewStatus") : t("approvalVerdict")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={current.status} />
|
||||
<MatchCell row={current} showReason={false} />
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">
|
||||
{current.reason?.replace(/^\[模型\]\s*/, "") || t("noApprovalReason")}
|
||||
</p>
|
||||
{audit?.decision_reason ? <p className="text-sm">{audit.decision_reason}</p> : null}
|
||||
{audit?.effective_action ? (
|
||||
<p className="text-sm">
|
||||
{t("finalAction")}: {t(`action.${audit.effective_action}`)}
|
||||
</p>
|
||||
) : null}
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-muted-foreground">{t("sourceLabel")}</dt>
|
||||
<dd className="break-words">
|
||||
<ApprovalOrigin row={current} detail />
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("requestedAt")}</dt>
|
||||
<dd>{fmtTime(row.created_at)}</dd>
|
||||
<dt className="text-muted-foreground">{t("decidedAt")}</dt>
|
||||
<dd>{fmtTime(current.decided_at)}</dd>
|
||||
{audit?.rule_name ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t("ruleSnapshot")}</dt>
|
||||
<dd>{audit.rule_name}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{audit?.profile_id ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t("approvalModelProfile")}</dt>
|
||||
<dd>#{audit.profile_id}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
{!readOnly ? <DecisionActions row={current} busy={busy} decide={decide} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{t("detailLoadFailedWith", { error })}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setRetry((v) => v + 1)}>
|
||||
{t("retryDetail")}
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!detail && !error ? <Skeleton className="h-8 w-60" /> : null}
|
||||
{detail && !audit ? (
|
||||
<Alert>
|
||||
<AlertDescription>{t("noDetailSnapshot")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{audit ? (
|
||||
<Collapsible open={more} onOpenChange={setMore}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronDownIcon data-icon="inline-start" className={cn(more && "rotate-180")} />
|
||||
{more ? t("collapseContext") : t("viewContextAndResult")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
{audit.model_input ? (
|
||||
<ModelReviewContext input={audit.model_input} />
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{audit.model_input_digest ? t("digestOnlyNote") : t("noModelInputNote")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{audit.user_message || audit.context?.length ? (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronDownIcon data-icon="inline-start" />
|
||||
{audit.model_input && audit.model_input.version >= 3
|
||||
? t("viewAuditFragmentNotSent")
|
||||
: t("viewAuditFragment")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-3">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
{audit.user_message ? (
|
||||
<CodeBlock
|
||||
label={t("auditCurrentTurn")}
|
||||
text={audit.user_message}
|
||||
truncated={audit.user_truncated}
|
||||
/>
|
||||
) : null}
|
||||
<section className="flex min-w-0 flex-col gap-3">
|
||||
<h3 className="font-medium text-muted-foreground text-xs">{t("visibleContext")}</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("savedContextNote", { time: fmtTime(audit.captured_at) })}
|
||||
</p>
|
||||
{audit.context_truncated ? (
|
||||
<p className="text-muted-foreground text-xs">{t("contextTruncated")}</p>
|
||||
) : null}
|
||||
{audit.context?.length ? (
|
||||
audit.context.map((entry, index) => (
|
||||
<CodeBlock
|
||||
key={`${entry.kind}-${entry.tool_use_id || index}`}
|
||||
label={`${t.has(`context.${entry.kind}`) ? t(`context.${entry.kind}`) : entry.kind}${entry.tool ? ` · ${entry.tool}` : ""}${entry.is_error ? ` · ${t("errorTag")}` : ""}`}
|
||||
text={entry.text}
|
||||
truncated={entry.truncated}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{t("noRelatedContext")}</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<CodeBlock
|
||||
label={`${initialLabel}: ${t.has(`action.${audit.initial_action}`) ? t(`action.${audit.initial_action}`) : audit.initial_action}`}
|
||||
text={audit.initial_reason.replace(/^\[模型\]\s*/, "")}
|
||||
/>
|
||||
<CodeBlock
|
||||
label={t("executionOutput")}
|
||||
text={
|
||||
audit.output ?? (audit.execution_status === "not_executed" ? t("toolNotExecuted") : t("noOutputYet"))
|
||||
}
|
||||
truncated={audit.output_truncated}
|
||||
/>
|
||||
{audit.correlation !== "exact" ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{audit.correlation === "ambiguous" ? t("ambiguousCorrelation") : t("noUniqueToolId")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<dl className="grid gap-2 text-muted-foreground text-xs [overflow-wrap:anywhere]">
|
||||
<div>
|
||||
{t("toolCallId")}: {audit.tool_use_id || t("notRecorded")}
|
||||
</div>
|
||||
<div>
|
||||
{t("inputDigest")}: {audit.input_digest}
|
||||
</div>
|
||||
<div>
|
||||
{t("configDigest")}: {audit.config_digest || t("notRecorded")}
|
||||
</div>
|
||||
{audit.model_input_digest ? (
|
||||
<div>
|
||||
{t("modelInputDigest")}: {audit.model_input_digest}
|
||||
</div>
|
||||
) : null}
|
||||
{audit.execution_ended_at ? (
|
||||
<div>
|
||||
{t("resultRecordedAt")}: {fmtTime(audit.execution_ended_at)}
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hidden cells do not occupy table columns. Keep the detail span in sync so
|
||||
// expansion cannot create empty columns and leave a gap in the selected row.
|
||||
const approvalBreakpoints = ["(min-width: 40rem)", "(min-width: 48rem)", "(min-width: 64rem)", "(min-width: 80rem)"];
|
||||
function subscribeColumns(onChange: () => void) {
|
||||
const queries = approvalBreakpoints.map((query) => window.matchMedia(query));
|
||||
for (const query of queries) query.addEventListener("change", onChange);
|
||||
return () => {
|
||||
for (const query of queries) query.removeEventListener("change", onChange);
|
||||
};
|
||||
}
|
||||
function visibleColumnCount() {
|
||||
const matches = approvalBreakpoints.map((query) => window.matchMedia(query).matches);
|
||||
return 3 + Number(matches[0]) + Number(matches[1]) + 2 * Number(matches[2]) + 2 * Number(matches[3]);
|
||||
}
|
||||
function serverColumnCount() {
|
||||
return 9;
|
||||
}
|
||||
|
||||
function ApprovalTable({
|
||||
rows,
|
||||
busy,
|
||||
decide,
|
||||
revision,
|
||||
label,
|
||||
}: {
|
||||
rows: InterceptApprovalRow[];
|
||||
busy: boolean;
|
||||
decide: Decide;
|
||||
revision: number;
|
||||
label: string;
|
||||
}) {
|
||||
const t = useTranslations("approval");
|
||||
const [expanded, setExpanded] = React.useState<Set<number>>(() => new Set());
|
||||
const columns = React.useSyncExternalStore(subscribeColumns, visibleColumnCount, serverColumnCount);
|
||||
const prefix = React.useId();
|
||||
function toggle(id: number) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Table className="table-fixed" aria-label={label}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<span className="sr-only">{t("table.expandDetail")}</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-14 sm:table-cell">#</TableHead>
|
||||
<TableHead className="w-28">{t("table.tool")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t("table.source")}</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">{t("table.matchRule")}</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">{t("table.params")}</TableHead>
|
||||
<TableHead className="w-24">{t("table.status")}</TableHead>
|
||||
<TableHead className="hidden w-36 lg:table-cell">{t("requestedAt")}</TableHead>
|
||||
<TableHead className="hidden w-36 xl:table-cell">{t("decidedAt")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const open = expanded.has(row.id);
|
||||
const panelID = `${prefix}-${row.id}`;
|
||||
return (
|
||||
<React.Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={open ? "selected" : undefined}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggle(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("row.toggleAria", { action: open ? t("collapse") : t("expand"), id: row.id })}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelID : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle(row.id);
|
||||
}}
|
||||
>
|
||||
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground sm:table-cell">{row.id}</TableCell>
|
||||
<TableCell>
|
||||
<code className="block truncate text-xs">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="truncate" title={originLabel(row, t)}>
|
||||
<ApprovalOrigin row={row} />
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{row.agent_name || row.conv_agent_key}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<MatchCell row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell">
|
||||
<code className="block truncate text-muted-foreground text-xs">{JSON.stringify(row.tool_input)}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={row.status} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground text-xs lg:table-cell">
|
||||
{fmtTime(row.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground text-xs xl:table-cell">
|
||||
{fmtTime(row.decided_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{open ? (
|
||||
<TableRow className="hover:bg-transparent has-aria-expanded:bg-transparent">
|
||||
<TableCell colSpan={columns} className="whitespace-normal p-0">
|
||||
<section id={panelID} aria-label={t("detailAria", { id: row.id })}>
|
||||
<ApprovalDetail row={row} busy={busy} decide={decide} revision={revision} />
|
||||
</section>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const t = useTranslations("approval");
|
||||
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
|
||||
const [pendingRows, setPendingRows] = React.useState<InterceptPending[]>([]);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [pageSize, setPageSize] = React.useState(20);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [filter, setFilter] = React.useState<InterceptApprovalFilter>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
const [pendingError, setPendingError] = React.useState("");
|
||||
const [deciding, setDeciding] = React.useState(false);
|
||||
const [revision, setRevision] = React.useState(0);
|
||||
const request = React.useRef(0);
|
||||
const decisionLock = React.useRef(false);
|
||||
const filterID = React.useId();
|
||||
const filtered = Boolean(filter.status || filter.decision_source);
|
||||
|
||||
// A task can stay mounted while the user switches between task details.
|
||||
// Reset the cursor so the new scope always starts at its newest records.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: taskId intentionally resets pagination when scope changes.
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [taskId]);
|
||||
|
||||
const load = React.useCallback(
|
||||
async (manual = false) => {
|
||||
const id = ++request.current;
|
||||
if (manual) setRefreshing(true);
|
||||
try {
|
||||
// The approval queue is independent of the current history page and its
|
||||
// filter. Older requests must remain actionable even when newer decisions
|
||||
// fill the page or a filter would hide them.
|
||||
const [history, pending] = await Promise.allSettled([
|
||||
taskId
|
||||
? api.interceptTaskPage(taskId, page, pageSize, filter)
|
||||
: api.interceptHistoryPage(page, pageSize, filter),
|
||||
api.interceptPending(),
|
||||
]);
|
||||
if (id !== request.current) return;
|
||||
if (history.status === "fulfilled") {
|
||||
setRows(history.value.items);
|
||||
setTotal(history.value.total);
|
||||
setError("");
|
||||
} else {
|
||||
setError((history.reason as Error).message || t("loadError"));
|
||||
}
|
||||
if (pending.status === "fulfilled") {
|
||||
setPendingRows(pending.value);
|
||||
setPendingError("");
|
||||
} else {
|
||||
setPendingError((pending.reason as Error).message || t("loadError"));
|
||||
}
|
||||
if (manual) setRevision((v) => v + 1);
|
||||
} catch (e) {
|
||||
if (id === request.current) setError((e as Error).message || t("loadError"));
|
||||
} finally {
|
||||
if (id === request.current) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[taskId, page, pageSize, filter, t],
|
||||
);
|
||||
const latestLoad = React.useRef(load);
|
||||
latestLoad.current = load;
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
const timer = setInterval(() => void load(), 5000);
|
||||
return () => {
|
||||
request.current++;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const changePageSize = (next: number) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeFilter = (next: InterceptApprovalFilter) => {
|
||||
// An in-flight response for the old filter must not repopulate the table.
|
||||
request.current++;
|
||||
setFilter(next);
|
||||
setPage(1);
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError("");
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
const decide: Decide = async (id, decision) => {
|
||||
if (decisionLock.current) return;
|
||||
decisionLock.current = true;
|
||||
setDeciding(true);
|
||||
try {
|
||||
await api.interceptDecide(id, decision);
|
||||
// Invalidate a list request started before this decision.
|
||||
request.current++;
|
||||
// Optimistically drop the decided item from the independent pending queue
|
||||
// for instant feedback; the re-fetch below reconciles with server truth.
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
|
||||
);
|
||||
setPendingRows((prev) => prev.filter((row) => row.id !== id));
|
||||
setRevision((v) => v + 1);
|
||||
toast.success(decision === "allowed" ? t("allowedExecution") : t("deniedExecution"));
|
||||
// Re-fetch counts and rows: a decided item may no longer match the filter.
|
||||
await latestLoad.current(true);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
await latestLoad.current(true);
|
||||
} finally {
|
||||
decisionLock.current = false;
|
||||
setDeciding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const historyById = new Map(rows.map((row) => [row.id, row]));
|
||||
const pending: InterceptApprovalRow[] = pendingRows
|
||||
.filter((row) => !taskId || row.task_id === taskId)
|
||||
.map((row) => ({
|
||||
conv_title: "",
|
||||
conv_agent_key: "",
|
||||
rule_name: row.rule_id ? t("ruleRef", { id: row.rule_id }) : "",
|
||||
...historyById.get(row.id),
|
||||
...row,
|
||||
}));
|
||||
const title = taskId ? t("title.task") : t("title.history");
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardListIcon className="size-5 text-muted-foreground" />
|
||||
<h1 className="font-semibold text-xl">{title}</h1>
|
||||
{pending.length ? <Badge variant="secondary">{t("pendingBadge", { count: pending.length })}</Badge> : null}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void load(true)} disabled={loading || refreshing}>
|
||||
<RefreshCwIcon data-icon="inline-start" className={cn(refreshing && "animate-spin")} />
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">{t("intro")}</p>
|
||||
<FieldGroup className="flex-row flex-wrap items-end gap-3" aria-label={t("filter.label")}>
|
||||
<Field className="w-full sm:w-40">
|
||||
<FieldLabel htmlFor={`${filterID}-status`}>{t("filter.status")}</FieldLabel>
|
||||
<Select
|
||||
value={filter.status ?? "all"}
|
||||
onValueChange={(value) =>
|
||||
changeFilter({
|
||||
...filter,
|
||||
status: value === "all" ? undefined : (value as InterceptApprovalFilter["status"]),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${filterID}-status`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">{t("filter.statusAll")}</SelectItem>
|
||||
<SelectItem value="denied">{t("status.denied")}</SelectItem>
|
||||
<SelectItem value="pending">{t("status.pending")}</SelectItem>
|
||||
<SelectItem value="allowed">{t("status.allowed")}</SelectItem>
|
||||
<SelectItem value="timeout">{t("status.timeout")}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field className="w-full sm:w-40">
|
||||
<FieldLabel htmlFor={`${filterID}-source`}>{t("filter.source")}</FieldLabel>
|
||||
<Select
|
||||
value={filter.decision_source ?? "all"}
|
||||
onValueChange={(value) =>
|
||||
changeFilter({
|
||||
...filter,
|
||||
decision_source: value === "all" ? undefined : (value as InterceptApprovalFilter["decision_source"]),
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={`${filterID}-source`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">{t("filter.sourceAll")}</SelectItem>
|
||||
<SelectItem value="model">{t("sourceOption.model")}</SelectItem>
|
||||
<SelectItem value="rule">{t("sourceOption.rule")}</SelectItem>
|
||||
<SelectItem value="unknown">{t("sourceOption.unknown")}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{filtered ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => changeFilter({})}>
|
||||
{t("filter.clear")}
|
||||
</Button>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{t("recordLoadError", { error })}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{pendingError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{t("pendingLoadError", { error: pendingError })}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{pending.length ? (
|
||||
<section className="overflow-hidden rounded-xl border">
|
||||
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-3 font-medium text-sm">
|
||||
<ShieldAlertIcon className="size-4" />
|
||||
{t("pendingSection", { count: pending.length })}{" "}
|
||||
<span className="text-muted-foreground text-xs">{t("pendingHint")}</span>
|
||||
</div>
|
||||
<ApprovalTable
|
||||
rows={pending}
|
||||
busy={deciding}
|
||||
decide={decide}
|
||||
revision={revision}
|
||||
label={t("pendingTableLabel")}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
<section className="overflow-hidden rounded-xl border">
|
||||
<div className="border-b px-4 py-3 font-medium text-sm">
|
||||
{filtered ? t("filteredResults") : t("allRecords")} ({total})
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !rows.length && !error ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ClipboardListIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{filtered ? t("emptyFiltered") : t("emptyTitle")}</EmptyTitle>
|
||||
<EmptyDescription>{filtered ? t("emptyFilteredDesc") : t("emptyDesc")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : null}
|
||||
{rows.length ? (
|
||||
<ApprovalTable
|
||||
rows={rows}
|
||||
busy={deciding}
|
||||
decide={decide}
|
||||
revision={revision}
|
||||
label={t("historyTableLabel")}
|
||||
/>
|
||||
) : null}
|
||||
{!loading && !error ? (
|
||||
<TablePagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={changePageSize}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ── DSL autocomplete ──────────────────────────────────────────────────────────
|
||||
// Shared by the global asset view (/function/assets) and the per-task 测试资产
|
||||
// search, so both search boxes behave and look identical. Field/operator/logic
|
||||
// descriptions are translation keys (namespace "assetSearch") resolved at render.
|
||||
|
||||
const STR_OPS = [
|
||||
{ op: "=", descKey: "op.fuzzy" },
|
||||
{ op: "==", descKey: "op.exact" },
|
||||
{ op: "!=", descKey: "op.exclude" },
|
||||
];
|
||||
const EXACT_OPS = [
|
||||
{ op: "==", descKey: "op.exact" },
|
||||
{ op: "!=", descKey: "op.exclude" },
|
||||
];
|
||||
const NUM_OPS = [
|
||||
{ op: "==", descKey: "op.eq" },
|
||||
{ op: "!=", descKey: "op.ne" },
|
||||
{ op: ">", descKey: "op.gt" },
|
||||
{ op: ">=", descKey: "op.gte" },
|
||||
{ op: "<", descKey: "op.lt" },
|
||||
{ op: "<=", descKey: "op.lte" },
|
||||
];
|
||||
const EQ_OPS = [{ op: "==", descKey: "op.eq" }];
|
||||
|
||||
const DSL_FIELDS: { name: string; descKey: string; ops: { op: string; descKey: string }[] }[] = [
|
||||
{ name: "domain", descKey: "field.domain", ops: STR_OPS },
|
||||
{ name: "ip", descKey: "field.ip", ops: STR_OPS },
|
||||
{ name: "url", descKey: "field.url", ops: STR_OPS },
|
||||
{ name: "root_domain", descKey: "field.root_domain", ops: STR_OPS },
|
||||
{ name: "page_title", descKey: "field.page_title", ops: STR_OPS },
|
||||
{ name: "icp", descKey: "field.icp", ops: STR_OPS },
|
||||
{ name: "service_name", descKey: "field.service_name", ops: STR_OPS },
|
||||
{ name: "app_name", descKey: "field.app_name", ops: STR_OPS },
|
||||
{ name: "bundle_id", descKey: "field.bundle_id", ops: STR_OPS },
|
||||
{ name: "category", descKey: "field.category", ops: STR_OPS },
|
||||
{ name: "app_icp", descKey: "field.app_icp", ops: STR_OPS },
|
||||
{ name: "method", descKey: "field.method", ops: EXACT_OPS },
|
||||
{ name: "service_type", descKey: "field.service_type", ops: EXACT_OPS },
|
||||
{ name: "record_type", descKey: "field.record_type", ops: EXACT_OPS },
|
||||
{ name: "technology", descKey: "field.technology", ops: STR_OPS },
|
||||
{ name: "port", descKey: "field.port", ops: NUM_OPS },
|
||||
{ name: "status_code", descKey: "field.status_code", ops: NUM_OPS },
|
||||
{ name: "company_id", descKey: "field.company_id", ops: EQ_OPS },
|
||||
{ name: "task_id", descKey: "field.task_id", ops: EQ_OPS },
|
||||
];
|
||||
|
||||
const LOGIC_OPS = [
|
||||
{ label: "AND", descKey: "logic.and" },
|
||||
{ label: "OR", descKey: "logic.or" },
|
||||
];
|
||||
|
||||
interface DslSuggestion {
|
||||
kind: "field" | "operator" | "logic";
|
||||
label: string;
|
||||
descKey: string;
|
||||
replaceStart: number;
|
||||
replaceEnd: number;
|
||||
insertText: string;
|
||||
}
|
||||
|
||||
function getDslSuggestions(text: string, cursor: number): DslSuggestion[] {
|
||||
const before = text.slice(0, cursor);
|
||||
// Current token: non-whitespace, non-paren run ending at cursor
|
||||
const tokenMatch = before.match(/([^\s()]*$)/);
|
||||
const currentToken = tokenMatch?.[1] ?? "";
|
||||
const tokenStart = cursor - currentToken.length;
|
||||
|
||||
// Token already contains field+operator → typing a value, no suggestions
|
||||
if (/^[a-z_]+(==|!=|>=|<=|=|>|<)/.test(currentToken)) return [];
|
||||
|
||||
// Complete known field name → suggest operators for that field
|
||||
const exactField = DSL_FIELDS.find((f) => f.name === currentToken.toLowerCase());
|
||||
if (exactField) {
|
||||
return exactField.ops.map(({ op, descKey }) => ({
|
||||
kind: "operator",
|
||||
label: `${exactField.name}${op}`,
|
||||
descKey,
|
||||
replaceStart: tokenStart,
|
||||
replaceEnd: cursor,
|
||||
insertText: `${exactField.name}${op}`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Everything before the current token (trimmed)
|
||||
const beforeToken = before.slice(0, tokenStart).trimEnd();
|
||||
const afterExpression = beforeToken.length > 0 && !/\b(AND|OR)\s*$/i.test(beforeToken) && !beforeToken.endsWith("(");
|
||||
|
||||
// Current token is a prefix of AND/OR and follows a complete expression
|
||||
if (/^(a|an|and|o|or)$/i.test(currentToken) && afterExpression) {
|
||||
return LOGIC_OPS.filter((l) => l.label.startsWith(currentToken.toUpperCase())).map(({ label, descKey }) => ({
|
||||
kind: "logic",
|
||||
label,
|
||||
descKey,
|
||||
replaceStart: tokenStart,
|
||||
replaceEnd: cursor,
|
||||
insertText: `${label} `,
|
||||
}));
|
||||
}
|
||||
|
||||
// No current token, after a complete expression → suggest AND/OR
|
||||
if (!currentToken && afterExpression) {
|
||||
return LOGIC_OPS.map(({ label, descKey }) => ({
|
||||
kind: "logic",
|
||||
label,
|
||||
descKey,
|
||||
replaceStart: cursor,
|
||||
replaceEnd: cursor,
|
||||
insertText: `${label} `,
|
||||
}));
|
||||
}
|
||||
|
||||
// Default: suggest fields filtered by prefix
|
||||
const prefix = currentToken.toLowerCase();
|
||||
return DSL_FIELDS.filter((f) => f.name.startsWith(prefix)).map((f) => ({
|
||||
kind: "field",
|
||||
label: f.name,
|
||||
descKey: f.descKey,
|
||||
replaceStart: tokenStart,
|
||||
replaceEnd: cursor,
|
||||
insertText: f.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function applyDslSuggestion(text: string, s: DslSuggestion): { text: string; cursor: number } {
|
||||
const newText = text.slice(0, s.replaceStart) + s.insertText + text.slice(s.replaceEnd);
|
||||
return { text: newText, cursor: s.replaceStart + s.insertText.length };
|
||||
}
|
||||
|
||||
const KIND_STYLE: Record<string, string> = {
|
||||
field: "text-blue-500 dark:text-blue-400",
|
||||
operator: "text-amber-500 dark:text-amber-400",
|
||||
logic: "text-emerald-500 dark:text-emerald-400",
|
||||
};
|
||||
|
||||
// AssetDslSearch is the shared DSL search box: a monospace input with a
|
||||
// field/operator/logic autocomplete popover and a status line (result count /
|
||||
// error / loading). Used by both the global asset view and the per-task view.
|
||||
export function AssetDslSearch({
|
||||
query,
|
||||
onChange,
|
||||
loading,
|
||||
error,
|
||||
count,
|
||||
}: {
|
||||
query: string;
|
||||
onChange: (v: string) => void;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
count?: number;
|
||||
}) {
|
||||
const t = useTranslations("assetSearch");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [suggestions, setSuggestions] = React.useState<DslSuggestion[]>([]);
|
||||
const [selIdx, setSelIdx] = React.useState(0);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const refresh = React.useCallback((val: string, pos: number) => {
|
||||
const suggs = getDslSuggestions(val, pos);
|
||||
setSuggestions(suggs);
|
||||
setSelIdx(0);
|
||||
setOpen(suggs.length > 0);
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
onChange(val);
|
||||
refresh(val, e.target.selectionStart ?? val.length);
|
||||
};
|
||||
|
||||
const apply = React.useCallback(
|
||||
(s: DslSuggestion) => {
|
||||
const cursor = inputRef.current?.selectionStart ?? query.length;
|
||||
// use cursor for logic-kind (insert at cursor), replaceStart/End for others
|
||||
const adjusted: DslSuggestion =
|
||||
s.kind === "logic" && !query.slice(s.replaceStart, s.replaceEnd)
|
||||
? { ...s, replaceStart: cursor, replaceEnd: cursor }
|
||||
: s;
|
||||
const { text: newText, cursor: newCursor } = applyDslSuggestion(query, adjusted);
|
||||
onChange(newText);
|
||||
requestAnimationFrame(() => {
|
||||
if (!inputRef.current) return;
|
||||
inputRef.current.setSelectionRange(newCursor, newCursor);
|
||||
inputRef.current.focus();
|
||||
refresh(newText, newCursor);
|
||||
});
|
||||
},
|
||||
[query, onChange, refresh],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!open || suggestions.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelIdx((i) => Math.min(i + 1, suggestions.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelIdx((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Tab" || e.key === "Enter") {
|
||||
const s = suggestions[selIdx];
|
||||
if (s) {
|
||||
e.preventDefault();
|
||||
apply(s);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cursorPos = () => inputRef.current?.selectionStart ?? query.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative max-w-lg">
|
||||
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t("placeholder")}
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => refresh(query, cursorPos())}
|
||||
onClick={() => refresh(query, cursorPos())}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 120)}
|
||||
className="h-8 pl-8 font-mono text-xs"
|
||||
/>
|
||||
{open && suggestions.length > 0 && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-max min-w-full max-w-sm rounded-md border bg-popover py-1 shadow-md">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3 px-3 py-1.5 text-left",
|
||||
i === selIdx ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
onMouseEnter={() => setSelIdx(i)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
apply(s);
|
||||
}}
|
||||
>
|
||||
<span className={cn("shrink-0 font-mono text-xs font-semibold", KIND_STYLE[s.kind])}>{s.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{t(s.descKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{query.trim() && !open && (
|
||||
<p className="pl-1 text-[11px] text-muted-foreground">
|
||||
{loading ? (
|
||||
t("loading")
|
||||
) : error ? (
|
||||
<span className="text-destructive">{error}</span>
|
||||
) : (
|
||||
t("found", { count: count ?? 0 })
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import type { AssetInterceptKind, AssetInterceptRuleInput } from "@/lib/types";
|
||||
|
||||
// 用 NativeSelect(原生 <select>)而非 shadcn Select:这个编辑器会用在 Sheet 抽屉内,
|
||||
// shadcn Select 的下拉 portal 到 body、点击外部会触发抽屉的「点击外部关闭」误关;原生下拉无此问题。
|
||||
// label 은 렌더 시점에 t(`kind.${value}`) 로 해석(ASSET_INTERCEPT_KIND_OPTIONS 외부 소비처 없음).
|
||||
export const ASSET_INTERCEPT_KIND_OPTIONS: {
|
||||
value: AssetInterceptKind;
|
||||
placeholder: string;
|
||||
}[] = [
|
||||
{ value: "exact_domain", placeholder: "example.gov.cn" },
|
||||
{ value: "exact_ip", placeholder: "203.0.113.10" },
|
||||
{ value: "exact_url", placeholder: "https://example.com/login" },
|
||||
{ value: "fuzzy_domain", placeholder: ".gov.cn" },
|
||||
{ value: "fuzzy_ip", placeholder: "203.0.113." },
|
||||
{ value: "fuzzy_url", placeholder: "/admin" },
|
||||
{ value: "cidr", placeholder: "192.168.0.0/16" },
|
||||
];
|
||||
|
||||
// AssetInterceptRulesEditor 是「拦截/允许规则」的受控多行编辑区(拦截block/允许allow +
|
||||
// 类型 + 匹配内容 + 备注),不自带持久化——由父组件决定何时提交。
|
||||
export function AssetInterceptRulesEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: AssetInterceptRuleInput[];
|
||||
onChange: (v: AssetInterceptRuleInput[]) => void;
|
||||
}) {
|
||||
const t = useTranslations("assetInterceptRules");
|
||||
function update(i: number, patch: Partial<AssetInterceptRuleInput>) {
|
||||
onChange(value.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
}
|
||||
function remove(i: number) {
|
||||
onChange(value.filter((_, idx) => idx !== i));
|
||||
}
|
||||
function add() {
|
||||
onChange([...value, { action: "block", kind: "fuzzy_domain", pattern: "", note: "", enabled: true }]);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
{value.map((r, i) => {
|
||||
const ph = ASSET_INTERCEPT_KIND_OPTIONS.find((o) => o.value === r.kind)?.placeholder ?? "";
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: 行无稳定 id,按索引受控即可
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
className="w-[84px] shrink-0"
|
||||
value={r.action}
|
||||
onChange={(e) => update(i, { action: e.target.value as "block" | "allow" })}
|
||||
>
|
||||
<NativeSelectOption value="block">{t("action.block")}</NativeSelectOption>
|
||||
<NativeSelectOption value="allow">{t("action.allow")}</NativeSelectOption>
|
||||
</NativeSelect>
|
||||
<NativeSelect
|
||||
size="sm"
|
||||
className="w-[120px] shrink-0"
|
||||
value={r.kind}
|
||||
onChange={(e) => update(i, { kind: e.target.value as AssetInterceptKind })}
|
||||
>
|
||||
{ASSET_INTERCEPT_KIND_OPTIONS.map((o) => (
|
||||
<NativeSelectOption key={o.value} value={o.value}>
|
||||
{t(`kind.${o.value}`)}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={ph}
|
||||
value={r.pattern}
|
||||
onChange={(e) => update(i, { pattern: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
className="w-[120px] shrink-0"
|
||||
placeholder={t("notePlaceholder")}
|
||||
value={r.note}
|
||||
onChange={(e) => update(i, { note: e.target.value })}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive size-8 shrink-0"
|
||||
onClick={() => remove(i)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button type="button" size="sm" variant="outline" className="w-fit" onClick={add}>
|
||||
<PlusIcon className="size-4" /> {t("add")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import FullCalendar, { type CalendarOptions, type DayCellInfo } from '@fullcalendar/react'
|
||||
import '@fullcalendar/react/skeleton.css'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const xxsTextClass = 'text-[0.6875rem]/[1.090909]'
|
||||
|
||||
const blockPointerResizerClass = 'absolute hidden group-hover:block'
|
||||
const rowPointerResizerClass = `${blockPointerResizerClass} inset-y-0 w-2`
|
||||
const columnPointerResizerClass = `${blockPointerResizerClass} inset-x-0 h-2`
|
||||
|
||||
const blockTouchResizerClass = 'absolute size-2 border border-(--fc-event-color) bg-background rounded-full'
|
||||
const rowTouchResizerClass = `${blockTouchResizerClass} top-1/2 -mt-1`
|
||||
const columnTouchResizerClass = `${blockTouchResizerClass} left-1/2 -ml-1`
|
||||
|
||||
const tallDayCellBottomClass = 'min-h-3'
|
||||
const getShortDayCellBottomClass = (info: DayCellInfo) => cn(
|
||||
!info.isNarrow && 'min-h-0.5'
|
||||
)
|
||||
|
||||
const dayRowCommonClasses: CalendarOptions = {
|
||||
|
||||
/* Day Row > List-Item Event
|
||||
----------------------------------------------------------------------------------------------- */
|
||||
|
||||
listItemEventClass: (info) => cn(
|
||||
'mb-px p-px rounded-sm',
|
||||
info.isNarrow ? 'mx-0.5' : 'mx-1',
|
||||
info.isSelected
|
||||
? 'bg-foreground/5'
|
||||
: 'hover:bg-foreground/5',
|
||||
),
|
||||
listItemEventInnerClass: (info) => cn(
|
||||
'flex flex-row items-center justify-between',
|
||||
info.isNarrow
|
||||
? `py-px ${xxsTextClass}`
|
||||
: 'py-0.5 text-xs',
|
||||
),
|
||||
listItemEventTimeClass: (info) => cn(
|
||||
info.isNarrow ? 'px-px' : 'px-0.5',
|
||||
'order-1 whitespace-nowrap overflow-hidden shrink-1',
|
||||
),
|
||||
listItemEventTitleClass: (info) => cn(
|
||||
info.isNarrow ? 'px-px' : 'px-0.5',
|
||||
'font-medium whitespace-nowrap overflow-hidden shrink-100',
|
||||
info.timeText && 'text-ellipsis',
|
||||
),
|
||||
|
||||
/* Day Row > Row Event
|
||||
----------------------------------------------------------------------------------------------- */
|
||||
|
||||
rowEventClass: (info) => cn(
|
||||
info.isStart && (info.isNarrow ? 'ms-0.5' : 'ms-1'),
|
||||
info.isEnd && (info.isNarrow ? 'me-0.5' : 'me-1'),
|
||||
),
|
||||
rowEventInnerClass: (info) => cn(info.isNarrow ? 'py-px' : 'py-0.5'),
|
||||
|
||||
/* Day Row > More-Link
|
||||
----------------------------------------------------------------------------------------------- */
|
||||
|
||||
rowMoreLinkClass: (info) => cn(
|
||||
'mb-px border rounded-sm',
|
||||
info.isNarrow
|
||||
? 'mx-0.5 border-primary hover:bg-foreground/5'
|
||||
: 'self-start mx-1 border-transparent bg-foreground/5 hover:bg-foreground/10',
|
||||
),
|
||||
rowMoreLinkInnerClass: (info) => cn(
|
||||
info.isNarrow
|
||||
? `p-px ${xxsTextClass}`
|
||||
: 'p-0.5 text-xs',
|
||||
),
|
||||
}
|
||||
|
||||
export type EventCalendarViewProps =
|
||||
CalendarOptions &
|
||||
Required<Pick<CalendarOptions, 'popoverCloseContent'>> // ensure callers define icons
|
||||
|
||||
export function EventCalendarViews({
|
||||
height,
|
||||
views: userViews,
|
||||
...restOptions
|
||||
}: EventCalendarViewProps) {
|
||||
return (
|
||||
<FullCalendar
|
||||
height={height}
|
||||
|
||||
/* Abstract Event
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
eventShortHeight={50}
|
||||
eventColor='var(--primary)'
|
||||
eventContrastColor='var(--primary-foreground)'
|
||||
eventClass={(info) => cn(
|
||||
info.isSelected
|
||||
? ['outline-3', info.isDragging && 'shadow-lg']
|
||||
: 'focus-visible:outline-3',
|
||||
'outline-ring/50',
|
||||
)}
|
||||
|
||||
/* Background Event
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
backgroundEventColor='var(--chart-3)'
|
||||
backgroundEventClass='not-print:bg-[color-mix(in_oklab,var(--fc-event-color)_10%,transparent)] print:border-1 print:border-(--fc-event-color)'
|
||||
backgroundEventTitleClass={(info) => cn(
|
||||
'opacity-50 italic',
|
||||
(info.isNarrow || info.isShort)
|
||||
? `p-1 ${xxsTextClass}`
|
||||
: 'p-2 text-xs',
|
||||
)}
|
||||
|
||||
/* List-Item Event
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
listItemEventTimeClass='text-muted-foreground'
|
||||
|
||||
/* Block Event
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
blockEventClass={(info) => cn(
|
||||
'group relative border-transparent print:border-(--fc-event-color) bg-(--fc-event-color) print:bg-white hover:bg-[color-mix(in_oklab,var(--fc-event-color)_92%,var(--fc-event-contrast-color))]',
|
||||
info.isInteractive && 'active:bg-[color-mix(in_oklab,var(--fc-event-color)_85%,var(--fc-event-contrast-color))]',
|
||||
(info.isDragging && !info.isSelected) && 'opacity-75',
|
||||
)}
|
||||
blockEventInnerClass='text-(--fc-event-contrast-color) print:text-black'
|
||||
blockEventTimeClass='whitespace-nowrap overflow-hidden shrink-1'
|
||||
blockEventTitleClass='whitespace-nowrap overflow-hidden shrink-100'
|
||||
|
||||
/* Row Event
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
rowEventClass={(info) => cn(
|
||||
'mb-px border-y',
|
||||
info.isStart && 'rounded-s-sm border-s',
|
||||
info.isEnd && 'rounded-e-sm border-e',
|
||||
)}
|
||||
rowEventBeforeClass={(info) => cn(
|
||||
info.isStartResizable && [
|
||||
info.isSelected ? rowTouchResizerClass : rowPointerResizerClass,
|
||||
'-start-1',
|
||||
]
|
||||
)}
|
||||
rowEventAfterClass={(info) => cn(
|
||||
info.isEndResizable && [
|
||||
info.isSelected ? rowTouchResizerClass : rowPointerResizerClass,
|
||||
'-end-1',
|
||||
]
|
||||
)}
|
||||
rowEventInnerClass={(info) => cn(
|
||||
'flex flex-row items-center',
|
||||
info.isNarrow ? xxsTextClass : 'text-xs',
|
||||
)}
|
||||
rowEventTimeClass={(info) => cn(
|
||||
info.isNarrow ? 'ps-0.5' : 'ps-1'
|
||||
)}
|
||||
rowEventTitleClass={(info) => cn(
|
||||
info.isNarrow ? 'px-0.5' : 'px-1',
|
||||
'font-medium',
|
||||
)}
|
||||
|
||||
/* Column Event
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
columnEventClass={(info) => cn(
|
||||
'border-x ring ring-background',
|
||||
info.isStart && ['border-t rounded-t-lg', info.isNarrow ? 'mt-px' : 'mt-0.5'],
|
||||
info.isEnd && ['border-b rounded-b-lg', info.isNarrow ? 'mb-px' : 'mb-0.5'],
|
||||
)}
|
||||
columnEventBeforeClass={(info) => cn(
|
||||
info.isStartResizable && [
|
||||
info.isSelected ? columnTouchResizerClass : columnPointerResizerClass,
|
||||
'-top-1',
|
||||
]
|
||||
)}
|
||||
columnEventAfterClass={(info) => cn(
|
||||
info.isEndResizable && [
|
||||
info.isSelected ? columnTouchResizerClass : columnPointerResizerClass,
|
||||
'-bottom-1',
|
||||
]
|
||||
)}
|
||||
columnEventInnerClass={(info) => cn(
|
||||
'flex',
|
||||
info.isShort
|
||||
? 'flex-row items-center gap-1 p-1'
|
||||
: ['flex-col', info.isNarrow ? 'px-1 py-0.5' : 'px-2 py-1'],
|
||||
(info.isNarrow || info.isShort) ? xxsTextClass : 'text-xs',
|
||||
)}
|
||||
columnEventTimeClass={(info) => cn(
|
||||
!info.isShort && (info.isNarrow ? 'pt-0.5' : 'pt-1')
|
||||
)}
|
||||
columnEventTitleClass={(info) => cn(
|
||||
!info.isShort && (info.isNarrow ? 'py-0.5' : 'py-1'),
|
||||
'font-medium',
|
||||
)}
|
||||
|
||||
/* More-Link
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
moreLinkClass="focus-visible:outline-3 outline-ring/50"
|
||||
moreLinkInnerClass='whitespace-nowrap overflow-hidden'
|
||||
columnMoreLinkClass="my-0.5 border border-transparent print:border-black rounded-md bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))] hover:bg-[color-mix(in_oklab,var(--foreground)_13%,var(--background))] print:bg-white ring ring-background"
|
||||
columnMoreLinkInnerClass={(info) => cn(
|
||||
info.isNarrow
|
||||
? `p-0.5 ${xxsTextClass}`
|
||||
: 'p-1 text-xs',
|
||||
)}
|
||||
|
||||
/* Day Header
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
dayHeaderClass={(info) => cn(
|
||||
'justify-center',
|
||||
info.inPopover ? 'border-b bg-foreground/5' :
|
||||
info.isMajor && 'border border-foreground/20',
|
||||
)}
|
||||
dayHeaderInnerClass={(info) => cn(
|
||||
'flex flex-row items-center',
|
||||
info.isNarrow ? 'text-xs' : 'text-sm',
|
||||
info.inPopover ? [
|
||||
'm-1.5 px-1 py-0.5 rounded-sm font-semibold',
|
||||
info.hasNavLink && 'hover:bg-foreground/5',
|
||||
] : !info.dayNumberText ? [
|
||||
'mx-0.5 my-1.5 py-0.5 px-1.5 rounded-sm text-muted-foreground',
|
||||
info.hasNavLink && 'hover:bg-foreground/5',
|
||||
] : !info.isToday ? [
|
||||
'mx-2 my-2.5 h-6 px-1.5 rounded-sm text-muted-foreground',
|
||||
info.hasNavLink && 'hover:bg-foreground/5',
|
||||
] : (
|
||||
'group mx-2 my-2 h-7 outline-none'
|
||||
)
|
||||
)}
|
||||
dayHeaderContent={(info) => (
|
||||
(info.inPopover || !info.dayNumberText || !info.isToday) ? (
|
||||
<>{info.text}</>
|
||||
) : (
|
||||
<>
|
||||
{info.textParts.map((textPart, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
'whitespace-pre',
|
||||
(textPart.type === 'day' && info.isToday)
|
||||
? [
|
||||
'first:-ms-1 last:-me-1 size-7 rounded-full font-semibold flex flex-row items-center justify-center bg-chart-1/50',
|
||||
info.hasNavLink && 'group-hover:bg-chart-1/60 group-focus-visible:outline-3 outline-ring/50',
|
||||
]
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>{textPart.value}</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
/* Day Cell
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
dayCellClass={(info) => cn(
|
||||
'border',
|
||||
info.isMajor && 'border-foreground/20',
|
||||
)}
|
||||
dayCellTopClass={(info) => cn(
|
||||
info.isNarrow ? 'min-h-0.5' : 'min-h-1',
|
||||
'flex flex-row justify-end',
|
||||
)}
|
||||
dayCellTopInnerClass={(info) => cn(
|
||||
'flex flex-row items-center',
|
||||
info.isNarrow
|
||||
? `my-px h-5 ${xxsTextClass}`
|
||||
: 'my-1 h-6 text-sm',
|
||||
!info.isToday
|
||||
? [
|
||||
'rounded-s-sm whitespace-nowrap',
|
||||
!info.isOther && 'font-semibold',
|
||||
info.isNarrow ? 'px-1' : 'px-2',
|
||||
!info.monthText && 'text-muted-foreground',
|
||||
info.hasNavLink && 'hover:bg-foreground/5',
|
||||
]
|
||||
: [
|
||||
'group outline-none',
|
||||
info.isNarrow
|
||||
? 'mx-px'
|
||||
: 'mx-2',
|
||||
]
|
||||
)}
|
||||
dayCellTopContent={(info) => (
|
||||
!info.isToday ? (
|
||||
<>{info.text}</>
|
||||
) : (
|
||||
<>
|
||||
{info.textParts.map((textPart, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
'whitespace-pre',
|
||||
(textPart.type === 'day' && info.isToday)
|
||||
? [
|
||||
'rounded-full font-semibold flex flex-row items-center justify-center bg-chart-1/50',
|
||||
info.hasNavLink && 'group-hover:bg-chart-1/60 group-focus-visible:outline-3 outline-ring/50',
|
||||
info.isNarrow
|
||||
? 'size-5'
|
||||
: 'size-6 first:-ms-1 last:-me-1',
|
||||
]
|
||||
: (!info.monthText && 'text-muted-foreground'),
|
||||
)}
|
||||
>{textPart.value}</span>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
dayCellInnerClass={(info) => cn(info.inPopover && 'p-2')}
|
||||
|
||||
/* Popover
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
popoverClass='border rounded-md overflow-hidden shadow-lg m-1 bg-popover text-popover-foreground min-w-55'
|
||||
popoverCloseClass="group absolute top-1.5 end-1.5 p-0.5 rounded-sm hover:bg-foreground/5 focus-visible:outline-3 outline-ring/50"
|
||||
|
||||
/* Lane
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
dayLaneClass={(info) => cn(
|
||||
'border',
|
||||
info.isMajor && 'border-foreground/20',
|
||||
info.isDisabled && 'bg-foreground/3',
|
||||
)}
|
||||
dayLaneInnerClass={(info) => (
|
||||
info.isStack
|
||||
? 'm-1'
|
||||
: info.isNarrow ? 'mx-px' : 'mx-0.5'
|
||||
)}
|
||||
slotLaneClass={(info) => cn(
|
||||
'border',
|
||||
info.isMinor && 'border-dotted',
|
||||
)}
|
||||
|
||||
/* List Day
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
listDayClass={(info) => cn(
|
||||
'flex flex-col',
|
||||
!info.isLast && 'border-b',
|
||||
)}
|
||||
listDayHeaderClass="-mb-px border-b bg-[color-mix(in_oklab,var(--foreground)_3%,var(--background))] flex flex-row items-center justify-between"
|
||||
listDayHeaderInnerClass={(info) => cn(
|
||||
'm-1.5 px-1.5 py-0.5 rounded-sm text-sm',
|
||||
!info.level && 'font-semibold',
|
||||
(!info.level && info.isToday)
|
||||
? [
|
||||
'bg-chart-1/50',
|
||||
info.hasNavLink && 'hover:bg-chart-1/60 focus-visible:outline-3 outline-ring/50',
|
||||
]
|
||||
: info.hasNavLink && 'hover:bg-foreground/5',
|
||||
)}
|
||||
listDayBodyClass='mt-px px-1.5 py-2 gap-2'
|
||||
|
||||
/* Single Month (in Multi-Month)
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
singleMonthClass={(info) => cn(
|
||||
info.multiMonthColumns > 1 && 'm-3',
|
||||
(info.multiMonthColumns === 1 && !info.isLast) && 'border-b',
|
||||
)}
|
||||
singleMonthHeaderClass={(info) => cn(
|
||||
info.multiMonthColumns > 1 ? 'pb-2' : 'py-1 border-b bg-background',
|
||||
'items-center',
|
||||
)}
|
||||
singleMonthHeaderInnerClass={(info) => cn(
|
||||
'px-1.5 py-0.5 rounded-sm text-base font-semibold',
|
||||
info.hasNavLink && 'hover:bg-foreground/5',
|
||||
)}
|
||||
|
||||
/* Misc Table
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
tableBodyClass='bg-background'
|
||||
fillerClass='border opacity-50'
|
||||
dayNarrowWidth={100}
|
||||
dayHeaderRowClass='border'
|
||||
dayRowClass='border'
|
||||
slotHeaderRowClass='border'
|
||||
slotHeaderInnerClass='text-muted-foreground'
|
||||
|
||||
/* Misc Content
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
navLinkClass="focus-visible:outline-3 outline-ring/50"
|
||||
inlineWeekNumberClass={(info) => cn(
|
||||
'absolute start-0 whitespace-nowrap rounded-e-sm text-muted-foreground',
|
||||
info.isNarrow
|
||||
? `top-0.5 my-px p-0.5 ${xxsTextClass}`
|
||||
: 'top-1 p-1 text-xs',
|
||||
info.hasNavLink && 'hover:bg-foreground/5',
|
||||
)}
|
||||
highlightClass='bg-chart-1/20'
|
||||
nonBusinessHoursClass='bg-foreground/3'
|
||||
nowIndicatorLineClass='-m-px border-1 border-destructive'
|
||||
nowIndicatorDotClass="-m-[6px] border-6 border-destructive size-0 rounded-full ring-2 ring-background"
|
||||
|
||||
/* View-Specific Options
|
||||
----------------------------------------------------------------------------------------- */
|
||||
|
||||
views={{
|
||||
...userViews,
|
||||
dayGrid: {
|
||||
...dayRowCommonClasses,
|
||||
tableHeaderClass: 'bg-background',
|
||||
dayHeaderAlign: (info) => info.inPopover ? 'start' : info.isNarrow ? 'center' : 'end',
|
||||
dayHeaderDividerClass: 'border-b',
|
||||
dayCellBottomClass: getShortDayCellBottomClass,
|
||||
...userViews?.dayGrid,
|
||||
},
|
||||
multiMonth: {
|
||||
...dayRowCommonClasses,
|
||||
className: 'bg-foreground/3',
|
||||
tableHeaderClass: (info) => cn(info.multiMonthColumns === 1 && 'bg-background'),
|
||||
dayHeaderAlign: (info) => info.inPopover ? 'start' : info.isNarrow ? 'center' : 'end',
|
||||
dayHeaderDividerClass: (info) => cn(info.multiMonthColumns === 1 && 'border-b'),
|
||||
dayCellBottomClass: getShortDayCellBottomClass,
|
||||
tableBodyClass: (info) => cn(info.multiMonthColumns > 1 && 'border rounded-sm overflow-hidden'),
|
||||
...userViews?.multiMonth,
|
||||
},
|
||||
timeGrid: {
|
||||
...dayRowCommonClasses,
|
||||
tableHeaderClass: 'bg-background',
|
||||
dayHeaderAlign: (info) => info.inPopover ? 'start' : 'center',
|
||||
dayHeaderDividerClass: (info) => cn(
|
||||
'border-b',
|
||||
!info.options.allDaySlot && 'border-foreground/20 not-print:shadow-sm',
|
||||
),
|
||||
dayCellBottomClass: tallDayCellBottomClass,
|
||||
|
||||
/* TimeGrid > Week Number Header
|
||||
------------------------------------------------------------------------------------- */
|
||||
|
||||
weekNumberHeaderClass: 'items-center justify-end',
|
||||
weekNumberHeaderInnerClass: (info) => cn(
|
||||
'mx-0.5 h-6 px-1.5 text-muted-foreground flex flex-row items-center rounded-sm',
|
||||
info.isNarrow ? 'text-xs' : 'text-sm',
|
||||
info.hasNavLink && 'hover:bg-foreground/5',
|
||||
),
|
||||
|
||||
/* TimeGrid > All-Day Header
|
||||
------------------------------------------------------------------------------------- */
|
||||
|
||||
allDayHeaderClass: 'items-center',
|
||||
allDayHeaderInnerClass: (info) => cn(
|
||||
'm-2 text-muted-foreground',
|
||||
info.isNarrow ? xxsTextClass : 'text-xs',
|
||||
),
|
||||
allDayDividerClass: 'border-b border-foreground/20 not-print:shadow-sm',
|
||||
|
||||
/* TimeGrid > Slot Header
|
||||
------------------------------------------------------------------------------------- */
|
||||
|
||||
slotHeaderClass: 'justify-end',
|
||||
slotHeaderInnerClass: (info) => cn(
|
||||
'relative m-2',
|
||||
info.isNarrow
|
||||
? `-top-3.5 ${xxsTextClass}`
|
||||
: '-top-4 text-xs',
|
||||
info.isFirst && 'hidden',
|
||||
),
|
||||
slotHeaderDividerClass: 'border-e',
|
||||
|
||||
...userViews?.timeGrid,
|
||||
},
|
||||
list: {
|
||||
|
||||
/* List-View > List-Item Event
|
||||
------------------------------------------------------------------------------------- */
|
||||
|
||||
listItemEventClass: (info) => cn(
|
||||
'group py-1 rounded-sm hover:bg-muted/50',
|
||||
info.isInteractive && 'focus-visible:bg-muted/50 -outline-offset-3',
|
||||
),
|
||||
listItemEventBeforeClass: 'w-1.5 bg-(--fc-event-color) rounded-full',
|
||||
listItemEventInnerClass: '[display:contents]',
|
||||
listItemEventTimeClass: "-order-1 shrink-0 w-1/2 max-w-60 px-4 py-2 whitespace-nowrap overflow-hidden text-ellipsis text-sm",
|
||||
listItemEventTitleClass: (info) => cn(
|
||||
'grow min-w-0 px-4 py-2 whitespace-nowrap overflow-hidden text-sm',
|
||||
info.event.url && 'group-hover:underline',
|
||||
),
|
||||
|
||||
/* No-Events Screen
|
||||
------------------------------------------------------------------------------------- */
|
||||
|
||||
noEventsClass: 'grow flex flex-col items-center justify-center',
|
||||
noEventsInnerClass: 'py-15 text-muted-foreground',
|
||||
|
||||
...userViews?.list,
|
||||
},
|
||||
}}
|
||||
|
||||
{...restOptions}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, copyText } from "@/lib/utils";
|
||||
|
||||
type CopyButtonProps = {
|
||||
// 要复制的文本;为空则按钮禁用。
|
||||
text: string | null | undefined;
|
||||
// 复制成功后的 toast 文案,默认「已复制」。
|
||||
successMessage?: string;
|
||||
label?: React.ReactNode;
|
||||
size?: React.ComponentProps<typeof Button>["size"];
|
||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// CopyButton 统一的「复制到剪贴板」按钮:内置成功/失败反馈,并在 HTTP 非安全上下文
|
||||
// 下自动降级(见 copyText)。
|
||||
export function CopyButton({
|
||||
text,
|
||||
successMessage,
|
||||
label,
|
||||
size = "sm",
|
||||
variant = "outline",
|
||||
className,
|
||||
}: CopyButtonProps) {
|
||||
const t = useTranslations("copyButton");
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleCopy() {
|
||||
if (!text) return;
|
||||
const ok = await copyText(text);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
toast.success(successMessage ?? t("success"));
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error(t("error"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" size={size} variant={variant} className={cn(className)} disabled={!text} onClick={handleCopy}>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
{label ?? t("label")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { format, subDays } from "date-fns";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
||||
interface DateRangePickerProps {
|
||||
value?: DateRange;
|
||||
onChange?: (value: DateRange | undefined) => void;
|
||||
}
|
||||
|
||||
export function DateRangePicker({ value, onChange }: DateRangePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [internalDateRange, setInternalDateRange] = React.useState<DateRange | undefined>(() => {
|
||||
const to = new Date();
|
||||
const from = subDays(to, 29);
|
||||
return { from, to };
|
||||
});
|
||||
const dateRange = value ?? internalDateRange;
|
||||
let dateRangeLabel = "Select date";
|
||||
|
||||
if (dateRange?.from) {
|
||||
dateRangeLabel = format(dateRange.from, "d MMM yyyy");
|
||||
}
|
||||
|
||||
if (dateRange?.from && dateRange.to) {
|
||||
dateRangeLabel = `${format(dateRange.from, "d MMM yyyy")} - ${format(dateRange.to, "d MMM yyyy")}`;
|
||||
}
|
||||
|
||||
const handleDateChange = (nextValue: DateRange | undefined) => {
|
||||
if (!value) {
|
||||
setInternalDateRange(nextValue);
|
||||
}
|
||||
onChange?.(nextValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" id="date" className="font-normal">
|
||||
{dateRangeLabel}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto overflow-hidden p-0" align="end">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={handleDateChange}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { RotateCcwIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingRetest } from "@/lib/types";
|
||||
|
||||
interface FindingRetestDialogProps {
|
||||
findingId: string;
|
||||
findingName?: string;
|
||||
onClose: () => void;
|
||||
onStarted?: (retest: FindingRetest) => void;
|
||||
}
|
||||
|
||||
// 仅在打开时挂载,关闭后清空说明;列表与详情共用提交锁及错误处理,启动后留在当前页。
|
||||
export function FindingRetestDialog({ findingId, findingName, onClose, onStarted }: FindingRetestDialogProps) {
|
||||
const t = useTranslations("retest.dialog");
|
||||
const notesId = React.useId();
|
||||
const [notes, setNotes] = React.useState("");
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const submitLock = React.useRef(false);
|
||||
|
||||
async function start() {
|
||||
if (submitLock.current) return;
|
||||
submitLock.current = true;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await api.startFindingRetest(findingId, notes.trim());
|
||||
onStarted?.(result.retest);
|
||||
onClose();
|
||||
toast.success(result.created ? t("startedNew") : t("alreadyRunning"));
|
||||
} catch (e) {
|
||||
toast.error(t("startFailed", { message: (e as Error).message }));
|
||||
} finally {
|
||||
submitLock.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !submitLock.current && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title", { id: findingId })}</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
{findingName ? <span className="mb-2 block">{findingName}</span> : null}
|
||||
{t("desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-disabled={submitting}>
|
||||
<FieldLabel htmlFor={notesId}>{t("notesLabel")}</FieldLabel>
|
||||
<Textarea
|
||||
id={notesId}
|
||||
value={notes}
|
||||
maxLength={4000}
|
||||
rows={4}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder={t("notesPlaceholder")}
|
||||
/>
|
||||
<FieldDescription>{t("notesDesc")}</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={submitting} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button disabled={submitting} onClick={() => void start()}>
|
||||
{submitting ? <Spinner data-icon="inline-start" /> : <RotateCcwIcon data-icon="inline-start" />}
|
||||
{submitting ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { RotateCcwIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingRetest } from "@/lib/types";
|
||||
|
||||
function active(r: FindingRetest) {
|
||||
return r.status === "pending" || r.status === "running";
|
||||
}
|
||||
|
||||
export function FindingRetestPanel({
|
||||
findingId,
|
||||
findingName,
|
||||
readOnly = false,
|
||||
onCompleted,
|
||||
}: {
|
||||
findingId: string;
|
||||
findingName?: string;
|
||||
readOnly?: boolean;
|
||||
onCompleted?: () => void;
|
||||
}) {
|
||||
const t = useTranslations("retest.panel");
|
||||
const [items, setItems] = React.useState<FindingRetest[] | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const requestSeq = React.useRef(0);
|
||||
const previousItems = React.useRef<FindingRetest[]>([]);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
const seq = ++requestSeq.current;
|
||||
try {
|
||||
const rows = await api.findingRetests(findingId);
|
||||
if (seq !== requestSeq.current) return;
|
||||
const completed = previousItems.current.some(
|
||||
(item) => active(item) && rows.some((row) => row.id === item.id && !active(row)),
|
||||
);
|
||||
previousItems.current = rows;
|
||||
setItems(rows);
|
||||
setError("");
|
||||
if (completed) onCompleted?.();
|
||||
} catch (e) {
|
||||
if (seq === requestSeq.current) setError((e as Error).message);
|
||||
}
|
||||
}, [findingId, onCompleted]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
return () => {
|
||||
requestSeq.current++;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error || !items?.some(active)) return;
|
||||
const timer = setTimeout(() => void load(), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error, items, load]);
|
||||
|
||||
const running = items?.find(active);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
<CardDescription>{t("desc")}</CardDescription>
|
||||
</div>
|
||||
{running?.conversation_id != null ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/chat?c=${running.conversation_id}`} title={t("viewRunning")}>
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
{t("status.running")}
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{!running && !readOnly ? (
|
||||
<Button size="sm" onClick={() => setOpen(true)} disabled={items === null || !!error}>
|
||||
<RotateCcwIcon data-icon="inline-start" />
|
||||
{t("start")}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("loadError", { error })}
|
||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!error && items === null ? <Skeleton className="h-16 w-full" /> : null}
|
||||
{!error && items?.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{t("emptyTitle")}</EmptyTitle>
|
||||
<EmptyDescription>{t("emptyDesc")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : null}
|
||||
{!error && items
|
||||
? items.map((item) => (
|
||||
<div key={item.id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant={item.status === "completed" && item.verdict === "reproduced" ? "destructive" : "secondary"}
|
||||
>
|
||||
{item.status === "completed" && item.verdict
|
||||
? t(`verdict.${item.verdict}`)
|
||||
: t(`status.${item.status}`)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
#{item.id} · {new Date(item.created_at).toLocaleString("ko-KR")}
|
||||
</span>
|
||||
{item.conversation_id != null ? (
|
||||
<Button asChild variant="ghost" size="sm" className="ml-auto">
|
||||
<Link href={`/chat?c=${item.conversation_id}`}>{t("viewSession")}</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">{t("sessionDeleted")}</span>
|
||||
)}
|
||||
</div>
|
||||
{item.status === "completed" && item.summary ? (
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{item.summary}</p>
|
||||
) : null}
|
||||
{item.error ? (
|
||||
<p className="whitespace-pre-wrap break-words text-destructive text-sm">{item.error}</p>
|
||||
) : null}
|
||||
{item.notes ? (
|
||||
<p className="whitespace-pre-wrap break-words text-muted-foreground text-xs">
|
||||
{t("notes", { notes: item.notes })}
|
||||
</p>
|
||||
) : null}
|
||||
{item.status === "completed" && item.evidence ? (
|
||||
<details className="min-w-0">
|
||||
<summary className="cursor-pointer text-sm">{t("evidence")}</summary>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<Markdown text={item.evidence} />
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</CardContent>
|
||||
{open ? (
|
||||
<FindingRetestDialog
|
||||
findingId={findingId}
|
||||
findingName={findingName}
|
||||
onClose={() => setOpen(false)}
|
||||
onStarted={(retest) => {
|
||||
requestSeq.current++;
|
||||
const rows = [retest, ...previousItems.current.filter((item) => item.id !== retest.id)];
|
||||
previousItems.current = rows;
|
||||
setItems(rows);
|
||||
setError("");
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
|
||||
import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingTraffic, FindingTrafficBinding, TrafficEvidenceRole } from "@/lib/types";
|
||||
|
||||
// Role values in display order; labels resolve via t(`traffic.role.${value}`) at render time.
|
||||
const ROLE_VALUES: TrafficEvidenceRole[] = ["baseline", "proof", "verification", "supporting"];
|
||||
|
||||
export function FindingTrafficPanel({
|
||||
findingId,
|
||||
contextTask,
|
||||
readOnly,
|
||||
onChanged,
|
||||
}: {
|
||||
findingId: string;
|
||||
contextTask?: string;
|
||||
readOnly?: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const t = useTranslations("findings");
|
||||
const [data, setData] = React.useState<FindingTraffic | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
|
||||
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
|
||||
const [note, setNote] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [reload, setReload] = React.useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: retry token intentionally refreshes this request.
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
setData(null);
|
||||
setError("");
|
||||
api
|
||||
.findingTraffic(findingId, contextTask)
|
||||
.then((value) => {
|
||||
if (active) setData(value);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (active) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [findingId, contextTask, reload]);
|
||||
|
||||
async function mutate(action: () => Promise<FindingTraffic>) {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await action());
|
||||
setEditing(null);
|
||||
onChanged();
|
||||
toast.success(t("traffic.updated"));
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
setReload((n) => n + 1);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
if (!data) return;
|
||||
const ids = data.bindings.map((b) => b.id);
|
||||
[ids[index], ids[index + delta]] = [ids[index + delta], ids[index]];
|
||||
void mutate(() => api.orderFindingTraffic(findingId, ids, data.version, contextTask));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<CardTitle>
|
||||
{t("traffic.title")} {data ? `(${data.bindings.length})` : ""}
|
||||
</CardTitle>
|
||||
{!readOnly ? (
|
||||
<Button variant="outline" size="sm" disabled={!data || busy} onClick={() => setAdding(true)}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
{t("traffic.bind")}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant="outline">{t("traffic.inherited")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>{t("traffic.description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{error}
|
||||
<Button variant="link" onClick={() => setReload((n) => n + 1)}>
|
||||
{t("traffic.retry")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!data ? (
|
||||
<Skeleton className="h-28 w-full" />
|
||||
) : data.bindings.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{t("traffic.empty")}</EmptyTitle>
|
||||
<EmptyDescription>{t("traffic.emptyHint")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : (
|
||||
data.bindings.map((b, index) => (
|
||||
<div key={b.id} className="flex min-w-0 flex-col gap-2 rounded-md border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">
|
||||
{index + 1}. {t(`traffic.role.${b.role}`)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{b.snapshot.method} · {b.snapshot.status}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">{t("traffic.evidenceNo", { id: b.id })}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start p-0 text-left whitespace-normal"
|
||||
onClick={() => setPreview(b.id)}
|
||||
>
|
||||
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
|
||||
</Button>
|
||||
{b.note ? <p className="text-sm whitespace-pre-wrap">{b.note}</p> : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(b.snapshot.captured_at * 1000).toLocaleString("ko-KR")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
|
||||
{t("traffic.viewRaw")}
|
||||
</Button>
|
||||
{!readOnly ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setEditing(b);
|
||||
setRole(b.role);
|
||||
setNote(b.note);
|
||||
}}
|
||||
>
|
||||
{t("traffic.editNote")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("traffic.moveUp", { id: b.id })}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("traffic.moveDown", { id: b.id })}
|
||||
disabled={busy || index === data.bindings.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void mutate(() => api.removeFindingTraffic(findingId, b.id, data.version, contextTask))
|
||||
}
|
||||
>
|
||||
{t("traffic.unbind")}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{adding && data ? (
|
||||
<TrafficPickerDialog
|
||||
findingId={findingId}
|
||||
contextTask={contextTask}
|
||||
bound={data.bindings}
|
||||
onClose={() => setAdding(false)}
|
||||
onBound={() => {
|
||||
setReload((n) => n + 1);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<TrafficEvidenceViewer
|
||||
findingId={findingId}
|
||||
bindingId={preview}
|
||||
contextTask={contextTask}
|
||||
onClose={() => setPreview(null)}
|
||||
/>
|
||||
<Dialog
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) setEditing(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("traffic.editTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("traffic.editDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="evidence-role">{t("traffic.roleLabel")}</FieldLabel>
|
||||
<Select value={role} onValueChange={(v) => setRole(v as TrafficEvidenceRole)}>
|
||||
<SelectTrigger id="evidence-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{ROLE_VALUES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`traffic.role.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="evidence-note">{t("traffic.noteLabel")}</FieldLabel>
|
||||
<Textarea id="evidence-note" value={note} onChange={(e) => setNote(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || !editing || !data}
|
||||
onClick={() => {
|
||||
if (editing && data)
|
||||
void mutate(() =>
|
||||
api.editFindingTraffic(findingId, editing.id, data.version, { role, note }, contextTask),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("traffic.saveNote")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
|
||||
import { CheckIcon, CopyIcon, WrapTextIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn, copyText } from "@/lib/utils";
|
||||
|
||||
function statusTone(status: number) {
|
||||
if (status >= 500) return "text-red-500";
|
||||
if (status >= 400) return "text-amber-500";
|
||||
if (status >= 300) return "text-blue-500";
|
||||
if (status >= 200) return "text-emerald-500";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function StartLine({ line }: { line: string }) {
|
||||
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
|
||||
if (response) {
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground">{response[1]}</span>
|
||||
{response[2]}
|
||||
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
|
||||
{response[4]}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const request = /^([A-Z]+)(\s+)(\S+)(\s+)(HTTP\/\S+)$/.exec(line);
|
||||
if (request) {
|
||||
return (
|
||||
<>
|
||||
<span className="font-semibold text-primary">{request[1]}</span>
|
||||
{request[2]}
|
||||
<span className="text-chart-2">{request[3]}</span>
|
||||
{request[4]}
|
||||
<span className="text-muted-foreground">{request[5]}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return line;
|
||||
}
|
||||
|
||||
function HeaderLine({ line }: { line: string }) {
|
||||
const separator = line.indexOf(":");
|
||||
if (separator <= 0) return line;
|
||||
return (
|
||||
<>
|
||||
<span className="text-primary">{line.slice(0, separator)}</span>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<span className="text-chart-2">{line.slice(separator + 1)}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<span key={`${index}-${match[0].length}`} className={className}>
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
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][^>]*>|<!--[\s\S]*?-->/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(
|
||||
<span key={`${index}-${match[0].length}`} className="text-primary">
|
||||
{match[0]}
|
||||
</span>,
|
||||
);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (cursor < body.length) parts.push(body.slice(cursor));
|
||||
return parts;
|
||||
}
|
||||
|
||||
type BodyFormat = "json" | "markup" | "plain";
|
||||
|
||||
function detectBodyFormat(body: string): BodyFormat {
|
||||
const trimmed = body.trimStart();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json";
|
||||
if (trimmed.startsWith("<")) return "markup";
|
||||
return "plain";
|
||||
}
|
||||
|
||||
function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) {
|
||||
if (format === "json") return <JsonBody body={body} />;
|
||||
if (format === "markup") return <MarkupBody body={body} />;
|
||||
return body;
|
||||
}
|
||||
|
||||
export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
const t = useTranslations("trafficEvidence.code");
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const value = raw || t("empty");
|
||||
const lines = value.replaceAll("\r\n", "\n").split("\n");
|
||||
const separator = lines.indexOf("");
|
||||
const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : "";
|
||||
const bodyFormat = detectBodyFormat(body);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timer = window.setTimeout(() => setCopied(false), 1500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [copied]);
|
||||
|
||||
const copyPacket = async () => {
|
||||
const ok = await copyText(value);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
return;
|
||||
}
|
||||
toast.error(t("copyFailed"));
|
||||
};
|
||||
|
||||
const renderLine = (line: string, index: number) => {
|
||||
if (index === 0) return <StartLine line={line} />;
|
||||
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
|
||||
if (index === separator) return null;
|
||||
return <HighlightedBody body={line} format={bodyFormat} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
|
||||
<div className="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={wrapLines ? t("wrapOff") : t("wrapOn")}
|
||||
aria-pressed={wrapLines}
|
||||
onClick={() => setWrapLines((current) => !current)}
|
||||
>
|
||||
<WrapTextIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{wrapLines ? t("wrapOff") : t("wrapOn")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={copied ? t("copiedPacket") : t("copyPacket")}
|
||||
onClick={() => void copyPacket()}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{copied ? t("copied") : t("copyPacket")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
|
||||
<div
|
||||
role="textbox"
|
||||
aria-label={t("label")}
|
||||
aria-multiline="true"
|
||||
aria-readonly="true"
|
||||
tabIndex={0}
|
||||
className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onKeyDown={(event) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return;
|
||||
event.preventDefault();
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(event.currentTarget);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}}
|
||||
>
|
||||
{lines.map((line, index) => (
|
||||
<div
|
||||
key={`${index}-${line}`}
|
||||
data-line={index + 1}
|
||||
className="grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] leading-relaxed before:sticky before:left-0 before:self-stretch before:border-r before:bg-muted/20 before:px-1 before:text-right before:text-muted-foreground/60 before:content-[attr(data-line)]"
|
||||
>
|
||||
<code
|
||||
className={cn(
|
||||
"min-h-[1lh] min-w-0 pr-16 pl-1.5 [tab-size:4]",
|
||||
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
|
||||
)}
|
||||
>
|
||||
{renderLine(line, index)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding, FindingsPage } from "@/lib/types";
|
||||
|
||||
export function LinkTrafficDialog({
|
||||
trafficIds,
|
||||
onClose,
|
||||
onBound,
|
||||
}: {
|
||||
trafficIds: string[];
|
||||
onClose: () => void;
|
||||
onBound: () => void;
|
||||
}) {
|
||||
const t = useTranslations("linkTraffic");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [data, setData] = React.useState<FindingsPage | null>(null);
|
||||
const [selected, setSelected] = React.useState<Finding | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const timer = setTimeout(() => {
|
||||
api
|
||||
.findingsPage({ page, pageSize: 20, query })
|
||||
.then((d) => {
|
||||
if (active) setData(d);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (active) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [query, page]);
|
||||
async function save() {
|
||||
if (!selected?.finding_id) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.bindFindingTraffic(
|
||||
selected.finding_id,
|
||||
trafficIds.map((traffic_id) => ({ traffic_id })),
|
||||
);
|
||||
toast.success(t("boundToast", { count: trafficIds.length, id: selected.finding_id }));
|
||||
onBound();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description", { count: trafficIds.length })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="link-finding-query">{t("findLabel")}</FieldLabel>
|
||||
<Input
|
||||
id="link-finding-query"
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="flex max-h-[40vh] flex-col gap-2 overflow-auto" aria-busy={loading}>
|
||||
{(data?.items ?? []).map((f) => (
|
||||
<Button
|
||||
key={f.finding_id ?? f.id}
|
||||
variant={selected?.finding_id === f.finding_id ? "secondary" : "outline"}
|
||||
className="h-auto justify-start p-3 text-left whitespace-normal"
|
||||
aria-pressed={selected?.finding_id === f.finding_id}
|
||||
disabled={busy || !f.finding_id || f.inherited}
|
||||
onClick={() => setSelected(f)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col gap-1">
|
||||
<span>
|
||||
#{f.finding_id ?? f.id} · {f.name || f.vulnclass}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-xs text-muted-foreground">{f.summary}</span>
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{!data?.items.length ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{loading ? t("loading") : t("empty")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button variant="outline" size="sm" disabled={loading || page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t("prevPage")}
|
||||
</Button>
|
||||
<span className="text-xs">{t("pageInfo", { page, total: data?.total ?? 0 })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || page * 20 >= (data?.total ?? 0)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{selected
|
||||
? t("selected", { id: selected.finding_id ?? "", name: selected.name || selected.vulnclass })
|
||||
: t("selectPrompt")}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button disabled={busy || !selected} onClick={() => void save()}>
|
||||
{busy ? t("saving") : t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
// Tailwind-styled element overrides (no typography plugin in this project, so we
|
||||
// style each element). `node` is stripped — it's not a valid DOM attribute.
|
||||
const components: Components = {
|
||||
h1: ({ node, ...p }) => <div className="mt-2.5 text-base font-semibold" {...p} />,
|
||||
h2: ({ node, ...p }) => <div className="mt-2 text-sm font-semibold" {...p} />,
|
||||
h3: ({ node, ...p }) => <div className="mt-1.5 text-sm font-medium text-foreground/90" {...p} />,
|
||||
h4: ({ node, ...p }) => <div className="mt-1.5 text-sm font-medium text-foreground/90" {...p} />,
|
||||
p: ({ node, ...p }) => <p className="leading-relaxed" {...p} />,
|
||||
ul: ({ node, ...p }) => <ul className="my-1 list-disc space-y-0.5 pl-5" {...p} />,
|
||||
ol: ({ node, ...p }) => <ol className="my-1 list-decimal space-y-0.5 pl-5" {...p} />,
|
||||
strong: ({ node, ...p }) => <strong className="font-semibold" {...p} />,
|
||||
a: ({ node, ...p }) => (
|
||||
<a className="text-primary underline underline-offset-2" target="_blank" rel="noreferrer" {...p} />
|
||||
),
|
||||
hr: () => <hr className="my-2 border-border" />,
|
||||
blockquote: ({ node, ...p }) => (
|
||||
<blockquote className="my-1 border-l-2 border-border pl-2.5 text-muted-foreground" {...p} />
|
||||
),
|
||||
pre: ({ node, ...p }) => (
|
||||
<pre className="my-1.5 overflow-auto rounded-md bg-muted/70 p-2.5 text-xs leading-relaxed" {...p} />
|
||||
),
|
||||
code: ({ node, className, children, ...rest }) => {
|
||||
const block = /language-/.test(className || "");
|
||||
return block ? (
|
||||
<code className={"font-mono text-xs " + (className || "")} {...rest}>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className="break-all rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" {...rest}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table: ({ node, ...p }) => (
|
||||
<div className="my-1.5 overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs" {...p} />
|
||||
</div>
|
||||
),
|
||||
th: ({ node, ...p }) => <th className="border border-border bg-muted/60 px-2 py-1 text-left font-medium" {...p} />,
|
||||
td: ({ node, ...p }) => <td className="border border-border px-2 py-1 align-top" {...p} />,
|
||||
};
|
||||
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5 text-sm [&>*:first-child]:mt-0">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { AtSignIcon, ChevronRightIcon, Loader2Icon, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import { activeMention, type ChatMention, mentionSearch, mentionToken, selectedMentions } from "@/lib/chat-mentions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Props = Omit<React.ComponentProps<"textarea">, "value" | "onChange" | "ref"> & {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
inputGroup?: boolean;
|
||||
};
|
||||
|
||||
export function MentionTextarea({ value, onValueChange, onKeyDown, className, inputGroup, disabled, ...props }: Props) {
|
||||
const t = useTranslations("mentionTextarea");
|
||||
const textarea = React.useRef<HTMLTextAreaElement>(null);
|
||||
const composing = React.useRef(false);
|
||||
const pendingKeyboardIndex = React.useRef<number | null>(null);
|
||||
const [cursor, setCursor] = React.useState<number | null>(null);
|
||||
const [dismissed, setDismissed] = React.useState(false);
|
||||
const [activeIndex, setActiveIndex] = React.useState(0);
|
||||
const [result, setResult] = React.useState<{
|
||||
key: string;
|
||||
items: ChatMention[];
|
||||
next_cursor?: string;
|
||||
error?: string;
|
||||
loadingMore?: boolean;
|
||||
pageError?: string;
|
||||
} | null>(null);
|
||||
const request = React.useRef<{ key: string; controller: AbortController; loadingMore: boolean } | null>(null);
|
||||
const listId = React.useId();
|
||||
const active = cursor === null ? null : activeMention(value, cursor);
|
||||
const search = mentionSearch(active?.query ?? "");
|
||||
const open = !!active && !dismissed && !disabled;
|
||||
const categories = search.categories;
|
||||
const requestKey = open && !categories.length ? `${search.kind}:${search.query}` : "";
|
||||
const loading = !!requestKey && result?.key !== requestKey;
|
||||
const items = result?.key === requestKey ? result.items : [];
|
||||
const error = result?.key === requestKey ? result.error : undefined;
|
||||
const nextCursor = result?.key === requestKey ? result.next_cursor : undefined;
|
||||
const loadingMore = result?.key === requestKey && result.loadingMore;
|
||||
const pageError = result?.key === requestKey ? result.pageError : undefined;
|
||||
const count = categories.length || items.length;
|
||||
const selected = selectedMentions(value);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!requestKey) return;
|
||||
setResult(null);
|
||||
const controller = new AbortController();
|
||||
request.current = { key: requestKey, controller, loadingMore: false };
|
||||
const timer = setTimeout(() => {
|
||||
api.chatMentions(search.kind, search.query, controller.signal).then(
|
||||
(page) => {
|
||||
if (!controller.signal.aborted) setResult({ key: requestKey, ...page });
|
||||
},
|
||||
(error: Error) => {
|
||||
if (!controller.signal.aborted) setResult({ key: requestKey, items: [], error: error.message });
|
||||
},
|
||||
);
|
||||
}, 180);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [requestKey, search.kind, search.query]);
|
||||
|
||||
function loadMore() {
|
||||
const session = request.current;
|
||||
if (
|
||||
!nextCursor ||
|
||||
!session ||
|
||||
session.key !== requestKey ||
|
||||
session.controller.signal.aborted ||
|
||||
session.loadingMore
|
||||
)
|
||||
return;
|
||||
session.loadingMore = true;
|
||||
setResult((current) =>
|
||||
current?.key === requestKey ? { ...current, loadingMore: true, pageError: undefined } : current,
|
||||
);
|
||||
api
|
||||
.chatMentions(search.kind, search.query, session.controller.signal, nextCursor)
|
||||
.then(
|
||||
(page) => {
|
||||
if (session.controller.signal.aborted) return;
|
||||
setResult((current) => {
|
||||
if (current?.key !== session.key) return current;
|
||||
const seen = new Set(current.items.map((item) => `${item.kind}:${item.id}`));
|
||||
return {
|
||||
...current,
|
||||
items: [...current.items, ...page.items.filter((item) => !seen.has(`${item.kind}:${item.id}`))],
|
||||
next_cursor: page.next_cursor,
|
||||
loadingMore: false,
|
||||
};
|
||||
});
|
||||
},
|
||||
(error: Error) => {
|
||||
if (!session.controller.signal.aborted)
|
||||
setResult((current) =>
|
||||
current?.key === session.key ? { ...current, loadingMore: false, pageError: error.message } : current,
|
||||
);
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
session.loadingMore = false;
|
||||
});
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) document.getElementById(`${listId}-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [activeIndex, listId, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && pendingKeyboardIndex.current !== null && pendingKeyboardIndex.current < count) {
|
||||
document.getElementById(`${listId}-${pendingKeyboardIndex.current}`)?.scrollIntoView({ block: "nearest" });
|
||||
pendingKeyboardIndex.current = null;
|
||||
}
|
||||
}, [count, listId, open]);
|
||||
|
||||
function replaceActive(text: string, close: boolean) {
|
||||
if (!active) return;
|
||||
const next = value.slice(0, active.start) + text + value.slice(active.end);
|
||||
const caret = active.start + text.length;
|
||||
onValueChange(next);
|
||||
setCursor(caret);
|
||||
setDismissed(close);
|
||||
setActiveIndex(0);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.current?.focus();
|
||||
textarea.current?.setSelectionRange(caret, caret);
|
||||
});
|
||||
}
|
||||
|
||||
function choose(index: number) {
|
||||
if (categories[index]) replaceActive(`@${categories[index].label} `, false);
|
||||
else if (items[index]) replaceActive(`${mentionToken(items[index])} `, true);
|
||||
}
|
||||
|
||||
function keyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229) return;
|
||||
if (open) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setDismissed(true);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (event.key === "ArrowDown" && count && activeIndex >= count - 1 && nextCursor) {
|
||||
pendingKeyboardIndex.current = count;
|
||||
setActiveIndex(count);
|
||||
loadMore();
|
||||
return;
|
||||
}
|
||||
if (count) setActiveIndex((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
|
||||
return;
|
||||
}
|
||||
if ((event.key === "Enter" || event.key === "Tab") && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
|
||||
if (event.key === "Tab" && (loading || !count)) {
|
||||
setDismissed(true);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!loading && count) choose(Math.min(activeIndex, count - 1));
|
||||
return;
|
||||
}
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
}
|
||||
|
||||
const Control = inputGroup ? InputGroupTextarea : Textarea;
|
||||
return (
|
||||
<div className="min-w-0 flex-1 self-stretch">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setDismissed(true);
|
||||
}}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<div>
|
||||
<Control
|
||||
{...props}
|
||||
ref={textarea}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
aria-label={props["aria-label"] ?? t("inputAria")}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={open ? listId : undefined}
|
||||
aria-expanded={open}
|
||||
aria-activedescendant={open && count ? `${listId}-${Math.min(activeIndex, count - 1)}` : undefined}
|
||||
onChange={(event) => {
|
||||
pendingKeyboardIndex.current = null;
|
||||
onValueChange(event.target.value);
|
||||
setCursor(event.target.selectionStart);
|
||||
setDismissed(false);
|
||||
setActiveIndex(0);
|
||||
}}
|
||||
onSelect={(event) => setCursor(event.currentTarget.selectionStart)}
|
||||
onCompositionStart={() => {
|
||||
composing.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composing.current = false;
|
||||
}}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-[min(28rem,calc(100vw-2rem))] gap-1 p-1.5"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => {
|
||||
if (event.target === textarea.current) event.preventDefault();
|
||||
}}
|
||||
aria-label={t("listboxAria")}
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-xs">
|
||||
<span>
|
||||
{categories.length
|
||||
? t("pickKind")
|
||||
: t("searchKind", {
|
||||
kind: search.kind ? t(`kind.${search.kind}`) : t("allRecords"),
|
||||
})}
|
||||
</span>
|
||||
<span>{t("hintKeys")}</span>
|
||||
</div>
|
||||
<div
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label={t("candidatesAria")}
|
||||
className="max-h-60 overflow-y-auto"
|
||||
onScroll={(event) => {
|
||||
const list = event.currentTarget;
|
||||
if (list.scrollHeight - list.scrollTop - list.clientHeight < 64) loadMore();
|
||||
}}
|
||||
>
|
||||
{categories.map((kind, index) => (
|
||||
<button
|
||||
key={kind.kind}
|
||||
id={`${listId}-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={activeIndex === index}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-accent",
|
||||
activeIndex === index && "bg-accent",
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
<AtSignIcon className="size-4 text-muted-foreground" />
|
||||
<span>{t(`kind.${kind.kind}`)}</span>
|
||||
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
{!categories.length && loading && (
|
||||
<div role="status" className="flex items-center gap-2 p-3 text-muted-foreground text-sm">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
{t("searching")}
|
||||
</div>
|
||||
)}
|
||||
{!categories.length && !loading && error && (
|
||||
<div role="alert" className="p-3 text-destructive text-sm">
|
||||
{t("searchError", { error })}
|
||||
</div>
|
||||
)}
|
||||
{!categories.length && !loading && !error && !items.length && (
|
||||
<div role="status" className="p-3 text-muted-foreground text-sm">
|
||||
{t("noMatch")}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={`${item.kind}:${item.id}`}
|
||||
id={`${listId}-${index}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={activeIndex === index}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-0.5 rounded-md px-2 py-2 text-left hover:bg-accent",
|
||||
activeIndex === index && "bg-accent",
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => choose(index)}
|
||||
>
|
||||
<span className="flex w-full items-center gap-2">
|
||||
<Badge variant="outline">{t(`kind.${item.kind}`)}</Badge>
|
||||
<span className="truncate text-sm">{item.label}</span>
|
||||
<span className="ml-auto shrink-0 text-muted-foreground text-xs">#{item.id}</span>
|
||||
</span>
|
||||
<span className="w-full truncate text-muted-foreground text-xs">{item.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!categories.length && (
|
||||
<p className="px-2 py-1 text-muted-foreground text-xs">
|
||||
{loadingMore && <span role="status">{t("loadingMore")}</span>}
|
||||
{!loadingMore && nextCursor && (
|
||||
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={loadMore}>
|
||||
{pageError ? t("pageError") : t("showingMore", { count: items.length })}
|
||||
</button>
|
||||
)}
|
||||
{!loadingMore &&
|
||||
!nextCursor &&
|
||||
!loading &&
|
||||
!error &&
|
||||
items.length > 0 &&
|
||||
t("showingAll", { count: items.length })}
|
||||
{!loadingMore && !nextCursor && (loading || !!error || items.length === 0) && t("searchHint")}
|
||||
</p>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1 px-1 pt-1">
|
||||
{selected.map((item) => {
|
||||
const label = `${t(`kind.${item.kind}`)} #${item.id}${item.text ? ` · ${item.text}` : ""}`;
|
||||
return (
|
||||
<Badge key={item.start} variant="secondary" className="max-w-full gap-1">
|
||||
<span className="max-w-64 truncate" title={label}>
|
||||
{label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label={t("removeAria", { label })}
|
||||
onClick={() => {
|
||||
onValueChange(value.slice(0, item.start) + value.slice(item.start + item.token.length));
|
||||
setCursor(null);
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
<span className="text-muted-foreground text-xs">{t("selectedHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MAX_COMPANY_SCOPE_VALUE_LENGTH, type ParsedCompanyScopeText } from "@/lib/company-scope";
|
||||
import type { CompanyScopeKind } from "@/lib/types";
|
||||
|
||||
// 라벨은 렌더 시점에 t(`kind.${kind}`) 로 해석(아래 배지).
|
||||
const SCOPE_KINDS: CompanyScopeKind[] = ["domain", "ip", "cidr", "icp", "keyword"];
|
||||
|
||||
export function ScopeTextEditor({
|
||||
id,
|
||||
value,
|
||||
onValueChange,
|
||||
parsed,
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
id: string;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
parsed: ParsedCompanyScopeText;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const t = useTranslations("scopeEditor");
|
||||
const counts = React.useMemo(() => {
|
||||
const result = new Map<CompanyScopeKind, number>();
|
||||
for (const rule of parsed.rules) result.set(rule.kind, (result.get(rule.kind) ?? 0) + 1);
|
||||
return result;
|
||||
}, [parsed.rules]);
|
||||
// company-scope.ts 는 번역할 수 없는 순수 모듈이라 오류 "코드"만 돌려준다. 여기서
|
||||
// 코드별 메시지로 해석한다(tooLong 은 최대 글자 수를 함께 보간).
|
||||
const errorText = React.useCallback(
|
||||
(code: string) => {
|
||||
if (code === "tooLong") return t("error.tooLong", { max: MAX_COMPANY_SCOPE_VALUE_LENGTH });
|
||||
const key = `error.${code}`;
|
||||
return t.has(key) ? t(key) : code;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Field data-invalid={parsed.errors.length > 0}>
|
||||
<FieldLabel htmlFor={id}>{label ?? t("defaultLabel")}</FieldLabel>
|
||||
<FieldDescription>{description ?? t("defaultDescription")}</FieldDescription>
|
||||
<Textarea
|
||||
id={id}
|
||||
rows={8}
|
||||
value={value}
|
||||
aria-invalid={parsed.errors.length > 0}
|
||||
placeholder={t("placeholder")}
|
||||
className="min-h-36 resize-y font-mono text-sm"
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
/>
|
||||
{parsed.rules.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs">
|
||||
<span>{t("recognized", { count: parsed.rules.length })}</span>
|
||||
{SCOPE_KINDS.map((kind) => {
|
||||
const count = counts.get(kind) ?? 0;
|
||||
return count > 0 ? (
|
||||
<Badge key={kind} variant="secondary" className="font-mono tabular-nums">
|
||||
{t(`kind.${kind}`)} {count}
|
||||
</Badge>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{parsed.errors.length > 0 && (
|
||||
<FieldError>
|
||||
{parsed.errors.slice(0, 5).map((item) => (
|
||||
<span key={`${item.line}-${item.error}`} className="block">
|
||||
{t("lineError", { line: item.line, error: errorText(item.error) })}
|
||||
</span>
|
||||
))}
|
||||
{parsed.errors.length > 5 && (
|
||||
<span className="block">{t("moreErrors", { count: parsed.errors.length - 5 })}</span>
|
||||
)}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { ArrowUpIcon, MessageCircleQuestionIcon, SquareIcon, Trash2Icon, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import type { SideQuestions } from "@/hooks/use-side-questions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ComposerLayout = "inline" | "stacked";
|
||||
|
||||
export function SideQuestionButton({ side }: { side: SideQuestions }) {
|
||||
const t = useTranslations("sideQuestion");
|
||||
if (!side.enabled) return null;
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title={t("buttonTitle")}>
|
||||
<MessageCircleQuestionIcon data-icon="inline-start" />
|
||||
{t("button")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanel({
|
||||
side,
|
||||
label,
|
||||
composerLayout,
|
||||
}: {
|
||||
side: SideQuestions;
|
||||
label: string;
|
||||
composerLayout: ComposerLayout;
|
||||
}) {
|
||||
const t = useTranslations("sideQuestion");
|
||||
const inlineComposer = composerLayout === "inline";
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
const pinned = useRef(true);
|
||||
const tail = side.items.at(-1);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: New cumulative text scrolls only readers who remain at the bottom.
|
||||
useEffect(() => {
|
||||
if (pinned.current && viewport.current) viewport.current.scrollTop = viewport.current.scrollHeight;
|
||||
}, [tail?.answer, tail?.id]);
|
||||
return (
|
||||
<section className="flex h-full min-h-0 flex-col bg-background" aria-label={t("panelLabel")}>
|
||||
<div className="flex items-center gap-2 border-b p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{t("title")} <span className="text-muted-foreground">/btw</span>
|
||||
</p>
|
||||
<p className="truncate text-muted-foreground text-xs">{label}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setConfirm(true)}
|
||||
disabled={!side.items.length || side.busy}
|
||||
aria-label={t("clearHistory")}
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={() => side.setOpen(false)} aria-label={t("closePanel")}>
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-b px-3 py-2 text-muted-foreground text-xs">
|
||||
{side.snapshot ? (
|
||||
<>
|
||||
<p>{side.snapshot.model.model}</p>
|
||||
<p>{t("contextUpdatedAt", { time: new Date(side.snapshot.captured_at).toLocaleString() })}</p>
|
||||
</>
|
||||
) : (
|
||||
t("firstRunHint")
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={viewport}
|
||||
onScroll={(event) => {
|
||||
const el = event.currentTarget;
|
||||
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
}}
|
||||
className="min-h-0 flex-1 overflow-y-auto p-3"
|
||||
>
|
||||
{side.nextCursor > 0 && (
|
||||
<Button variant="ghost" size="sm" onClick={() => void side.load(side.nextCursor)}>
|
||||
{t("loadEarlier")}
|
||||
</Button>
|
||||
)}
|
||||
{side.loading && <Skeleton className="h-16 w-full" />}
|
||||
{!side.loading && side.items.length === 0 && (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{t("emptyTitle")}</EmptyTitle>
|
||||
<EmptyDescription>{t("emptyDesc")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
<div className="flex flex-col gap-5">
|
||||
{side.items.map((item) => (
|
||||
<article key={item.id} className="flex min-w-0 flex-col gap-2">
|
||||
<div className="whitespace-pre-wrap break-words rounded-lg bg-muted p-3 text-sm">{item.question}</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
|
||||
<Badge variant="secondary">{t(`status.${item.status}`)}</Badge>
|
||||
<span className="truncate">{item.model.model}</span>
|
||||
<time dateTime={item.snapshot_at} title={new Date(item.snapshot_at).toLocaleString()}>
|
||||
{t("contextTime", { time: new Date(item.snapshot_at).toLocaleTimeString() })}
|
||||
</time>
|
||||
</div>
|
||||
{item.context?.estimated_input_tokens != null && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("recentExchanges", { count: item.context.recent_exchanges })}
|
||||
{item.context.history_summarized && ` · ${t("historySummarized")}`}
|
||||
{item.context.snapshot_summarized && ` · ${t("snapshotSummarized")}`}
|
||||
</p>
|
||||
)}
|
||||
{item.answer && <Markdown text={item.answer} />}
|
||||
{!item.answer && item.status === "running" && (
|
||||
<p role="status" className="text-muted-foreground text-sm">
|
||||
{t(`phase.${item.context?.phase ?? "answering"}`)}
|
||||
</p>
|
||||
)}
|
||||
{item.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{item.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 border-t p-3">
|
||||
{(side.error || side.snapshot?.reason) && (
|
||||
<Alert variant="destructive" className="mb-2">
|
||||
<AlertDescription>{side.error || side.snapshot?.reason}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<InputGroup className={inlineComposer ? "min-h-10" : "min-h-9"}>
|
||||
<InputGroupTextarea
|
||||
rows={1}
|
||||
className={cn("overflow-y-auto", inlineComposer ? "max-h-40 min-h-0" : "max-h-36 min-h-9")}
|
||||
aria-label={t("questionAria")}
|
||||
placeholder={t("askPlaceholder")}
|
||||
value={side.draft}
|
||||
maxLength={4000}
|
||||
disabled={side.busy}
|
||||
onChange={(event) => side.setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
void side.ask(side.draft);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<InputGroupAddon align={inlineComposer ? "inline-end" : "block-end"}>
|
||||
{!inlineComposer && <span className="text-muted-foreground text-xs">{t("independentNote")}</span>}
|
||||
{side.running ? (
|
||||
<InputGroupButton
|
||||
className="ml-auto"
|
||||
variant="destructive"
|
||||
size="icon-xs"
|
||||
onClick={() => void side.stop()}
|
||||
aria-label={t("stopAnswer")}
|
||||
>
|
||||
<SquareIcon />
|
||||
</InputGroupButton>
|
||||
) : (
|
||||
<InputGroupButton
|
||||
className="ml-auto"
|
||||
variant="default"
|
||||
size="icon-xs"
|
||||
onClick={() => void side.ask(side.draft)}
|
||||
disabled={side.busy || !side.draft.trim() || !side.snapshot?.available}
|
||||
aria-label={t("sendQuestion")}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
{inlineComposer && (
|
||||
<div className="shrink-0 truncate px-3 pt-0.5 pb-1 text-muted-foreground text-xs">{t("independentNote")}</div>
|
||||
)}
|
||||
<AlertDialog open={confirm} onOpenChange={setConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("clearConfirmTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("clearConfirmDesc")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => void side.clear()}>{t("clearConfirm")}</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SideQuestionWorkspace({
|
||||
side,
|
||||
label,
|
||||
children,
|
||||
composerLayout = "stacked",
|
||||
}: {
|
||||
side: SideQuestions;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
composerLayout?: ComposerLayout;
|
||||
}) {
|
||||
const t = useTranslations("sideQuestion");
|
||||
const mobile = useIsMobile();
|
||||
return (
|
||||
<>
|
||||
<ResizablePanelGroup orientation="horizontal" className="min-h-0 min-w-0 flex-1">
|
||||
<ResizablePanel id="main-conversation" minSize="35%" className="flex min-h-0 min-w-0 flex-col">
|
||||
{children}
|
||||
</ResizablePanel>
|
||||
{side.open && side.enabled && !mobile && (
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel id="side-question" defaultSize="38%" minSize="280px" maxSize="65%">
|
||||
<SidePanel side={side} label={label} composerLayout={composerLayout} />
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
</ResizablePanelGroup>
|
||||
<Drawer open={mobile && side.open && side.enabled} onOpenChange={side.setOpen}>
|
||||
<DrawerContent className="h-[85svh]">
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>{t("title")}</DrawerTitle>
|
||||
<DrawerDescription>{t("drawerDesc", { label })}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<SidePanel side={side} label={label} composerLayout={composerLayout} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type * as React from "react";
|
||||
|
||||
import type { SimpleIcon as SimpleIconType } from "simple-icons";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SimpleIconProps = {
|
||||
icon: SimpleIconType;
|
||||
className?: string;
|
||||
} & React.SVGProps<SVGSVGElement>;
|
||||
|
||||
export function SimpleIcon({ icon, className, ...props }: SimpleIconProps) {
|
||||
const { title, path } = icon;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-label={title}
|
||||
aria-hidden="false"
|
||||
focusable="false"
|
||||
className={cn("size-5 fill-foreground", className)}
|
||||
{...props}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<path d={path} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { type StatusDomain, statusMeta, type Tone, toneClasses, toneDot } from "@/lib/status";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function StatusBadge({
|
||||
domain,
|
||||
value,
|
||||
dot = false,
|
||||
className,
|
||||
}: {
|
||||
domain: StatusDomain;
|
||||
value: string;
|
||||
dot?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const meta = statusMeta(domain, value);
|
||||
// Shared status labels live in the "status" message namespace (ko/zh);
|
||||
// lib/status.ts only carries the color tone. The raw value is the last-resort
|
||||
// fallback for an unknown status (every real status.* key exists in ko/zh).
|
||||
const t = useTranslations("status");
|
||||
const key = `${domain}.${value}`;
|
||||
const label = t.has(key) ? t(key) : value;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium whitespace-nowrap",
|
||||
toneClasses[meta.tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{dot && <span className={cn("size-1.5 rounded-full", toneDot[meta.tone])} />}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToneDot({ tone }: { tone: Tone }) {
|
||||
return <span className={cn("size-2 rounded-full", toneDot[tone])} />;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem } from "@/components/ui/pagination";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
interface TablePaginationProps {
|
||||
page: number; // 1-based
|
||||
pageSize: number;
|
||||
total: number; // total (after filtering)
|
||||
onPageChange: (p: number) => void;
|
||||
onPageSizeChange: (s: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
// pageWindows returns the sequence of page numbers / ellipsis to render.
|
||||
function pageWindows(page: number, total: number): (number | "...")[] {
|
||||
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
|
||||
const out: (number | "...")[] = [1];
|
||||
if (page > 3) out.push("...");
|
||||
const lo = Math.max(2, page - 1);
|
||||
const hi = Math.min(total - 1, page + 1);
|
||||
for (let i = lo; i <= hi; i++) out.push(i);
|
||||
if (page < total - 2) out.push("...");
|
||||
out.push(total);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function TablePagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 20, 50],
|
||||
}: TablePaginationProps) {
|
||||
const t = useTranslations("pagination");
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(Math.max(1, page), totalPages);
|
||||
const from = total === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
||||
const to = Math.min(safePage * pageSize, total);
|
||||
const onPageChangeRef = useRef(onPageChange);
|
||||
onPageChangeRef.current = onPageChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (page !== safePage) onPageChangeRef.current(safePage);
|
||||
}, [page, safePage]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-4 py-2 lg:px-6">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
|
||||
<SelectTrigger size="sm" className="h-7 w-16">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((s) => (
|
||||
<SelectItem key={s} value={String(s)}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>{t("perPage")}</span>
|
||||
{total > 0 ? (
|
||||
<span className="tabular-nums">{t("range", { from, to, total })}</span>
|
||||
) : (
|
||||
<span>{t("empty")}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination className="mx-0 w-auto justify-end">
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={safePage === 1}
|
||||
onClick={() => onPageChange(safePage - 1)}
|
||||
aria-label={t("prev")}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
{pageWindows(safePage, totalPages).map((p, i) =>
|
||||
p === "..." ? (
|
||||
<PaginationItem key={`el-${i}`}>
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
) : (
|
||||
<PaginationItem key={p}>
|
||||
<Button
|
||||
variant={p === safePage ? "outline" : "ghost"}
|
||||
size="icon-sm"
|
||||
onClick={() => onPageChange(p)}
|
||||
aria-current={p === safePage ? "page" : undefined}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
),
|
||||
)}
|
||||
<PaginationItem>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
disabled={safePage === totalPages}
|
||||
onClick={() => onPageChange(safePage + 1)}
|
||||
aria-label={t("next")}
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { LLMProfile } from "@/lib/types";
|
||||
|
||||
interface TaskLLMProfileChainProps {
|
||||
profiles: LLMProfile[];
|
||||
value: string[];
|
||||
onValueChange: (value: string[]) => void;
|
||||
activeProfileId?: string;
|
||||
onActiveProfileChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
inputId?: string;
|
||||
portalContainer?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
function ProfileRoleBadge({ index, currentIndex }: { index: number; currentIndex: number }) {
|
||||
const t = useTranslations("llmChain");
|
||||
if (index === currentIndex) return <Badge variant="default">{t("current")}</Badge>;
|
||||
if (index < currentIndex) {
|
||||
return (
|
||||
<Badge variant="outline" title={t("skippedTitle")}>
|
||||
{t("skipped")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">{t("backup", { n: index - currentIndex })}</Badge>;
|
||||
}
|
||||
|
||||
export function TaskLLMProfileChain({
|
||||
profiles,
|
||||
value,
|
||||
onValueChange,
|
||||
activeProfileId,
|
||||
onActiveProfileChange,
|
||||
disabled = false,
|
||||
inputId,
|
||||
portalContainer,
|
||||
}: TaskLLMProfileChainProps) {
|
||||
const t = useTranslations("llmChain");
|
||||
const profilesByID = React.useMemo(() => new Map(profiles.map((profile) => [profile.id, profile])), [profiles]);
|
||||
const itemIDs = React.useMemo(() => profiles.map((profile) => profile.id), [profiles]);
|
||||
const profilesUnavailable = profiles.length === 0;
|
||||
const currentProfileID = activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0];
|
||||
const currentIndex = value.indexOf(currentProfileID);
|
||||
// api_key_hint is display-only and is empty for valid keys shorter than four
|
||||
// characters. The task API remains authoritative for profile validation.
|
||||
|
||||
const profileLabel = React.useCallback(
|
||||
(id: string) => {
|
||||
const profile = profilesByID.get(id);
|
||||
return profile ? `${profile.name} ${profile.model}` : t("profileRef", { id });
|
||||
},
|
||||
[profilesByID, t],
|
||||
);
|
||||
|
||||
const move = (index: number, offset: -1 | 1) => {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= value.length) return;
|
||||
const next = [...value];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onValueChange(next);
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
const next = value.filter((profileID) => profileID !== id);
|
||||
onValueChange(next);
|
||||
if (activeProfileId === id) onActiveProfileChange?.(next[0] ?? "");
|
||||
};
|
||||
|
||||
const handleValueChange = (next: string[]) => {
|
||||
onValueChange(next);
|
||||
if (activeProfileId && !next.includes(activeProfileId)) {
|
||||
onActiveProfileChange?.(next[0] ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Combobox
|
||||
items={itemIDs}
|
||||
itemToStringValue={profileLabel}
|
||||
multiple
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxChips>
|
||||
<ComboboxValue>
|
||||
{value.map((id) => (
|
||||
<ComboboxChip key={id}>{profilesByID.get(id)?.name ?? `#${id}`}</ComboboxChip>
|
||||
))}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput
|
||||
id={inputId}
|
||||
placeholder={profiles.length > 0 ? t("searchPlaceholder") : t("noProfiles")}
|
||||
disabled={disabled ? true : profilesUnavailable}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent portalContainer={portalContainer}>
|
||||
<ComboboxEmpty>{t("noMatch")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id) => {
|
||||
const profile = profilesByID.get(id);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} disabled={!profile}>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">
|
||||
{profile?.name ?? t("profileRef", { id })}
|
||||
{profile?.is_default ? t("activeSuffix") : ""}
|
||||
</span>
|
||||
</span>
|
||||
{profile && <span className="truncate text-muted-foreground text-xs">{profile.model}</span>}
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
|
||||
{value.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">{t("emptyHint")}</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y rounded-lg border">
|
||||
{value.map((id, index) => {
|
||||
const profile = profilesByID.get(id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-2 sm:flex"
|
||||
>
|
||||
<span className="w-5 shrink-0 text-center text-muted-foreground text-xs tabular-nums">{index + 1}</span>
|
||||
<div className="min-w-0 sm:flex-1">
|
||||
<p className="truncate font-medium text-sm">{profile?.name ?? t("profileRef", { id })}</p>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-muted-foreground text-xs">{profile?.model ?? t("unavailable")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileRoleBadge index={index} currentIndex={currentIndex} />
|
||||
<div className="col-span-2 col-start-2 flex shrink-0 items-center gap-1 justify-self-end sm:col-auto sm:justify-self-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("moveUp")}
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={disabled ? true : index === 0}
|
||||
>
|
||||
<ArrowUpIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("moveDown")}
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={disabled ? true : index === value.length - 1}
|
||||
>
|
||||
<ArrowDownIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={t("remove")}
|
||||
onClick={() => remove(id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<XIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onActiveProfileChange && value.length > 0 && (
|
||||
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span className="font-medium text-sm">{t("currentProfile")}</span>
|
||||
<Select
|
||||
value={activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0]}
|
||||
onValueChange={onActiveProfileChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-full sm:min-w-48 sm:max-w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{value.map((id) => {
|
||||
const profile = profilesByID.get(id);
|
||||
return (
|
||||
<SelectItem key={id} value={id} disabled={!profile}>
|
||||
{profile?.name ?? t("profileRef", { id })}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { LibraryIcon, PlusIcon, SaveIcon, Settings2Icon, Trash2Icon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { AssetInterceptRuleInput, TaskCategory, TaskTemplate } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TemplateDraft {
|
||||
name: string;
|
||||
description: string;
|
||||
goal: string;
|
||||
categoryID: number | null;
|
||||
interceptRules: AssetInterceptRuleInput[];
|
||||
}
|
||||
|
||||
// TemplateSeed 是「另存为模板」时从创建表单带入的初值。
|
||||
type TemplateSeed = Pick<TemplateDraft, "description" | "goal" | "categoryID" | "interceptRules">;
|
||||
|
||||
interface TaskTemplateManagerProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
templates: TaskTemplate[];
|
||||
seed: TemplateSeed | null;
|
||||
onCreated: (template: TaskTemplate) => void;
|
||||
onUpdated: (template: TaskTemplate) => void;
|
||||
onDeleted: (id: number) => void;
|
||||
}
|
||||
|
||||
const emptyDraft = (): TemplateDraft => ({
|
||||
name: "",
|
||||
description: "",
|
||||
goal: "",
|
||||
categoryID: null,
|
||||
interceptRules: [],
|
||||
});
|
||||
|
||||
function templateDraft(template: TaskTemplate): TemplateDraft {
|
||||
return {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
goal: template.goal,
|
||||
categoryID: template.category_id ?? null,
|
||||
interceptRules: template.intercept_rules ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function TaskTemplateManager({
|
||||
open,
|
||||
onOpenChange,
|
||||
templates,
|
||||
seed,
|
||||
onCreated,
|
||||
onUpdated,
|
||||
onDeleted,
|
||||
}: TaskTemplateManagerProps) {
|
||||
const t = useTranslations("taskTemplate");
|
||||
const [selectedID, setSelectedID] = React.useState<number | null>(null);
|
||||
const [draft, setDraft] = React.useState<TemplateDraft>(emptyDraft);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = React.useState(false);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
const [categories, setCategories] = React.useState<TaskCategory[]>([]);
|
||||
const wasOpen = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
api
|
||||
.taskCategories()
|
||||
.then(setCategories)
|
||||
.catch(() => setCategories([]));
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open && !wasOpen.current) {
|
||||
if (seed) {
|
||||
setSelectedID(null);
|
||||
setDraft({
|
||||
name: "",
|
||||
description: seed.description,
|
||||
goal: seed.goal,
|
||||
categoryID: seed.categoryID,
|
||||
interceptRules: seed.interceptRules,
|
||||
});
|
||||
} else if (templates[0]) {
|
||||
setSelectedID(templates[0].id);
|
||||
setDraft(templateDraft(templates[0]));
|
||||
} else {
|
||||
setSelectedID(null);
|
||||
setDraft(emptyDraft());
|
||||
}
|
||||
}
|
||||
wasOpen.current = open;
|
||||
}, [open, seed, templates]);
|
||||
|
||||
const selectTemplate = (template: TaskTemplate) => {
|
||||
setSelectedID(template.id);
|
||||
setDraft(templateDraft(template));
|
||||
};
|
||||
|
||||
const startNew = () => {
|
||||
setSelectedID(null);
|
||||
setDraft(emptyDraft());
|
||||
};
|
||||
|
||||
const updateDraft = (field: "name" | "description" | "goal", value: string) => {
|
||||
setDraft((current) => ({ ...current, [field]: value }));
|
||||
};
|
||||
|
||||
const patchDraft = (patch: Partial<TemplateDraft>) => {
|
||||
setDraft((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
async function save() {
|
||||
const input = {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim(),
|
||||
goal: draft.goal.trim(),
|
||||
category_id: draft.categoryID,
|
||||
intercept_rules: draft.interceptRules
|
||||
.map((r) => ({ ...r, pattern: r.pattern.trim() }))
|
||||
.filter((r) => r.pattern !== ""),
|
||||
};
|
||||
if (!input.name || !input.description || !input.goal) {
|
||||
toast.error(t("manager.validation"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (selectedID == null) {
|
||||
const created = await api.createTaskTemplate(input);
|
||||
onCreated(created);
|
||||
setSelectedID(created.id);
|
||||
setDraft(templateDraft(created));
|
||||
toast.success(t("manager.created"));
|
||||
} else {
|
||||
const updated = await api.updateTaskTemplate(selectedID, input);
|
||||
onUpdated(updated);
|
||||
setDraft(templateDraft(updated));
|
||||
toast.success(t("manager.updated"));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("manager.saveError", { error: (error as Error).message }));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (selectedID == null) return;
|
||||
const deletedID = selectedID;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteTaskTemplate(deletedID);
|
||||
onDeleted(deletedID);
|
||||
const next = templates.find((template) => template.id !== deletedID);
|
||||
if (next) {
|
||||
setSelectedID(next.id);
|
||||
setDraft(templateDraft(next));
|
||||
} else {
|
||||
startNew();
|
||||
}
|
||||
setDeleteOpen(false);
|
||||
toast.success(t("manager.deleted"));
|
||||
} catch (error) {
|
||||
toast.error(t("manager.deleteError", { error: (error as Error).message }));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
let saveLabel = saving ? t("manager.saving") : t("manager.saveChanges");
|
||||
if (!saving && selectedID == null) saveLabel = t("manager.create");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
<SheetHeader className="border-b px-6 py-5">
|
||||
<SheetTitle>{t("manager.title")}</SheetTitle>
|
||||
<SheetDescription>{t("manager.description")}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
|
||||
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
|
||||
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
{t("manager.new")}
|
||||
</Button>
|
||||
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
|
||||
<div className="flex flex-col gap-1 pr-2">
|
||||
{templates.length === 0 && (
|
||||
<p className="px-2 py-6 text-center text-muted-foreground text-sm">{t("manager.noTemplates")}</p>
|
||||
)}
|
||||
{templates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"min-w-0 rounded-md px-2.5 py-2 text-left transition-colors",
|
||||
selectedID === template.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => selectTemplate(template)}
|
||||
>
|
||||
<span className="block truncate font-medium text-sm">{template.name}</span>
|
||||
<span className="block truncate text-muted-foreground text-xs">{template.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0">
|
||||
<FieldGroup className="p-6">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-name">{t("manager.nameLabel")}</FieldLabel>
|
||||
<Input
|
||||
id="task-template-name"
|
||||
value={draft.name}
|
||||
maxLength={120}
|
||||
placeholder={t("manager.namePlaceholder")}
|
||||
onChange={(event) => updateDraft("name", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-description">{t("manager.descriptionLabel")}</FieldLabel>
|
||||
<Textarea
|
||||
id="task-template-description"
|
||||
className="min-h-28"
|
||||
value={draft.description}
|
||||
placeholder={t("manager.descriptionPlaceholder")}
|
||||
onChange={(event) => updateDraft("description", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-goal">{t("manager.goalLabel")}</FieldLabel>
|
||||
<Textarea
|
||||
id="task-template-goal"
|
||||
className="min-h-28"
|
||||
value={draft.goal}
|
||||
placeholder={t("manager.goalPlaceholder")}
|
||||
onChange={(event) => updateDraft("goal", event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="task-template-category">{t("manager.categoryLabel")}</FieldLabel>
|
||||
<NativeSelect
|
||||
id="task-template-category"
|
||||
className="w-full"
|
||||
value={draft.categoryID == null ? "" : String(draft.categoryID)}
|
||||
onChange={(event) =>
|
||||
patchDraft({ categoryID: event.target.value === "" ? null : Number(event.target.value) })
|
||||
}
|
||||
>
|
||||
<NativeSelectOption value="">{t("manager.categoryUnclassified")}</NativeSelectOption>
|
||||
{categories.map((c) => (
|
||||
<NativeSelectOption key={c.id} value={String(c.id)}>
|
||||
{c.name}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<FieldDescription>{t("manager.categoryHint")}</FieldDescription>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>{t("manager.rulesLabel")}</FieldLabel>
|
||||
<AssetInterceptRulesEditor
|
||||
value={draft.interceptRules}
|
||||
onChange={(rules) => patchDraft({ interceptRules: rules })}
|
||||
/>
|
||||
<FieldDescription>{t("manager.rulesHint")}</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
|
||||
{selectedID != null && (
|
||||
<Button type="button" variant="destructive" className="sm:mr-auto" onClick={() => setDeleteOpen(true)}>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
{t("manager.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("manager.close")}
|
||||
</Button>
|
||||
<Button type="button" disabled={saving} onClick={() => void save()}>
|
||||
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
|
||||
{saveLabel}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("deleteDialog.title", { name: draft.name || t("deleteDialog.untitled") })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("deleteDialog.description")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>{t("deleteDialog.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
void remove();
|
||||
}}
|
||||
>
|
||||
{deleting && <Spinner data-icon="inline-start" />}
|
||||
{deleting ? t("deleteDialog.deleting") : t("deleteDialog.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskTemplateControlsProps {
|
||||
description: string;
|
||||
goal: string;
|
||||
categoryID?: number;
|
||||
interceptRules?: AssetInterceptRuleInput[];
|
||||
selectedTemplateID: number | null;
|
||||
onSelectedTemplateIDChange: (id: number | null) => void;
|
||||
onApply: (template: TaskTemplate) => void;
|
||||
portalContainer?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export function TaskTemplateControls({
|
||||
description,
|
||||
goal,
|
||||
categoryID,
|
||||
interceptRules,
|
||||
selectedTemplateID,
|
||||
onSelectedTemplateIDChange,
|
||||
onApply,
|
||||
portalContainer,
|
||||
}: TaskTemplateControlsProps) {
|
||||
const t = useTranslations("taskTemplate");
|
||||
const [templates, setTemplates] = React.useState<TaskTemplate[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [templateInputValue, setTemplateInputValue] = React.useState("");
|
||||
const [pendingTemplate, setPendingTemplate] = React.useState<TaskTemplate | null>(null);
|
||||
const [managerOpen, setManagerOpen] = React.useState(false);
|
||||
const [managerSeed, setManagerSeed] = React.useState<TemplateSeed | null>(null);
|
||||
|
||||
const loadTemplates = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setTemplates(await api.taskTemplates());
|
||||
} catch (error) {
|
||||
setTemplates([]);
|
||||
toast.error(t("picker.loadError", { error: (error as Error).message }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
const selectedTemplate = React.useMemo(
|
||||
() => templates.find((template) => template.id === selectedTemplateID) ?? null,
|
||||
[selectedTemplateID, templates],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTemplateInputValue(selectedTemplate?.name ?? "");
|
||||
}, [selectedTemplate]);
|
||||
|
||||
const applyTemplate = (template: TaskTemplate) => {
|
||||
onApply(template);
|
||||
onSelectedTemplateIDChange(template.id);
|
||||
setPendingTemplate(null);
|
||||
};
|
||||
|
||||
const chooseTemplate = (template: TaskTemplate | null) => {
|
||||
if (!template) {
|
||||
setTemplateInputValue("");
|
||||
onSelectedTemplateIDChange(null);
|
||||
return;
|
||||
}
|
||||
setTemplateInputValue(template.name);
|
||||
const hasContent = description.trim() !== "" || goal.trim() !== "";
|
||||
const changesContent = description !== template.description || goal !== template.goal;
|
||||
if (hasContent && changesContent) {
|
||||
setPendingTemplate(template);
|
||||
return;
|
||||
}
|
||||
applyTemplate(template);
|
||||
};
|
||||
|
||||
const openManager = (seed: TemplateSeed | null) => {
|
||||
setManagerSeed(seed);
|
||||
setManagerOpen(true);
|
||||
};
|
||||
|
||||
const upsertTemplate = (template: TaskTemplate) => {
|
||||
setTemplates((current) => [template, ...current.filter((item) => item.id !== template.id)]);
|
||||
};
|
||||
|
||||
const deleteTemplate = (id: number) => {
|
||||
setTemplates((current) => current.filter((template) => template.id !== id));
|
||||
if (selectedTemplateID === id) onSelectedTemplateIDChange(null);
|
||||
};
|
||||
|
||||
let pickerPlaceholder = loading ? t("picker.loading") : t("picker.empty");
|
||||
if (!loading && templates.length > 0) pickerPlaceholder = t("picker.search");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<FieldLabel htmlFor="task-template-picker">{t("picker.label")}</FieldLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => openManager(null)}>
|
||||
<Settings2Icon data-icon="inline-start" />
|
||||
{t("picker.manage")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!description.trim() || !goal.trim()}
|
||||
onClick={() =>
|
||||
openManager({
|
||||
description,
|
||||
goal,
|
||||
categoryID: categoryID ?? null,
|
||||
interceptRules: interceptRules ?? [],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SaveIcon data-icon="inline-start" />
|
||||
{t("picker.saveAs")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Combobox
|
||||
items={templates}
|
||||
itemToStringLabel={(template) => template.name}
|
||||
itemToStringValue={(template) => String(template.id)}
|
||||
inputValue={templateInputValue}
|
||||
onInputValueChange={(value) => setTemplateInputValue(value)}
|
||||
value={selectedTemplate}
|
||||
onValueChange={chooseTemplate}
|
||||
>
|
||||
<ComboboxInput
|
||||
id="task-template-picker"
|
||||
className="w-full"
|
||||
placeholder={pickerPlaceholder}
|
||||
disabled={loading || templates.length === 0}
|
||||
showClear
|
||||
/>
|
||||
<ComboboxContent portalContainer={portalContainer}>
|
||||
<ComboboxEmpty>{t("picker.noMatch")}</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(template) => (
|
||||
<ComboboxItem key={template.id} value={template}>
|
||||
<LibraryIcon />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-sm">{template.name}</p>
|
||||
{template.description && (
|
||||
<p className="truncate text-muted-foreground text-xs">{template.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldDescription>{t("picker.hint")}</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingTemplate != null}
|
||||
onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
setPendingTemplate(null);
|
||||
setTemplateInputValue(selectedTemplate?.name ?? "");
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("apply.title", { name: pendingTemplate?.name ?? "" })}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("apply.description")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("apply.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => pendingTemplate && applyTemplate(pendingTemplate)}>
|
||||
{t("apply.confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<TaskTemplateManager
|
||||
open={managerOpen}
|
||||
onOpenChange={setManagerOpen}
|
||||
templates={templates}
|
||||
seed={managerSeed}
|
||||
onCreated={upsertTemplate}
|
||||
onUpdated={upsertTemplate}
|
||||
onDeleted={deleteTemplate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { ListTodo } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// TodoPopover shows the latest TodoWrite state of a session (chat conversation or
|
||||
// task worker/planner replay). The full todo list isn't in the activity list
|
||||
// (detail is lazy), so on open it fetches the detail of the most-recent TodoWrite
|
||||
// call — by seq — and parses its {todos:[…]} JSON. Purely client-side; the caller
|
||||
// supplies the seq + a detail fetcher (conversation or exploration endpoint).
|
||||
export function TodoPopover({
|
||||
seq,
|
||||
fetchDetail,
|
||||
}: {
|
||||
seq: number | null;
|
||||
fetchDetail: (seq: number) => Promise<string>;
|
||||
}) {
|
||||
const tr = useTranslations("todoPopover");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [todos, setTodos] = React.useState<{ content: string; status: string }[] | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [err, setErr] = React.useState("");
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (seq == null) return;
|
||||
setLoading(true);
|
||||
setErr("");
|
||||
try {
|
||||
const detail = await fetchDetail(seq);
|
||||
const start = detail.indexOf("{"); // detail 可能带 "TodoWrite " 前缀
|
||||
const parsed = JSON.parse(start >= 0 ? detail.slice(start) : detail);
|
||||
setTodos(Array.isArray(parsed?.todos) ? parsed.todos : []);
|
||||
} catch {
|
||||
setErr(tr("parseError"));
|
||||
setTodos(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [seq, fetchDetail, tr]);
|
||||
|
||||
// refetch on each open — todos change as the run progresses.
|
||||
React.useEffect(() => {
|
||||
if (open) void load();
|
||||
}, [open, load]);
|
||||
|
||||
const disabled = seq == null;
|
||||
const MARK: Record<string, string> = { pending: "☐", in_progress: "▶", completed: "✔" };
|
||||
return (
|
||||
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabled ? tr("none") : tr("view")}
|
||||
className="text-muted-foreground/70 hover:text-primary flex items-center gap-0.5 text-xs disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<ListTodo className="size-3" />
|
||||
Todo
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="max-h-80 w-80 overflow-auto p-2">
|
||||
<p className="text-muted-foreground px-1 pb-1 text-[11px] font-medium">
|
||||
{tr("recent")}
|
||||
{loading ? tr("loadingSuffix") : ""}
|
||||
</p>
|
||||
{err && <p className="text-destructive px-1 text-xs">{err}</p>}
|
||||
{todos && todos.length === 0 && !loading && <p className="text-muted-foreground px-1 text-xs">{tr("empty")}</p>}
|
||||
<ul className="space-y-0.5">
|
||||
{(todos ?? []).map((t, i) => (
|
||||
<li
|
||||
key={`${i}:${t.content}`}
|
||||
className={cn(
|
||||
"flex gap-1.5 px-1 text-xs",
|
||||
t.status === "completed" && "text-muted-foreground line-through",
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">{MARK[t.status] ?? "☐"}</span>
|
||||
<span className="break-words">{t.content}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingTrafficDetail, TrafficDetail } from "@/lib/types";
|
||||
|
||||
export function TrafficEvidenceViewer({
|
||||
findingId,
|
||||
bindingId,
|
||||
contextTask,
|
||||
onClose,
|
||||
}: {
|
||||
findingId: string;
|
||||
bindingId: string | null;
|
||||
contextTask?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations("trafficEvidence");
|
||||
const [detail, setDetail] = React.useState<FindingTrafficDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (!bindingId) return;
|
||||
let active = true;
|
||||
setDetail(null);
|
||||
setError("");
|
||||
api
|
||||
.findingTrafficDetail(findingId, bindingId, contextTask)
|
||||
.then((d) => {
|
||||
if (active) setDetail(d);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (active) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [findingId, bindingId, contextTask]);
|
||||
|
||||
async function more(side: "request" | "response") {
|
||||
if (!detail || !bindingId) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const next = await api.findingTrafficBody(findingId, bindingId, side, detail[side].next_offset, contextTask);
|
||||
setDetail((current) =>
|
||||
current?.binding.id === bindingId
|
||||
? { ...current, [side]: { ...next, content: current[side].content + next.content } }
|
||||
: current,
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={bindingId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("viewer.title", { id: bindingId ?? "" })}</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{detail?.binding.snapshot.url ?? t("viewer.fallbackDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">{t("viewer.request")}</TabsTrigger>
|
||||
<TabsTrigger value="response">{t("viewer.response")}</TabsTrigger>
|
||||
</TabsList>
|
||||
{(["request", "response"] as const).map((side) => (
|
||||
<TabsContent key={side} value={side}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("viewer.bodyBytes", { bytes: detail[side].total.toLocaleString() })}
|
||||
{detail[side].truncated ? t("viewer.previewSuffix") : ""}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
void api
|
||||
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
|
||||
.catch((e: Error) => toast.error(e.message))
|
||||
}
|
||||
>
|
||||
{side === "request" ? t("viewer.downloadRequest") : t("viewer.downloadResponse")}
|
||||
</Button>
|
||||
</div>
|
||||
<HttpCodeBlock
|
||||
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
|
||||
/>
|
||||
{detail[side].truncated && !detail[side].binary ? (
|
||||
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
|
||||
{t("viewer.loadMore")}
|
||||
</Button>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onClose: () => void }) {
|
||||
const t = useTranslations("trafficEvidence");
|
||||
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
React.useEffect(() => {
|
||||
if (!id) return;
|
||||
let active = true;
|
||||
setDetail(null);
|
||||
setError("");
|
||||
api
|
||||
.trafficExchange(id)
|
||||
.then((d) => {
|
||||
if (active) setDetail(d);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (active) setError(e.message);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [id]);
|
||||
return (
|
||||
<Dialog
|
||||
open={id !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("captured.title")}</DialogTitle>
|
||||
<DialogDescription>{t("captured.description", { id: id ?? "" })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : detail ? (
|
||||
<Tabs defaultValue="request">
|
||||
<TabsList>
|
||||
<TabsTrigger value="request">{t("viewer.request")}</TabsTrigger>
|
||||
<TabsTrigger value="response">{t("viewer.response")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="request">
|
||||
<HttpCodeBlock raw={detail.req} />
|
||||
</TabsContent>
|
||||
<TabsContent value="response">
|
||||
<HttpCodeBlock raw={detail.resp} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<Skeleton className="h-56 w-full" />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { CapturedTrafficViewer } from "@/components/traffic-evidence-viewer";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingTrafficBinding, TrafficResp } from "@/lib/types";
|
||||
|
||||
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
|
||||
|
||||
export function TrafficPickerDialog({
|
||||
findingId,
|
||||
contextTask,
|
||||
bound,
|
||||
onClose,
|
||||
onBound,
|
||||
}: {
|
||||
findingId: string;
|
||||
contextTask?: string;
|
||||
bound: FindingTrafficBinding[];
|
||||
onClose: () => void;
|
||||
onBound: () => void;
|
||||
}) {
|
||||
const t = useTranslations("trafficEvidence");
|
||||
const [host, setHost] = React.useState("");
|
||||
const [method, setMethod] = React.useState("all");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [data, setData] = React.useState<TrafficResp | null>(null);
|
||||
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
|
||||
const [preview, setPreview] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
const alreadyBound = new Set(bound.map((b) => b.snapshot.source_traffic_id));
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
const timer = setTimeout(() => {
|
||||
api
|
||||
.traffic(page, 25, host, method === "all" ? "" : method, query)
|
||||
.then((d) => {
|
||||
if (active) setData(d);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (active) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [page, host, method, query]);
|
||||
|
||||
const rows = data?.exchanges ?? [];
|
||||
const selectable = rows.filter((e) => !alreadyBound.has(e.id));
|
||||
function toggle(id: string, checked: boolean) {
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await api.bindFindingTraffic(
|
||||
findingId,
|
||||
[...selected].map((traffic_id) => ({ traffic_id })),
|
||||
contextTask,
|
||||
);
|
||||
toast.success(t("picker.boundToast", { count: selected.size }));
|
||||
onBound();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !busy) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("picker.title")}</DialogTitle>
|
||||
<DialogDescription>{t("picker.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup className="flex flex-col gap-3 sm:flex-row">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="evidence-host">{t("picker.host")}</FieldLabel>
|
||||
<Input
|
||||
id="evidence-host"
|
||||
value={host}
|
||||
placeholder={t("picker.hostPlaceholder")}
|
||||
onChange={(e) => {
|
||||
setHost(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="evidence-method">{t("picker.method")}</FieldLabel>
|
||||
<Select
|
||||
value={method}
|
||||
onValueChange={(v) => {
|
||||
setMethod(v);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="evidence-method">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">{t("picker.allMethods")}</SelectItem>
|
||||
{METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="evidence-query">{t("picker.keyword")}</FieldLabel>
|
||||
<Input
|
||||
id="evidence-query"
|
||||
value={query}
|
||||
placeholder={t("picker.keywordPlaceholder")}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="max-h-[45vh] overflow-auto rounded-md border" aria-busy={loading}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Checkbox
|
||||
aria-label={t("picker.selectPage")}
|
||||
disabled={loading || busy || selectable.length === 0}
|
||||
checked={selectable.length > 0 && selectable.every((e) => selected.has(e.id))}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous);
|
||||
for (const e of selectable) {
|
||||
if (checked === true) next.add(e.id);
|
||||
else next.delete(e.id);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>{t("picker.colMethodUrl")}</TableHead>
|
||||
<TableHead>{t("picker.colTime")}</TableHead>
|
||||
<TableHead>{t("picker.colStatus")}</TableHead>
|
||||
<TableHead>{t("picker.colAction")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((e) => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
aria-label={t("picker.selectOne", { id: e.id })}
|
||||
checked={selected.has(e.id) || alreadyBound.has(e.id)}
|
||||
disabled={busy || loading || alreadyBound.has(e.id)}
|
||||
onCheckedChange={(checked) => toggle(e.id, checked === true)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs">
|
||||
{e.method} {e.url}
|
||||
</span>
|
||||
{alreadyBound.has(e.id) ? <Badge variant="secondary">{t("picker.bound")}</Badge> : null}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{new Date(e.ts).toLocaleString("ko-KR")}
|
||||
</TableCell>
|
||||
<TableCell>{e.status}</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" onClick={() => setPreview(e.id)}>
|
||||
{t("picker.preview")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!rows.length ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center">
|
||||
{loading ? t("picker.loading") : t("picker.noMatch")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm">
|
||||
{t("picker.selectedSummary", { selected: selected.size, total: data?.total ?? 0 })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
{t("picker.prevPage")}
|
||||
</Button>
|
||||
<span className="text-xs">{t("picker.pageNo", { page: page + 1 })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading || (page + 1) * 25 >= (data?.total ?? 0)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
{t("picker.nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={busy} onClick={onClose}>
|
||||
{t("picker.cancel")}
|
||||
</Button>
|
||||
<Button disabled={busy || selected.size === 0} onClick={() => void save()}>
|
||||
{busy ? t("picker.saving") : t("picker.bindCount", { count: selected.size })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CapturedTrafficViewer id={preview} onClose={() => setPreview(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,802 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CrosshairIcon,
|
||||
Flag,
|
||||
MessageSquare,
|
||||
PaperclipIcon,
|
||||
ShieldAlertIcon,
|
||||
Terminal,
|
||||
UserIcon,
|
||||
Wrench,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ApprovalDetail } from "@/components/approval-records";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Activity, InterceptPending } from "@/lib/types";
|
||||
|
||||
// ---- per-agent lane color (planner + work#1/#2/#3 …) ---------------------------
|
||||
const workerColors = ["bg-sky-600", "bg-violet-600", "bg-teal-600", "bg-pink-600", "bg-orange-600"];
|
||||
function workerColor(name: string): string {
|
||||
if (name === "planner") return "bg-amber-600"; // the intent generator, distinct
|
||||
if (name === "mainagent") return "bg-primary";
|
||||
const m = /#(\d+)/.exec(name);
|
||||
const i = m ? (parseInt(m[1], 10) - 1) % workerColors.length : 0;
|
||||
return workerColors[Math.max(0, i)];
|
||||
}
|
||||
|
||||
const chip = (worker: string) =>
|
||||
"mt-0.5 shrink-0 rounded px-1 text-[9px] font-medium text-white " + workerColor(worker);
|
||||
|
||||
// useInView latches true when the ref'd element first comes within `rootMargin` of
|
||||
// the enclosing scroll viewport. Blocks that always show their full body (user
|
||||
// bubbles, answers) use it to defer fetching that body until they're about to be
|
||||
// seen — so opening a long thread doesn't fire a detail request for every off-
|
||||
// screen step. Observes the ScrollArea viewport (falls back to eager load when
|
||||
// IntersectionObserver is unavailable, e.g. SSR).
|
||||
function useInView(rootMargin = "400px"): [React.RefObject<HTMLDivElement | null>, boolean] {
|
||||
const ref = React.useRef<HTMLDivElement | null>(null);
|
||||
const [inView, setInView] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (inView) return; // latch: once seen, stop observing
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setInView(true);
|
||||
return;
|
||||
}
|
||||
const root = el.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null;
|
||||
const ob = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) setInView(true);
|
||||
},
|
||||
{ root, rootMargin },
|
||||
);
|
||||
ob.observe(el);
|
||||
return () => ob.disconnect();
|
||||
}, [inView, rootMargin]);
|
||||
return [ref, inView];
|
||||
}
|
||||
|
||||
// A tool group pairs a tool_use with its matching tool_result (by tool_use_id);
|
||||
// a run of consecutive conversational steps (text/thinking/result) from the same
|
||||
// agent is one "message".
|
||||
type Group =
|
||||
| { type: "user"; key: number; step: Activity; intent?: boolean }
|
||||
| { type: "answer"; key: number; step: Activity }
|
||||
| { type: "round"; key: number; label: string }
|
||||
| { type: "tool"; key: number; worker: string; use?: Activity; result?: Activity }
|
||||
| { type: "msg"; key: number; worker: string; steps: Activity[] }
|
||||
| { type: "intercept"; key: number; step: Activity };
|
||||
|
||||
function groupSteps(steps: Activity[], chat: boolean): Group[] {
|
||||
const out: Group[] = [];
|
||||
const byToolId = new Map<string, Extract<Group, { type: "tool" }>>();
|
||||
for (const s of steps) {
|
||||
if (s.kind === "usage") continue; // live token-usage marker — not a rendered step
|
||||
if (s.kind === "round") {
|
||||
out.push({ type: "round", key: s.seq, label: s.summary || "" }); // planner round boundary (empty → localized fallback at render)
|
||||
continue;
|
||||
}
|
||||
if (s.kind === "intercept_request") {
|
||||
out.push({ type: "intercept", key: s.seq, step: s });
|
||||
continue;
|
||||
}
|
||||
if (s.kind === "user" || s.kind === "intent") {
|
||||
// human turn OR the LLM-generated intent leading a worker session — both are
|
||||
// right-aligned bubbles; `intent` swaps the avatar to a non-human icon.
|
||||
out.push({ type: "user", key: s.seq, step: s, intent: s.kind === "intent" });
|
||||
continue;
|
||||
}
|
||||
// In a chat (main agent) the assistant's text/result IS the answer — render it
|
||||
// full (markdown), never collapsed. thinking still folds into a compact block.
|
||||
if (s.kind === "result" || (chat && s.kind === "text")) {
|
||||
out.push({ type: "answer", key: s.seq, step: s });
|
||||
continue;
|
||||
}
|
||||
if (s.kind === "tool_use") {
|
||||
const g: Extract<Group, { type: "tool" }> = { type: "tool", key: s.seq, worker: s.worker, use: s };
|
||||
if (s.tool_use_id) byToolId.set(s.tool_use_id, g);
|
||||
out.push(g);
|
||||
continue;
|
||||
}
|
||||
if (s.kind === "tool_result") {
|
||||
// bind to its tool_use by id (NOT adjacency — tools can run in parallel)
|
||||
const g = s.tool_use_id ? byToolId.get(s.tool_use_id) : undefined;
|
||||
if (g && !g.result) g.result = s;
|
||||
else out.push({ type: "tool", key: s.seq, worker: s.worker, result: s }); // orphan result
|
||||
continue;
|
||||
}
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.type === "msg" && last.worker === s.worker) last.steps.push(s);
|
||||
else out.push({ type: "msg", key: s.seq, worker: s.worker, steps: [s] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// maps a step kind to its translation sub-key under transcript.kind.* (label resolved at render)
|
||||
const kindKey = (k: string) => (k === "thinking" ? "thinking" : k === "result" ? "result" : "note");
|
||||
|
||||
function ActivityTime({ ts }: { ts: string }) {
|
||||
const date = new Date(ts);
|
||||
if (!ts || Number.isNaN(date.getTime())) return null;
|
||||
return (
|
||||
<time
|
||||
dateTime={date.toISOString()}
|
||||
title={date.toLocaleString("ko-KR")}
|
||||
className="text-[10px] text-muted-foreground tabular-nums"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{date.toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
})}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
// toolInputText renders a tool_use input for display. For Bash it pulls the shell
|
||||
// command out of the raw input JSON ({"command":…,"description":…}) so the UI shows
|
||||
// the command itself instead of JSON; other tools fall back to the raw text.
|
||||
function toolInputText(tool: string, raw: string): string {
|
||||
if (tool !== "Bash") return raw;
|
||||
// full input (expanded detail): parse and pull the command out.
|
||||
try {
|
||||
const o = JSON.parse(raw);
|
||||
if (o && typeof o.command === "string") return o.command;
|
||||
} catch {
|
||||
// collapsed-row summaries are truncated to ~200 chars by the backend, so the
|
||||
// JSON tail is cut off and JSON.parse fails — fall through and extract the
|
||||
// "command" field by hand, tolerating the missing closing quote.
|
||||
}
|
||||
const m = raw.match(/"command"\s*:\s*"((?:\\.|[^"\\])*)/);
|
||||
if (m) {
|
||||
try {
|
||||
// re-wrap the captured body and parse to unescape \n, \", \\, etc.
|
||||
return JSON.parse('"' + m[1] + '"');
|
||||
} catch {
|
||||
// truncated mid-escape — unescape the common sequences best-effort.
|
||||
return m[1].replace(/\\(["\\/nrt])/g, (_s, c) => (c === "n" ? "\n" : c === "r" ? "\r" : c === "t" ? "\t" : c));
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
// InterceptCard renders an inline intercept_request approval card. The pending_id
|
||||
// is extracted from the summary (format: "도구 X 승인 요청 (#N)") so buttons are
|
||||
// available immediately without waiting for the detail load.
|
||||
function InterceptCard({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
|
||||
const t = useTranslations("transcript");
|
||||
// extract pending_id from summary: "도구 Bash 승인 요청 (#42)"
|
||||
const pendingId = React.useMemo(() => {
|
||||
const m = /\(#(\d+)\)/.exec(step.summary);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}, [step.summary]);
|
||||
|
||||
const toolName = React.useMemo(() => {
|
||||
const m = /도구\s+(\S+)\s+승인/.exec(step.summary);
|
||||
return m ? m[1] : step.summary;
|
||||
}, [step.summary]);
|
||||
|
||||
const [detail, setDetail] = React.useState<Record<string, unknown> | null>(null);
|
||||
const [decided, setDecided] = React.useState<"allowed" | "denied" | "timeout" | null>(null);
|
||||
const [deciding, setDeciding] = React.useState(false);
|
||||
const [expanded, setExpanded] = React.useState(false);
|
||||
const [pending, setPending] = React.useState<InterceptPending | null>(null);
|
||||
const [statusError, setStatusError] = React.useState("");
|
||||
const [retry, setRetry] = React.useState(0);
|
||||
|
||||
// Load persisted detail JSON + check the real current status from the backend
|
||||
// so that a page refresh shows the already-decided state instead of re-offering buttons.
|
||||
React.useEffect(() => {
|
||||
let live = true;
|
||||
getDetail(step.seq)
|
||||
.then((raw) => {
|
||||
if (!live || !raw) return;
|
||||
try {
|
||||
setDetail(JSON.parse(raw));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [step.seq, getDetail]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Retry explicitly reloads the same approval after a failed request.
|
||||
React.useEffect(() => {
|
||||
if (!pendingId) return;
|
||||
let live = true;
|
||||
api
|
||||
.interceptGetOne(pendingId)
|
||||
.then((p) => {
|
||||
if (!live) return;
|
||||
setPending(p);
|
||||
setStatusError("");
|
||||
if (p.status !== "pending") setDecided(p.status as "allowed" | "denied" | "timeout");
|
||||
})
|
||||
.catch((error) => {
|
||||
if (live) setStatusError((error as Error).message || t("approval.loadError"));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [pendingId, retry]);
|
||||
|
||||
async function decide(decision: "allowed" | "denied") {
|
||||
if (step.inherited || !pendingId || deciding) return;
|
||||
setDeciding(true);
|
||||
try {
|
||||
await api.interceptDecide(pendingId, decision);
|
||||
setDecided(decision);
|
||||
toast.success(decision === "allowed" ? t("approval.allowedToast") : t("approval.deniedToast"));
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setDeciding(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputStr = detail?.input ? JSON.stringify(detail.input).slice(0, 200) : null;
|
||||
|
||||
const row = pending
|
||||
? { ...pending, status: decided || pending.status, conv_title: "", conv_agent_key: "", rule_name: "" }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded-lg border border-amber-400/50 bg-amber-50/40 dark:bg-amber-950/15 p-3 text-xs">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<ShieldAlertIcon className="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<div className="flex items-center gap-1.5 font-medium">
|
||||
<span className="text-amber-700 dark:text-amber-400">{t("approval.request")}</span>
|
||||
<code className="rounded bg-amber-100 dark:bg-amber-900/50 px-1 font-mono text-amber-800 dark:text-amber-300">
|
||||
{toolName}
|
||||
</code>
|
||||
{pendingId && <span className="text-muted-foreground">#{pendingId}</span>}
|
||||
</div>
|
||||
{inputStr && <p className="font-mono text-muted-foreground truncate">{inputStr}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{step.inherited ? (
|
||||
<Badge variant="outline">{t("approval.historyReadonly")}</Badge>
|
||||
) : decided ? (
|
||||
<span
|
||||
className={
|
||||
"shrink-0 rounded px-2 py-0.5 text-[11px] font-medium " +
|
||||
(decided === "allowed"
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400"
|
||||
: decided === "timeout"
|
||||
? "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
|
||||
: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400")
|
||||
}
|
||||
>
|
||||
{decided === "allowed"
|
||||
? t("approval.allowed")
|
||||
: decided === "timeout"
|
||||
? t("approval.timeout")
|
||||
: t("approval.denied")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={deciding || !pendingId}
|
||||
onClick={() => decide("allowed")}
|
||||
>
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
{t("approval.allow")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-6 px-2 text-xs"
|
||||
disabled={deciding || !pendingId}
|
||||
onClick={() => decide("denied")}
|
||||
>
|
||||
<XIcon className="h-3 w-3" />
|
||||
{t("approval.deny")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{pendingId ? (
|
||||
<Collapsible open={expanded} onOpenChange={setExpanded} className="mt-2 min-w-0">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" aria-label={expanded ? t("approval.collapse") : t("approval.expand")}>
|
||||
{expanded ? <ChevronDown data-icon="inline-start" /> : <ChevronRight data-icon="inline-start" />}
|
||||
{expanded ? t("approval.collapse") : t("approval.expand")}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
{expanded && row ? (
|
||||
<ApprovalDetail
|
||||
row={row}
|
||||
busy={deciding}
|
||||
decide={(_id, decision) => decide(decision)}
|
||||
revision={retry}
|
||||
readOnly={Boolean(step.inherited)}
|
||||
defaultExpanded
|
||||
onResolved={setDecided}
|
||||
/>
|
||||
) : statusError ? (
|
||||
<div className="flex flex-wrap items-center gap-2 p-3" role="alert">
|
||||
<span>{statusError}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setRetry((value) => value + 1)}>
|
||||
{t("approval.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="p-3 text-muted-foreground">{t("approval.loading")}</p>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ToolBlock renders ONE tool call: the command and its result bound into a single
|
||||
// row (collapsed shows the command + a status/result preview; expand shows the
|
||||
// full input AND output together). Lazy-loads both details on expand.
|
||||
function ToolBlock({
|
||||
group,
|
||||
getDetail,
|
||||
showWorker,
|
||||
focused = false,
|
||||
}: {
|
||||
group: Extract<Group, { type: "tool" }>;
|
||||
getDetail: (seq: number) => Promise<string>;
|
||||
showWorker?: boolean;
|
||||
focused?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("transcript");
|
||||
const [open, setOpen] = React.useState(focused);
|
||||
const targetRef = React.useRef<HTMLElement>(null);
|
||||
const [detail, setDetail] = React.useState<string | null>(null);
|
||||
// what we last loaded, keyed by the underlying step seqs. When the tool result
|
||||
// arrives after we expanded mid-run (command only), this key changes and the
|
||||
// effect below re-fetches — so the output shows up instead of being cached out.
|
||||
const loadedKey = React.useRef<string | null>(null);
|
||||
const { use, result } = group;
|
||||
const toolName = use?.tool || result?.tool || t("tool.name");
|
||||
const ToolIcon = toolName === "Bash" ? Terminal : Wrench;
|
||||
const running = !result;
|
||||
const ok = !!result && !result.is_error;
|
||||
const statusTone = running
|
||||
? "text-muted-foreground"
|
||||
: ok
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: "text-red-600 dark:text-red-400";
|
||||
const rawCmd =
|
||||
use && use.summary.startsWith(toolName) ? use.summary.slice(toolName.length).trimStart() : (use?.summary ?? "");
|
||||
const cmd = toolInputText(toolName, rawCmd);
|
||||
// status only — the full result lives behind the expand (output section), not previewed inline
|
||||
const statusText = running ? t("tool.running") : ok ? "✓" : t("tool.failed");
|
||||
|
||||
// key over the seqs we'd load; changes when the result (or command) arrives.
|
||||
const detailKey = `${use?.seq ?? ""}:${result?.seq ?? ""}`;
|
||||
React.useEffect(() => {
|
||||
if (!open || loadedKey.current === detailKey) return;
|
||||
let live = true;
|
||||
const segs: { label: string; seq: number; cmd?: boolean }[] = [];
|
||||
if (use) segs.push({ label: t("tool.command"), seq: use.seq, cmd: true });
|
||||
if (result) segs.push({ label: t("tool.output") + (result.is_error ? " ✕" : " ✓"), seq: result.seq });
|
||||
void Promise.all(
|
||||
segs.map((x) =>
|
||||
getDetail(x.seq)
|
||||
.then((d) => d || t("tool.empty"))
|
||||
.catch(() => t("tool.loadFailed")),
|
||||
),
|
||||
).then((parts) => {
|
||||
if (!live) return;
|
||||
setDetail(
|
||||
segs.map((x, i) => `【${x.label}】\n${x.cmd ? toolInputText(toolName, parts[i]) : parts[i]}`).join("\n\n"),
|
||||
);
|
||||
loadedKey.current = detailKey;
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [open, detailKey, use, result, getDetail, toolName, t]);
|
||||
|
||||
const scrolledRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
scrolledRef.current = false;
|
||||
if (focused) setOpen(true);
|
||||
}, [focused]);
|
||||
React.useEffect(() => {
|
||||
if (!focused || !open || detail === null || scrolledRef.current) return;
|
||||
const el = targetRef.current;
|
||||
const viewport = el?.closest('[data-slot="scroll-area-viewport"]');
|
||||
if (!el || !viewport) return;
|
||||
scrolledRef.current = true;
|
||||
const center = () => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
viewport.scrollTop += rect.top + rect.height / 2 - bounds.top - bounds.height / 2;
|
||||
};
|
||||
// User bubbles load their full text lazily. Allow their initial layout to
|
||||
// settle, but stop anchoring immediately if the user interacts or after 2s.
|
||||
const observer = new ResizeObserver(center);
|
||||
observer.observe(el.parentElement ?? el);
|
||||
observer.observe(viewport);
|
||||
const stop = () => observer.disconnect();
|
||||
viewport.addEventListener("wheel", stop, { passive: true, once: true });
|
||||
viewport.addEventListener("touchstart", stop, { passive: true, once: true });
|
||||
viewport.addEventListener("pointerdown", stop, { once: true });
|
||||
const frame = requestAnimationFrame(center);
|
||||
const timer = setTimeout(stop, 2000);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
clearTimeout(timer);
|
||||
stop();
|
||||
viewport.removeEventListener("wheel", stop);
|
||||
viewport.removeEventListener("touchstart", stop);
|
||||
viewport.removeEventListener("pointerdown", stop);
|
||||
};
|
||||
}, [focused, open, detail]);
|
||||
|
||||
function toggle() {
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
ref={targetRef}
|
||||
aria-label={focused ? t("tool.pinned", { seq: use?.seq ?? "" }) : undefined}
|
||||
className={focused ? "rounded-lg border-2 border-primary bg-primary/5 p-3 text-xs" : "text-xs"}
|
||||
>
|
||||
<button type="button" onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
|
||||
<span className="mt-0.5 text-muted-foreground">
|
||||
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
</span>
|
||||
<ToolIcon className={"mt-0.5 size-3.5 shrink-0 " + (running ? "text-sky-600 dark:text-sky-400" : statusTone)} />
|
||||
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
|
||||
<span className="shrink-0 font-medium text-sky-600 dark:text-sky-400">{toolName}</span>
|
||||
{cmd && <span className="min-w-0 flex-1 truncate font-mono text-muted-foreground">{cmd}</span>}
|
||||
<span className={"ml-auto shrink-0 font-medium " + statusTone}>{statusText}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
|
||||
{detail ?? t("loading")}
|
||||
</pre>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// MessageBlock renders a coalesced agent message (merged streaming text/thinking/
|
||||
// result fragments into one block) so the conversation reads as messages, not rows.
|
||||
function MessageBlock({
|
||||
group,
|
||||
getDetail,
|
||||
showWorker,
|
||||
}: {
|
||||
group: Extract<Group, { type: "msg" }>;
|
||||
getDetail: (seq: number) => Promise<string>;
|
||||
showWorker?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("transcript");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [detail, setDetail] = React.useState<string | null>(null);
|
||||
// like ToolBlock: keyed by the group's step seqs so streamed steps arriving
|
||||
// after an early expand re-fetch instead of being cached out.
|
||||
const loadedKey = React.useRef<string | null>(null);
|
||||
const speak = group.steps.filter((s) => s.kind !== "thinking");
|
||||
const hasThinking = group.steps.some((s) => s.kind === "thinking");
|
||||
const isError = group.steps.some((s) => s.is_error);
|
||||
const body = (speak.length ? speak : group.steps).map((s) => s.summary).join(" ") || "…";
|
||||
const Icon = isError ? Flag : MessageSquare;
|
||||
const tone = isError ? "text-red-600 dark:text-red-400" : "text-foreground";
|
||||
|
||||
const detailKey = group.steps.map((s) => s.seq).join(",");
|
||||
React.useEffect(() => {
|
||||
if (!open || loadedKey.current === detailKey) return;
|
||||
let live = true;
|
||||
void Promise.all(
|
||||
group.steps.map((s) =>
|
||||
getDetail(s.seq)
|
||||
.then((d) => d || s.summary)
|
||||
.catch(() => s.summary),
|
||||
),
|
||||
).then((parts) => {
|
||||
if (!live) return;
|
||||
setDetail(group.steps.map((s, i) => `【${t(`kind.${kindKey(s.kind)}`)}】\n${parts[i]}`).join("\n\n"));
|
||||
loadedKey.current = detailKey;
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [open, detailKey, group.steps, getDetail, t]);
|
||||
|
||||
function toggle() {
|
||||
setOpen((o) => !o);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-xs">
|
||||
<button type="button" onClick={toggle} className="flex w-full items-start gap-2 py-1 text-left hover:bg-muted/40">
|
||||
<span className="mt-0.5 text-muted-foreground">
|
||||
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
</span>
|
||||
<Icon className={"mt-0.5 size-3.5 shrink-0 " + tone} />
|
||||
{showWorker && <span className={chip(group.worker)}>{group.worker}</span>}
|
||||
<span className={"min-w-0 flex-1 truncate " + tone}>
|
||||
{body}
|
||||
{hasThinking && <span className="ml-1 text-[10px] text-muted-foreground">· {t("withThinking")}</span>}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="ml-7 mb-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/50 p-2 font-mono text-[11px] leading-relaxed">
|
||||
{detail ?? t("loading")}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// UserRow renders a right-aligned chat bubble: either a human turn (the message you
|
||||
// sent the main agent) or, in a worker session, the LLM-generated intent that leads
|
||||
// it (intent=true) — same bubble, but a target icon instead of the human avatar.
|
||||
// summary is a truncated first line, so the full message is pulled from the detail
|
||||
// and shown in full (bubble is whitespace-pre-wrap, so long/multi-line text wraps).
|
||||
// fmtBytes renders a human file size for attachment chips.
|
||||
function fmtBytes(n: number): string {
|
||||
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
||||
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
type MsgAttachment = { name: string; path: string; size: number };
|
||||
|
||||
// parseUserBody splits a user turn's body into text + attachments. The backend stores
|
||||
// Detail as JSON {text, attachments} when files were uploaded, else plain text — so we
|
||||
// parse defensively and fall back to treating the whole body as text.
|
||||
function parseUserBody(body: string): { text: string; attachments: MsgAttachment[] } {
|
||||
if (body.startsWith("{")) {
|
||||
try {
|
||||
const p = JSON.parse(body);
|
||||
if (p && Array.isArray(p.attachments)) {
|
||||
return { text: typeof p.text === "string" ? p.text : "", attachments: p.attachments };
|
||||
}
|
||||
} catch {
|
||||
/* plain text that merely starts with "{" */
|
||||
}
|
||||
}
|
||||
return { text: body, attachments: [] };
|
||||
}
|
||||
|
||||
function UserRow({
|
||||
step,
|
||||
intent,
|
||||
getDetail,
|
||||
}: {
|
||||
step: Activity;
|
||||
intent?: boolean;
|
||||
getDetail: (seq: number) => Promise<string>;
|
||||
}) {
|
||||
const Icon = intent ? CrosshairIcon : UserIcon;
|
||||
const [ref, inView] = useInView();
|
||||
// Optimistic echoes carry their detail inline; persisted rows lazy-load it on scroll.
|
||||
const inline = step.detail && step.detail.length > 0 ? step.detail : null;
|
||||
const [full, setFull] = React.useState<string | null>(inline);
|
||||
React.useEffect(() => {
|
||||
if (inline) return; // already have the body (optimistic echo)
|
||||
if (!inView) return; // fetch the full message only when the bubble nears view
|
||||
let live = true;
|
||||
getDetail(step.seq)
|
||||
.then((d) => {
|
||||
if (live) setFull(d || step.summary);
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setFull(step.summary);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [inView, step.seq, getDetail, step.summary, inline]);
|
||||
const { text, attachments } = parseUserBody(full ?? step.summary);
|
||||
return (
|
||||
<div ref={ref} className="mt-3 mb-2 flex min-w-0 justify-end gap-2">
|
||||
<div className="flex min-w-0 max-w-[85%] flex-col items-end gap-1.5">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex min-w-0 max-w-full flex-wrap justify-end gap-1.5">
|
||||
{attachments.map((a) => (
|
||||
<div
|
||||
key={a.path}
|
||||
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md border bg-card px-2 py-1 text-xs shadow-sm"
|
||||
title={a.path}
|
||||
>
|
||||
<PaperclipIcon className="size-3 shrink-0 text-primary" />
|
||||
<span className="max-w-[180px] truncate font-medium">{a.name}</span>
|
||||
<span className="shrink-0 text-muted-foreground">{fmtBytes(a.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{text && (
|
||||
<div className="min-w-0 max-w-full whitespace-pre-wrap rounded-lg rounded-tr-sm bg-primary px-3 py-1.5 text-sm text-primary-foreground [overflow-wrap:anywhere]">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
<ActivityTime ts={step.ts} />
|
||||
</div>
|
||||
<div className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// AnswerBlock renders the agent's FINAL answer (kind="result") in full — never
|
||||
// collapsed. The summary is a truncated first line, so the full text is pulled
|
||||
// from the detail and shown inline.
|
||||
function AnswerBlock({ step, getDetail }: { step: Activity; getDetail: (seq: number) => Promise<string> }) {
|
||||
const [ref, inView] = useInView();
|
||||
const [full, setFull] = React.useState<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (!inView) return; // fetch the full answer only when it nears view
|
||||
let live = true;
|
||||
getDetail(step.seq)
|
||||
.then((d) => {
|
||||
if (live) setFull(d || step.summary);
|
||||
})
|
||||
.catch(() => {
|
||||
if (live) setFull(step.summary);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [inView, step.seq, getDetail, step.summary]);
|
||||
return (
|
||||
<div ref={ref} className="mb-2 mt-1 flex min-w-0 flex-col gap-1">
|
||||
<div
|
||||
className={
|
||||
"min-w-0 flex-1 break-words rounded-lg bg-muted px-3 py-2 " +
|
||||
(step.is_error ? "text-sm text-red-600 dark:text-red-400" : "")
|
||||
}
|
||||
>
|
||||
{step.is_error ? (
|
||||
<span className="whitespace-pre-wrap">{full ?? step.summary}</span>
|
||||
) : (
|
||||
<Markdown text={full ?? step.summary} />
|
||||
)}
|
||||
</div>
|
||||
<ActivityTime ts={step.ts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ExecView renders an agent execution replay (planner / worker / main agent) in
|
||||
// the compact, grouped, expand-to-detail format — thinking, tool calls/results,
|
||||
// and (for the main agent) the human turns. Worker lane chips show only when the
|
||||
// view actually mixes agents.
|
||||
function ExecView({
|
||||
activity,
|
||||
taskId,
|
||||
chat,
|
||||
fetchDetail,
|
||||
focusedSeq,
|
||||
}: {
|
||||
activity: Activity[];
|
||||
taskId?: string;
|
||||
chat?: boolean;
|
||||
fetchDetail?: (seq: number) => Promise<string>;
|
||||
focusedSeq?: number;
|
||||
}) {
|
||||
const t = useTranslations("transcript");
|
||||
const showWorker = new Set(activity.map((a) => a.worker)).size > 1;
|
||||
// default detail fetcher: the task-scoped activity endpoint. The chat page passes
|
||||
// its own (conversation-scoped) fetcher instead.
|
||||
const getDetail = React.useCallback(
|
||||
(seq: number) => (fetchDetail ? fetchDetail(seq) : api.activityDetail(seq, taskId).then((r) => r.detail ?? "")),
|
||||
[fetchDetail, taskId],
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{groupSteps(activity, !!chat).map((g) =>
|
||||
g.type === "round" ? (
|
||||
<div key={"r" + g.key} className="my-2 flex items-center gap-2 text-[10px] font-medium text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
{g.label || t("newRound")}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
) : g.type === "user" ? (
|
||||
<UserRow key={"u" + g.key} step={g.step} intent={g.intent} getDetail={getDetail} />
|
||||
) : g.type === "answer" ? (
|
||||
<AnswerBlock key={"a" + g.key} step={g.step} getDetail={getDetail} />
|
||||
) : g.type === "tool" ? (
|
||||
<ToolBlock
|
||||
key={"t" + g.key}
|
||||
group={g}
|
||||
getDetail={getDetail}
|
||||
showWorker={showWorker}
|
||||
focused={focusedSeq != null && g.use?.seq === focusedSeq}
|
||||
/>
|
||||
) : g.type === "intercept" ? (
|
||||
<InterceptCard key={"ic" + g.key} step={g.step} getDetail={getDetail} />
|
||||
) : (
|
||||
<MessageBlock key={"m" + g.key} group={g} getDetail={getDetail} showWorker={showWorker} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Transcript renders a session's activity as a compact grouped execution replay:
|
||||
// human turns, thinking, tool calls (command+result paired), and messages — for
|
||||
// the main agent (interactive) and worker/planner (read-only) alike.
|
||||
export function Transcript({
|
||||
activity,
|
||||
live,
|
||||
taskId,
|
||||
chat,
|
||||
fetchDetail,
|
||||
focusedSeq,
|
||||
}: {
|
||||
activity: Activity[];
|
||||
live?: boolean;
|
||||
taskId?: string;
|
||||
chat?: boolean;
|
||||
fetchDetail?: (seq: number) => Promise<string>;
|
||||
focusedSeq?: number;
|
||||
}) {
|
||||
const t = useTranslations("transcript");
|
||||
const transcriptRef = React.useRef<HTMLDivElement>(null);
|
||||
const [focusPadding, setFocusPadding] = React.useState(0);
|
||||
React.useLayoutEffect(() => {
|
||||
if (focusedSeq == null) {
|
||||
setFocusPadding(0);
|
||||
return;
|
||||
}
|
||||
const viewport = transcriptRef.current?.closest('[data-slot="scroll-area-viewport"]');
|
||||
if (!viewport) return;
|
||||
const measure = () => setFocusPadding(viewport.clientHeight / 2);
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [focusedSeq]);
|
||||
return (
|
||||
<div
|
||||
ref={transcriptRef}
|
||||
className="flex flex-col gap-1"
|
||||
style={focusPadding ? { paddingBlock: focusPadding } : undefined}
|
||||
>
|
||||
<ExecView activity={activity} taskId={taskId} chat={chat} fetchDetail={fetchDetail} focusedSeq={focusedSeq} />
|
||||
{live && (
|
||||
<div className="flex items-center gap-2 pl-2 pt-1 text-xs text-muted-foreground">
|
||||
<span className="flex gap-1">
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.3s]" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-blue-500 [animation-delay:-0.15s]" />
|
||||
<span className="size-1.5 animate-bounce rounded-full bg-blue-500" />
|
||||
</span>
|
||||
{t("streaming")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring disabled:pointer-events-none disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--radix-accordion-content-height) pt-0 pb-2.5 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_minmax(0,1fr)] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"min-w-0 font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"min-w-0 text-sm text-balance text-muted-foreground [overflow-wrap:anywhere] md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { AspectRatio as AspectRatioPrimitive } from "radix-ui"
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -0,0 +1,204 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
const attachmentVariants = cva(
|
||||
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-xl border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
default:
|
||||
"gap-2 text-sm has-data-[slot=attachment-content]:px-2.5 has-data-[slot=attachment-content]:py-2 has-data-[slot=attachment-media]:p-2",
|
||||
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
|
||||
xs: "gap-1.5 rounded-lg text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
|
||||
},
|
||||
orientation: {
|
||||
horizontal: "min-w-40 items-center",
|
||||
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Attachment({
|
||||
className,
|
||||
state = "done",
|
||||
size = "default",
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> &
|
||||
VariantProps<typeof attachmentVariants> & {
|
||||
state?: "idle" | "uploading" | "processing" | "error" | "done"
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment"
|
||||
data-state={state}
|
||||
data-size={size}
|
||||
data-orientation={orientation}
|
||||
className={cn(attachmentVariants({ size, orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const attachmentMediaVariants = cva(
|
||||
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-md group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
icon: "",
|
||||
image:
|
||||
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "icon",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function AttachmentMedia({
|
||||
className,
|
||||
variant = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-media"
|
||||
data-variant={variant}
|
||||
className={cn(attachmentMediaVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-content"
|
||||
className={cn(
|
||||
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="attachment-title"
|
||||
className={cn(
|
||||
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="attachment-description"
|
||||
className={cn(
|
||||
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
|
||||
"max-w-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentActions({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-actions"
|
||||
className={cn(
|
||||
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentAction({
|
||||
className,
|
||||
variant,
|
||||
size = "icon-xs",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="attachment-action"
|
||||
variant={variant ?? "ghost"}
|
||||
size={size}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentTrigger({
|
||||
className,
|
||||
asChild = false,
|
||||
type,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="attachment-trigger"
|
||||
type={asChild ? undefined : (type ?? "button")}
|
||||
className={cn("absolute inset-0 z-10 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="attachment-group"
|
||||
className={cn(
|
||||
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Attachment,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentContent,
|
||||
AttachmentTitle,
|
||||
AttachmentDescription,
|
||||
AttachmentActions,
|
||||
AttachmentAction,
|
||||
AttachmentTrigger,
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user