diff --git a/db/commands.go b/db/commands.go index 82b9c45..27172da 100644 --- a/db/commands.go +++ b/db/commands.go @@ -209,6 +209,18 @@ func (d *DB) ListLLMRecords(model, session, task string, page, size int) ([]LLMR return out, total, rows.Err() } +// ModelTokenStat is one model's aggregated token usage for a task, summed from the +// llm_usage metering ledger (see db/llm_usage.go). Calls is the number of LLM calls +// that hit this model. +type ModelTokenStat struct { + Model string `json:"model"` + Calls int `json:"calls"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_tokens"` + CacheWriteTokens int `json:"cache_write_tokens"` +} + // LLMTask is one distinct task with its LLM-record count. type LLMTask struct { TaskID string `json:"task_id"` diff --git a/db/llm_usage.go b/db/llm_usage.go new file mode 100644 index 0000000..ecbf83a --- /dev/null +++ b/db/llm_usage.go @@ -0,0 +1,183 @@ +package db + +import "strconv" + +// LLMUsage is one lightweight LLM-call metering row — the always-on usage ledger, +// distinct from llm_records (which stores full request/response bodies and is a +// gated debug feature). One row per completion call, written on both success and +// error, so token accounting is complete even for interrupted/failed runs. Carries +// only the dimensions needed to slice token spend (model / profile / task / agent), +// never any prompt or response content. +type LLMUsage struct { + TaskID string `json:"task_id"` // task registry id (matches llm_records.task_id) + ExplorationID int64 `json:"exploration_id"` // exploration id parsed from the session (0 = unknown/non-task) + Worker string `json:"worker"` // agent lane: worker / planner / mainagent / goals + Model string `json:"model"` + ProfileName string `json:"profile_name"` + LatencyMs int `json:"latency_ms"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheRead int `json:"cache_read"` + CacheWrite int `json:"cache_write"` + Status string `json:"status"` // ok | error +} + +const llmUsageSchema = ` +CREATE TABLE IF NOT EXISTS llm_usage ( + id BIGSERIAL PRIMARY KEY, + ts TIMESTAMPTZ NOT NULL DEFAULT now(), + task_id TEXT, + exploration_id BIGINT, + worker TEXT, + model TEXT, + profile_name TEXT, + latency_ms INTEGER, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read INTEGER NOT NULL DEFAULT 0, + cache_write INTEGER NOT NULL DEFAULT 0, + status TEXT +); +CREATE INDEX IF NOT EXISTS idx_llm_usage_task ON llm_usage(task_id); +CREATE INDEX IF NOT EXISTS idx_llm_usage_model ON llm_usage(task_id, model); +CREATE INDEX IF NOT EXISTS idx_llm_usage_exp ON llm_usage(exploration_id); +` + +// EnsureLLMUsageTable creates the llm_usage metering table if it does not exist. +func (d *DB) EnsureLLMUsageTable() error { + _, err := d.Exec(llmUsageSchema) + return err +} + +// InsertLLMUsage appends one metering row. Best-effort: callers log and continue on +// error (a lost metering row must never break the LLM call). +func (d *DB) InsertLLMUsage(u *LLMUsage) error { + var expID any + if u.ExplorationID > 0 { + expID = u.ExplorationID + } + _, err := d.Exec(` +INSERT INTO llm_usage(task_id, exploration_id, worker, model, profile_name, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status) +VALUES (NULLIF($1,''),$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),$6,$7,$8,$9,$10,$11)`, + u.TaskID, expID, u.Worker, u.Model, u.ProfileName, + u.LatencyMs, u.InputTokens, u.OutputTokens, u.CacheRead, u.CacheWrite, u.Status) + return err +} + +// TokenByModel aggregates a task's LLM token usage grouped by model, most-used +// first, from the always-on llm_usage ledger. taskID is the task registry id. +// Accurate even with per-agent model bindings, pool rotation/failover, and +// interrupted runs, since every call (success or error) is metered. +func (d *DB) TokenByModel(taskID string) ([]ModelTokenStat, error) { + rows, err := d.Query(` +SELECT COALESCE(NULLIF(model,''),'(unknown)') AS model, COUNT(*) AS calls, + COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), + COALESCE(SUM(cache_read),0), COALESCE(SUM(cache_write),0) +FROM llm_usage +WHERE COALESCE(task_id,'') = $1 +GROUP BY model +ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC, model`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ModelTokenStat{} + for rows.Next() { + var m ModelTokenStat + if err := rows.Scan(&m.Model, &m.Calls, &m.InputTokens, &m.OutputTokens, + &m.CacheReadTokens, &m.CacheWriteTokens); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// ProfileUsage aggregates the whole ledger's token spend for one LLM profile +// (global, all tasks). Powers the dashboard's per-profile token card (new source). +type ProfileUsage struct { + ProfileName string `json:"profile_name"` + Calls int `json:"calls"` + Tasks int `json:"tasks"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_tokens"` + CacheWriteTokens int `json:"cache_write_tokens"` +} + +// UsageByProfile returns global token spend grouped by profile name, most-used +// first. profile_name may be empty for calls made on env/non-persisted configs. +func (d *DB) UsageByProfile() ([]ProfileUsage, error) { + rows, err := d.Query(` +SELECT COALESCE(profile_name,'') AS profile_name, COUNT(*) AS calls, + COUNT(DISTINCT task_id) AS tasks, + COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), + COALESCE(SUM(cache_read),0), COALESCE(SUM(cache_write),0) +FROM llm_usage +GROUP BY profile_name +ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ProfileUsage{} + for rows.Next() { + var p ProfileUsage + if err := rows.Scan(&p.ProfileName, &p.Calls, &p.Tasks, + &p.InputTokens, &p.OutputTokens, &p.CacheReadTokens, &p.CacheWriteTokens); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ProfileDayUsage is one (profile, UTC calendar day) token bucket for the daily +// chart. Unlike the activity-based chart, ts is the real call time, so this is +// actual per-day consumption rather than tokens bucketed by task creation date. +type ProfileDayUsage struct { + ProfileName string `json:"profile_name"` + Date string `json:"date"` // YYYY-MM-DD (UTC) + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_tokens"` +} + +// UsageDaily returns per-(profile, day) token buckets for the past `days` days +// (default 365 when days<=0), so the dashboard can slice by profile + range. +func (d *DB) UsageDaily(days int) ([]ProfileDayUsage, error) { + if days <= 0 { + days = 365 + } + rows, err := d.Query(` +SELECT COALESCE(profile_name,'') AS profile_name, + to_char(ts AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day, + COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), COALESCE(SUM(cache_read),0) +FROM llm_usage +WHERE ts >= now() - ($1 * interval '1 day') +GROUP BY profile_name, day +ORDER BY day`, days) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ProfileDayUsage{} + for rows.Next() { + var p ProfileDayUsage + if err := rows.Scan(&p.ProfileName, &p.Date, &p.InputTokens, &p.OutputTokens, &p.CacheReadTokens); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ParseExpID turns the exploration-id segment parsed from a session string into an +// int64 (0 when empty/non-numeric, e.g. chat sessions keyed by conversation id). +func ParseExpID(s string) int64 { + n, err := strconv.ParseInt(s, 10, 64) + if err != nil || n < 0 { + return 0 + } + return n +} diff --git a/server/commands.go b/server/commands.go index bcb35e0..12904a7 100644 --- a/server/commands.go +++ b/server/commands.go @@ -4,6 +4,8 @@ import ( "net/http" "strconv" "strings" + + "github.com/Autumn-27/artex/db" ) // pgListCommands returns tool executions (any tool) from the activity table. @@ -45,6 +47,53 @@ func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"records": records, "total": total}) } +// pgTokenByModel returns a task's LLM token usage grouped by model, from the +// always-on llm_usage metering ledger. Accurate even with per-agent model bindings, +// pool rotation/failover, and interrupted runs (every call is metered on success or +// error). No recording toggle required. +func (s *Server) pgTokenByModel(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.URL.Query().Get("task")) + if id == "" { + writeErr(w, 400, "missing task") + return + } + pg := s.m.PG() + if pg == nil { + writeJSON(w, 200, map[string]any{"models": []db.ModelTokenStat{}}) + return + } + models, err := pg.TokenByModel(id) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"models": models}) +} + +// pgUsageStats returns global llm_usage aggregates for the dashboard's "new" token +// view: per-profile totals (all-time) + per-(profile, day) buckets for the daily +// chart. Sourced from the always-on metering ledger, so it is accurate across +// per-agent bindings, pool rotation, and interrupted runs. +func (s *Server) pgUsageStats(w http.ResponseWriter, r *http.Request) { + pg := s.m.PG() + if pg == nil { + writeJSON(w, 200, map[string]any{"by_profile": []db.ProfileUsage{}, "daily": []db.ProfileDayUsage{}}) + return + } + days := atoiDefault(r.URL.Query().Get("days"), 365) + byProfile, err := pg.UsageByProfile() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + daily, err := pg.UsageDaily(days) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"by_profile": byProfile, "daily": daily}) +} + // pgLLMTasks returns distinct recorded tasks with counts, for the page's task // picker (pick a task → filter the list, then delete its conversations). func (s *Server) pgLLMTasks(w http.ResponseWriter, r *http.Request) { diff --git a/web/src/app/(main)/dashboard/page.tsx b/web/src/app/(main)/dashboard/page.tsx index 9aaab90..df1b5b3 100644 --- a/web/src/app/(main)/dashboard/page.tsx +++ b/web/src/app/(main)/dashboard/page.tsx @@ -1,41 +1,31 @@ "use client"; import * as React from "react"; + import Link from "next/link"; + import { ActivityIcon, ArrowUpRightIcon, BugIcon, ClockIcon, NetworkIcon, - PlusIcon, ShieldCheckIcon, TargetIcon, ZapIcon, } from "lucide-react"; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip as RechartsTooltip, -} from "recharts"; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; -import { cn } from "@/lib/utils"; +import { Bar, BarChart, CartesianGrid, Tooltip as RechartsTooltip, XAxis, YAxis } from "recharts"; + +import { StatusBadge } from "@/components/status-badge"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; import { Progress } from "@/components/ui/progress"; -import { StatusBadge } from "@/components/status-badge"; import { api } from "@/lib/api"; import type { Activity, Agent, + ConvTokenSummary, Finding, InterceptPending, LLMProfile, @@ -47,14 +37,15 @@ import type { TokenTotal, Tool, TrafficExchange, - ConvTokenSummary, + UsageStats, } from "@/lib/types"; +import { cn } from "@/lib/utils"; // ── chart constants ─────────────────────────────────────────────────────────── const dailyTrendConfig = { - input: { label: "输入", color: "hsl(217 91% 60%)" }, - output: { label: "输出", color: "hsl(263 70% 60%)" }, + input: { label: "输入", color: "hsl(217 91% 60%)" }, + output: { label: "输出", color: "hsl(263 70% 60%)" }, cacheRead: { label: "缓存读", color: "hsl(160 60% 45%)" }, } satisfies ChartConfig; @@ -62,9 +53,7 @@ const dailyTrendConfig = { function fmtRel(ts?: string | number): string { if (!ts) return "—"; - const ms = - Date.now() - - (typeof ts === "number" ? ts * 1000 : Date.parse(ts as string)); + const ms = Date.now() - (typeof ts === "number" ? ts * 1000 : Date.parse(ts as string)); const s = Math.floor(ms / 1000); if (s < 60) return `${s}s 前`; const m = Math.floor(s / 60); @@ -115,14 +104,7 @@ function statusBg(code: number): string { // ── sub-components ──────────────────────────────────────────────────────────── function LiveDot({ className }: { className?: string }) { - return ( - - ); + return ; } function SectionTitle({ @@ -157,6 +139,7 @@ export default function DashboardPage() { const [activity, setActivity] = React.useState([]); const [tokens, setTokens] = React.useState(null); const [convTokens, setConvTokens] = React.useState([]); + const [usageStats, setUsageStats] = React.useState(null); const [assetCounts, setAssetCounts] = React.useState>({}); const [traffic, setTraffic] = React.useState([]); const [agents, setAgents] = React.useState([]); @@ -193,9 +176,12 @@ export default function DashboardPage() { /* transient errors — next poll retries */ } }; - load(); + void load(); const t = setInterval(load, 5000); - return () => { alive = false; clearInterval(t); }; + return () => { + alive = false; + clearInterval(t); + }; }, []); // slow poll: traffic, assets, system-static (every 15s) @@ -203,16 +189,16 @@ export default function DashboardPage() { let alive = true; const load = async () => { try { - const [traf, counts, agentList, mcpList, skillList, toolList, profileList] = - await Promise.all([ - api.traffic(0, 50), - api.assetCounts(), - api.agents(), - api.mcpServers(), - api.skills(), - api.tools(), - api.llmProfiles(), - ]); + const [traf, counts, agentList, mcpList, skillList, toolList, profileList, usage] = await Promise.all([ + api.traffic(0, 50), + api.assetCounts(), + api.agents(), + api.mcpServers(), + api.skills(), + api.tools(), + api.llmProfiles(), + api.usageStats(365), + ]); if (!alive) return; setTraffic(traf.exchanges ?? []); setAssetCounts(counts ?? {}); @@ -221,13 +207,17 @@ export default function DashboardPage() { setSkills(skillList); setTools(toolList); setLLMProfiles(profileList); + setUsageStats(usage); } catch { /* transient errors */ } }; - load(); + void load(); const t = setInterval(load, 15000); - return () => { alive = false; clearInterval(t); }; + return () => { + alive = false; + clearInterval(t); + }; }, []); // ── derived ─────────────────────────────────────────────────────────────── @@ -249,20 +239,13 @@ export default function DashboardPage() { const sortedTasks = React.useMemo( () => [...tasks] - .sort( - (a, b) => - (b.last_activity_unix ?? b.created_unix ?? 0) - - (a.last_activity_unix ?? a.created_unix ?? 0), - ) + .sort((a, b) => (b.last_activity_unix ?? b.created_unix ?? 0) - (a.last_activity_unix ?? a.created_unix ?? 0)) .slice(0, 5), [tasks], ); const recentFindings = React.useMemo( - () => - [...findings] - .sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)) - .slice(0, 8), + () => [...findings].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)).slice(0, 8), [findings], ); @@ -275,10 +258,7 @@ export default function DashboardPage() { [activity], ); - const totalAssets = React.useMemo( - () => Object.values(assetCounts).reduce((a, b) => a + b, 0), - [assetCounts], - ); + const totalAssets = React.useMemo(() => Object.values(assetCounts).reduce((a, b) => a + b, 0), [assetCounts]); // asset type breakdown const assetByType = React.useMemo(() => { @@ -303,9 +283,7 @@ export default function DashboardPage() { }, [traffic]); const trafficMax = Math.max(...trafficByCodes.map((x) => x.n), 1); - const recentTraffic = [...traffic] - .sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)) - .slice(0, 5); + const recentTraffic = [...traffic].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)).slice(0, 5); // system const activeProfile = llmProfiles.find((p) => p.is_default); @@ -316,100 +294,147 @@ export default function DashboardPage() { // tasks whose llm_profile_id is null/undefined used the active default profile const defaultProfileId = activeProfile ? Number(activeProfile.id) : null; - const tokenByProfile = React.useMemo< - Map - >(() => { - const m = new Map(); - for (const t of tasks) { - // null means "used whatever was active" → bucket under defaultProfileId - const key = t.llm_profile_id != null ? t.llm_profile_id : defaultProfileId; - const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; - m.set(key, { - input: prev.input + (t.tokens?.input_tokens ?? 0), - output: prev.output + (t.tokens?.output_tokens ?? 0), - cacheRead: prev.cacheRead + (t.tokens?.cache_read_tokens ?? 0), - cacheWrite: prev.cacheWrite + (t.tokens?.cache_write_tokens ?? 0), - taskCount: prev.taskCount + 1, - }); - } - // merge conversation (chat) usage into the same per-profile buckets — token sums - // only; taskCount stays a task count. - for (const c of convTokens) { - const key = c.llm_profile_id != null ? c.llm_profile_id : defaultProfileId; - const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; - m.set(key, { - input: prev.input + c.input_tokens, - output: prev.output + c.output_tokens, - cacheRead: prev.cacheRead + c.cache_read_tokens, - cacheWrite: prev.cacheWrite + c.cache_write_tokens, - taskCount: prev.taskCount, - }); - } - return m; - }, [tasks, convTokens, defaultProfileId]); - + // 数据源开关:旧版 = activity(task.tokens + 会话),新版 = llm_usage 计量账本。 + const [tokenVersion, setTokenVersion] = React.useState<"old" | "new">("old"); // selected profile tab: "all" = 全部; number = specific profile id const [tokenTab, setTokenTab] = React.useState("all"); // day range for the daily bar chart const [tokenDays, setTokenDays] = React.useState<7 | 30 | 90 | 180 | 365>(30); + // profile 名 → id,用于把 llm_usage 的 profile_name 映射到现有 profile 分栏。 + const profileIdByName = React.useMemo(() => { + const m = new Map(); + for (const p of llmProfiles) m.set(p.name, Number(p.id)); + return m; + }, [llmProfiles]); + + type Bucket = { input: number; output: number; cacheRead: number; cacheWrite: number; taskCount: number }; + + // 旧版:按 profile 归桶(来自 activity 的 task.tokens + 会话用量)。 + const tokenByProfileOld = React.useMemo>(() => { + const m = new Map(); + const fold = (key: number | null, inp: number, out: number, cr: number, cw: number, addTask: boolean) => { + const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; + m.set(key, { + input: prev.input + inp, + output: prev.output + out, + cacheRead: prev.cacheRead + cr, + cacheWrite: prev.cacheWrite + cw, + taskCount: prev.taskCount + (addTask ? 1 : 0), + }); + }; + for (const t of tasks) { + fold( + t.llm_profile_id ?? defaultProfileId, + t.tokens?.input_tokens ?? 0, + t.tokens?.output_tokens ?? 0, + t.tokens?.cache_read_tokens ?? 0, + t.tokens?.cache_write_tokens ?? 0, + true, + ); + } + for (const c of convTokens) { + fold( + c.llm_profile_id ?? defaultProfileId, + c.input_tokens, + c.output_tokens, + c.cache_read_tokens, + c.cache_write_tokens, + false, + ); + } + return m; + }, [tasks, convTokens, defaultProfileId]); + + // 新版:按 profile 归桶(来自 llm_usage 全局聚合,逐次调用精确)。 + const tokenByProfileNew = React.useMemo>(() => { + const m = new Map(); + for (const p of usageStats?.by_profile ?? []) { + // 未匹配到现有 profile(改名/删除/空名)→ 落到默认桶,仍计入「全部」。 + const key = profileIdByName.get(p.profile_name) ?? defaultProfileId; + const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; + m.set(key, { + input: prev.input + p.input_tokens, + output: prev.output + p.output_tokens, + cacheRead: prev.cacheRead + p.cache_read_tokens, + cacheWrite: prev.cacheWrite + p.cache_write_tokens, + taskCount: prev.taskCount + p.tasks, + }); + } + return m; + }, [usageStats, profileIdByName, defaultProfileId]); + + const tokenByProfile = tokenVersion === "new" ? tokenByProfileNew : tokenByProfileOld; + const displayedTokens = React.useMemo(() => { if (tokenTab === "all") { - // sum across all profiles - let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, taskCount = 0; + let input = 0, + output = 0, + cacheRead = 0, + cacheWrite = 0, + taskCount = 0; for (const v of tokenByProfile.values()) { - input += v.input; output += v.output; - cacheRead += v.cacheRead; cacheWrite += v.cacheWrite; + input += v.input; + output += v.output; + cacheRead += v.cacheRead; + cacheWrite += v.cacheWrite; taskCount += v.taskCount; } return { input, output, cacheRead, cacheWrite, taskCount }; } const v = tokenByProfile.get(tokenTab as number | null); - return v ? { input: v.input, output: v.output, cacheRead: v.cacheRead, cacheWrite: v.cacheWrite, taskCount: v.taskCount } - : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; + return v + ? { input: v.input, output: v.output, cacheRead: v.cacheRead, cacheWrite: v.cacheWrite, taskCount: v.taskCount } + : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; }, [tokenTab, tokenByProfile]); - // daily token data: tasks grouped by date, filtered by day range and selected profile - const dailyTokenData = React.useMemo(() => { + // 旧版每日:把任务/会话的总量按其创建日期归桶(近似,非真实每日消耗)。 + const dailyTokenDataOld = React.useMemo(() => { const now = new Date(); now.setDate(now.getDate() - tokenDays); const cutoff = now.toISOString().slice(0, 10); - const m = new Map(); + const fold = (date: string, inp: number, out: number, cr: number) => { + if (date < cutoff) return; + const prev = m.get(date) ?? { input: 0, output: 0, cacheRead: 0 }; + m.set(date, { input: prev.input + inp, output: prev.output + out, cacheRead: prev.cacheRead + cr }); + }; for (const t of tasks) { - // filter by profile tab when not "all" - if (tokenTab !== "all") { - const taskKey = t.llm_profile_id ?? defaultProfileId; - if (taskKey !== tokenTab) continue; - } - const date = t.created_at.slice(0, 10); - if (date < cutoff) continue; - const prev = m.get(date) ?? { input: 0, output: 0, cacheRead: 0 }; - m.set(date, { - input: prev.input + (t.tokens?.input_tokens ?? 0), - output: prev.output + (t.tokens?.output_tokens ?? 0), - cacheRead: prev.cacheRead + (t.tokens?.cache_read_tokens ?? 0), - }); + if (tokenTab !== "all" && (t.llm_profile_id ?? defaultProfileId) !== tokenTab) continue; + fold( + t.created_at.slice(0, 10), + t.tokens?.input_tokens ?? 0, + t.tokens?.output_tokens ?? 0, + t.tokens?.cache_read_tokens ?? 0, + ); } - // merge conversation usage by conversation created_at date, same profile filter. for (const c of convTokens) { - if (tokenTab !== "all") { - const convKey = c.llm_profile_id ?? defaultProfileId; - if (convKey !== tokenTab) continue; - } - const date = c.created_at.slice(0, 10); - if (date < cutoff) continue; - const prev = m.get(date) ?? { input: 0, output: 0, cacheRead: 0 }; - m.set(date, { - input: prev.input + c.input_tokens, - output: prev.output + c.output_tokens, - cacheRead: prev.cacheRead + c.cache_read_tokens, + if (tokenTab !== "all" && (c.llm_profile_id ?? defaultProfileId) !== tokenTab) continue; + fold(c.created_at.slice(0, 10), c.input_tokens, c.output_tokens, c.cache_read_tokens); + } + return [...m.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, v]) => ({ date: date.slice(5), ...v })); + }, [tasks, convTokens, tokenDays, tokenTab, defaultProfileId]); + + // 新版每日:来自 llm_usage 的真实每日消耗(ts 是实际调用时刻)。 + const dailyTokenDataNew = React.useMemo(() => { + const now = new Date(); + now.setDate(now.getDate() - tokenDays); + const cutoff = now.toISOString().slice(0, 10); + const m = new Map(); + for (const d of usageStats?.daily ?? []) { + if (d.date < cutoff) continue; + if (tokenTab !== "all" && (profileIdByName.get(d.profile_name) ?? defaultProfileId) !== tokenTab) continue; + const prev = m.get(d.date) ?? { input: 0, output: 0, cacheRead: 0 }; + m.set(d.date, { + input: prev.input + d.input_tokens, + output: prev.output + d.output_tokens, + cacheRead: prev.cacheRead + d.cache_read_tokens, }); } - return [...m.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, v]) => ({ date: date.slice(5), ...v })); - }, [tasks, convTokens, tokenDays, tokenTab, defaultProfileId]); + return [...m.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, v]) => ({ date: date.slice(5), ...v })); + }, [usageStats, tokenDays, tokenTab, profileIdByName, defaultProfileId]); + + const dailyTokenData = tokenVersion === "new" ? dailyTokenDataNew : dailyTokenDataOld; // activity kind label function kindLabel(a: Activity): string { @@ -434,24 +459,16 @@ export default function DashboardPage() { return (
- {/* ── Header ── */} -
+

