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
detections / detections (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s

This commit is contained in:
dela
2026-10-09 08:38:16 +08:00
commit 0335d572de
756 changed files with 201663 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
*.sh
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# i18n — 생성물. zh.json/ko.json 은 커밋하되, 키별 출처(파일:줄) 색인은
# 용량이 크고 소스 수정 때마다 줄 번호가 밀려 diff 가 요동쳐 제외한다.
# `npm run extract:i18n` 으로 언제든 재생성된다(B2 배선·B3 번역 때 사용).
/messages/zh.sources.json
+3
View File
@@ -0,0 +1,3 @@
npm run generate:presets
git add src/lib/preferences/theme.ts
npm exec -- lint-staged
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Mohammed Arham Khan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+138
View File
@@ -0,0 +1,138 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
"react",
"react/**",
":BLANK_LINE:",
"next/**",
":BLANK_LINE:",
":PACKAGE:",
":BLANK_LINE:",
":ALIAS:",
":BLANK_LINE:",
":PATH:"
]
}
}
}
}
},
"files": {
"ignoreUnknown": true,
"includes": [
"**",
"!.github",
"!.husky",
"!node_modules",
"!.next",
"!dist",
"!out",
"!src/components/ui",
"!src/components/calendar"
]
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf",
"lineWidth": 120,
"attributePosition": "auto",
"bracketSameLine": false,
"bracketSpacing": true,
"expand": "auto",
"useEditorconfig": true
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"a11y": {
"noLabelWithoutControl": {
"level": "error",
"options": {
"inputComponents": ["Checkbox", "Switch"]
}
}
},
"correctness": {
"noUndeclaredVariables": "error",
"noNestedComponentDefinitions": "error",
"noUndeclaredDependencies": "error"
},
"nursery": {
"useSortedClasses": "on",
"useNullishCoalescing": "error",
"noFloatingPromises": "error",
"noMisusedPromises": "error"
},
"performance": {
"noImgElement": "warn"
},
"style": {
"noCommonJs": "error",
"noHeadElement": "warn",
"noNamespace": "error",
"useAsConstAssertion": "error",
"useSelfClosingElements": "error",
"noNestedTernary": "warn",
"noParameterAssign": "error",
"noInferrableTypes": "error",
"noUselessElse": "error",
"useFilenamingConvention": "error"
},
"suspicious": {
"noArrayIndexKey": "warn",
"noConstantBinaryExpressions": "error",
"noEmptyBlockStatements": "error",
"noUnknownAtRules": "off",
"noImportCycles": "error",
"noUnnecessaryConditions": "warn"
},
"complexity": {
"noImportantStyles": "off"
}
},
"domains": {
"next": "recommended",
"react": "recommended"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"javascript": {
"formatter": {
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all",
"semicolons": "always",
"arrowParentheses": "always",
"bracketSameLine": false,
"quoteStyle": "double",
"attributePosition": "auto",
"bracketSpacing": true
}
},
"html": {
"formatter": {
"indentScriptAndStyle": false,
"selfCloseVoidElements": "always"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+3694
View File
File diff suppressed because it is too large Load Diff
+3694
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
import createNextIntlPlugin from "next-intl/plugin";
import { fileURLToPath } from "node:url";
// 静态导出:`NEXT_EXPORT=1 next build` 产出纯静态目录到 web/out,可直接丢进
// nginx web 根目录运行。开发(next dev)不设该变量,保留 /api 反代与热更新。
const isExport = process.env.NEXT_EXPORT === "1";
// Vercel demo:整站走 mock,无后端,无需 /api 反代。
const isMock = process.env.NEXT_PUBLIC_MOCK === "1";
/** @type {import('next').NextConfig} */
const nextConfig = {
// 避免父目录的 lockfile 影响根目录推断及资源路径生成。
turbopack: { root: fileURLToPath(new URL(".", import.meta.url)) },
reactCompiler: true,
// 允许从局域网 IP 访问 dev 资源(HMR),按需增删。
// dev 阶段放开任意 IPv4 来源访问 /_next/* 与 HMR(局域网 IP 变动也不受影响)。
// 注意:Next 出于安全禁止裸 "*",需用分段通配;"*.*.*.*" 匹配任意 IPv4。
allowedDevOrigins: ["*.*.*.*"],
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
...(isExport
? {
// 纯静态导出:无 Node 运行时;图片不经优化;每个路由产出 <route>/index.html。
output: "export",
images: { unoptimized: true },
trailingSlash: true,
}
: isMock
? {
// Vercel mock demo:无后端,不需要 /api 反代。
images: { unoptimized: true },
}
: {
// 开发:把 /api/* 反代到 Go 后端(默认 :8787,可用 AUTOPENTEST_API 覆盖)。
async rewrites() {
const backend = process.env.AUTOPENTEST_API ?? "http://localhost:8787";
return [{ source: "/api/:path*", destination: `${backend}/api/:path*` }];
},
}),
};
// next-intl 플러그인. 요청 설정은 src/i18n/request.ts 에 둔다. i18n 경로 라우팅을
// 쓰지 않으므로 미들웨어는 추가하지 않는다(정적 내보내기 output: "export" 와 호환).
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
export default withNextIntl(nextConfig);
+11899
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
{
"name": "artex-web",
"version": "2.2.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build:static": "NEXT_EXPORT=1 next build",
"start": "next start",
"lint": "biome lint",
"format": "biome format --write",
"check": "biome check",
"check:fix": "biome check --write",
"prepare": "husky || true",
"generate:presets": "ts-node -P tsconfig.scripts.json src/scripts/generate-theme-presets.ts",
"extract:i18n": "ts-node -P tsconfig.scripts.json src/scripts/extract-i18n.ts"
},
"lint-staged": {
"*.{js,ts,jsx,tsx}": ["biome check --write --no-errors-on-unmatched"]
},
"dependencies": {
"@antv/g6": "^5.1.1",
"@antv/graphin": "^3.0.5",
"@base-ui/react": "^1.6.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@fullcalendar/react": "^7.0.0",
"@hookform/resolvers": "^5.4.0",
"@shadcn/react": "^0.1.0",
"@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-geo": "^3.1.1",
"date-fns": "^4.4.0",
"embla-carousel-react": "^8.6.0",
"geist": "^1.7.2",
"input-otp": "^1.4.2",
"lucide-react": "^1.22.0",
"next": "^16.2.9",
"next-intl": "^4.14.9",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.0",
"react": "^19.2.7",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.7",
"react-hook-form": "^7.80.0",
"react-is": "^19.2.7",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.0",
"recharts": "^3.8.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.12.0",
"simple-icons": "^16.24.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"temporal-polyfill": "^1.0.1",
"topojson-client": "^3.1.0",
"vaul": "^1.1.2",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@biomejs/biome": "^2.5.1",
"@tailwindcss/postcss": "^4.3.2",
"@types/d3-geo": "^3.1.0",
"@types/node": "^22.20.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
"babel-plugin-react-compiler": "^1.0.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"postcss": "^8.5.16",
"tailwindcss": "^4.1.5",
"ts-node": "^10.9.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 62 KiB

+5
View File
@@ -0,0 +1,5 @@
import type { ReactNode } from "react";
export default function AuthLayout({ children }: { readonly children: ReactNode }) {
return <>{children}</>;
}
+291
View File
@@ -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>
);
}
+112
View File
@@ -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>
);
}
+6
View File
@@ -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&nbsp;
<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>
);
}
+481
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}
+270
View File
@@ -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>
);
}
+966
View File
@@ -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>
);
}
+271
View File
@@ -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>
);
}
+468
View File
@@ -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) });
}
+607
View File
@@ -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>
);
}
+659
View File
@@ -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
+956
View File
@@ -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

+284
View File
@@ -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

+60
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+944
View File
@@ -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>
);
}
+272
View File
@@ -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}
/>
)
}
+62
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+180
View File
@@ -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>
</>
);
}
+218
View File
@@ -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>
);
}
+163
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+355
View File
@@ -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>
);
}
+88
View File
@@ -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>
</>
);
}
+30
View File
@@ -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>
);
}
+42
View File
@@ -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])} />;
}

Some files were not shown because too many files have changed in this diff Show More