merge: maestro/tsk_lDx6zd-EbA00 [指标统计:执行时长 / 成功率 / 额度消耗估算]
# Conflicts: # web/style.css
This commit is contained in:
@@ -155,6 +155,13 @@ export function buildServer(opts: ApiOptions): FastifyInstance {
|
||||
return store.nextExecutable(id) ?? { next: null };
|
||||
});
|
||||
|
||||
// ---------- Metrics(健康度 + 成本聚合)----------
|
||||
// GET /api/metrics?project=<id>;不带 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 };
|
||||
|
||||
@@ -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<string, number> = {
|
||||
'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,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -780,6 +784,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<string, Record<Complexity, string>>();
|
||||
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<string, { runs: number; durationMs: number }>();
|
||||
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<TaskResult>;
|
||||
try { res = JSON.parse(t.result) as Partial<TaskResult>; } 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),天然排除。
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
+96
-2
@@ -62,6 +62,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
|
||||
@@ -101,6 +102,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 '';
|
||||
@@ -261,9 +279,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); }
|
||||
}
|
||||
@@ -271,7 +297,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); }
|
||||
}
|
||||
@@ -306,6 +332,7 @@ function renderAll() {
|
||||
renderFilterBar();
|
||||
renderEvents();
|
||||
renderParentOptions();
|
||||
renderMetrics();
|
||||
renderArchive();
|
||||
renderPreview();
|
||||
restoreDrafts(snap);
|
||||
@@ -482,6 +509,73 @@ function renderGates() {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── 渲染:指标面板(数字卡 + 按模型额度分布) ──
|
||||
function metricCard(label, value, sub, cls) {
|
||||
return `<div class="metric-card${cls ? ' ' + cls : ''}">
|
||||
<div class="metric-val">${value}</div>
|
||||
<div class="metric-label">${esc(label)}</div>
|
||||
${sub ? `<div class="metric-sub">${sub}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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)}<span class="metric-unit">u</span>`,
|
||||
`执行 ${fmtDur(totalDur)} · <span class="metric-est">估算</span>`, 'est'));
|
||||
|
||||
const dist = (m.byModel || []).length
|
||||
? `<div class="metric-models">
|
||||
<div class="metric-models-head">按模型分布 <span class="metric-est">(时长×档位 粗估)</span></div>
|
||||
${m.byModel.map((x) => {
|
||||
const w = totalUnits > 0 ? Math.round(Number(x.estCostUnits) / totalUnits * 100) : 0;
|
||||
return `<div class="metric-model-row">
|
||||
<span class="metric-model-name">${esc(shortModel(x.model))}</span>
|
||||
<span class="metric-model-bar"><span class="metric-model-fill" style="width:${w}%"></span></span>
|
||||
<span class="metric-model-num">${Number(x.estCostUnits).toFixed(1)}u · ${x.runs} run · ${fmtDur(x.durationMs)}</span>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
$('#metricsBody').innerHTML = `<div class="metric-cards">${cards.join('')}</div>${dist}`;
|
||||
}
|
||||
|
||||
// ── 渲染:归档区(done/取消,updatedAt 倒序,分页) ──
|
||||
function renderArchive() {
|
||||
const sec = $('#archiveSection');
|
||||
|
||||
@@ -159,6 +159,12 @@
|
||||
<div id="taskTree"></div>
|
||||
</section>
|
||||
|
||||
<!-- ════════ 指标面板(执行时长 / 成功率 / 复审 / 重试 / 额度估算) ════════ -->
|
||||
<section id="metricsSection" class="metrics-section" hidden>
|
||||
<div class="sec-head"><span class="head-mark cyan">▍</span>指标 · 健康度与成本<span id="metricsScope" class="metrics-scope"></span></div>
|
||||
<div id="metricsBody"></div>
|
||||
</section>
|
||||
|
||||
<!-- ════════ 已归档(done / 取消,时间倒序分页) ════════ -->
|
||||
<section id="archiveSection" hidden>
|
||||
<div class="sec-head"><span class="head-mark">▣</span>已归档 · <span id="archiveCount">0</span></div>
|
||||
|
||||
@@ -890,3 +890,52 @@ li.ev-updated { --ev: var(--muted); }
|
||||
.t-fold > summary { cursor: pointer; color: var(--muted); font-size: 11.5px; }
|
||||
.t-fold > summary:hover { color: var(--green); }
|
||||
.t-fold .t-pre { max-height: 480px; }
|
||||
|
||||
/* ════════ 指标面板(健康度与成本) ════════ */
|
||||
#metricsSection { margin-top: 28px; }
|
||||
.metrics-scope { margin-left: 8px; font-size: 11px; color: var(--muted); letter-spacing: .04em; }
|
||||
.metric-cards {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.metric-card {
|
||||
border: 1px solid var(--line); background: var(--panel);
|
||||
padding: 10px 12px; min-width: 0;
|
||||
}
|
||||
.metric-card.est { border-style: dashed; }
|
||||
.metric-val {
|
||||
font-size: 26px; font-weight: 700; line-height: 1; color: var(--ink);
|
||||
}
|
||||
.metric-card.ok .metric-val { color: var(--green); text-shadow: 0 0 14px rgba(95,221,125,.35); }
|
||||
.metric-card.warn .metric-val { color: var(--amber); text-shadow: 0 0 14px rgba(240,180,41,.3); }
|
||||
.metric-card.crit .metric-val { color: var(--red); text-shadow: 0 0 14px rgba(255,93,93,.35); }
|
||||
.metric-unit { font-size: 13px; font-weight: 600; color: var(--muted); margin-left: 2px; }
|
||||
.metric-label {
|
||||
margin-top: 6px; font-size: 10px; font-weight: 600;
|
||||
letter-spacing: .16em; text-transform: uppercase; color: var(--muted);
|
||||
}
|
||||
.metric-sub { margin-top: 4px; font-size: 11px; color: var(--faint); }
|
||||
.metric-est { color: var(--cyan); }
|
||||
|
||||
.metric-models {
|
||||
margin-top: 14px; border: 1px solid var(--line-soft);
|
||||
background: var(--bg-deep); padding: 10px 12px;
|
||||
}
|
||||
.metric-models-head {
|
||||
font-size: 10px; font-weight: 600; letter-spacing: .14em;
|
||||
text-transform: uppercase; color: var(--muted); margin-bottom: 8px;
|
||||
}
|
||||
.metric-model-row {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 3px 0; font-size: 11.5px;
|
||||
}
|
||||
.metric-model-name { width: 90px; flex: none; color: var(--cyan); }
|
||||
.metric-model-bar {
|
||||
flex: 1; height: 8px; min-width: 40px;
|
||||
background: var(--panel-2); border: 1px solid var(--line-soft); overflow: hidden;
|
||||
}
|
||||
.metric-model-fill {
|
||||
display: block; height: 100%;
|
||||
background: linear-gradient(90deg, var(--cyan-dim), var(--cyan));
|
||||
}
|
||||
.metric-model-num { flex: none; color: var(--muted); white-space: nowrap; }
|
||||
|
||||
Reference in New Issue
Block a user