总览

系统全局状态 · 实时刷新

- - -
{/* ── Row 1: 5 stat cards ── */}
- {/* 活跃任务 */} @@ -459,25 +476,15 @@ export default function DashboardPage() { 活跃任务
- - {tasksByStatus.running ?? 0} - + {tasksByStatus.running ?? 0} / {tasks.length}
- {(tasksByStatus.running ?? 0) > 0 && ( - 探索 {tasksByStatus.running} - )} - {(tasksByStatus.paused ?? 0) > 0 && ( - 暂停 {tasksByStatus.paused} - )} - {(tasksByStatus.done ?? 0) > 0 && ( - 完成 {tasksByStatus.done} - )} - {tasks.length === 0 && ( - 暂无任务 - )} + {(tasksByStatus.running ?? 0) > 0 && 探索 {tasksByStatus.running}} + {(tasksByStatus.paused ?? 0) > 0 && 暂停 {tasksByStatus.paused}} + {(tasksByStatus.done ?? 0) > 0 && 完成 {tasksByStatus.done}} + {tasks.length === 0 && 暂无任务} @@ -487,9 +494,7 @@ export default function DashboardPage() {
确认发现
-
- {findings.length} -
+
{findings.length}
严重 {findingsBySev.critical} @@ -505,13 +510,9 @@ export default function DashboardPage() {
资产节点
-
- {totalAssets} -
+
{totalAssets}
- - 跨任务共享 - + 跨任务共享 {/* 流量交互 */} @@ -520,9 +521,7 @@ export default function DashboardPage() {
流量交互
-
- {traffic.length} -
+
{traffic.length}
{settings?.traffic_capture ? ( @@ -547,7 +546,8 @@ export default function DashboardPage() {
- 入 {fmtTokens(displayedTokens.input)}(含缓存 {fmtTokens(displayedTokens.cacheRead)})· 出 {fmtTokens(displayedTokens.output)} + 入 {fmtTokens(displayedTokens.input)}(含缓存 {fmtTokens(displayedTokens.cacheRead)})· 出{" "} + {fmtTokens(displayedTokens.output)}
@@ -559,13 +559,42 @@ export default function DashboardPage() {
LLM Token 消耗 - - {tasks.length} 个任务累计 + {/* 数据源开关:旧版=activity 统计(含历史任务),新版=llm_usage 计量账本(更准,仅覆盖启用后) */} +
+ {( + [ + { v: "old", label: "旧版" }, + { v: "new", label: "新版" }, + ] as const + ).map(({ v, label }) => ( + + ))} +
+ + {tokenVersion === "new" ? "llm_usage" : "activity"}
{/* Profile tabs */}
{dailyTokenData.length === 0 ? ( -
+
暂无数据
) : ( - + (
- {(dailyTrendConfig as Record)[String(name)]?.label ?? String(name)} - - {fmtTokens(Number(value))} + + {(dailyTrendConfig as Record)[String(name)]?.label ?? + String(name)} + {fmtTokens(Number(value))}
)} /> } /> - - + +
@@ -765,7 +812,6 @@ export default function DashboardPage() { {/* ── Row 3: 活动流 | 发现 ── */}
- {/* 活动流 */}
@@ -787,27 +833,20 @@ export default function DashboardPage() {
- {(["tool_use", "tool_result", "text", "thinking", "result"] as const).map( - (kind) => { - const count = activity.filter((a) => a.kind === kind).length; - if (count === 0) return null; - return ( - - {kind} {count} - - ); - }, - )} + {(["tool_use", "tool_result", "text", "thinking", "result"] as const).map((kind) => { + const count = activity.filter((a) => a.kind === kind).length; + if (count === 0) return null; + return ( + + {kind} {count} + + ); + })}
{recentActivity.length === 0 ? ( -
- 暂无活动记录 -
+
暂无活动记录
) : ( recentActivity.map((a) => (
@@ -821,20 +860,13 @@ export default function DashboardPage() {
{kindLabel(a)}
-
- {a.summary} -
+
{a.summary}
- + {a.kind} -
- {fmtRel(a.ts)} -
+
{fmtRel(a.ts)}
)) @@ -859,9 +891,7 @@ export default function DashboardPage() {
{recentFindings.length === 0 ? ( -
- 暂无发现 -
+
暂无发现
) : ( recentFindings.map((f) => (
@@ -874,18 +904,14 @@ export default function DashboardPage() {
{f.vulnclass} {f.task_description && ( - - {f.task_description} - + {f.task_description} )}
{f.summary}
- - {fmtRel(f.ts)} - + {fmtRel(f.ts)}
)) )} @@ -901,9 +927,7 @@ export default function DashboardPage() { 任务
- - {tasks.length} 个任务 - + {tasks.length} 个任务 - {["任务", "状态", "引擎", "目标进度", "在途", "最近活动"].map( - (h) => ( - - {h} - - ), - )} + {["任务", "状态", "引擎", "目标进度", "在途", "最近活动"].map((h) => ( + + {h} + + ))} {sortedTasks.length === 0 ? ( - + 暂无任务 ) : ( sortedTasks.map((t) => { - const goalsPct = t.goals_total - ? Math.round(((t.goals_met ?? 0) / t.goals_total) * 100) - : null; + const goalsPct = t.goals_total ? Math.round(((t.goals_met ?? 0) / t.goals_total) * 100) : null; return ( - + - - - {t.description} - - - {t.id} - + + {t.description} + {t.id} @@ -983,7 +990,7 @@ export default function DashboardPage() { )} - {t.in_flight ?? 0 > 0 ? ( + {(t.in_flight ?? 0) > 0 ? ( {t.in_flight} ) : ( — @@ -1002,7 +1009,6 @@ export default function DashboardPage() { {/* ── Row 5: 资产分布 | 流量状态码 | 拦截 & 待审批 ── */}
- {/* 资产分布 */} @@ -1010,9 +1016,7 @@ export default function DashboardPage() { {assetByType.length === 0 ? ( -
- 暂无资产数据 -
+
暂无资产数据
) : (
{assetByType.map(([type, count]) => ( @@ -1032,9 +1036,7 @@ export default function DashboardPage() {
)} -
- 共 {totalAssets} 节点 -
+
共 {totalAssets} 节点
{/* 流量状态码 */} @@ -1045,36 +1047,24 @@ export default function DashboardPage() { {/* bar chart */} {trafficByCodes.length === 0 ? ( -
- 暂无流量数据 -
+
暂无流量数据
) : ( <>
{trafficByCodes.map(({ code, n }) => (
- - {n} - + {n}
- - {code} - + {code}
))}
-
- 最近请求 -
+
最近请求
{recentTraffic.map((e) => (
@@ -1092,14 +1082,7 @@ export default function DashboardPage() { {e.host} {e.url.replace(/^https?:\/\/[^/]+/, "").substring(0, 30)} - - {e.status} - + {e.status}
))}
@@ -1154,9 +1137,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}