merge: maestro/tsk_lDx6zd-EbA00 [指标统计:执行时长 / 成功率 / 额度消耗估算]

# Conflicts:
#	web/style.css
This commit is contained in:
wangjia
2026-06-13 12:01:18 +08:00
8 changed files with 582 additions and 2 deletions
+7
View File
@@ -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 };
+110
View File
@@ -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/failedstarted/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 verdictapprove/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-5hard)最贵 → opusmedium)次之 → sonneteasy)最省。
* 未知模型用 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
View File
@@ -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';
+119
View File
@@ -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),天然排除。
+194
View File
@@ -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
View File
@@ -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');
+6
View File
@@ -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>
+49
View File
@@ -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; }