| 1 | import { type User, userNav } from "./auth"; |
| 2 | import { esc, page } from "./shell"; |
| 3 | import { clip, i18n, i18nHTML, statusPill } from "./stats"; |
| 4 | |
| 5 | function fmtDevice(deviceJSON: string): string { |
| 6 | try { |
| 7 | const d = JSON.parse(deviceJSON) as { osVersion?: string; cpu?: string; cores?: number; ramGb?: number }; |
| 8 | return [d.osVersion, d.cpu, d.cores ? `${d.cores} cores` : "", d.ramGb ? `${d.ramGb} GB RAM` : ""] |
| 9 | .filter(Boolean) |
| 10 | .join(" · "); |
| 11 | } catch { |
| 12 | return ""; |
| 13 | } |
| 14 | } |
| 15 | |
| 16 | export type Group = { |
| 17 | fingerprint: string; |
| 18 | kind: string; |
| 19 | count: number; |
| 20 | first_seen: string; |
| 21 | last_seen: string; |
| 22 | first_version: string; |
| 23 | last_version: string; |
| 24 | status: string; |
| 25 | note: string; |
| 26 | title: string; |
| 27 | source: string; |
| 28 | label: string; |
| 29 | error_type: string; |
| 30 | top_frame: string; |
| 31 | severity: string; |
| 32 | last_os: string; |
| 33 | last_arch: string; |
| 34 | last_build_commit: string; |
| 35 | last_channel: string; |
| 36 | resolved_in: string; |
| 37 | resolved_at: string; |
| 38 | regressed_at: string; |
| 39 | regression_review?: string; |
| 40 | resolution_platform?: string; |
| 41 | resolution_runtime?: string; |
| 42 | resolution_basis?: string; |
| 43 | }; |
| 44 | |
| 45 | export type ReportSample = { |
| 46 | version: string; |
| 47 | os: string; |
| 48 | arch: string; |
| 49 | message: string; |
| 50 | device: string; |
| 51 | created_at: string; |
| 52 | source: string; |
| 53 | label: string; |
| 54 | error_type: string; |
| 55 | error_message: string; |
| 56 | error_family?: string; |
| 57 | top_frame: string; |
| 58 | build_commit: string; |
| 59 | channel: string; |
| 60 | language: string; |
| 61 | view: string; |
| 62 | breadcrumbs: string; |
| 63 | component_stack: string; |
| 64 | stack: string; |
| 65 | occurred_at: string; |
| 66 | webview2: string; |
| 67 | web_runtime: string; |
| 68 | event_id?: string; |
| 69 | incident_id?: string; |
| 70 | diagnostics?: string; |
| 71 | }; |
| 72 | |
| 73 | type GroupDiagnosticSummary = { |
| 74 | windowEvents: number; |
| 75 | identifiedEvents: number; |
| 76 | affectedInstalls: number; |
| 77 | linkedIncidents?: number; |
| 78 | distributions: { facet: string; value: string; installs: number; events: number }[]; |
| 79 | }; |
| 80 | |
| 81 | function manageGroup(group: Group): string { |
| 82 | const fp = esc(group.fingerprint); |
| 83 | const setStatus = (s: string, label: string, zhLabel: string, cls: string) => |
| 84 | group.status === s |
| 85 | ? "" |
| 86 | : `<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>`; |
| 87 | 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")} |
| 88 | <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> |
| 89 | <div class="manage-grid"> |
| 90 | <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><label>${i18n("Platform", "适用平台")}<input type="text" name="resolutionPlatform" placeholder="windows" value="${esc(group.resolution_platform ?? "")}"></label><label>${i18n("Runtime", "适用运行时")}<input type="text" name="resolutionRuntime" placeholder="webview2" value="${esc(group.resolution_runtime ?? "")}"></label><label>${i18n("Basis", "修复依据")}<input type="text" name="resolutionBasis" value="${esc(group.resolution_basis ?? "")}"></label><button class="btn sm" type="submit">${i18n("Save", "保存")}</button></form> |
| 91 | <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> |
| 92 | <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> |
| 93 | </div></div>`; |
| 94 | } |
| 95 | |
| 96 | function breadcrumbsList(json: string): string { |
| 97 | try { |
| 98 | const rows = JSON.parse(json) as { cat?: string; msg?: string }[]; |
| 99 | if (!Array.isArray(rows) || rows.length === 0) return ""; |
| 100 | return `<details class="sample-nested"><summary>${i18n("breadcrumbs", "面包屑")}</summary><pre>${esc(rows.map((b) => `[${b.cat ?? ""}] ${b.msg ?? ""}`).join("\n"))}</pre></details>`; |
| 101 | } catch { |
| 102 | return ""; |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | function sampleReport(r: ReportSample, i: number): string { |
| 107 | const dev = fmtDevice(r.device); |
| 108 | const platform = [r.os, r.arch].filter(Boolean).join("/"); |
| 109 | const title = r.error_message || r.message.split("\n").find((line) => line.trim()) || r.error_type || "sample"; |
| 110 | const structured = [ |
| 111 | r.source && [i18n("source", "来源"), r.source], |
| 112 | r.label && [i18n("label", "标签"), r.label], |
| 113 | r.error_type && [i18n("type", "类型"), r.error_type], |
| 114 | r.error_family ? [i18n("error family", "错误族"), r.error_family] : "", |
| 115 | r.top_frame && [i18n("top", "顶层"), r.top_frame], |
| 116 | r.build_commit && [i18n("build", "构建"), r.build_commit], |
| 117 | r.channel && [i18n("channel", "渠道"), r.channel], |
| 118 | r.view && [i18n("view", "视图"), r.view], |
| 119 | ] |
| 120 | .filter(Boolean) |
| 121 | .map(([label, value]) => `<span><b>${label}</b>${esc(value)}</span>`) |
| 122 | .join(""); |
| 123 | const stack = r.stack || r.component_stack; |
| 124 | let diagnostic: Record<string, unknown> = {}; |
| 125 | try { |
| 126 | diagnostic = JSON.parse(r.diagnostics || "{}"); |
| 127 | } catch { |
| 128 | diagnostic = {}; |
| 129 | } |
| 130 | const observerVersion = typeof diagnostic.observerVersion === "string" ? diagnostic.observerVersion : ""; |
| 131 | const observedAt = typeof diagnostic.observedAt === "string" ? diagnostic.observedAt : r.created_at; |
| 132 | const lastPhaseAt = typeof diagnostic.lastPhaseAt === "string" ? diagnostic.lastPhaseAt : ""; |
| 133 | const occurredAt = r.occurred_at; |
| 134 | let webRuntime = ""; |
| 135 | try { |
| 136 | const diagnostic = JSON.parse(r.web_runtime || r.webview2 || "") as Record<string, unknown>; |
| 137 | webRuntime = Object.entries(diagnostic) |
| 138 | .filter(([, value]) => value !== "" && value !== undefined && value !== null) |
| 139 | .map(([key, value]) => `${key}: ${String(value)}`) |
| 140 | .join("\n"); |
| 141 | } catch { |
| 142 | webRuntime = ""; |
| 143 | } |
| 144 | return `<details class="sample" ${i === 0 ? "open" : ""}><summary> |
| 145 | <span class="sample-id"><b>${esc(r.version)}</b><small>${esc(observerVersion && observerVersion !== r.version ? `${i18n("reported by", "上报于")} ${observerVersion}` : platform || "unknown platform")}</small></span> |
| 146 | <span class="sample-title">${esc(clip(title, 110))}</span> |
| 147 | <span class="sample-time">${esc((occurredAt || observedAt).slice(0, 19).replace("T", " "))}</span> |
| 148 | </summary> |
| 149 | <div class="sample-body"> |
| 150 | <div class="sample-meta">${dev ? `<span><b>${i18n("device", "设备")}</b>${esc(dev)}</span>` : ""}${structured} |
| 151 | ${observedAt ? `<span><b>${i18n("observed", "观察时间")}</b>${esc(observedAt)}</span>` : ""} |
| 152 | ${lastPhaseAt ? `<span><b>${i18n("last phase", "最后阶段时间")}</b>${esc(lastPhaseAt)}</span>` : ""} |
| 153 | ${occurredAt ? `<span><b>${i18n("occurred", "故障时间")}</b>${esc(occurredAt)}</span>` : `<span><b>${i18n("occurred", "故障时间")}</b>${i18n("unknown", "未知")}</span>`}</div> |
| 154 | <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>${stack ? `<button class="btn ghost sm copy-btn" type="button" data-copy="${esc(stack)}"><span class="copy-label">${i18n("Copy stack", "复制堆栈")}</span></button>` : ""}</div> |
| 155 | <pre>${esc(r.message)}</pre> |
| 156 | ${stack ? `<details class="sample-nested"><summary>${i18n("stack", "堆栈")}</summary><pre>${esc(stack)}</pre></details>` : ""} |
| 157 | ${breadcrumbsList(r.breadcrumbs)} |
| 158 | ${webRuntime ? `<details class="sample-nested"><summary>Web Runtime</summary><pre>${esc(webRuntime)}</pre></details>` : ""} |
| 159 | </div></details>`; |
| 160 | } |
| 161 | |
| 162 | function sampleReports(reports: ReportSample[], options: { limit?: number; truncated?: boolean } = {}): string { |
| 163 | if (!reports.length) return `<div class="empty">${i18n("No raw samples stored for this group", "这个分组没有保存原始样本")}</div>`; |
| 164 | const limit = options.limit ?? 10; |
| 165 | const visible = reports.slice(0, limit); |
| 166 | const hidden = reports.slice(limit); |
| 167 | const visibleSamples = visible.map((r, i) => sampleReport(r, i)).join(""); |
| 168 | const hiddenSamples = hidden.map((r, i) => sampleReport(r, i + limit)).join(""); |
| 169 | const history = hidden.length > 0 |
| 170 | ? `<details class="sample-more"><summary>${i18nHTML(`Historical samples ${hidden.length}`, `历史样本 ${hidden.length}`)}</summary><div class="sample-more-list">${hiddenSamples}</div></details>` |
| 171 | : ""; |
| 172 | const boundary = options.truncated |
| 173 | ? `<p class="group-note sample-boundary">${i18n("Showing the first retained sample and the latest 5 samples; older raw samples are omitted to keep this page responsive.", "当前展示首个保留样本和最近 5 个样本;更早的原始样本已省略,以保证页面稳定。")}</p>` |
| 174 | : ""; |
| 175 | return `${boundary}<div class="sample-list">${visibleSamples}${history}</div>`; |
| 176 | } |
| 177 | |
| 178 | export function renderGroup( |
| 179 | group: Group, |
| 180 | reports: ReportSample[], |
| 181 | user: User, |
| 182 | diagnostics?: GroupDiagnosticSummary, |
| 183 | lifecycle?: { state: "active" | "compacted" | "archiving" | "archived"; epoch: number }, |
| 184 | warnings: { samplesUnavailable?: boolean; diagnosticsUnavailable?: boolean } = {}, |
| 185 | ): string { |
| 186 | const samples = sampleReports(reports, { truncated: group.count > reports.length }); |
| 187 | const platform = [group.last_os, group.last_arch].filter(Boolean).join("/"); |
| 188 | const status = statusPill(group.status) || `<span class="pill open">${i18n("open", "未处理")}</span>`; |
| 189 | const tags = [ |
| 190 | [i18n("source", "来源"), group.source || "legacy"], |
| 191 | group.label && [i18n("label", "标签"), group.label], |
| 192 | group.error_type && [i18n("type", "类型"), group.error_type], |
| 193 | group.top_frame && [i18n("top frame", "顶层帧"), group.top_frame], |
| 194 | platform && [i18n("platform", "平台"), platform], |
| 195 | group.last_build_commit && [i18n("build", "构建"), group.last_build_commit], |
| 196 | group.last_channel && [i18n("channel", "渠道"), group.last_channel], |
| 197 | ].filter(Boolean).map(([label, value]) => `<span><b>${label}</b>${esc(value)}</span>`).join(""); |
| 198 | const metrics = [ |
| 199 | [i18n("Occurrences", "出现次数"), String(group.count)], |
| 200 | ...(diagnostics ? [ |
| 201 | [i18n("Affected installs (30d)", "受影响安装(30 天)"), String(diagnostics.affectedInstalls)], |
| 202 | [i18n("Window events (30d)", "窗口事件(30 天)"), String(diagnostics.windowEvents)], |
| 203 | [i18n("Linked incidents (30d)", "关联故障(30 天)"), String(diagnostics.linkedIncidents ?? 0)], |
| 204 | [i18n("Identity coverage", "身份覆盖率"), diagnostics.windowEvents > 0 && diagnostics.identifiedEvents / diagnostics.windowEvents >= 0.9 ? `${Math.round(diagnostics.identifiedEvents / diagnostics.windowEvents * 100)}%` : "sample incomplete / 样本不完整"], |
| 205 | ] : []), |
| 206 | [i18n("First seen", "首次出现"), `${group.first_seen.slice(0, 10)} · ${group.first_version || "?"}`], |
| 207 | [i18n("Last seen", "最近出现"), `${group.last_seen.slice(0, 10)} · ${group.last_version || "?"}`], |
| 208 | [i18n("Version range", "版本范围"), `${group.first_version || "?"} → ${group.last_version || "?"}`], |
| 209 | group.resolved_in && [i18n("Resolved in", "解决版本"), group.resolved_in], |
| 210 | group.regressed_at && [i18n("Regressed", "回归时间"), group.regressed_at.slice(0, 10)], |
| 211 | group.regression_review |
| 212 | ? [i18n("Regression review", "回归核对"), group.regression_review] |
| 213 | : "", |
| 214 | ].filter(Boolean).map(([label, value]) => `<div><span>${label}</span><b>${esc(value)}</b></div>`).join(""); |
| 215 | const distributions = diagnostics?.distributions.length |
| 216 | ? `<div class="card full sample-card"><h2>${i18n("30-day technical distributions", "30 天技术分布")}</h2><div class="group-metrics">${diagnostics.distributions |
| 217 | .map((row) => `<div><span>${esc(row.facet)} · ${esc(row.value)}</span><b>${row.installs} ${i18n("installs", "安装")} · ${row.events} ${i18n("events", "事件")}</b></div>`) |
| 218 | .join("")}</div></div>` |
| 219 | : ""; |
| 220 | const lifecycleNotice = lifecycle?.state === "compacted" |
| 221 | ? `<div class="card full"><p>${i18n("Recent samples were removed by the 30-day policy; only the first retained-cycle sample remains.", "最近样本已按 30 天策略清理;仅保留当前保留周期的首个样本。")}</p></div>` |
| 222 | : lifecycle?.state === "archiving" |
| 223 | ? `<div class="card full"><p>${i18n("This group is completing its 60-day Firebase sample archive.", "该分组正在执行 60 天 Firebase 样本归档。")}</p></div>` |
| 224 | : lifecycle?.state === "archived" |
| 225 | ? `<div class="card full"><p>${i18n("Firebase raw samples were removed; D1 aggregates, status, notes, and audit history remain.", "Firebase 原始样本已清理;D1 聚合、状态、备注和审计仍保留。")}</p></div>` |
| 226 | : lifecycle && lifecycle.epoch > 1 |
| 227 | ? `<div class="card full"><p>${i18nHTML(`Samples belong to retained cycle ${lifecycle.epoch}; Lifetime First Seen remains the D1 value above.`, `样本属于第 ${lifecycle.epoch} 个保留周期;Lifetime First Seen 仍以上方 D1 值为准。`)}</p></div>` |
| 228 | : ""; |
| 229 | const englishDetails = [warnings.samplesUnavailable && "raw samples", warnings.diagnosticsUnavailable && "technical distributions"] |
| 230 | .filter(Boolean) |
| 231 | .join(", "); |
| 232 | const chineseDetails = [warnings.samplesUnavailable && "原始样本", warnings.diagnosticsUnavailable && "技术分布"] |
| 233 | .filter(Boolean) |
| 234 | .join("、"); |
| 235 | const degradedNotice = englishDetails |
| 236 | ? `<div class="card full notice warn"><p>${i18nHTML(`Some ${englishDetails} could not be loaded. Core group aggregates remain available.`, `部分${chineseDetails}暂时无法加载,分组核心汇总仍可用。`)}</p></div>` |
| 237 | : ""; |
| 238 | return page( |
| 239 | `Reasonix · ${group.fingerprint.slice(0, 8)}`, |
| 240 | `stats / ${group.fingerprint.slice(0, 8)}`, |
| 241 | `<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> |
| 242 | <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> |
| 243 | ${group.title ? `<p class="summary group-summary">${esc(group.title)}</p>` : ""} |
| 244 | <div class="group-tags">${tags}</div> |
| 245 | <div class="group-metrics">${metrics}</div> |
| 246 | ${group.note ? `<p class="group-note">${i18n("Note", "备注")}: ${esc(group.note)}</p>` : ""}</section> |
| 247 | ${degradedNotice} |
| 248 | ${lifecycleNotice} |
| 249 | <div class="card full sample-card"><h2>${i18nHTML("Samples <b>— newest first, retained-cycle first plus latest 5 kept</b>", "样本 <b>— 最新优先,保留当前周期首个样本和最近 5 个</b>")}</h2>${samples}</div> |
| 250 | ${distributions} |
| 251 | ${user.role === "admin" ? manageGroup(group) : ""} |
| 252 | <a class="back" href="/stats">${i18n("Back to stats", "返回统计")}</a>`, |
| 253 | userNav(user), |
| 254 | ); |
| 255 | } |
| 256 |