From 3696604e13afa48f6e1f3339871694e82e28ad62 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 13 Jun 2026 10:55:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=87=E6=A0=87=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=EF=BC=88=E6=89=A7=E8=A1=8C=E6=97=B6=E9=95=BF/=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E7=8E=87/=E5=A4=8D=E5=AE=A1=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E7=8E=87/=E9=87=8D=E8=AF=95=E7=8E=87/=E9=A2=9D=E5=BA=A6?= =?UTF-8?q?=E4=BC=B0=E7=AE=97=EF=BC=89tsk=5FlDx6zd-EbA00?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 runs/tasks/events 聚合健康度与成本指标,看板新增指标小面板。 - Store.metrics(projectId?):执行时长(avg/p50/p95,未结束与负时长不计)、 executor 成功率、复审/安全 verdict 通过率、failed→queued 重试率 + needs_attention、按模型额度粗估(时长×档位权重,标注估算) - src/model/metrics.ts:类型 + 纯函数(percentile/ratio/estimateCostUnits/emptyMetrics) - API:GET /api/metrics?project= - web:归档区上方指标面板(数字卡 + 按模型分布),失败/空数据静默隐藏 - test/metrics.test.ts:9 个用例(纯函数/空数据/时长/成功率/复审/重试/按模型/项目过滤) Co-Authored-By: Claude Opus 4.8 --- src/api/server.ts | 7 ++ src/model/metrics.ts | 110 ++++++++++++++++++++++++ src/store/index.ts | 1 + src/store/store.ts | 119 ++++++++++++++++++++++++++ test/metrics.test.ts | 194 +++++++++++++++++++++++++++++++++++++++++++ web/app.js | 98 +++++++++++++++++++++- web/index.html | 6 ++ web/style.css | 49 +++++++++++ 8 files changed, 582 insertions(+), 2 deletions(-) create mode 100644 src/model/metrics.ts create mode 100644 test/metrics.test.ts diff --git a/src/api/server.ts b/src/api/server.ts index 91284f5..75c35d7 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -147,6 +147,13 @@ export function buildServer(opts: ApiOptions): FastifyInstance { return store.nextExecutable(id) ?? { next: null }; }); + // ---------- Metrics(健康度 + 成本聚合)---------- + // GET /api/metrics?project=;不带 project = 全部项目聚合。 + app.get('/api/metrics', (req) => { + const q = req.query as { project?: string }; + return store.metrics(q.project && q.project.trim() ? q.project.trim() : undefined); + }); + // ---------- Tasks ---------- app.post('/api/projects/:id/tasks', (req) => { const { id } = req.params as { id: string }; diff --git a/src/model/metrics.ts b/src/model/metrics.ts new file mode 100644 index 0000000..863ff3a --- /dev/null +++ b/src/model/metrics.ts @@ -0,0 +1,110 @@ +/** + * 指标聚合的类型与纯函数(无 I/O,便于单测)。 + * 实际从 runs/events/tasks 聚合的逻辑见 Store.metrics()。 + */ + +/** executor 执行时长统计(仅计入已结束、时长非负的 run;单位毫秒) */ +export interface DurationStats { + count: number; // 计入统计的 run 数 + avgMs: number; // 平均时长 + p50Ms: number; // 中位数 + p95Ms: number; // 95 分位 + maxMs: number; // 最长 +} + +/** executor 运行成功率(只统计已落定的 succeeded/failed,started/cancelled 不计) */ +export interface RunStats { + total: number; // succeeded + failed + succeeded: number; + failed: number; + successRate: number | null; // succeeded / total;无样本 → null +} + +/** 复审通过率(code review + 安全审计;verdict=approve 占比) */ +export interface ReviewStats { + reviewTotal: number; // 有 review verdict(approve/reject)的任务数 + reviewApprove: number; + reviewRate: number | null; // approve / total;无样本 → null + securityTotal: number; // 有 security verdict 的任务数 + securityApprove: number; + securityRate: number | null; +} + +/** 重试 / 需人工关注 */ +export interface RetryStats { + retries: number; // failed→queued 重试发生次数(来自 status.changed 事件) + taskCount: number; // 范围内任务总数(重试率分母) + retryRate: number | null; // retries / taskCount;无任务 → null + needsAttention: number; // 当前处于 needs_attention 的任务数 +} + +/** 按模型的额度估算(无 token 数据时按 时长×档位 粗估,estimated=true) */ +export interface ModelUsage { + model: string; + runs: number; // 计入时长的 executor run 数 + durationMs: number; // 累计执行时长 + estCostUnits: number; // 估算额度(相对单位:执行分钟数 × 档位权重) + estimated: boolean; // 是否为估算(当前恒 true) +} + +export interface Metrics { + projectId: string | null; // null = 全部项目 + taskCount: number; + duration: DurationStats; + runs: RunStats; + review: ReviewStats; + retry: RetryStats; + byModel: ModelUsage[]; // 按估算额度降序 + estimated: boolean; // 额度是否为估算(无 token 用量 → true) +} + +/** + * 各模型档位的“每分钟相对额度权重”(粗估用,非真实计费): + * fable-5(hard)最贵 → opus(medium)次之 → sonnet(easy)最省。 + * 未知模型用 DEFAULT_COST_PER_MIN 兜底。 + */ +export const MODEL_COST_PER_MIN: Record = { + 'claude-fable-5': 1.0, + 'claude-opus-4-8': 0.6, + 'claude-sonnet-4-6': 0.2, +}; +export const DEFAULT_COST_PER_MIN = 0.4; + +export function costPerMin(model: string): number { + return MODEL_COST_PER_MIN[model] ?? DEFAULT_COST_PER_MIN; +} + +/** 最近秩法分位数(sortedAsc 必须升序);空数组 → 0。 */ +export function percentile(sortedAsc: number[], p: number): number { + if (sortedAsc.length === 0) return 0; + const rank = Math.ceil((p / 100) * sortedAsc.length); + const idx = Math.min(sortedAsc.length - 1, Math.max(0, rank - 1)); + return sortedAsc[idx]; +} + +/** 比率(保留 4 位小数);分母 ≤ 0 → null。 */ +export function ratio(num: number, den: number): number | null { + return den > 0 ? Math.round((num / den) * 10000) / 10000 : null; +} + +/** 由时长(毫秒)与模型档位估算额度单位(保留 2 位小数)。 */ +export function estimateCostUnits(durationMs: number, model: string): number { + return Math.round((durationMs / 60000) * costPerMin(model) * 100) / 100; +} + +/** 空指标(无数据时各项归零,不崩)。 */ +export function emptyMetrics(projectId: string | null): Metrics { + return { + projectId, + taskCount: 0, + duration: { count: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 }, + runs: { total: 0, succeeded: 0, failed: 0, successRate: null }, + review: { + reviewTotal: 0, reviewApprove: 0, reviewRate: null, + securityTotal: 0, securityApprove: 0, securityRate: null, + }, + retry: { retries: 0, taskCount: 0, retryRate: null, needsAttention: 0 }, + byModel: [], + estimated: true, + }; +} diff --git a/src/store/index.ts b/src/store/index.ts index cd84c3f..aa9430f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,4 +1,5 @@ export { Store, StoreError } from './store.js'; export type { CreateProjectInput, CreateTaskInput, PatchProjectInput, PatchTaskInput, ActiveRun } from './store.js'; +export type { Metrics, DurationStats, RunStats, ReviewStats, RetryStats, ModelUsage } from '../model/metrics.js'; export { openDb } from './db.js'; export type { DB } from './db.js'; diff --git a/src/store/store.ts b/src/store/store.ts index 0495a0e..67a14d8 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -8,6 +8,10 @@ import type { Project, Task, ApprovalRecord, Run, Event, TaskResult, Autonomy, E import { DEFAULT_MAX_DEPTH, HARD_MAX_DEPTH } from '../model/types.js'; import type { Complexity } from '../model/complexity.js'; import { rankByScore } from '../model/scoring.js'; +import { + type Metrics, type ModelUsage, percentile, ratio, estimateCostUnits, emptyMetrics, +} from '../model/metrics.js'; +import { resolvedExecutorModels } from '../executor/models.js'; import { type TaskStatus, type GateKind, canTransition, initialNextStatus, gateOf, STATUS_LABEL, } from '../model/status.js'; @@ -692,6 +696,121 @@ export class Store { return { deleted: toDelete.length }; } + /** + * 聚合指标(健康度 + 成本):从 runs / tasks / events 算执行时长、成功率、复审通过率、 + * 重试率与按模型额度估算。projectId 省略 = 全部项目。空数据时各项归零/为 null,不崩。 + * + * - 执行时长:仅计入 executor run 中已结束(ended_at 非空)且 ended≥started 的样本, + * 避免未结束 run 与时区/时钟问题产生负值。 + * - 成功率:executor run 中 succeeded/(succeeded+failed),started/cancelled 不计。 + * - 复审通过率:任务 result 里 verdict / securityVerdict = approve 的占比。 + * - 重试率:status.changed 事件中 failed→queued 次数 / 任务数;并计当前 needs_attention 数。 + * - 额度:无 token 用量记录,按 执行分钟数 × 模型档位权重 粗估(estimated=true),按模型汇总。 + */ + metrics(projectId?: string): Metrics { + // 范围内项目(用于把 run 解析到实际执行模型)。指定项目却不存在 → 空指标。 + const projects = projectId + ? (this.getProject(projectId) ? [this.getProject(projectId)!] : []) + : this.listProjects(); + if (projectId && projects.length === 0) return emptyMetrics(projectId); + const modelByProject = new Map>(); + for (const p of projects) modelByProject.set(p.id, resolvedExecutorModels(p)); + + // ---- executor runs(联 tasks 取 complexity / project,用于时长 + 成功率 + 模型归集)---- + const runSql = + `SELECT r.started_at AS started, r.ended_at AS ended, r.status AS status, + t.complexity AS complexity, t.project_id AS project_id + FROM runs r JOIN tasks t ON t.id = r.task_id + WHERE r.kind = 'executor'${projectId ? ' AND t.project_id = @pid' : ''}`; + const runStmt = this.db.prepare(runSql); + const runRows = (projectId ? runStmt.all({ pid: projectId }) : runStmt.all()) as Array<{ + started: string; ended: string | null; status: string; complexity: string; project_id: string; + }>; + + const durations: number[] = []; + const modelAgg = new Map(); + let exSucceeded = 0; + let exFailed = 0; + for (const r of runRows) { + if (r.status === 'succeeded') exSucceeded++; + else if (r.status === 'failed') exFailed++; + if (!r.ended) continue; // 未结束 → 不计时长 + const ms = Date.parse(r.ended) - Date.parse(r.started); + if (!Number.isFinite(ms) || ms < 0) continue; // 畸形/负值(时区/时钟)→ 跳过 + durations.push(ms); + const resolved = modelByProject.get(r.project_id); + const model = resolved?.[r.complexity as Complexity] ?? 'unknown'; + const agg = modelAgg.get(model) ?? { runs: 0, durationMs: 0 }; + agg.runs++; agg.durationMs += ms; + modelAgg.set(model, agg); + } + durations.sort((a, b) => a - b); + const durSum = durations.reduce((a, b) => a + b, 0); + const duration = { + count: durations.length, + avgMs: durations.length ? Math.round(durSum / durations.length) : 0, + p50Ms: percentile(durations, 50), + p95Ms: percentile(durations, 95), + maxMs: durations.length ? durations[durations.length - 1] : 0, + }; + const exTotal = exSucceeded + exFailed; + const runs = { + total: exTotal, succeeded: exSucceeded, failed: exFailed, + successRate: ratio(exSucceeded, exTotal), + }; + const byModel: ModelUsage[] = [...modelAgg.entries()] + .map(([model, a]) => ({ + model, runs: a.runs, durationMs: a.durationMs, + estCostUnits: estimateCostUnits(a.durationMs, model), estimated: true, + })) + .sort((x, y) => y.estCostUnits - x.estCostUnits || y.durationMs - x.durationMs); + + // ---- tasks(复审通过率 + needs_attention + 任务数)---- + const taskRows = (projectId + ? this.db.prepare(`SELECT result, status FROM tasks WHERE project_id = ?`).all(projectId) + : this.db.prepare(`SELECT result, status FROM tasks`).all()) as Array<{ result: string | null; status: string }>; + let needsAttention = 0; + let reviewTotal = 0; let reviewApprove = 0; + let securityTotal = 0; let securityApprove = 0; + for (const t of taskRows) { + if (t.status === 'needs_attention') needsAttention++; + if (!t.result) continue; + let res: Partial; + try { res = JSON.parse(t.result) as Partial; } catch { continue; } + if (res.verdict === 'approve' || res.verdict === 'reject') { + reviewTotal++; if (res.verdict === 'approve') reviewApprove++; + } + if (res.securityVerdict === 'approve' || res.securityVerdict === 'reject') { + securityTotal++; if (res.securityVerdict === 'approve') securityApprove++; + } + } + const taskCount = taskRows.length; + const review = { + reviewTotal, reviewApprove, reviewRate: ratio(reviewApprove, reviewTotal), + securityTotal, securityApprove, securityRate: ratio(securityApprove, securityTotal), + }; + + // ---- events(重试次数:status.changed 中 failed→queued)---- + const evRows = (projectId + ? this.db.prepare(`SELECT payload FROM events WHERE project_id = ? AND type = 'status.changed'`).all(projectId) + : this.db.prepare(`SELECT payload FROM events WHERE type = 'status.changed'`).all()) as Array<{ payload: string }>; + let retries = 0; + for (const e of evRows) { + try { + const p = JSON.parse(e.payload) as { from?: string; to?: string }; + if (p.from === 'failed' && p.to === 'queued') retries++; + } catch { /* 畸形 payload 跳过 */ } + } + const retry = { + retries, taskCount, retryRate: ratio(retries, taskCount), needsAttention, + }; + + return { + projectId: projectId ?? null, + taskCount, duration, runs, review, retry, byModel, estimated: true, + }; + } + /** * 取下一个可执行任务(叶子、ready、依赖全部 done)。供编排器领取。 * 被拆解的 Hard 容器任务不会是 ready(停在 decomposed),天然排除。 diff --git a/test/metrics.test.ts b/test/metrics.test.ts new file mode 100644 index 0000000..b2b4390 --- /dev/null +++ b/test/metrics.test.ts @@ -0,0 +1,194 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Store } from '../src/store/index.js'; +import { + percentile, ratio, estimateCostUnits, emptyMetrics, costPerMin, DEFAULT_COST_PER_MIN, +} from '../src/model/metrics.js'; +import type { RunStatus } from '../src/model/types.js'; + +function fresh(): Store { return new Store(':memory:'); } + +const BASE = 1_700_000_000_000; // 固定基准时刻,避免依赖真实时间 + +/** 造一条 executor run 并精确设置起止时间(durMs=null → 未结束)。 */ +function mkRun( + s: Store, taskId: string, + opts: { status?: RunStatus; startMs?: number; durMs?: number | null } = {}, +): string { + const status = opts.status ?? 'succeeded'; + const r = s.startRun(taskId, 'executor'); + if (status !== 'started') s.finishRun(r.id, status); + const started = new Date(BASE + (opts.startMs ?? 0)).toISOString(); + const ended = opts.durMs == null ? null : new Date(BASE + (opts.startMs ?? 0) + opts.durMs).toISOString(); + s.db.prepare(`UPDATE runs SET started_at = @s, ended_at = @e WHERE id = @id`) + .run({ s: started, e: ended, id: r.id }); + return r.id; +} + +// ──────────────── 纯函数 ──────────────── +test('metrics 纯函数:percentile / ratio / estimateCostUnits', () => { + assert.equal(percentile([], 50), 0); + assert.equal(percentile([10], 95), 10); + assert.equal(percentile([10, 20, 30, 40], 50), 20); // ceil(0.5*4)=2 → idx 1 + assert.equal(percentile([10, 20, 30, 40], 95), 40); // ceil(0.95*4)=4 → idx 3 + + assert.equal(ratio(1, 2), 0.5); + assert.equal(ratio(2, 3), 0.6667); + assert.equal(ratio(0, 0), null); // 无样本不除零 + + assert.equal(costPerMin('claude-fable-5'), 1.0); + assert.equal(costPerMin('未知模型'), DEFAULT_COST_PER_MIN); + assert.equal(estimateCostUnits(60_000, 'claude-fable-5'), 1.0); // 1 分钟 × 1.0 + assert.equal(estimateCostUnits(120_000, 'claude-opus-4-8'), 1.2); // 2 分钟 × 0.6 +}); + +// ──────────────── 空数据不崩 ──────────────── +test('metrics 空数据:全 0 / null,不崩', () => { + const s = fresh(); + const m = s.metrics(); + assert.equal(m.projectId, null); + assert.equal(m.taskCount, 0); + assert.deepEqual(m.duration, { count: 0, avgMs: 0, p50Ms: 0, p95Ms: 0, maxMs: 0 }); + assert.equal(m.runs.successRate, null); + assert.equal(m.review.reviewRate, null); + assert.equal(m.review.securityRate, null); + assert.equal(m.retry.retryRate, null); + assert.equal(m.retry.needsAttention, 0); + assert.deepEqual(m.byModel, []); + assert.equal(m.estimated, true); + s.close(); +}); + +test('metrics:不存在的项目 → emptyMetrics(带 projectId)', () => { + const s = fresh(); + const m = s.metrics('prj_does_not_exist'); + assert.deepEqual(m, emptyMetrics('prj_does_not_exist')); + s.close(); +}); + +// ──────────────── 执行时长 ──────────────── +test('metrics 执行时长:avg/p50/p95;未结束与负时长不计入', () => { + const s = fresh(); + const p = s.createProject({ name: 'd', repoPath: '/tmp/d-' + Math.random() }); + const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' }); + mkRun(s, t.id, { status: 'succeeded', startMs: 0, durMs: 1000 }); + mkRun(s, t.id, { status: 'succeeded', startMs: 10_000, durMs: 2000 }); + mkRun(s, t.id, { status: 'succeeded', startMs: 20_000, durMs: 3000 }); + mkRun(s, t.id, { status: 'started', startMs: 30_000, durMs: null }); // 未结束 → 不计 + mkRun(s, t.id, { status: 'failed', startMs: 40_000, durMs: -500 }); // 负时长 → 不计 + + const m = s.metrics(p.id); + assert.equal(m.duration.count, 3); + assert.equal(m.duration.avgMs, 2000); + assert.equal(m.duration.p50Ms, 2000); + assert.equal(m.duration.p95Ms, 3000); + assert.equal(m.duration.maxMs, 3000); + s.close(); +}); + +// ──────────────── 成功率 ──────────────── +test('metrics 成功率:仅 succeeded/failed 计入,started/cancelled 不计', () => { + const s = fresh(); + const p = s.createProject({ name: 's', repoPath: '/tmp/s-' + Math.random() }); + const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' }); + mkRun(s, t.id, { status: 'succeeded', durMs: 1000 }); + mkRun(s, t.id, { status: 'succeeded', durMs: 1000 }); + mkRun(s, t.id, { status: 'failed', durMs: 1000 }); + mkRun(s, t.id, { status: 'cancelled', durMs: 1000 }); + mkRun(s, t.id, { status: 'started', durMs: null }); + + const m = s.metrics(p.id); + assert.equal(m.runs.total, 3); + assert.equal(m.runs.succeeded, 2); + assert.equal(m.runs.failed, 1); + assert.equal(m.runs.successRate, 0.6667); + s.close(); +}); + +// ──────────────── 复审通过率 ──────────────── +test('metrics 复审通过率:verdict / securityVerdict = approve 占比', () => { + const s = fresh(); + const p = s.createProject({ name: 'r', repoPath: '/tmp/r-' + Math.random() }); + const base = { branch: null, worktree: null, diffSummary: null, commits: [], prUrl: null, summary: null, securitySummary: null, mergeTaskId: null }; + const t1 = s.createTask({ projectId: p.id, title: 'a', complexity: 'easy' }); + const t2 = s.createTask({ projectId: p.id, title: 'b', complexity: 'easy' }); + const t3 = s.createTask({ projectId: p.id, title: 'c', complexity: 'easy' }); + s.setResult(t1.id, { ...base, verdict: 'approve', securityVerdict: 'approve' }); + s.setResult(t2.id, { ...base, verdict: 'approve', securityVerdict: 'reject' }); + s.setResult(t3.id, { ...base, verdict: 'reject', securityVerdict: null }); // 安全未审 → 不计 + + const m = s.metrics(p.id); + assert.equal(m.review.reviewTotal, 3); + assert.equal(m.review.reviewApprove, 2); + assert.equal(m.review.reviewRate, 0.6667); + assert.equal(m.review.securityTotal, 2); + assert.equal(m.review.securityApprove, 1); + assert.equal(m.review.securityRate, 0.5); + s.close(); +}); + +// ──────────────── 重试率 / needs_attention ──────────────── +test('metrics 重试率:failed→queued 次数 / 任务数;needs_attention 计数', () => { + const s = fresh(); + const p = s.createProject({ name: 'rt', repoPath: '/tmp/rt-' + Math.random() }); + // 任务 A:跑两轮失败重试(failed→queued ×2) + const a = s.createTask({ projectId: p.id, title: 'a', complexity: 'easy' }); // ready + s.transition(a.id, 'queued'); + s.transition(a.id, 'executing'); + s.transition(a.id, 'failed'); + s.transition(a.id, 'queued'); // 重试 1 + s.transition(a.id, 'executing'); + s.transition(a.id, 'failed'); + s.transition(a.id, 'queued'); // 重试 2 + // 任务 B:失败后升级 needs_attention(不是 failed→queued,不计重试) + const b = s.createTask({ projectId: p.id, title: 'b', complexity: 'easy' }); + s.transition(b.id, 'queued'); + s.transition(b.id, 'executing'); + s.transition(b.id, 'failed'); + s.transition(b.id, 'needs_attention'); + + const m = s.metrics(p.id); + assert.equal(m.retry.retries, 2); + assert.equal(m.retry.taskCount, 2); + assert.equal(m.retry.retryRate, 1); // 2 / 2 + assert.equal(m.retry.needsAttention, 1); + s.close(); +}); + +// ──────────────── 按模型额度估算 ──────────────── +test('metrics 按模型额度:时长×档位 粗估,estimated=true', () => { + const s = fresh(); + const p = s.createProject({ name: 'mdl', repoPath: '/tmp/mdl-' + Math.random(), model: 'claude-opus-4-8' }); + const t = s.createTask({ projectId: p.id, title: 'x', complexity: 'easy' }); + mkRun(s, t.id, { status: 'succeeded', startMs: 0, durMs: 60_000 }); + mkRun(s, t.id, { status: 'succeeded', startMs: 100_000, durMs: 60_000 }); + + const m = s.metrics(p.id); + assert.equal(m.byModel.length, 1); + assert.deepEqual(m.byModel[0], { + model: 'claude-opus-4-8', runs: 2, durationMs: 120_000, estCostUnits: 1.2, estimated: true, + }); + assert.equal(m.estimated, true); + s.close(); +}); + +// ──────────────── 项目过滤 ──────────────── +test('metrics 项目过滤:只统计指定项目', () => { + const s = fresh(); + const pa = s.createProject({ name: 'A', repoPath: '/tmp/A-' + Math.random() }); + const pb = s.createProject({ name: 'B', repoPath: '/tmp/B-' + Math.random() }); + const ta = s.createTask({ projectId: pa.id, title: 'a', complexity: 'easy' }); + const tb = s.createTask({ projectId: pb.id, title: 'b', complexity: 'easy' }); + mkRun(s, ta.id, { status: 'succeeded', durMs: 1000 }); + mkRun(s, tb.id, { status: 'failed', durMs: 1000 }); + + const ma = s.metrics(pa.id); + assert.equal(ma.taskCount, 1); + assert.equal(ma.runs.succeeded, 1); + assert.equal(ma.runs.failed, 0); + + const all = s.metrics(); + assert.equal(all.taskCount, 2); + assert.equal(all.runs.total, 2); + s.close(); +}); diff --git a/web/app.js b/web/app.js index a11abd4..b267acc 100644 --- a/web/app.js +++ b/web/app.js @@ -60,6 +60,7 @@ const S = { filter: { cplx: new Set(), status: new Set(), statusGroups: new Set(), kw: '' },// 任务树筛选(组选与单选分离) matchCount: 0, agents: null, // GET /api/agents 结果(404 时为 null) + metrics: null, // GET /api/metrics 结果(失败时为 null) cplxMenuFor: null, // 复杂度下拉打开的任务 id syncReqAt: 0, // 本端发起 sync 的时间(避免 WS 重复 toast) previewId: null, // 全局预览中的任务 id @@ -97,6 +98,23 @@ function shortModel(m) { return String(m || '—').replace(/^claude-/, ''); } +/** 毫秒 → 紧凑时长("45s" / "1m 23s" / "2h 5m");无效 → '—' */ +function fmtDur(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n <= 0) return '—'; + const s = Math.round(n / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return s % 60 ? `${m}m ${s % 60}s` : `${m}m`; + const h = Math.floor(m / 60); + return m % 60 ? `${h}h ${m % 60}m` : `${h}h`; +} + +/** 0-1 比率 → 百分比文本;null/无效 → '—' */ +function fmtRate(rate) { + return rate == null || !Number.isFinite(Number(rate)) ? '—' : `${Math.round(Number(rate) * 100)}%`; +} + /** fmtRel 的未来版:到 iso 还有多久("3h 33m" / "42m" / "<1m";已过/无效 → '') */ function fmtUntil(iso) { if (!iso) return ''; @@ -257,9 +275,17 @@ async function loadAgents() { catch { S.agents = null; } } +// 指标聚合(当前项目);失败静默降级为 null(面板隐藏) +async function loadMetrics() { + const pid = S.currentProjectId; + if (!pid) { S.metrics = null; return; } + try { S.metrics = await api(`/api/metrics?project=${encodeURIComponent(pid)}`); } + catch { S.metrics = null; } +} + async function refresh() { try { - await loadProjectData(); + await Promise.all([loadProjectData(), loadMetrics()]); renderAll(); } catch (e) { toast(e.message); } } @@ -267,7 +293,7 @@ async function refresh() { async function fullRefresh() { try { await loadProjects(); - await Promise.all([loadProjectData(), loadAgents()]); + await Promise.all([loadProjectData(), loadAgents(), loadMetrics()]); renderAll(); } catch (e) { toast(e.message); } } @@ -302,6 +328,7 @@ function renderAll() { renderFilterBar(); renderEvents(); renderParentOptions(); + renderMetrics(); renderArchive(); renderPreview(); restoreDrafts(snap); @@ -478,6 +505,73 @@ function renderGates() { }).join(''); } +// ── 渲染:指标面板(数字卡 + 按模型额度分布) ── +function metricCard(label, value, sub, cls) { + return `
+
${value}
+
${esc(label)}
+ ${sub ? `
${sub}
` : ''} +
`; +} + +function renderMetrics() { + const sec = $('#metricsSection'); + const m = S.metrics; + // 无项目或无任何任务数据 → 隐藏面板(不打扰空项目) + if (!m || !S.currentProjectId || (m.taskCount === 0 && m.runs.total === 0 && m.duration.count === 0)) { + sec.hidden = true; + return; + } + sec.hidden = false; + $('#metricsScope').textContent = `· ${m.taskCount} 任务`; + + const d = m.duration; + const r = m.runs; + const rv = m.review; + const rt = m.retry; + + // 成功率配色:≥80% 绿、≥50% 琥珀、其余红 + const rateCls = (rate) => { + if (rate == null) return ''; + const v = Number(rate); + return v >= 0.8 ? 'ok' : v >= 0.5 ? 'warn' : 'crit'; + }; + + const cards = [ + metricCard('平均执行时长', fmtDur(d.avgMs), + d.count ? `P50 ${fmtDur(d.p50Ms)} · P95 ${fmtDur(d.p95Ms)} · n=${d.count}` : '暂无已结束执行'), + metricCard('执行成功率', fmtRate(r.successRate), + r.total ? `${r.succeeded}/${r.total} 成功 · 失败 ${r.failed}` : '暂无执行', rateCls(r.successRate)), + metricCard('复审通过率', fmtRate(rv.reviewRate), + `复审 ${rv.reviewApprove}/${rv.reviewTotal} · 安全 ${fmtRate(rv.securityRate)} (${rv.securityApprove}/${rv.securityTotal})`, + rateCls(rv.reviewRate)), + metricCard('重试率', fmtRate(rt.retryRate), + `重试 ${rt.retries} 次 · 需人工 ${rt.needsAttention}`, rt.needsAttention > 0 ? 'warn' : ''), + ]; + + // 按模型额度分布(估算) + const totalUnits = (m.byModel || []).reduce((a, x) => a + Number(x.estCostUnits || 0), 0); + const totalDur = (m.byModel || []).reduce((a, x) => a + Number(x.durationMs || 0), 0); + cards.push(metricCard('额度消耗', `${totalUnits.toFixed(1)}u`, + `执行 ${fmtDur(totalDur)} · 估算`, 'est')); + + const dist = (m.byModel || []).length + ? `
+
按模型分布 (时长×档位 粗估)
+ ${m.byModel.map((x) => { + const w = totalUnits > 0 ? Math.round(Number(x.estCostUnits) / totalUnits * 100) : 0; + return `
+ ${esc(shortModel(x.model))} + + ${Number(x.estCostUnits).toFixed(1)}u · ${x.runs} run · ${fmtDur(x.durationMs)} +
`; + }).join('')} +
` + : ''; + + $('#metricsBody').innerHTML = `
${cards.join('')}
${dist}`; +} + // ── 渲染:归档区(done/取消,updatedAt 倒序,分页) ── function renderArchive() { const sec = $('#archiveSection'); diff --git a/web/index.html b/web/index.html index ab81c94..cc7efe7 100644 --- a/web/index.html +++ b/web/index.html @@ -158,6 +158,12 @@
+ + +