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
+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; }