返回 DeepSeek-Reasonix
group.ts
根目录 / workers / crash-report / src / group.ts
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
256 lines TYPESCRIPT