返回 DeepSeek-Reasonix
stats.ts
根目录 / workers / crash-report / src / stats.ts
1 import { esc, page } from "./shell";
2 import { type User, userNav } from "./auth";
3
4 type Daily = { date: string; users: number; opens: number };
5 type MetricRow = { signal: string; bucket: string; total: number };
6 type BarRow = { label: string; users: number };
7 type BarListOptions = {
8 limit?: number;
9 className?: string;
10 labelFormatter?: (label: string) => string;
11 };
12
13 type OverviewCounts = {
14 latestAdoptionPct: number | null;
15 openReports: number;
16 newLatestReports: number;
17 regressedReports: number;
18 criticalOpenReports: number;
19 };
20
21 export type StatsModule = "diagnostics" | "usage" | "preferences" | "health";
22
23 function lastDays(rows: Daily[], count: 7 | 30): Daily[] {
24 const byDate = new Map(rows.map((r) => [r.date, r]));
25 const out: Daily[] = [];
26 for (let i = count - 1; i >= 0; i--) {
27 const date = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10);
28 out.push(byDate.get(date) ?? { date, users: 0, opens: 0 });
29 }
30 return out;
31 }
32
33 function chartTickStep(max: number, targetTicks = 4): number {
34 if (max <= targetTicks) return 1;
35 const raw = Math.max(1, max) / targetTicks;
36 const pow = 10 ** Math.floor(Math.log10(raw));
37 const fraction = raw / pow;
38 if (fraction <= 1) return pow;
39 if (fraction <= 2) return 2 * pow;
40 if (fraction <= 5) return 5 * pow;
41 return 10 * pow;
42 }
43
44 function chartTickLabel(n: number): string {
45 if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1))}m`;
46 if (n >= 1_000) return `${Number((n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1))}k`;
47 return String(Math.round(n));
48 }
49
50 function i18n(en: string, zh: string): string {
51 return `<span data-i18n="en">${esc(en)}</span><span data-i18n="zh">${esc(zh)}</span>`;
52 }
53
54 function i18nHTML(en: string, zh: string): string {
55 return `<span data-i18n="en">${en}</span><span data-i18n="zh">${zh}</span>`;
56 }
57
58 function dailyChart(days: Daily[]): string {
59 const W = 960;
60 const H = 220;
61 const plotLeft = 50;
62 const plotRight = 8;
63 const plotTop = 16;
64 const baseY = H - 26;
65 const plotH = baseY - plotTop;
66 const slot = (W - plotLeft - plotRight) / days.length;
67 const max = Math.max(1, ...days.map((d) => d.opens));
68 const step = chartTickStep(max);
69 const chartMax = Math.max(step, Math.ceil(max / step) * step);
70 const h = (v: number) => (v / chartMax) * plotH;
71 const ticks: number[] = [];
72 for (let v = 0; v <= chartMax; v += step) ticks.push(v);
73 const grid = ticks
74 .map((v) => {
75 const y = baseY - h(v);
76 return `<g><line x1="${plotLeft}" y1="${y}" x2="${W - plotRight}" y2="${y}" class="gridline"/><text x="${plotLeft - 8}" y="${y + 4}" text-anchor="end" class="ay">${chartTickLabel(v)}</text></g>`;
77 })
78 .join("");
79 const bars = days
80 .map((d, i) => {
81 const x = plotLeft + i * slot;
82 const label = i % 5 === 4 ? `<text x="${x + slot / 2}" y="${H - 8}" text-anchor="middle" class="ax">${d.date.slice(5)}</text>` : "";
83 return `<g><title>${esc(`${d.date} — ${d.users} users · ${d.opens} opens`)}</title>
84 <rect x="${x}" y="${plotTop}" width="${slot}" height="${plotH}" fill="transparent" pointer-events="all"/>
85 <rect x="${x + slot * 0.18}" y="${baseY - h(d.opens)}" width="${slot * 0.64}" height="${h(d.opens)}" rx="3" fill="var(--accent)" opacity="0.22"/>
86 <rect x="${x + slot * 0.3}" y="${baseY - h(d.users)}" width="${slot * 0.4}" height="${h(d.users)}" rx="3" fill="var(--accent)"/>
87 ${label}</g>`;
88 })
89 .join("");
90 return `<svg class="chart" viewBox="0 0 ${W} ${H}" role="img" aria-label="Daily active installs chart"><style>.ax,.ay{font:11px var(--mono);fill:var(--ink-3)}.gridline{stroke:var(--line);stroke-width:1}</style>
91 ${grid}${bars}</svg>`;
92 }
93
94 function bucketDisplayLabel(signal: string, bucket: string): string {
95 if (signal.includes("_model") && bucket.startsWith("custom_")) {
96 const model = bucket.slice("custom_".length).replace(/_/g, " ");
97 return `<span class="bucket-prefix">custom</span><span class="bucket-main">${esc(model)}</span>`;
98 }
99 return esc(bucket);
100 }
101
102 function barRow(r: BarRow, max: number, labelFormatter?: (label: string) => string): string {
103 const label = labelFormatter ? labelFormatter(r.label) : esc(r.label);
104 return `<div class="row" title="${esc(r.label)}"><span class="row-label">${label}</span><div class="row-bar"><div class="bar" style="width:${Math.max(3, Math.round((r.users / max) * 100))}%"></div></div><span class="n">${r.users}</span></div>`;
105 }
106
107 function listBars(rows: BarRow[], options: BarListOptions = {}): string {
108 if (!rows.length) return `<div class="empty">${i18n("No data in this window", "当前时间窗口暂无数据")}</div>`;
109 const max = Math.max(1, ...rows.map((r) => r.users));
110 const limit = options.limit ?? 5;
111 const visible = limit > 0 ? rows.slice(0, limit) : rows;
112 const hidden = limit > 0 ? rows.slice(limit) : [];
113 const className = options.className ? ` ${esc(options.className)}` : "";
114 const visibleRows = visible.map((r) => barRow(r, max, options.labelFormatter)).join("");
115 if (!hidden.length) return `<div class="bars-list${className}">${visibleRows}</div>`;
116 return `<div class="bars-list${className}">${visibleRows}<details class="bars-more"><summary><span class="more-closed">${i18nHTML(
117 `Show ${hidden.length} more`,
118 `展开 ${hidden.length} 项`,
119 )}</span><span class="more-open">${i18nHTML(`Hide ${hidden.length}`, `收起 ${hidden.length} 项`)}</span></summary><div class="bars-more-list">${hidden
120 .map((r) => barRow(r, max, options.labelFormatter))
121 .join("")}</div></details></div>`;
122 }
123
124 function labelizeBucket(bucket: string): string {
125 return bucket.replace(/^n_/, "").replace(/_/g, " ");
126 }
127
128 function sumMetric(rows: MetricRow[], signal: string): number {
129 return rows.filter((r) => r.signal === signal).reduce((sum, r) => sum + r.total, 0);
130 }
131
132 function topMetricBucket(rows: MetricRow[], signal: string): string {
133 const row = rows.filter((r) => r.signal === signal).sort((a, b) => b.total - a.total)[0];
134 return row ? `${labelizeBucket(row.bucket)} · ${row.total}` : "none";
135 }
136
137 function cacheHitRate(rows: MetricRow[]): number | null {
138 const cacheRows = rows.filter((r) => r.signal === "cache_hit");
139 const total = cacheRows.reduce((sum, r) => sum + r.total, 0);
140 if (!total) return null;
141 const weighted = cacheRows.reduce((sum, r) => {
142 const m = r.bucket.match(/^(\d+)_(\d+)$/);
143 const midpoint = m ? (Number(m[1]) + Number(m[2])) / 2 : 0;
144 return sum + midpoint * r.total;
145 }, 0);
146 return weighted / total;
147 }
148
149 function pct(n: number | null): string {
150 if (n === null || !Number.isFinite(n)) return "n/a";
151 return `${Math.round(n)}%`;
152 }
153
154 function ratioPer100(rows: MetricRow[], signal: string): number | null {
155 const turns = sumMetric(rows, "turns");
156 if (!turns) return null;
157 return (sumMetric(rows, signal) / turns) * 100;
158 }
159
160 function deltaLabel(current: number | null, previous: number | null, suffix = ""): string {
161 if (current === null || previous === null) return "new";
162 const delta = current - previous;
163 if (Math.abs(delta) < 0.05) return "flat";
164 const sign = delta > 0 ? "+" : "";
165 const rounded = Math.abs(delta) >= 10 ? Math.round(delta) : Number(delta.toFixed(1));
166 return `${sign}${rounded}${suffix}`;
167 }
168
169 const METRIC_SIGNAL_LABELS: Record<string, { en: string; zh: string }> = {
170 finish_reason: { en: "Finish reason", zh: "结束原因" },
171 empty_final: { en: "Empty final guard", zh: "空回复拦截" },
172 provider_error: { en: "Provider errors", zh: "Provider 错误" },
173 cache_hit: { en: "Cache hit rate", zh: "缓存命中率" },
174 tool_error: { en: "Tool errors", zh: "工具错误" },
175 updater_error: { en: "Updater errors", zh: "更新器错误" },
176 updater_event: { en: "Updater events", zh: "更新器事件" },
177 compaction: { en: "Compactions", zh: "压缩" },
178 turns: { en: "Turns", zh: "轮次" },
179 desktop_hang: { en: "Desktop hangs", zh: "桌面卡死" },
180 desktop_hang_age: { en: "Desktop hang age", zh: "桌面卡死时长" },
181 desktop_exit: { en: "Desktop exits", zh: "桌面退出" },
182 desktop_exit_phase: { en: "Abnormal exit phase", zh: "异常退出阶段" },
183 desktop_uptime: { en: "Uptime before exit", zh: "退出前运行时长" },
184 desktop_install: { en: "Install profile", zh: "安装方式" },
185 desktop_update_transition: { en: "Update transition", zh: "升级阶段" },
186 desktop_restore: { en: "Window restore", zh: "窗口恢复" },
187 desktop_webview2_failure: { en: "WebView2 failures", zh: "WebView2 故障" },
188 recovery_failure: { en: "Recovery failures", zh: "恢复失败" },
189 recovery_rule_continue: { en: "Rule recovery continues", zh: "规则恢复继续" },
190 recovery_review_continue: { en: "Review recovery continues", zh: "复核恢复继续" },
191 recovery_human_prompt: { en: "Recovery prompts", zh: "恢复询问" },
192 recovery_human_continue: { en: "Human recovery continues", zh: "人工恢复继续" },
193 recovery_human_revise: { en: "Human recovery revisions", zh: "人工恢复修订" },
194 recovery_review_error: { en: "Recovery review errors", zh: "恢复复核错误" },
195 recovery_repeat_prompt: { en: "Repeated recovery prompts", zh: "重复恢复询问" },
196 recovery_review_latency: { en: "Recovery review latency", zh: "恢复复核耗时" },
197 client_surface: { en: "Client surface", zh: "客户端形态" },
198 client_version: { en: "Client version", zh: "客户端版本" },
199 settings_language: { en: "Settings: language", zh: "设置:语言" },
200 settings_desktop_layout: { en: "Settings: desktop style", zh: "设置:桌面风格" },
201 settings_theme: { en: "Settings: light/dark", zh: "设置:深浅模式" },
202 settings_theme_style: { en: "Settings: theme style", zh: "设置:主题" },
203 settings_close_behavior: { en: "Settings: close behavior", zh: "设置:关闭行为" },
204 settings_display_mode: { en: "Settings: transcript mode", zh: "设置:会话展示" },
205 settings_status_bar_style: { en: "Settings: status bar style", zh: "设置:信息栏样式" },
206 settings_status_bar_items_count: { en: "Settings: status bar items", zh: "设置:信息栏项数" },
207 settings_check_updates: { en: "Settings: update checks", zh: "设置:更新检查" },
208 settings_default_model: { en: "Settings: default model", zh: "设置:默认模型" },
209 settings_planner_model: { en: "Settings: planner model", zh: "设置:规划模型" },
210 settings_subagent_model: { en: "Settings: subagent model", zh: "设置:子代理模型" },
211 settings_subagent_effort: { en: "Settings: subagent effort", zh: "设置:子代理 effort" },
212 settings_reasoning_language: { en: "Settings: reasoning language", zh: "设置:推理语言" },
213 settings_provider_count: { en: "Settings: provider count", zh: "设置:Provider 数量" },
214 settings_provider_access_count: { en: "Settings: enabled providers", zh: "设置:启用 Provider 数量" },
215 settings_provider_access: { en: "Settings: provider access", zh: "设置:Provider 选择" },
216 settings_bot_enabled: { en: "Bot: enabled", zh: "机器人:总开关" },
217 settings_bot_model: { en: "Bot: default model", zh: "机器人:默认模型" },
218 settings_bot_tool_approval: { en: "Bot: tool approval", zh: "机器人:工具审批" },
219 settings_bot_allowlist: { en: "Bot: allowlist", zh: "机器人:白名单" },
220 settings_bot_allow_all: { en: "Bot: allow all", zh: "机器人:允许所有人" },
221 settings_bot_qq_enabled: { en: "Bot: QQ legacy", zh: "机器人:QQ 旧配置" },
222 settings_bot_feishu_enabled: { en: "Bot: Feishu legacy", zh: "机器人:飞书旧配置" },
223 settings_bot_weixin_enabled: { en: "Bot: Weixin legacy", zh: "机器人:微信旧配置" },
224 settings_bot_connection_count: { en: "Bot: connection count", zh: "机器人:连接数量" },
225 settings_bot_connection_provider: { en: "Bot: connection provider", zh: "机器人:连接渠道" },
226 settings_bot_connection_enabled: { en: "Bot: connection enabled", zh: "机器人:连接开关" },
227 settings_bot_connection_status: { en: "Bot: connection status", zh: "机器人:连接状态" },
228 settings_bot_connection_model: { en: "Bot: connection model", zh: "机器人:连接模型" },
229 settings_bot_connection_approval: { en: "Bot: connection approval", zh: "机器人:连接审批" },
230 cli_mode: { en: "CLI mode", zh: "CLI 模式" },
231 cli_profile: { en: "CLI profile", zh: "CLI 配置档" },
232 cli_permission_mode: { en: "CLI permission mode", zh: "CLI 权限模式" },
233 cli_session_mode: { en: "CLI session mode", zh: "CLI 会话模式" },
234 cli_turn_latency: { en: "CLI turn latency", zh: "CLI turn 延迟" },
235 cli_exit: { en: "CLI turn outcome", zh: "CLI turn 结果" },
236 };
237
238 const AGENT_METRIC_SIGNALS = [
239 "finish_reason",
240 "empty_final",
241 "provider_error",
242 "cache_hit",
243 "tool_error",
244 "updater_error",
245 "updater_event",
246 "compaction",
247 "turns",
248 "desktop_hang",
249 "desktop_hang_age",
250 "desktop_exit",
251 "desktop_exit_phase",
252 "desktop_uptime",
253 "desktop_install",
254 "desktop_update_transition",
255 "desktop_restore",
256 "desktop_webview2_failure",
257 "cli_turn_latency",
258 "cli_exit",
259 "recovery_failure",
260 "recovery_rule_continue",
261 "recovery_review_continue",
262 "recovery_human_prompt",
263 "recovery_human_continue",
264 "recovery_human_revise",
265 "recovery_review_error",
266 "recovery_repeat_prompt",
267 "recovery_review_latency",
268 ];
269 const DEFAULT_OPEN_SETTING_GROUPS = new Set(["Client", "Models", "Providers"]);
270
271 const SETTINGS_METRIC_GROUPS: { en: string; zh: string; signals: string[] }[] = [
272 {
273 en: "Client",
274 zh: "客户端",
275 signals: ["client_surface", "client_version", "settings_language", "cli_mode", "cli_profile", "cli_permission_mode", "cli_session_mode"],
276 },
277 {
278 en: "Appearance and layout",
279 zh: "外观与布局",
280 signals: [
281 "settings_desktop_layout",
282 "settings_theme",
283 "settings_theme_style",
284 "settings_display_mode",
285 "settings_status_bar_style",
286 "settings_status_bar_items_count",
287 ],
288 },
289 {
290 en: "Models",
291 zh: "模型",
292 signals: [
293 "settings_default_model",
294 "settings_planner_model",
295 "settings_subagent_model",
296 "settings_subagent_effort",
297 "settings_reasoning_language",
298 ],
299 },
300 {
301 en: "Providers",
302 zh: "Provider",
303 signals: ["settings_provider_count", "settings_provider_access_count", "settings_provider_access"],
304 },
305 {
306 en: "Behavior toggles",
307 zh: "行为开关",
308 signals: ["settings_close_behavior", "settings_check_updates"],
309 },
310 {
311 en: "Bots",
312 zh: "机器人",
313 signals: [
314 "settings_bot_enabled",
315 "settings_bot_model",
316 "settings_bot_tool_approval",
317 "settings_bot_allowlist",
318 "settings_bot_allow_all",
319 "settings_bot_qq_enabled",
320 "settings_bot_feishu_enabled",
321 "settings_bot_weixin_enabled",
322 "settings_bot_connection_count",
323 "settings_bot_connection_provider",
324 "settings_bot_connection_enabled",
325 "settings_bot_connection_status",
326 "settings_bot_connection_model",
327 "settings_bot_connection_approval",
328 ],
329 },
330 ];
331
332 function metricSignalLabel(signal: string): string {
333 const label = METRIC_SIGNAL_LABELS[signal];
334 return label ? i18n(label.en, label.zh) : esc(signal);
335 }
336
337 function metricsBySignal(rows: MetricRow[]): Map<string, { label: string; users: number }[]> {
338 const bySignal = new Map<string, { label: string; users: number }[]>();
339 for (const r of rows) {
340 const list = bySignal.get(r.signal) ?? [];
341 list.push({ label: r.bucket, users: r.total });
342 bySignal.set(r.signal, list);
343 }
344 return bySignal;
345 }
346
347 function metricBlocks(bySignal: Map<string, BarRow[]>, signals: string[], options: { barLimit?: number } = {}): string {
348 return signals
349 .filter((signal) => bySignal.has(signal))
350 .map((signal) => {
351 const rows = bySignal.get(signal) ?? [];
352 return `<div class="metric-block"><h3>${metricSignalLabel(signal)}<span>${rows.length}</span></h3>${listBars(rows, {
353 limit: options.barLimit ?? 5,
354 className: "metric-bars",
355 labelFormatter: (label) => bucketDisplayLabel(signal, label),
356 })}</div>`;
357 })
358 .join("");
359 }
360
361 function metricsCards(rows: MetricRow[], signals = AGENT_METRIC_SIGNALS): string {
362 if (!rows.length)
363 return `<div class="empty">${i18n("No metrics yet — flows in once an opt-in build ships", "暂无运行指标 — 等 opt-in 版本发布后有数据")}</div>`;
364 const bySignal = metricsBySignal(rows);
365 const blocks = metricBlocks(bySignal, signals);
366 return blocks ? `<div class="metrics">${blocks}</div>` : `<div class="empty">${i18n("No data in this window", "当前时间窗口暂无数据")}</div>`;
367 }
368
369 function settingsDashboard(rows: MetricRow[], options: { collapseSections?: boolean } = {}): string {
370 const bySignal = metricsBySignal(rows);
371 const sections = SETTINGS_METRIC_GROUPS.map((group) => {
372 const availableSignals = group.signals.filter((signal) => bySignal.has(signal));
373 const blocks = metricBlocks(bySignal, group.signals);
374 if (!blocks) return "";
375 const heading = `<h3>${i18n(group.en, group.zh)}<span>${i18nHTML(`${availableSignals.length} metrics`, `${availableSignals.length} 项指标`)}</span></h3>`;
376 if (options.collapseSections && !DEFAULT_OPEN_SETTING_GROUPS.has(group.en)) {
377 return `<details class="pref-section pref-section-collapsed"><summary>${heading}</summary><div class="metrics pref-metrics">${blocks}</div></details>`;
378 }
379 return `<section class="pref-section">${heading}<div class="metrics pref-metrics">${blocks}</div></section>`;
380 })
381 .filter(Boolean)
382 .join("");
383 if (!sections) return `<div class="empty">${i18n("No settings preference metrics yet", "暂无设置偏好指标")}</div>`;
384 return `<div class="preference-dashboard">${sections}</div>`;
385 }
386
387 function healthLevel(kind: "cache" | "rate", value: number | null): "good" | "warn" | "bad" | "unknown" {
388 if (value === null) return "unknown";
389 if (kind === "cache") {
390 if (value >= 80) return "good";
391 if (value >= 50) return "warn";
392 return "bad";
393 }
394 if (value <= 1) return "good";
395 if (value <= 5) return "warn";
396 return "bad";
397 }
398
399 function countHealthLevel(value: number): "good" | "warn" | "bad" {
400 if (value <= 0) return "good";
401 if (value <= 2) return "warn";
402 return "bad";
403 }
404
405 function levelText(level: "good" | "warn" | "bad" | "unknown"): string {
406 if (level === "good") return i18n("Good", "健康");
407 if (level === "warn") return i18n("Watch", "关注");
408 if (level === "bad") return i18n("Risk", "风险");
409 return i18n("No data", "暂无数据");
410 }
411
412 function healthCard(
413 label: { en: string; zh: string },
414 value: string,
415 level: "good" | "warn" | "bad" | "unknown",
416 deltaHTML: string,
417 detailHTML: string,
418 ): string {
419 return `<div class="health-card ${level}"><div class="health-top"><span>${i18n(label.en, label.zh)}</span><b>${levelText(level)}</b></div>
420 <strong>${esc(value)}</strong><small>${deltaHTML}</small><p>${detailHTML}</p></div>`;
421 }
422
423 function healthDeltaHTML(value: string): string {
424 return i18nHTML(`${esc(value)} vs previous window`, `${esc(value)} 较上一窗口`);
425 }
426
427 function healthDetailHTML(rows: MetricRow[], signal: string): string {
428 return i18nHTML(`${esc(topMetricBucket(rows, signal))} top bucket`, `主要分桶:${esc(topMetricBucket(rows, signal))}`);
429 }
430
431 function agentHealth(rows: MetricRow[], previousRows: MetricRow[]): string {
432 if (!rows.length) return `<div class="empty">${i18n("No agent health metrics yet", "暂无运行健康指标")}</div>`;
433 const cache = cacheHitRate(rows);
434 const prevCache = cacheHitRate(previousRows);
435 const desktopHangs = sumMetric(rows, "desktop_hang");
436 const prevDesktopHangs = sumMetric(previousRows, "desktop_hang");
437 const abnormalExits = rows.filter((r) => r.signal === "desktop_exit" && r.bucket === "abnormal").reduce((sum, r) => sum + r.total, 0);
438 const prevAbnormalExits = previousRows.filter((r) => r.signal === "desktop_exit" && r.bucket === "abnormal").reduce((sum, r) => sum + r.total, 0);
439 const webViewFailures = sumMetric(rows, "desktop_webview2_failure");
440 const prevWebViewFailures = sumMetric(previousRows, "desktop_webview2_failure");
441 const rateCard = (signal: string, en: string, zh: string) => {
442 const value = ratioPer100(rows, signal);
443 const prev = ratioPer100(previousRows, signal);
444 return healthCard(
445 { en, zh },
446 value === null ? "n/a" : `${Number(value.toFixed(value < 10 ? 1 : 0))}/100`,
447 healthLevel("rate", value),
448 healthDeltaHTML(deltaLabel(value, prev, "/100")),
449 healthDetailHTML(rows, signal),
450 );
451 };
452 return `<div class="health-grid">
453 ${healthCard(
454 { en: "Cache hit rate", zh: "缓存命中率" },
455 pct(cache),
456 healthLevel("cache", cache),
457 healthDeltaHTML(deltaLabel(cache, prevCache, "pp")),
458 healthDetailHTML(rows, "cache_hit"),
459 )}
460 ${rateCard("provider_error", "Provider errors", "Provider 错误")}
461 ${rateCard("tool_error", "Tool errors", "工具错误")}
462 ${rateCard("empty_final", "Empty final guard", "空回复拦截")}
463 ${rateCard("compaction", "Compactions", "压缩")}
464 ${healthCard(
465 { en: "Desktop hangs", zh: "桌面卡死" },
466 String(desktopHangs),
467 countHealthLevel(desktopHangs),
468 healthDeltaHTML(deltaLabel(desktopHangs, prevDesktopHangs)),
469 healthDetailHTML(rows, "desktop_hang_age"),
470 )}
471 ${healthCard(
472 { en: "Abnormal desktop exits", zh: "桌面异常退出" },
473 String(abnormalExits),
474 countHealthLevel(abnormalExits),
475 healthDeltaHTML(deltaLabel(abnormalExits, prevAbnormalExits)),
476 healthDetailHTML(rows, "desktop_exit_phase"),
477 )}
478 ${healthCard(
479 { en: "WebView2 process failures", zh: "WebView2 进程故障" },
480 String(webViewFailures),
481 countHealthLevel(webViewFailures),
482 healthDeltaHTML(deltaLabel(webViewFailures, prevWebViewFailures)),
483 healthDetailHTML(rows, "desktop_webview2_failure"),
484 )}
485 </div>`;
486 }
487
488 function statusPill(status: string): string {
489 if (status === "resolved") return `<span class="pill resolved">resolved</span>`;
490 if (status === "ignored") return `<span class="pill ignored">ignored</span>`;
491 return "";
492 }
493
494 type CrashRow = {
495 fingerprint: string;
496 kind: string;
497 count: number;
498 first_version: string;
499 last_version: string;
500 seen: string;
501 status: string;
502 title: string;
503 source: string;
504 label: string;
505 error_type: string;
506 top_frame: string;
507 severity: string;
508 last_os: string;
509 last_arch: string;
510 last_channel: string;
511 regressed_at: string;
512 development?: boolean;
513 };
514
515 function clip(s: string, n: number): string {
516 return s.length > n ? `${s.slice(0, n - 1)}…` : s;
517 }
518
519 function filterTab(label: string, zhLabel: string, href: string, active: boolean): string {
520 return `<a class="filter-tab${active ? " active" : ""}" href="${esc(href)}">${i18n(label, zhLabel)}</a>`;
521 }
522
523 function facetChip(row: { label: string; users: number }, active: string, hrefFor: (label: string) => string): string {
524 const label = row.label || "legacy";
525 return `<a class="facet-chip${active === row.label ? " active" : ""}" href="${esc(hrefFor(row.label))}" title="${esc(label)}"><span class="facet-label">${esc(label)}</span><b>${row.users}</b></a>`;
526 }
527
528 function facetChips(rows: { label: string; users: number }[], active: string, hrefFor: (label: string) => string, limit = 5): string {
529 if (!rows.length) return `<span class="filter-empty">${i18n("none", "暂无")}</span>`;
530 const visible = rows.slice(0, limit);
531 const activeRow = active ? rows.find((r) => r.label === active) : undefined;
532 if (activeRow && !visible.some((r) => r.label === activeRow.label)) visible.push(activeRow);
533 const visibleKeys = new Set(visible.map((r) => r.label));
534 const hidden = rows.filter((r) => !visibleKeys.has(r.label));
535 const chips = visible.map((r) => facetChip(r, active, hrefFor)).join("");
536 if (!hidden.length) return chips;
537 return `${chips}<details class="facet-more"><summary>${i18nHTML(`More ${hidden.length}`, `更多 ${hidden.length}`)}</summary><div class="facet-more-list">${hidden
538 .map((r) => facetChip(r, active, hrefFor))
539 .join("")}</div></details>`;
540 }
541
542 function statCard(label: { en: string; zh: string }, value: string, note: string, href: string, tone = ""): string {
543 return `<a class="overview-card ${tone}" href="${esc(href)}"><span>${i18n(label.en, label.zh)}</span><strong>${esc(value)}</strong><small>${note}</small></a>`;
544 }
545
546 function latestVersionShare(adoptionPct: number | null): string {
547 return adoptionPct === null ? "n/a" : `${Math.round(adoptionPct)}%`;
548 }
549
550 function topSeverityTone(openReports: number, regressedReports: number, criticalOpenReports: number): string {
551 if (criticalOpenReports || regressedReports) return "bad";
552 if (openReports) return "warn";
553 return "good";
554 }
555
556 function navLink(href: string, label: { en: string; zh: string }, active = false): string {
557 return `<a${active ? ` class="active" aria-current="page"` : ""} href="${esc(href)}">${i18n(label.en, label.zh)}</a>`;
558 }
559
560 function preferencePanel(title: string, body: string, active: boolean): string {
561 return `<section class="module-panel preference-panel${active ? " active" : ""}"${active ? ` aria-current="true"` : ""}>
562 <h3>${title}</h3>${body}</section>`;
563 }
564
565 function reportGroups(rows: CrashRow[], compact = false): string {
566 if (!rows.length) return `<div class="empty">${i18n("No diagnostic reports yet — that's the good kind of empty", "还没有诊断报告,这是好消息")}</div>`;
567 return `<div class="crash-list${compact ? " compact" : ""}"><div class="crash-head"><span>${i18n("summary", "摘要")}</span><span>${i18n("scope", "范围")}</span><span>${i18n("health", "状态")}</span><span title="${i18n("Groups are filtered by the selected window; occurrence totals are lifetime counts", "分组按所选时间窗口过滤;次数为全生命周期累计")}">${i18n("lifetime count", "累计次数")}</span></div>${rows
568 .map((c) => {
569 const platform = [c.last_os, c.last_arch].filter(Boolean).join("/");
570 const versions = `${c.first_version || "?"} → ${c.last_version || "?"}`;
571 const title = c.title || c.error_type || c.top_frame || c.fingerprint;
572 return `<a class="crash-item" href="/stats/group/${esc(c.fingerprint)}" title="${esc(title)}">
573 <span class="crash-summary"><span>${c.title ? esc(clip(c.title, compact ? 88 : 120)) : `<span class="muted">${i18n("No summary captured", "暂无摘要")}</span>`}</span><small>${esc(c.fingerprint.slice(0, 8))} · ${esc(c.seen)}</small>${
574 c.regressed_at ? `<em>${i18nHTML(`regressed ${esc(c.regressed_at.slice(0, 10))}`, `回归 ${esc(c.regressed_at.slice(0, 10))}`)}</em>` : ""
575 }</span>
576 <span class="crash-scope"><small>${esc(c.source || "legacy")}</small><small>${esc(versions)}</small><small>${platform ? esc(platform) : "unknown platform"}</small>${c.last_channel && c.last_channel !== "stable" ? `<small>${esc(c.last_channel)}</small>` : ""}</span>
577 <span class="crash-health"><span class="pill">${esc(c.severity || "medium")}</span><span class="pill ${c.kind === "crash" ? "crash" : ""}">${esc(c.kind)}</span>${statusPill(c.status)}</span>
578 <span class="crash-count">${c.count}</span>
579 </a>`;
580 })
581 .join("")}</div>`;
582 }
583
584 export function renderStats(
585 data: {
586 daily: Daily[];
587 versions: { label: string; users: number }[];
588 platforms: { label: string; users: number }[];
589 crashes: CrashRow[];
590 metrics: MetricRow[];
591 previousMetrics: MetricRow[];
592 metricUsers: MetricRow[];
593 metricUsersUnavailable: boolean;
594 /** Oldest computed_at in the rollup; empty when the window was queried live. */
595 metricUsersComputedAt: string;
596 sources: { label: string; users: number }[];
597 overview: OverviewCounts;
598 latestVersion: string;
599 filters: {
600 surface: "desktop" | "cli";
601 status: string;
602 source: string;
603 version: string;
604 os: string;
605 platform: string;
606 newLatest: boolean;
607 regressed: boolean;
608 windowDays: 7 | 30;
609 preferenceMode: "users" | "opens";
610 };
611 },
612 user: User,
613 activeModule: StatsModule = "usage",
614 ): string {
615 const days = lastDays(data.daily, data.filters.windowDays);
616 const range = data.filters.windowDays;
617 const rangeText = `${range}d`;
618 const totalUsers = days.at(-1)?.users ?? 0;
619 const anyPing = days.some((d) => d.opens > 0);
620 const agentMetrics = data.metrics.filter((r) => AGENT_METRIC_SIGNALS.includes(r.signal));
621 const previousAgentMetrics = data.previousMetrics.filter((r) => AGENT_METRIC_SIGNALS.includes(r.signal));
622 const agentMetricUsers = data.metricUsers.filter((r) => AGENT_METRIC_SIGNALS.includes(r.signal));
623 const isSettingsSignal = (signal: string) =>
624 signal === "client_surface" || signal === "client_version" || signal.startsWith("settings_") ||
625 ["cli_mode", "cli_profile", "cli_permission_mode", "cli_session_mode"].includes(signal);
626 const settingsMetrics = data.metrics.filter((r) => isSettingsSignal(r.signal));
627 const settingsMetricUsers = data.metricUsers.filter((r) => isSettingsSignal(r.signal));
628 const cache = cacheHitRate(agentMetrics);
629 const providerRate = ratioPer100(agentMetrics, "provider_error");
630 const toolRate = ratioPer100(agentMetrics, "tool_error");
631 const desktopHangs = sumMetric(agentMetrics, "desktop_hang");
632 const abnormalExits = agentMetrics
633 .filter((r) => r.signal === "desktop_exit" && r.bucket === "abnormal")
634 .reduce((sum, r) => sum + r.total, 0);
635 const webViewFailures = sumMetric(agentMetrics, "desktop_webview2_failure");
636 const healthWatchCount =
637 [healthLevel("cache", cache), healthLevel("rate", providerRate), healthLevel("rate", toolRate)].filter((v) => v === "warn" || v === "bad").length +
638 (desktopHangs > 0 ? 1 : 0) +
639 (abnormalExits > 0 ? 1 : 0) +
640 (webViewFailures > 0 ? 1 : 0);
641 const modulePath = (module: StatsModule) => (module === "usage" ? "/stats" : `/stats/${module}`);
642 const filterQS = (patch: Record<string, string>, module: StatsModule = activeModule) => {
643 const params = new URLSearchParams();
644 const put = (k: string, v: string) => {
645 if (v) params.set(k, v);
646 };
647 put("status", data.filters.status);
648 put("source", data.filters.source);
649 put("version", data.filters.version);
650 put("os", data.filters.os);
651 put("platform", data.filters.platform);
652 put("surface", data.filters.surface === "cli" ? "cli" : "");
653 if (data.filters.newLatest) params.set("new", "latest");
654 if (data.filters.regressed) params.set("regressed", "1");
655 if (data.filters.windowDays === 7) params.set("window", "7d");
656 if (module === "preferences" && data.filters.preferenceMode === "opens") params.set("prefs", "opens");
657 for (const [k, v] of Object.entries(patch)) {
658 if (v) params.set(k, v);
659 else params.delete(k);
660 }
661 const qs = params.toString();
662 const path = modulePath(module);
663 return qs ? `${path}?${qs}` : path;
664 };
665 const clearFiltersHref = filterQS({ status: "", source: "", version: "", os: "", platform: "", new: "", regressed: "" });
666 const hasFilters = Boolean(
667 data.filters.status || data.filters.source || data.filters.version || data.filters.os || data.filters.platform || data.filters.newLatest || data.filters.regressed,
668 );
669 const windowControls = `<div class="segmented" aria-label="Time window">
670 <a class="${range === 7 ? "active" : ""}"${range === 7 ? ` aria-current="true"` : ""} href="${esc(filterQS({ window: "7d" }))}">7d</a>
671 <a class="${range === 30 ? "active" : ""}"${range === 30 ? ` aria-current="true"` : ""} href="${esc(filterQS({ window: "" }))}">30d</a>
672 </div>`;
673 const surfaceControls = `<div class="segmented" aria-label="Client surface">
674 <a class="${data.filters.surface === "desktop" ? "active" : ""}"${data.filters.surface === "desktop" ? ` aria-current="true"` : ""} href="${esc(filterQS({ surface: "" }))}">${i18n("Desktop", "桌面端")}</a>
675 <a class="${data.filters.surface === "cli" ? "active" : ""}"${data.filters.surface === "cli" ? ` aria-current="true"` : ""} href="${esc(filterQS({ surface: "cli" }))}">CLI</a>
676 </div>`;
677 const preferenceControls = `<div class="segmented" aria-label="Preference metric mode">
678 <a class="${data.filters.preferenceMode === "users" ? "active" : ""}"${data.filters.preferenceMode === "users" ? ` aria-current="true"` : ""} href="${esc(
679 filterQS({ prefs: "" }, "preferences"),
680 )}">${i18n("Installs", "按安装")}</a>
681 <a class="${data.filters.preferenceMode === "opens" ? "active" : ""}"${data.filters.preferenceMode === "opens" ? ` aria-current="true"` : ""} href="${esc(
682 filterQS({ prefs: "opens" }, "preferences"),
683 )}">${i18n("Opens", "按启动")}</a>
684 </div>`;
685 const overviewTone = topSeverityTone(data.overview.openReports, data.overview.regressedReports, data.overview.criticalOpenReports);
686 const isDevelopmentDiagnostic = (row: CrashRow) => row.development ?? row.fingerprint.startsWith("dev:");
687 const releaseCrashes = data.crashes.filter(
688 (row) => row.kind !== "performance" && row.severity !== "low" && !isDevelopmentDiagnostic(row),
689 );
690 const performanceDiagnostics = data.crashes.filter(
691 (row) => row.kind === "performance" && !isDevelopmentDiagnostic(row),
692 );
693 const developmentDiagnostics = data.crashes.filter(isDevelopmentDiagnostic);
694 const overview = `<section class="overview-grid">
695 ${statCard({ en: "Active today", zh: "今日活跃" }, String(totalUsers), i18n("anonymous installs", "匿名安装"), filterQS({}, "usage"))}
696 ${statCard({ en: "Latest adoption", zh: "最新版本占比" }, latestVersionShare(data.overview.latestAdoptionPct), i18nHTML(`latest ${esc(data.latestVersion || "n/a")}`, `最新 ${esc(data.latestVersion || "n/a")}`), filterQS({}, "usage"))}
697 ${statCard({ en: "Open reports", zh: "未处理报告" }, String(data.overview.openReports), i18n("needs triage", "需要分诊"), filterQS({}, "diagnostics"), overviewTone)}
698 ${statCard({ en: "New in latest", zh: "最新新增" }, String(data.overview.newLatestReports), i18n("first seen on latest", "首次出现在最新版"), filterQS({}, "diagnostics"), data.overview.newLatestReports ? "warn" : "good")}
699 ${statCard({ en: "Regressions", zh: "回归问题" }, String(data.overview.regressedReports), i18n("previously resolved", "曾经解决后复现"), filterQS({}, "diagnostics"), data.overview.regressedReports ? "bad" : "good")}
700 ${statCard({ en: "Agent health", zh: "运行健康" }, healthWatchCount ? String(healthWatchCount) : "OK", i18nHTML(`${pct(cache)} cache · ${providerRate === null ? "n/a" : Number(providerRate.toFixed(1))}/100 provider · ${desktopHangs} hangs`, `${pct(cache)} 缓存 · ${providerRate === null ? "n/a" : Number(providerRate.toFixed(1))}/100 Provider · ${desktopHangs} 次卡死`), filterQS({}, "health"), healthWatchCount ? "warn" : "good")}
701 </section>`;
702 const pageOverview = activeModule === "usage" ? overview : "";
703 const dashboardNav = `<nav class="site-nav" aria-label="Stats navigation">
704 ${navLink(filterQS({}, "usage"), { en: "Home", zh: "主页" }, activeModule === "usage")}
705 ${navLink(filterQS({}, "diagnostics"), { en: "Diagnostics", zh: "诊断分诊" }, activeModule === "diagnostics")}
706 ${navLink(filterQS({}, "preferences"), { en: "Preferences", zh: "设置偏好" }, activeModule === "preferences")}
707 ${navLink(filterQS({}, "health"), { en: "Agent Health", zh: "运行健康" }, activeModule === "health")}
708 </nav>`;
709 const filters = `<div class="filter-card"><div class="filter-head"><h2>${i18n("Report filters", "诊断筛选")}</h2><span>${i18nHTML(`latest ${esc(data.latestVersion || "n/a")}`, `最新 ${esc(data.latestVersion || "n/a")}`)}</span></div>
710 <div class="filter-tabs">
711 ${filterTab("All", "全部", clearFiltersHref, !hasFilters)}
712 ${filterTab("Open", "未处理", filterQS({ status: "open" }), data.filters.status === "open")}
713 ${filterTab("Resolved", "已解决", filterQS({ status: "resolved" }), data.filters.status === "resolved")}
714 ${filterTab("Ignored", "已忽略", filterQS({ status: "ignored" }), data.filters.status === "ignored")}
715 ${filterTab("New in latest", "最新新增", filterQS({ new: data.filters.newLatest ? "" : "latest" }), data.filters.newLatest)}
716 ${filterTab("Regressed", "回归", filterQS({ regressed: data.filters.regressed ? "" : "1" }), data.filters.regressed)}
717 </div>
718 <div class="facet-grid">
719 <section><h3>${i18n("Source", "来源")}</h3><div class="facet-list">${facetChips(data.sources, data.filters.source, (label) => filterQS({ source: label }), 4)}</div></section>
720 <section><h3>${i18n("Version", "版本")}</h3><div class="facet-list">${facetChips(data.versions, data.filters.version, (label) => filterQS({ version: label }), 5)}</div></section>
721 <section><h3>${i18n("Platform", "平台")}</h3><div class="facet-list">${facetChips(data.platforms, data.filters.platform, (label) => filterQS({ platform: label }), 4)}</div></section>
722 </div></div>`;
723 const usageModule = `<section id="usage" class="card full module-card"><div class="module-head"><div><span>${i18n("Module", "模块")}</span><h2>${i18n("Usage distribution", "使用分布")}</h2></div></div>
724 <div class="module-panel wide"><h3>${i18nHTML(`Daily active installs <b>— ${rangeText}</b> (solid: users, faded: opens)`, `每日活跃 <b>— ${rangeText}</b>(实线:用户,淡色:打开次数)`)}</h3>
725 ${anyPing ? dailyChart(days) : `<div class="empty">${i18n("No pings yet — data starts flowing once a telemetry-enabled build ships", "暂无启动 ping — 等带统计的版本发布后这里开始有数据")}</div>`}</div>
726 <div class="module-split">
727 <section class="module-panel"><h3>${i18nHTML(`Versions <b>— ${rangeText}</b>`, `版本分布 <b>— ${rangeText}</b>`)}</h3>${listBars(data.versions)}</section>
728 <section class="module-panel"><h3>${i18nHTML(`Platforms <b>— ${rangeText}</b>`, `平台分布 <b>— ${rangeText}</b>`)}</h3>${listBars(data.platforms)}</section>
729 </div></section>`;
730 const diagnosticsModule = `<section id="diagnostics" class="card full module-card"><div class="module-head"><div><span>${i18n("Module", "模块")}</span><h2>${i18n("Diagnostic triage", "诊断分诊")}</h2></div><a class="module-action" href="#top">${i18n("Back to overview", "回到概览")}</a></div>
731 <section class="module-panel"><h3>${i18nHTML("Needs attention <b>— top 10 release crashes and exceptions</b>", "优先处理 <b>— 正式版崩溃与异常 Top 10</b>")}</h3>${reportGroups(releaseCrashes.slice(0, 10), true)}</section>
732 ${performanceDiagnostics.length ? `<section class="module-panel"><h3>${i18nHTML("Performance signals <b>— tracked separately from crashes</b>", "性能信号 <b>— 与崩溃分开统计</b>")}</h3>${reportGroups(performanceDiagnostics.slice(0, 5), true)}</section>` : ""}
733 ${developmentDiagnostics.length ? `<section class="module-panel"><h3>${i18nHTML("Development diagnostics <b>— excluded from release priority</b>", "开发版诊断 <b>— 不计入正式版优先级</b>")}</h3>${reportGroups(developmentDiagnostics.slice(0, 5), true)}</section>` : ""}
734 ${filters}
735 <section class="module-panel"><h3>${i18nHTML("All report groups <b>— open, regression, severity, count, recency</b>", "全部诊断分组 <b>— 未处理、回归、严重性、次数和最近出现</b>")}</h3>${reportGroups(data.crashes)}</section>
736 </section>`;
737 const sevenDayHref = esc(filterQS({ window: "7d" }, "preferences"));
738 const unavailableNotice =
739 range === 30
740 ? i18nHTML(
741 `The 30-day deduplication is precomputed hourly and has not reached every signal yet. <a href="${sevenDayHref}">Use 7d</a> meanwhile.`,
742 `30 天去重统计由后台每小时预聚合,目前还没覆盖到全部信号。<a href="${sevenDayHref}">先看 7 天</a>。`,
743 )
744 : i18nHTML(`The ${rangeText} deduplication did not finish.`, `${rangeText} 的去重统计没能跑完。`);
745 // A precomputed window can silently go stale if the rollup cron stops, so the
746 // heading carries how old the least recently recomputed signal is.
747 const computedAt = data.metricUsersComputedAt
748 ? ` <b>${esc(data.metricUsersComputedAt.slice(0, 16).replace("T", " "))}Z</b>`
749 : "";
750 const healthComputedAt = data.metricUsersComputedAt
751 ? ` ${esc(data.metricUsersComputedAt.slice(0, 16).replace("T", " "))}Z`
752 : "";
753 const installsPanel = preferencePanel(
754 i18nHTML(
755 `Deduplicated installs <b>— ${rangeText}</b>${computedAt ? ` computed${computedAt}` : ""}`,
756 `按安装去重 <b>— ${rangeText}</b>${computedAt ? ` 统计于${computedAt}` : ""}`,
757 ),
758 data.metricUsersUnavailable
759 ? `<div class="empty">${unavailableNotice}</div>`
760 : settingsDashboard(settingsMetricUsers, { collapseSections: true }),
761 data.filters.preferenceMode === "users",
762 );
763 const opensPanel = preferencePanel(
764 i18nHTML(`Launch/open snapshots <b>— ${rangeText}</b>`, `启动/开启快照 <b>— ${rangeText}</b>`),
765 settingsDashboard(settingsMetrics, { collapseSections: true }),
766 data.filters.preferenceMode === "opens",
767 );
768 const preferencePanels = data.filters.preferenceMode === "opens" ? `${opensPanel}${installsPanel}` : `${installsPanel}${opensPanel}`;
769 const preferencesModule = `<section id="preferences" class="card full module-card"><div class="module-head"><div><span>${i18n("Module", "模块")}</span><h2>${i18n("Settings preferences", "设置偏好")}</h2></div><div class="module-actions">${preferenceControls}</div></div>
770 <div class="preference-compare">${preferencePanels}</div></section>`;
771 const healthModule = `<section id="health" class="card full module-card"><div class="module-head"><div><span>${i18n("Module", "模块")}</span><h2>${i18n("Agent health", "运行健康")}</h2></div><div class="module-actions"><a class="module-action" href="${esc(filterQS({}, "preferences"))}">${i18n("Preferences", "设置偏好")}</a></div></div>
772 <section class="module-panel"><h3>${i18nHTML(`Health summary <b>— ${rangeText}, compared with previous window</b>`, `健康摘要 <b>— ${rangeText},对比上一窗口</b>`)}</h3>${agentHealth(agentMetrics, previousAgentMetrics)}</section>
773 <section class="module-panel"><h3>${i18nHTML(`Affected installs <b>— ${rangeText}, deduplicated${healthComputedAt ? `, computed ${healthComputedAt}` : ""}</b>`, `受影响安装 <b>— ${rangeText},按安装去重${healthComputedAt ? `,统计于 ${healthComputedAt}` : ""}</b>`)}</h3>${
774 data.metricUsersUnavailable
775 ? `<div class="empty">${range === 30
776 ? i18nHTML(`The 30-day deduplication is not ready. <a href="${esc(filterQS({ window: "7d" }, "health"))}">Use 7d</a> meanwhile.`, `30 天去重统计尚未就绪。<a href="${esc(filterQS({ window: "7d" }, "health"))}">先看 7 天</a>。`)
777 : i18n(`The ${rangeText} deduplication did not finish.`, `${rangeText} 的去重统计没能跑完。`)}</div>`
778 : metricsCards(agentMetricUsers, ["desktop_hang", "desktop_hang_age", "desktop_webview2_failure", "desktop_restore", "desktop_exit"])
779 }</section>
780 <section class="module-panel"><h3>${i18nHTML(`Signal distributions <b>— ${rangeText}, opt-in aggregate</b>`, `信号分布 <b>— ${rangeText},opt-in 汇总</b>`)}</h3>${metricsCards(agentMetrics)}</section>
781 </section>`;
782 const activeModuleHTML: Record<StatsModule, string> = {
783 diagnostics: diagnosticsModule,
784 usage: usageModule,
785 preferences: preferencesModule,
786 health: healthModule,
787 };
788
789 return page(
790 "Reasonix · Crash & Telemetry",
791 "health",
792 `${dashboardNav}
793 <div id="top" class="hero-line"><div><h1>${i18n("Crash & Telemetry", "客户端健康看板")}</h1><p class="sub">${i18nHTML(
794 `${rangeText} window · anonymous launch pings, opt-in aggregate metrics, and user-sent diagnostic reports only`,
795 `${rangeText} 时间窗口 · 仅包含匿名启动 ping、opt-in 汇总指标和用户发送的诊断报告`,
796 )}</p></div><div class="module-actions">${surfaceControls}${windowControls}</div></div>
797 ${pageOverview}
798 <div class="grid">
799 ${activeModuleHTML[activeModule]}
800 </div>`,
801 userNav(user),
802 );
803 }
804
805 function fmtDevice(deviceJSON: string): string {
806 try {
807 const d = JSON.parse(deviceJSON) as { osVersion?: string; cpu?: string; cores?: number; ramGb?: number };
808 return [d.osVersion, d.cpu, d.cores ? `${d.cores} cores` : "", d.ramGb ? `${d.ramGb} GB RAM` : ""]
809 .filter(Boolean)
810 .join(" · ");
811 } catch {
812 return "";
813 }
814 }
815
816 export type Group = {
817 fingerprint: string;
818 kind: string;
819 count: number;
820 first_seen: string;
821 last_seen: string;
822 first_version: string;
823 last_version: string;
824 status: string;
825 note: string;
826 title: string;
827 source: string;
828 label: string;
829 error_type: string;
830 top_frame: string;
831 severity: string;
832 last_os: string;
833 last_arch: string;
834 last_build_commit: string;
835 last_channel: string;
836 resolved_in: string;
837 resolved_at: string;
838 regressed_at: string;
839 };
840
841 type ReportSample = {
842 version: string;
843 os: string;
844 arch: string;
845 message: string;
846 device: string;
847 created_at: string;
848 source: string;
849 label: string;
850 error_type: string;
851 error_message: string;
852 top_frame: string;
853 build_commit: string;
854 channel: string;
855 language: string;
856 view: string;
857 breadcrumbs: string;
858 component_stack: string;
859 stack: string;
860 occurred_at: string;
861 };
862
863 function manageGroup(group: Group): string {
864 const fp = esc(group.fingerprint);
865 const setStatus = (s: string, label: string, zhLabel: string, cls: string) =>
866 group.status === s
867 ? ""
868 : `<form method="post" action="/stats/group/${fp}" class="inline"><input type="hidden" name="action" value="status"><input type="hidden" name="status" value="${s}"><button class="btn ${cls} sm" type="submit">${i18n(label, zhLabel)}</button></form>`;
869 return `<div class="card full manage-card"><div class="manage-head"><h2>${i18nHTML("Manage <b>— admin</b>", "管理 <b>— 管理员</b>")}</h2><div class="manage-actions">${setStatus("resolved", "Mark resolved", "标记已解决", "ghost")}${setStatus("ignored", "Ignore", "忽略", "ghost")}${setStatus("open", "Reopen", "重新打开", "ghost")}
870 <form method="post" action="/stats/group/${fp}" class="inline" onsubmit="return confirm('Delete this crash group and all its samples?')"><input type="hidden" name="action" value="delete"><button class="btn danger sm" type="submit">${i18n("Delete group", "删除分组")}</button></form></div></div>
871 <div class="manage-grid">
872 <form method="post" action="/stats/group/${fp}" class="manage-form"><input type="hidden" name="action" value="resolution"><label>${i18n("Resolved in", "解决版本")}<input type="text" name="resolvedIn" placeholder="v1.10.1" value="${esc(group.resolved_in)}"></label><button class="btn sm" type="submit">${i18n("Save", "保存")}</button></form>
873 <form method="post" action="/stats/group/${fp}" class="manage-form"><input type="hidden" name="action" value="severity"><label>${i18n("Severity", "严重级别")}<select name="severity"><option${group.severity === "low" ? " selected" : ""}>low</option><option${group.severity === "medium" ? " selected" : ""}>medium</option><option${group.severity === "high" ? " selected" : ""}>high</option><option${group.severity === "critical" ? " selected" : ""}>critical</option></select></label><button class="btn sm" type="submit">${i18n("Save", "保存")}</button></form>
874 <form method="post" action="/stats/group/${fp}" class="manage-form wide"><input type="hidden" name="action" value="note"><label>${i18n("Note", "备注")}<input type="text" name="note" placeholder="${esc("Add investigation note")}" value="${esc(group.note)}"></label><button class="btn sm" type="submit">${i18n("Save", "保存")}</button></form>
875 </div></div>`;
876 }
877
878 function breadcrumbsList(json: string): string {
879 try {
880 const rows = JSON.parse(json) as { cat?: string; msg?: string }[];
881 if (!Array.isArray(rows) || rows.length === 0) return "";
882 return `<details class="sample-nested"><summary>${i18n("breadcrumbs", "面包屑")}</summary><pre>${esc(rows.map((b) => `[${b.cat ?? ""}] ${b.msg ?? ""}`).join("\n"))}</pre></details>`;
883 } catch {
884 return "";
885 }
886 }
887
888 function sampleReport(r: ReportSample, i: number): string {
889 const dev = fmtDevice(r.device);
890 const platform = [r.os, r.arch].filter(Boolean).join("/");
891 const title = r.error_message || r.message.split("\n").find((line) => line.trim()) || r.error_type || "sample";
892 const structured = [
893 r.source && [i18n("source", "来源"), r.source],
894 r.label && [i18n("label", "标签"), r.label],
895 r.error_type && [i18n("type", "类型"), r.error_type],
896 r.top_frame && [i18n("top", "顶层"), r.top_frame],
897 r.build_commit && [i18n("build", "构建"), r.build_commit],
898 r.channel && [i18n("channel", "渠道"), r.channel],
899 r.view && [i18n("view", "视图"), r.view],
900 ]
901 .filter(Boolean)
902 .map(([label, value]) => `<span><b>${label}</b>${esc(value)}</span>`)
903 .join("");
904 const stack = r.stack || r.component_stack;
905 return `<details class="sample" ${i === 0 ? "open" : ""}><summary>
906 <span class="sample-id"><b>${esc(r.version)}</b><small>${esc(platform || "unknown platform")}</small></span>
907 <span class="sample-title">${esc(clip(title, 110))}</span>
908 <span class="sample-time">${esc((r.occurred_at || r.created_at).slice(0, 19).replace("T", " "))}</span>
909 </summary>
910 <div class="sample-body">
911 <div class="sample-meta">${dev ? `<span><b>${i18n("device", "设备")}</b>${esc(dev)}</span>` : ""}${structured}</div>
912 <div class="sample-actions"><button class="btn ghost sm copy-btn" type="button" data-copy="${esc(r.message)}"><span class="copy-label">${i18n("Copy message", "复制消息")}</span></button>${
913 stack
914 ? `<button class="btn ghost sm copy-btn" type="button" data-copy="${esc(stack)}"><span class="copy-label">${i18n("Copy stack", "复制堆栈")}</span></button>`
915 : ""
916 }</div>
917 <pre>${esc(r.message)}</pre>
918 ${stack ? `<details class="sample-nested"><summary>${i18n("stack", "堆栈")}</summary><pre>${esc(stack)}</pre></details>` : ""}
919 ${breadcrumbsList(r.breadcrumbs)}
920 </div></details>`;
921 }
922
923 function sampleReports(reports: ReportSample[], options: { limit?: number } = {}): string {
924 if (!reports.length) return `<div class="empty">${i18n("No raw samples stored for this group", "这个分组没有保存原始样本")}</div>`;
925 const limit = options.limit ?? 10;
926 const visible = reports.slice(0, limit);
927 const hidden = reports.slice(limit);
928 const visibleSamples = visible.map((r, i) => sampleReport(r, i)).join("");
929 const hiddenSamples = hidden.map((r, i) => sampleReport(r, i + limit)).join("");
930 const history =
931 hidden.length > 0
932 ? `<details class="sample-more"><summary>${i18nHTML(`Historical samples ${hidden.length}`, `历史样本 ${hidden.length}`)}</summary><div class="sample-more-list">${hiddenSamples}</div></details>`
933 : "";
934 return `<div class="sample-list">${visibleSamples}${history}</div>`;
935 }
936
937 export function renderGroup(
938 group: Group,
939 reports: ReportSample[],
940 user: User,
941 ): string {
942 const samples = sampleReports(reports);
943 const platform = [group.last_os, group.last_arch].filter(Boolean).join("/");
944 const status = statusPill(group.status) || `<span class="pill open">${i18n("open", "未处理")}</span>`;
945 const tags = [
946 [i18n("source", "来源"), group.source || "legacy"],
947 group.label && [i18n("label", "标签"), group.label],
948 group.error_type && [i18n("type", "类型"), group.error_type],
949 group.top_frame && [i18n("top frame", "顶层帧"), group.top_frame],
950 platform && [i18n("platform", "平台"), platform],
951 group.last_build_commit && [i18n("build", "构建"), group.last_build_commit],
952 group.last_channel && [i18n("channel", "渠道"), group.last_channel],
953 ]
954 .filter(Boolean)
955 .map(([label, value]) => `<span><b>${label}</b>${esc(value)}</span>`)
956 .join("");
957 const metrics = [
958 [i18n("Occurrences", "出现次数"), String(group.count)],
959 [i18n("First seen", "首次出现"), `${group.first_seen.slice(0, 10)} · ${group.first_version || "?"}`],
960 [i18n("Last seen", "最近出现"), `${group.last_seen.slice(0, 10)} · ${group.last_version || "?"}`],
961 [i18n("Version range", "版本范围"), `${group.first_version || "?"} → ${group.last_version || "?"}`],
962 group.resolved_in && [i18n("Resolved in", "解决版本"), group.resolved_in],
963 group.regressed_at && [i18n("Regressed", "回归时间"), group.regressed_at.slice(0, 10)],
964 ]
965 .filter(Boolean)
966 .map(([label, value]) => `<div><span>${label}</span><b>${esc(value)}</b></div>`)
967 .join("");
968
969 return page(
970 `Reasonix · ${group.fingerprint.slice(0, 8)}`,
971 `stats / ${group.fingerprint.slice(0, 8)}`,
972 `<section class="group-hero"><div class="group-nav"><a class="back" href="/stats">${i18n("Back to stats", "返回统计")}</a><button class="btn ghost sm copy-btn" type="button" data-copy="${esc(group.fingerprint)}"><span class="copy-label">${i18n("Copy fingerprint", "复制指纹")}</span></button></div>
973 <div class="group-title"><span class="pill ${group.kind === "crash" ? "crash" : ""}">${esc(group.kind)}</span><h1>${esc(group.fingerprint.slice(0, 8))}</h1>${status}</div>
974 ${group.title ? `<p class="summary group-summary">${esc(group.title)}</p>` : ""}
975 <div class="group-tags">${tags}</div>
976 <div class="group-metrics">${metrics}</div>
977 ${group.note ? `<p class="group-note">${i18n("Note", "备注")}: ${esc(group.note)}</p>` : ""}</section>
978 <div class="card full sample-card"><h2>${i18nHTML("Samples <b>— newest first, first sample plus latest 5 kept</b>", "样本 <b>— 最新优先,保留首个样本和最近 5 个</b>")}</h2>${samples}</div>
979 ${user.role === "admin" ? manageGroup(group) : ""}
980 <a class="back" href="/stats">${i18n("Back to stats", "返回统计")}</a>`,
981 userNav(user),
982 );
983 }
984
984 lines TYPESCRIPT