|
-
-
- {t.description}
-
-
- {t.id}
-
+
+ {t.description}
+ {t.id}
|
@@ -983,7 +993,7 @@ export default function DashboardPage() {
)}
|
- {t.in_flight ?? 0 > 0 ? (
+ {(t.in_flight ?? 0 > 0) ? (
{t.in_flight}
) : (
—
@@ -1002,7 +1012,6 @@ export default function DashboardPage() {
{/* ── Row 5: 资产分布 | 流量状态码 | 拦截 & 待审批 ── */}
-
{/* 资产分布 */}
@@ -1010,9 +1019,7 @@ export default function DashboardPage() {
{assetByType.length === 0 ? (
-
- 暂无资产数据
-
+ 暂无资产数据
) : (
{assetByType.map(([type, count]) => (
@@ -1032,9 +1039,7 @@ export default function DashboardPage() {
)}
-
- 共 {totalAssets} 节点
-
+ 共 {totalAssets} 节点
{/* 流量状态码 */}
@@ -1045,36 +1050,24 @@ export default function DashboardPage() {
{/* bar chart */}
{trafficByCodes.length === 0 ? (
-
- 暂无流量数据
-
+ 暂无流量数据
) : (
<>
{trafficByCodes.map(({ code, n }) => (
-
- {n}
-
+ {n}
-
- {code}
-
+ {code}
))}
-
- 最近请求
-
+ 最近请求
{recentTraffic.map((e) => (
@@ -1092,14 +1085,7 @@ export default function DashboardPage() {
{e.host}
{e.url.replace(/^https?:\/\/[^/]+/, "").substring(0, 30)}
-
- {e.status}
-
+ {e.status}
))}
@@ -1154,9 +1140,7 @@ export default function DashboardPage() {
{/* pending approvals */}
{pendingCount > 0 && (
-
- 待审批 ({pendingCount})
-
+ 待审批 ({pendingCount})
{pending.slice(0, 3).map((p) => (
{p.tool_name}
-
- {p.agent_name}
-
+ {p.agent_name}
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx
index 49722b7..3c06a2b 100644
--- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx
+++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx
@@ -7,6 +7,7 @@ import {
AlertTriangleIcon,
BugIcon,
ClockIcon,
+ CoinsIcon,
PlusIcon,
RefreshCwIcon,
ShieldCheckIcon,
@@ -21,7 +22,20 @@ import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Progress } from "@/components/ui/progress";
import { api } from "@/lib/api";
-import type { Finding, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types";
+import type { Finding, ModelTokenStat, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types";
+
+// 紧凑格式化 token 数(12345 → 12.3k,2000000 → 2M)。
+function fmtTokens(n: number): string {
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
+ if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
+ return String(n);
+}
+
+// 缓存命中率 = 缓存读 / 输入(InputTokens 已含 cache_read 子集,故比值在 0–100%)。
+function cacheHitRate(cacheRead: number, input: number): string {
+ if (input <= 0) return "—";
+ return Math.round((cacheRead / input) * 100) + "%";
+}
// 测试范围一条的显示值:域名 / 网段 / 公司。
function scopeValue(row: TaskScopeRow): string {
@@ -89,6 +103,17 @@ export function OverviewTab({ taskId }: { taskId: string }) {
const [scopeValueInput, setScopeValueInput] = React.useState("");
const [scopeBusy, setScopeBusy] = React.useState(false);
const [scopeErr, setScopeErr] = React.useState("");
+ // 按模型的 token 用量(来自常开的 llm_usage 计量账本,逐次精确)。
+ const [modelTokens, setModelTokens] = React.useState ([]);
+
+ const loadTokens = React.useCallback(async () => {
+ try {
+ const resp = await api.tokensByModel(taskId);
+ setModelTokens(resp.models);
+ } catch {
+ // 忽略:无 PG 时接口报错,卡片自然为空
+ }
+ }, [taskId]);
const loadScope = React.useCallback(async () => {
try {
@@ -189,18 +214,34 @@ export function OverviewTab({ taskId }: { taskId: string }) {
load();
void loadScope();
- const timer = setInterval(load, 3000);
+ void loadTokens();
+ const timer = setInterval(() => {
+ void load();
+ void loadTokens();
+ }, 3000);
return () => {
cancelled = true;
clearInterval(timer);
};
- }, [taskId, loadScope]);
+ }, [taskId, loadScope, loadTokens]);
const running = intents.filter((i) => i.state === "running");
const open = intents.filter((i) => i.state === "open");
const blocked = intents.filter((i) => i.state === "blocked");
const taskFindings = findings.filter((f) => f.task_id === taskId);
const goalsPct = task?.goals_total ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) : 0;
+ // token 合计(跨全部模型),用于卡片头部总览。
+ const tokenTotals = modelTokens.reduce(
+ (acc, m) => {
+ acc.input += m.input_tokens;
+ acc.output += m.output_tokens;
+ acc.cacheRead += m.cache_read_tokens;
+ acc.cacheWrite += m.cache_write_tokens;
+ acc.calls += m.calls;
+ return acc;
+ },
+ { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, calls: 0 },
+ );
return (
@@ -255,6 +296,77 @@ export function OverviewTab({ taskId }: { taskId: string }) {
)}
+ {/* LLM Token 用量:按模型分组,数据来自 llm_records(需开启 LLM 录制)。 */}
+
+
+
+ LLM Token 用量
+
+ (按模型统计{tokenTotals.calls > 0 ? `,共 ${tokenTotals.calls} 次调用` : ""})
+
+
+
+
+ {modelTokens.length > 0 ? (
+ <>
+ {/* 合计总览 */}
+
+
+ 输入
+ {fmtTokens(tokenTotals.input)}
+
+
+ 输出
+ {fmtTokens(tokenTotals.output)}
+
+
+ 缓存读
+ {fmtTokens(tokenTotals.cacheRead)}
+
+
+ 缓存命中率
+
+ {cacheHitRate(tokenTotals.cacheRead, tokenTotals.input)}
+
+
+
+ {/* 按模型明细表 */}
+
+
+
+
+ | 模型 |
+ 调用 |
+ 输入 |
+ 输出 |
+ 缓存读 |
+ 命中率 |
+
+
+
+ {modelTokens.map((m) => (
+
+ |
+ {m.model}
+ |
+ {m.calls} |
+ {fmtTokens(m.input_tokens)} |
+ {fmtTokens(m.output_tokens)} |
+ {fmtTokens(m.cache_read_tokens)} |
+
+ {cacheHitRate(m.cache_read_tokens, m.input_tokens)}
+ |
+
+ ))}
+
+
+
+ >
+ ) : (
+ 暂无 LLM 用量(任务尚未产生调用,或记录仍在写入)。
+ )}
+
+
{/* 测试范围:覆盖度分母 + 授权边界,可手动增删。 */}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 63595d4..29e7455 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -39,6 +39,7 @@ import type {
LLMTask,
MCPServer,
MCPTool,
+ ModelTokenStat,
PromptVar,
PromptVersion,
Settings,
@@ -57,6 +58,7 @@ import type {
TrafficDetail,
TrafficHost,
TrafficResp,
+ UsageStats,
WorkspaceFile,
WorkspaceListing,
} from "@/lib/types";
@@ -228,6 +230,8 @@ export const api = {
}>(`/tasks/${id}/coverage`),
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
taskCoverageGraph: (id: string) => get(`/tasks/${id}/coverage-graph`),
+ // 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
+ usageStats: (days = 365) => get(`/tokens/usage?days=${days}`),
// 本任务测试范围列表(含继承自来源任务的范围)。
taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`),
// 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。
@@ -812,4 +816,8 @@ export const api = {
llmRecordDetail: (id: number) => get(`/llm/records/${id}`),
llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`),
llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
+ // 按模型聚合本任务的 token 用量(来自常开的 llm_usage 计量账本,逐次调用精确,
+ // per-agent 绑定 / 轮询 / 中断消耗都覆盖)。
+ tokensByModel: (task: string) =>
+ get<{ models: ModelTokenStat[] }>(`/llm/records/by-model?task=${encodeURIComponent(task)}`),
};
diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts
index bd926e5..655b848 100644
--- a/web/src/lib/mock/handler.ts
+++ b/web/src/lib/mock/handler.ts
@@ -155,6 +155,68 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
return { id: Date.now(), task_id: Number(seg[1]), kind: b.kind, domain: b.value, source: "manual" };
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") return { ok: true };
+ // ── 全局 llm_usage 聚合(仪表盘新版视图,demo)──
+ if (path === "/tokens/usage")
+ return {
+ by_profile: [
+ {
+ profile_name: "default",
+ calls: 60,
+ tasks: 4,
+ input_tokens: 1570000,
+ output_tokens: 110000,
+ cache_read_tokens: 1120000,
+ cache_write_tokens: 140000,
+ },
+ ],
+ daily: [
+ {
+ profile_name: "default",
+ date: "2026-08-18",
+ input_tokens: 520000,
+ output_tokens: 38000,
+ cache_read_tokens: 370000,
+ },
+ {
+ profile_name: "default",
+ date: "2026-08-19",
+ input_tokens: 640000,
+ output_tokens: 45000,
+ cache_read_tokens: 460000,
+ },
+ {
+ profile_name: "default",
+ date: "2026-08-20",
+ input_tokens: 410000,
+ output_tokens: 27000,
+ cache_read_tokens: 290000,
+ },
+ ],
+ };
+
+ // ── 按模型 token 用量(demo:一条示例)──
+ if (path === "/llm/records/by-model")
+ return {
+ models: [
+ {
+ model: "claude-opus-4-6",
+ calls: 42,
+ input_tokens: 1250000,
+ output_tokens: 86000,
+ cache_read_tokens: 940000,
+ cache_write_tokens: 120000,
+ },
+ {
+ model: "claude-haiku-4-5",
+ calls: 18,
+ input_tokens: 320000,
+ output_tokens: 24000,
+ cache_read_tokens: 180000,
+ cache_write_tokens: 20000,
+ },
+ ],
+ };
+
// ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})──
if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? "");
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
@@ -206,18 +268,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
};
}
// 单条 finding:GET 详情 / PATCH 改状态/严重度/名称/类别(demo 直接改内存对象)。
- if (
- seg[0] === "exploration" &&
- seg[1] === "findings" &&
- seg.length === 3 &&
- seg[2] !== "stats"
- ) {
+ if (seg[0] === "exploration" && seg[1] === "findings" && seg.length === 3 && seg[2] !== "stats") {
const f = mockFindings.find((x) => x.id === seg[2]);
if (!f) return {};
if (m === "PATCH") {
if (typeof b.status === "string") f.status = b.status as typeof f.status;
- if (typeof b.severity === "string")
- f.severity = b.severity as typeof f.severity;
+ if (typeof b.severity === "string") f.severity = b.severity as typeof f.severity;
if (typeof b.name === "string") f.name = b.name;
if (typeof b.vulnclass === "string") f.vulnclass = b.vulnclass;
}
@@ -294,7 +350,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/exploration/activity" && seg.length === 2) {
const since = Number(q.get("since") ?? 0);
const limit = Math.max(1, Number(q.get("limit") ?? 300));
- const items = D.activityForTask().filter((item) => item.seq > since).slice(0, limit);
+ const items = D.activityForTask()
+ .filter((item) => item.seq > since)
+ .slice(0, limit);
return { items, cursor: items.length ? items[items.length - 1].seq : since };
}
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
@@ -315,7 +373,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/settings" && m === "PUT") return { ...D.settings, ...b };
if (path === "/settings/web-search/test") return { ok: true, count: 5, backend: D.settings.web_search_backend };
if (path === "/settings/python/detect") return { python_interpreter: "/usr/bin/python3" };
- if (path === "/chat") return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" };
+ if (path === "/chat")
+ return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" };
if (path === "/gc") return { removed: 0 };
// ── 工具执行历史 ──
@@ -343,11 +402,20 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/llm/pool" && m === "GET") return D.llmPool;
if (path === "/llm/pool/reset")
return { ...D.llmPool, chain: D.llmPool.chain.map((c) => ({ ...c, state: "ok", fails: 0, cooldown_secs: 0 })) };
- if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE") return { deleted: Number(seg[2]) };
+ if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE")
+ return { deleted: Number(seg[2]) };
// ── agents ──
if (path === "/agents" && m === "GET") return { agents: D.agents };
- if (path === "/agents" && m === "POST") return { id: "9", key: String(b.key ?? "custom"), name: String(b.name ?? ""), role: "custom", builtin: false, enabled: true };
+ if (path === "/agents" && m === "POST")
+ return {
+ id: "9",
+ key: String(b.key ?? "custom"),
+ name: String(b.name ?? ""),
+ role: "custom",
+ builtin: false,
+ enabled: true,
+ };
if (seg[0] === "agents" && seg.length === 2 && m === "GET") return D.agentDetail(seg[1]);
if (seg[0] === "agents" && seg[2] === "triggers" && m === "GET") return { triggers: [] };
if (seg[0] === "agents" && seg[2] === "prompts") return { versions: D.agentDetail(seg[1]).versions };
@@ -358,7 +426,14 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── conversations ──
if (path === "/conversations" && m === "GET") return { conversations: D.conversations };
- if (path === "/conversations" && m === "POST") return { id: 3, agent_key: String(b.agent_key ?? "mainagent"), title: String(b.title ?? "新会话"), created_at: "2026-07-26T04:00:00Z", updated_at: "2026-07-26T04:00:00Z" };
+ if (path === "/conversations" && m === "POST")
+ return {
+ id: 3,
+ agent_key: String(b.agent_key ?? "mainagent"),
+ title: String(b.title ?? "新会话"),
+ created_at: "2026-07-26T04:00:00Z",
+ updated_at: "2026-07-26T04:00:00Z",
+ };
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
const items = D.conversationMessages[Number(seg[1])] ?? [];
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
@@ -384,7 +459,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (seg[0] === "mcp" && seg.length === 2 && m === "DELETE") return { deleted: Number(seg[1]) };
// ── scopesentry(demo:未配置)──
- if (path === "/sync/scopesentry/status") return { exists: false, configured: false, enabled: false, reachable: false, tools: [] };
+ if (path === "/sync/scopesentry/status")
+ return { exists: false, configured: false, enabled: false, reachable: false, tools: [] };
if (path === "/sync/scopesentry/projects") return { projects: [], tag: {} };
if (path === "/sync/scopesentry/tasks") return { tasks: [] };
if (path === "/sync/scopesentry/sync") return { synced: {}, companies: null, warnings: null, errors: null };
@@ -401,7 +477,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── intercept ──
if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules };
- if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle") return { ok: true, enabled: b.enabled ?? true };
+ if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle")
+ return { ok: true, enabled: b.enabled ?? true };
if (path === "/intercept/pending" && m === "GET") return { pending: D.interceptPending };
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") return { ok: true };
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
@@ -415,5 +492,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (["POST", "PUT", "PATCH", "DELETE"].includes(m)) return { ok: true };
// ── 读兜底:集合类给 [],其余 {} ──
- return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test(path) ? [] : {};
+ return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test(
+ path,
+ )
+ ? []
+ : {};
}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index cc5edf7..995e893 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -242,9 +242,9 @@ export interface ScopeRow {
id: number;
company_id: number;
kind: "domain" | "ip" | "cidr";
- domain?: string; // kind=domain 时有值
- net?: string; // kind=ip|cidr 时有值
- raw: string; // 原始用户输入,用于显示和回填
+ domain?: string; // kind=domain 时有值
+ net?: string; // kind=ip|cidr 时有值
+ raw: string; // 原始用户输入,用于显示和回填
reason?: string;
}
@@ -365,11 +365,11 @@ export type ActivityKind =
| "thinking"
| "result"
| "user"
- | "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
- | "round" // planner round boundary marker (engine-emitted)
- | "usage" // live cumulative token usage (per model turn); not rendered
- | "llm_switch" // automatic/manual task-level LLM switch
- | "llm_failover" // task-level provider switch / chain exhaustion audit event
+ | "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
+ | "round" // planner round boundary marker (engine-emitted)
+ | "usage" // live cumulative token usage (per model turn); not rendered
+ | "llm_switch" // automatic/manual task-level LLM switch
+ | "llm_failover" // task-level provider switch / chain exhaustion audit event
| "intercept_request"; // user-approval request from the intercept layer
// ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。
@@ -487,6 +487,43 @@ export interface TokenTotal {
cache_write_tokens: number;
}
+// Global per-profile token spend from the llm_usage ledger (GET /api/tokens/usage).
+export interface ProfileUsage {
+ profile_name: string;
+ calls: number;
+ tasks: number;
+ input_tokens: number;
+ output_tokens: number;
+ cache_read_tokens: number;
+ cache_write_tokens: number;
+}
+
+// One (profile, UTC day) token bucket for the dashboard's daily chart (new source).
+export interface ProfileDayUsage {
+ profile_name: string;
+ date: string; // YYYY-MM-DD
+ input_tokens: number;
+ output_tokens: number;
+ cache_read_tokens: number;
+}
+
+// Response of GET /api/tokens/usage — the dashboard's "new" (llm_usage) token view.
+export interface UsageStats {
+ by_profile: ProfileUsage[];
+ daily: ProfileDayUsage[];
+}
+
+// Per-model token usage for one task (GET /api/llm/records/by-model), from the
+// always-on llm_usage metering ledger. calls = number of LLM calls on this model.
+export interface ModelTokenStat {
+ model: string;
+ calls: number;
+ input_tokens: number;
+ output_tokens: number;
+ cache_read_tokens: number;
+ cache_write_tokens: number;
+}
+
export interface Session {
id: string;
role: SessionRole;
@@ -702,12 +739,12 @@ export interface MCPTool {
// Fields align with the agentskills.io open specification.
// description covers both "what the skill does" and "when to use it".
export interface SkillItem {
- name: string; // unique key = directory name
- description?: string; // required per spec; covers what + when to use
- license?: string; // optional: SPDX identifier or free text
+ name: string; // unique key = directory name
+ description?: string; // required per spec; covers what + when to use
+ license?: string; // optional: SPDX identifier or free text
compatibility?: string; // optional: environment requirements
- mcps?: string[]; // MCP server names this skill unlocks on load
- files: string[]; // files in the skill directory
+ mcps?: string[]; // MCP server names this skill unlocks on load
+ files: string[]; // files in the skill directory
}
// ---- Tools (内置工具目录) ----
@@ -720,12 +757,12 @@ export interface Tool {
description: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema: Record; // full JSON-Schema (object with properties)
- agents: string[]; // bound agent keys
+ agents: string[]; // bound agent keys
enabled: boolean;
kind?: "builtin" | "shell" | "command" | "script" | "http"; // 自定义工具类型
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- exec?: Record; // 自定义工具执行规格(kind!=builtin)
- deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
+ exec?: Record; // 自定义工具执行规格(kind!=builtin)
+ deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
}
// ---- Stats ----
@@ -776,14 +813,14 @@ export interface InterceptPending {
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
export interface InterceptApprovalRow extends InterceptPending {
- conv_title: string; // "" if no linked conversation
- conv_agent_key: string; // "" if no linked conversation
- rule_name: string; // "" if rule was deleted
+ conv_title: string; // "" if no linked conversation
+ conv_agent_key: string; // "" if no linked conversation
+ rule_name: string; // "" if rule was deleted
}
// ── 资产同步 (ScopeSentry 数据源) ──────────────────────────────────────────────
export interface SSProject {
- id: string; // MongoDB ObjectID — used as filter.project
+ id: string; // MongoDB ObjectID — used as filter.project
name: string;
logo?: string;
AssetCount?: number;
@@ -792,7 +829,7 @@ export interface SSProject {
export interface SSTask {
id: string;
- name: string; // used as filter.task
+ name: string; // used as filter.task
status?: number;
progress?: number;
creatTime?: string;
|