| 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 |