mioku-plugin-help 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1202 @@
1
+ import {
2
+ escapeHtml,
3
+ } from "../utils";
4
+ import {
5
+ getHelpTheme,
6
+ HELP_BACKGROUND_IMAGE_URL,
7
+ } from "../theme";
8
+ import type {
9
+ AIUsageStatsLite,
10
+ BotAccountStatus,
11
+ DiskEntry,
12
+ NetworkSample,
13
+ ResourceStatus,
14
+ StatusSnapshot,
15
+ } from "./types";
16
+
17
+ const WIDTH = 760;
18
+ const PIE_RADIUS = 44;
19
+ const PIE_CIRCUMFERENCE = 2 * Math.PI * PIE_RADIUS;
20
+
21
+ function sectionTitle(text: string): string {
22
+ return `<div class="status-section-title"><span>${escapeHtml(text)}</span></div>`;
23
+ }
24
+
25
+ function fmtPercent(n: number, digits = 1): string {
26
+ if (!Number.isFinite(n) || n <= 0) {
27
+ return "0%";
28
+ }
29
+ if (n > 100) {
30
+ return "100%";
31
+ }
32
+ if (n < 10) {
33
+ return `${n.toFixed(digits)}%`;
34
+ }
35
+ return `${Math.round(n)}%`;
36
+ }
37
+
38
+ function fmtNumber(n: number): string {
39
+ if (!Number.isFinite(n)) {
40
+ return "—";
41
+ }
42
+ if (n >= 1_000_000) {
43
+ return `${(n / 1_000_000).toFixed(2)}M`;
44
+ }
45
+ if (n >= 10_000) {
46
+ return `${(n / 1000).toFixed(1)}K`;
47
+ }
48
+ return n.toLocaleString("zh-CN");
49
+ }
50
+
51
+ function fmtBytes(n: number, digits = 2): string {
52
+ if (!Number.isFinite(n) || n <= 0) {
53
+ return "0 B";
54
+ }
55
+ if (n >= 1024 ** 3) {
56
+ return `${(n / 1024 ** 3).toFixed(digits)} GB`;
57
+ }
58
+ if (n >= 1024 ** 2) {
59
+ return `${(n / 1024 ** 2).toFixed(digits)} MB`;
60
+ }
61
+ if (n >= 1024) {
62
+ return `${(n / 1024).toFixed(digits)} KB`;
63
+ }
64
+ return `${n.toFixed(0)} B`;
65
+ }
66
+
67
+ function fmtBps(bps: number): string {
68
+ if (!Number.isFinite(bps) || bps <= 0) {
69
+ return "0 B/s";
70
+ }
71
+ return `${fmtBytes(bps)}/s`;
72
+ }
73
+
74
+ function fmtUptime(ms: number): string {
75
+ if (!Number.isFinite(ms) || ms <= 0) {
76
+ return "—";
77
+ }
78
+ const sec = Math.floor(ms / 1000);
79
+ const days = Math.floor(sec / 86400);
80
+ const hours = Math.floor((sec % 86400) / 3600);
81
+ const minutes = Math.floor((sec % 3600) / 60);
82
+ if (days > 0) {
83
+ return `${days}天${String(hours).padStart(2, "0")}时${String(minutes).padStart(2, "0")}分`;
84
+ }
85
+ if (hours > 0) {
86
+ return `${hours}时${String(minutes).padStart(2, "0")}分`;
87
+ }
88
+ return `${minutes}分`;
89
+ }
90
+
91
+ function progressBar(percent: number, color: string): string {
92
+ const clamped = Math.max(0, Math.min(100, percent));
93
+ return `<div class="status-bar"><div class="status-bar__fill" style="width:${clamped}%;background:${color};"></div></div>`;
94
+ }
95
+
96
+ function progressColor(percent: number, theme: ReturnType<typeof getHelpTheme>): string {
97
+ if (percent >= 85) {
98
+ return "linear-gradient(90deg, #ef4444, #f97316)";
99
+ }
100
+ if (percent >= 65) {
101
+ return "linear-gradient(90deg, #f59e0b, #fbbf24)";
102
+ }
103
+ return `linear-gradient(90deg, ${theme.eyebrow}, ${theme.commandTitle})`;
104
+ }
105
+
106
+ function pieColor(percent: number, theme: ReturnType<typeof getHelpTheme>): string {
107
+ if (percent >= 85) {
108
+ return "#ef4444";
109
+ }
110
+ if (percent >= 65) {
111
+ return "#f59e0b";
112
+ }
113
+ return theme.eyebrow;
114
+ }
115
+
116
+ function renderPieChart(percent: number, theme: ReturnType<typeof getHelpTheme>): string {
117
+ const clamped = Math.max(0, Math.min(100, percent));
118
+ const filled = (clamped / 100) * PIE_CIRCUMFERENCE;
119
+ const rest = PIE_CIRCUMFERENCE - filled;
120
+ const color = pieColor(percent, theme);
121
+ const track = theme.isNightMode
122
+ ? "rgba(125, 211, 197, 0.12)"
123
+ : "rgba(15, 118, 110, 0.12)";
124
+ // If percent is 0 the slice disappears; the caller already substitutes
125
+ // an alternate "未配置" line for SWAP-with-no-swap, but keep the visual
126
+ // graceful by also letting the label fall back to "—".
127
+ const text = clamped <= 0 ? "—" : fmtPercent(percent, 0);
128
+ return `
129
+ <svg viewBox="0 0 110 110" class="status-pie">
130
+ <circle cx="55" cy="55" r="${PIE_RADIUS}" fill="none" stroke="${track}" stroke-width="10"/>
131
+ <circle cx="55" cy="55" r="${PIE_RADIUS}" fill="none" stroke="${color}" stroke-width="10"
132
+ stroke-dasharray="${filled.toFixed(2)} ${rest.toFixed(2)}"
133
+ stroke-linecap="round"
134
+ transform="rotate(-90 55 55)"/>
135
+ <text x="55" y="55" text-anchor="middle" dominant-baseline="central"
136
+ font-size="26" font-weight="800" font-family="SF Mono, monospace"
137
+ fill="${theme.panelTitle}">${escapeHtml(text)}</text>
138
+ </svg>
139
+ `;
140
+ }
141
+
142
+ function renderHero(
143
+ snapshot: StatusSnapshot,
144
+ theme: ReturnType<typeof getHelpTheme>,
145
+ ): string {
146
+ const bots = snapshot.bots;
147
+
148
+ const accountRows = bots.length === 0
149
+ ? `<div class="status-hero__empty">当前没有在线账号</div>`
150
+ : bots
151
+ .map((bot) => {
152
+ const statusText = bot.online ? "在线" : "离线";
153
+ const statusColor = bot.online ? "#10b981" : "#ef4444";
154
+ const frameworkText =
155
+ bot.appVersion && bot.appVersion !== "unknown"
156
+ ? `${bot.framework} ${bot.appVersion} · 协议 ${bot.protocolVersion}`
157
+ : bot.framework;
158
+ // Nickname is rendered as a bold large heading above the avatar
159
+ // row (not as a chip). Chips below only carry the metadata.
160
+ const tags = [
161
+ { text: String(bot.uin || "—"), kind: "data" },
162
+ { text: statusText, kind: bot.online ? "ok" : "danger" },
163
+ { text: `好友 ${fmtNumber(bot.friendCount)}`, kind: "data" },
164
+ { text: `群聊 ${fmtNumber(bot.groupCount)}`, kind: "data" },
165
+ { text: `运行时长 ${fmtUptime(bot.onlineDurationMs)}`, kind: "data" },
166
+ { text: `收 ${fmtNumber(bot.receive)}`, kind: "data" },
167
+ { text: `发 ${fmtNumber(bot.send)}`, kind: "data" },
168
+ { text: frameworkText, kind: "data" },
169
+ ];
170
+ const tagsHtml = tags
171
+ .map(
172
+ (t) => `<span class="status-chip status-chip--${t.kind}">${escapeHtml(t.text)}</span>`,
173
+ )
174
+ .join("");
175
+ return `
176
+ <div class="status-hero__account">
177
+ <div class="status-hero__account-name">${escapeHtml(bot.nickname)}</div>
178
+ <div class="status-hero__account-body">
179
+ <span class="status-hero__account-avatar-wrap">
180
+ <img class="status-hero__account-avatar" src="${escapeHtml(bot.avatarUrl)}" alt="${escapeHtml(bot.nickname)}" loading="lazy" onerror="this.style.visibility='hidden'"/>
181
+ <span class="status-hero__account-avatar-dot" style="background:${statusColor};"></span>
182
+ </span>
183
+ <span class="status-hero__account-tags">${tagsHtml}</span>
184
+ </div>
185
+ </div>
186
+ `;
187
+ })
188
+ .join("");
189
+
190
+ return `
191
+ <header class="status-hero">
192
+ <div class="status-hero__content">
193
+ <div class="status-hero__eyebrow">MIOKU STATUS</div>
194
+ <div class="status-hero__accounts">${accountRows}</div>
195
+ </div>
196
+ </header>
197
+ `;
198
+ }
199
+
200
+ function renderResourceCard(
201
+ label: string,
202
+ percent: number,
203
+ lines: string[],
204
+ theme: ReturnType<typeof getHelpTheme>,
205
+ ): string {
206
+ const linesHtml = lines
207
+ .map(
208
+ (line) =>
209
+ `<div class="status-pie-card__line">${escapeHtml(line)}</div>`,
210
+ )
211
+ .join("");
212
+ return `
213
+ <div class="status-pie-card">
214
+ ${renderPieChart(percent, theme)}
215
+ <div class="status-pie-card__label">${escapeHtml(label)}</div>
216
+ <div class="status-pie-card__lines">${linesHtml}</div>
217
+ </div>
218
+ `;
219
+ }
220
+
221
+ function renderResourcesSection(
222
+ snapshot: StatusSnapshot,
223
+ theme: ReturnType<typeof getHelpTheme>,
224
+ ): string {
225
+ const r = snapshot.resources;
226
+ const hasSwap = r.swapTotalGB > 0;
227
+ const cards = [
228
+ renderResourceCard(
229
+ "CPU",
230
+ r.cpuPercent,
231
+ [r.cpuModelShort, `${r.cpuCores} 核 · ${r.cpuSpeedGHz}`],
232
+ theme,
233
+ ),
234
+ renderResourceCard(
235
+ "内存",
236
+ r.memPercent,
237
+ [
238
+ `${r.memUsedGB} / ${r.memTotalGB} GB`,
239
+ r.memBuffCacheGB > 0
240
+ ? `缓存 ${r.memBuffCacheGB} GB`
241
+ : `${snapshot.framework.runtime} 进程视角`,
242
+ ],
243
+ theme,
244
+ ),
245
+ renderResourceCard(
246
+ "SWAP",
247
+ hasSwap ? r.swapPercent : 0,
248
+ hasSwap
249
+ ? [`${r.swapUsedGB} / ${r.swapTotalGB} GB`, `可用 ${Math.max(0, r.swapTotalGB - r.swapUsedGB).toFixed(2)} GB`]
250
+ : ["未配置", ""],
251
+ theme,
252
+ ),
253
+ ].join("");
254
+ return `
255
+ ${sectionTitle("系统性能")}
256
+ <div class="status-pie-grid">${cards}</div>
257
+ `;
258
+ }
259
+
260
+ function renderRuntimeSection(
261
+ snapshot: StatusSnapshot,
262
+ theme: ReturnType<typeof getHelpTheme>,
263
+ ): string {
264
+ const r = snapshot.runtime;
265
+ const runtimeName = snapshot.framework.runtime;
266
+ const gc = r.gc;
267
+ const items: Array<{ label: string; value: string }> = [
268
+ { label: "Heap Used", value: fmtBytes(r.heapUsedMB * 1024 ** 2, 1) },
269
+ { label: "Heap Total", value: fmtBytes(r.heapTotalMB * 1024 ** 2, 1) },
270
+ { label: "RSS", value: fmtBytes(r.rssMB * 1024 ** 2, 1) },
271
+ { label: "External", value: fmtBytes(r.externalMB * 1024 ** 2, 1) },
272
+ { label: "ArrayBuffer", value: fmtBytes(r.arrayBuffersMB * 1024 ** 2, 1) },
273
+ { label: "Loop Delay", value: `${r.eventLoopDelayMs.mean.toFixed(2)} ms` },
274
+ { label: "Loop p99", value: `${r.eventLoopDelayMs.p99.toFixed(2)} ms` },
275
+ ];
276
+ // GC count only works under V8 with --expose-gc. Skip the row entirely
277
+ // for Bun (which doesn't expose it) instead of showing a confusing
278
+ // "N/A" tile.
279
+ if (gc && gc.available) {
280
+ items.push({ label: "GC Count", value: `${gc.count} 次` });
281
+ }
282
+ const cardsHtml = items
283
+ .map(
284
+ (it) => `
285
+ <div class="status-runtime-block">
286
+ <div class="status-runtime-block__label">${escapeHtml(it.label)}</div>
287
+ <div class="status-runtime-block__value">${escapeHtml(it.value)}</div>
288
+ </div>
289
+ `,
290
+ )
291
+ .join("");
292
+ return `
293
+ ${sectionTitle(`${runtimeName} Runtime`)}
294
+ <div class="status-runtime-grid">${cardsHtml}</div>
295
+ `;
296
+ }
297
+
298
+ function buildSmoothPath(points: Array<{ x: number; y: number }>): string {
299
+ if (points.length === 0) return "";
300
+ if (points.length === 1) {
301
+ return `M ${points[0].x} ${points[0].y}`;
302
+ }
303
+ if (points.length === 2) {
304
+ return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
305
+ }
306
+ // Catmull-Rom → cubic Bézier. The curve passes through every data point
307
+ // (unlike the previous midpoint-based quadratic, which skipped them and
308
+ // visually flattened peaks). Tension 0.5 is the standard Catmull-Rom
309
+ // default; lowering it makes the curve flatter between samples.
310
+ let d = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;
311
+ const tension = 0.35;
312
+ for (let i = 0; i < points.length - 1; i++) {
313
+ const p0 = points[Math.max(0, i - 1)];
314
+ const p1 = points[i];
315
+ const p2 = points[i + 1];
316
+ const p3 = points[Math.min(points.length - 1, i + 2)];
317
+ const cp1x = p1.x + (p2.x - p0.x) * tension / 3;
318
+ const cp1y = p1.y + (p2.y - p0.y) * tension / 3;
319
+ const cp2x = p2.x - (p3.x - p1.x) * tension / 3;
320
+ const cp2y = p2.y - (p3.y - p1.y) * tension / 3;
321
+ d += ` C ${cp1x.toFixed(2)} ${cp1y.toFixed(2)}, ${cp2x.toFixed(2)} ${cp2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;
322
+ }
323
+ return d;
324
+ }
325
+
326
+ function renderNetworkChart(history: NetworkSample[]): string {
327
+ const w = WIDTH - 56;
328
+ const h = 120;
329
+ const marginL = 56;
330
+ const marginR = 12;
331
+ const marginT = 10;
332
+ const marginB = 22;
333
+ const innerW = w - marginL - marginR;
334
+ const innerH = h - marginT - marginB;
335
+
336
+ if (history.length < 2) {
337
+ return `<svg viewBox="0 0 ${w} ${h}" class="status-chart"><text x="${w / 2}" y="${h / 2}" text-anchor="middle" fill="#94a3b8" font-size="11">数据采集中…</text></svg>`;
338
+ }
339
+
340
+ const maxVal = Math.max(
341
+ 1,
342
+ ...history.map((s) => Math.max(s.rxBps, s.txBps)),
343
+ );
344
+ const yMax = maxVal * 1.2;
345
+ const t0 = history[0].ts;
346
+ const t1 = history[history.length - 1].ts;
347
+ const dt = Math.max(1, t1 - t0);
348
+
349
+ const xOf = (ts: number) => marginL + ((ts - t0) / dt) * innerW;
350
+ const yOf = (v: number) => marginT + innerH - (v / yMax) * innerH;
351
+
352
+ const rxPoints = history.map((s) => ({ x: xOf(s.ts), y: yOf(s.rxBps) }));
353
+ const txPoints = history.map((s) => ({ x: xOf(s.ts), y: yOf(s.txBps) }));
354
+
355
+ const rxPath = buildSmoothPath(rxPoints);
356
+ const txPath = buildSmoothPath(txPoints);
357
+
358
+ // 用同一条 path 做面积填充(淡色)
359
+ const rxArea =
360
+ rxPoints.length > 0
361
+ ? `${rxPath} L ${rxPoints[rxPoints.length - 1].x.toFixed(2)} ${(marginT + innerH).toFixed(2)} L ${rxPoints[0].x.toFixed(2)} ${(marginT + innerH).toFixed(2)} Z`
362
+ : "";
363
+
364
+ // 4 条横向网格
365
+ const gridY: number[] = [];
366
+ for (let i = 0; i <= 3; i++) {
367
+ gridY.push(marginT + (innerH * i) / 3);
368
+ }
369
+ // 6 条竖向网格
370
+ const gridX: number[] = [];
371
+ for (let i = 0; i <= 5; i++) {
372
+ gridX.push(marginL + (innerW * i) / 5);
373
+ }
374
+
375
+ const yLabels = gridY
376
+ .map((gy) => {
377
+ const v = ((marginT + innerH - gy) / innerH) * yMax;
378
+ return `<text x="${marginL - 6}" y="${gy + 3}" text-anchor="end" font-size="10" fill="#94a3b8">${fmtBytes(v)}/s</text>`;
379
+ })
380
+ .join("");
381
+ const xLabels = gridX
382
+ .map((gx) => {
383
+ const tsAt = t0 + ((gx - marginL) / innerW) * dt;
384
+ const minutesAgo = Math.round(((t1 - tsAt) / 60000) * 10) / 10;
385
+ const label = minutesAgo <= 0.1 ? "now" : `-${minutesAgo}m`;
386
+ return `<text x="${gx}" y="${h - 6}" text-anchor="middle" font-size="10" fill="#94a3b8">${label}</text>`;
387
+ })
388
+ .join("");
389
+
390
+ const gridLines = gridY
391
+ .map(
392
+ (gy) =>
393
+ `<line x1="${marginL}" y1="${gy}" x2="${marginL + innerW}" y2="${gy}" stroke="rgba(148,163,184,0.18)" stroke-dasharray="2 3"/>`,
394
+ )
395
+ .join("");
396
+
397
+ return `
398
+ <svg viewBox="0 0 ${w} ${h}" class="status-chart">
399
+ ${gridLines}
400
+ <path d="${rxArea}" fill="rgba(34, 211, 238, 0.12)" stroke="none"/>
401
+ <path d="${txPath}" fill="none" stroke="#f59e0b" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>
402
+ <path d="${rxPath}" fill="none" stroke="#22d3ee" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>
403
+ ${yLabels}
404
+ ${xLabels}
405
+ </svg>
406
+ `;
407
+ }
408
+
409
+ function renderNetworkSection(
410
+ snapshot: StatusSnapshot,
411
+ theme: ReturnType<typeof getHelpTheme>,
412
+ ): string {
413
+ const n = snapshot.network;
414
+ const cells = [
415
+ { label: "↑ 速度", value: fmtBps(n.txBps) },
416
+ { label: "↓ 速度", value: fmtBps(n.rxBps) },
417
+ { label: "↑ 总量", value: fmtBytes(n.txTotalBytes) },
418
+ { label: "↓ 总量", value: fmtBytes(n.rxTotalBytes) },
419
+ ]
420
+ .map(
421
+ (c) => `
422
+ <div class="status-net-cell">
423
+ <div class="status-net-cell__label">${escapeHtml(c.label)}</div>
424
+ <div class="status-net-cell__value">${escapeHtml(c.value)}</div>
425
+ </div>
426
+ `,
427
+ )
428
+ .join("");
429
+ return `
430
+ ${sectionTitle("网络状态 · 最近 30 分钟")}
431
+ <div class="status-chart-wrap">${renderNetworkChart(n.history)}</div>
432
+ <div class="status-net-grid">${cells}</div>
433
+ `;
434
+ }
435
+
436
+ function renderDiskSection(
437
+ snapshot: StatusSnapshot,
438
+ theme: ReturnType<typeof getHelpTheme>,
439
+ ): string {
440
+ const disk = snapshot.disk;
441
+ if (!disk.entries || disk.entries.length === 0) {
442
+ return `
443
+ ${sectionTitle("磁盘状态")}
444
+ <div class="status-empty">磁盘信息不可用</div>
445
+ `;
446
+ }
447
+ const bars = disk.entries
448
+ .map((entry: DiskEntry) => {
449
+ return `
450
+ <div class="status-disk-row">
451
+ <div class="status-disk-row__head">
452
+ <span class="status-disk-row__mount">${escapeHtml(entry.mount)}</span>
453
+ <span class="status-disk-row__usage">${entry.usedGB}GB / ${entry.totalGB}GB · ${fmtPercent(entry.percent)}</span>
454
+ </div>
455
+ ${progressBar(entry.percent, progressColor(entry.percent, theme))}
456
+ </div>
457
+ `;
458
+ })
459
+ .join("");
460
+ return `
461
+ ${sectionTitle("磁盘状态")}
462
+ <div class="status-disk-list">${bars}</div>
463
+ `;
464
+ }
465
+
466
+ function renderSystemSection(
467
+ snapshot: StatusSnapshot,
468
+ theme: ReturnType<typeof getHelpTheme>,
469
+ ): string {
470
+ const s = snapshot.system;
471
+ const r = snapshot.resources;
472
+ // Horizontal layout: label and value on the same row, label fixed-width
473
+ // on the left, value monospace on the right. Multi-GPU / multi-stick get
474
+ // their own row with an indexed label (e.g. "显卡 1", "内存条 2").
475
+ const items: Array<{ label: string; value: string }> = [];
476
+
477
+ items.push({ label: "OS", value: s.os });
478
+ items.push({ label: "内核", value: s.kernel });
479
+ items.push({ label: "处理器", value: `${s.cpu} · ${r.cpuCores} 核` });
480
+
481
+ // GPUs: list every controller. Many systems have an integrated + discrete
482
+ // pair (Apple Silicon + eGPU, Intel iGPU + NVIDIA dGPU). vramGB is 0 for
483
+ // integrated / unknown, in which case we skip the vram suffix.
484
+ if (s.gpus.length === 0) {
485
+ items.push({ label: "显卡", value: "N/A" });
486
+ } else {
487
+ s.gpus.forEach((g, i) => {
488
+ const vram = g.vramGB > 0 ? ` · ${g.vramGB} GB` : "";
489
+ const vendor = g.vendor && g.vendor !== g.model ? `${g.vendor} ` : "";
490
+ items.push({
491
+ label: `显卡 ${i + 1}`,
492
+ value: `${vendor}${g.model}${vram}`.trim(),
493
+ });
494
+ });
495
+ }
496
+
497
+ // Memory sticks: memLayout() is Linux/Win only. macOS returns [] and we
498
+ // surface "N/A" rather than hide the row. Headline on the value side;
499
+ // part number is appended inline when present.
500
+ if (s.memSticks.length === 0) {
501
+ items.push({ label: "内存条", value: "N/A" });
502
+ } else {
503
+ s.memSticks.forEach((m, i) => {
504
+ const size =
505
+ m.sizeGB >= 1
506
+ ? `${m.sizeGB} GB`
507
+ : `${(m.sizeGB * 1024).toFixed(0)} MB`;
508
+ const speed = m.speedMTs > 0 ? ` · ${m.speedMTs} MT/s` : "";
509
+ const manu =
510
+ m.manufacturer && m.manufacturer !== "Manufacturer"
511
+ ? `${m.manufacturer} `
512
+ : "";
513
+ const bank = m.bank && m.bank !== "BANK 0" ? `${m.bank} · ` : "";
514
+ const headline = `${bank}${manu}${m.type} ${size}${speed}`.trim();
515
+ const value =
516
+ m.partNum && m.partNum !== "Unknown" && m.partNum !== "00000000"
517
+ ? `${headline} · PN ${m.partNum}`
518
+ : headline;
519
+ items.push({
520
+ label: `内存条 ${i + 1}`,
521
+ value,
522
+ });
523
+ });
524
+ }
525
+
526
+ const biosDate = s.bios.releaseDate ? ` · ${s.bios.releaseDate}` : "";
527
+ items.push({
528
+ label: "BIOS",
529
+ value: `${s.bios.vendor} ${s.bios.version}${biosDate}`.trim(),
530
+ });
531
+
532
+ // Physical disk drives. Show vendor + name + type + size. On a typical
533
+ // server this might be "Samsung SSD 990 PRO 2TB · NVMe"; on macOS disk
534
+ // details are often hidden so we fall back to size only.
535
+ if (s.disks.length === 0) {
536
+ items.push({ label: "硬盘", value: "N/A" });
537
+ } else {
538
+ s.disks.forEach((d, i) => {
539
+ const sizeLabel =
540
+ d.sizeGB >= 1000
541
+ ? `${(d.sizeGB / 1000).toFixed(1)} TB`
542
+ : `${d.sizeGB} GB`;
543
+ const vendor = d.vendor && d.vendor !== d.name ? `${d.vendor} ` : "";
544
+ const name = d.name || d.type || "Unknown";
545
+ const iface = d.interfaceType && d.interfaceType !== "Unknown" ? ` · ${d.interfaceType}` : "";
546
+ items.push({
547
+ label: `硬盘 ${i + 1}`,
548
+ value: `${vendor}${name} · ${sizeLabel}${iface}`.trim(),
549
+ });
550
+ });
551
+ }
552
+
553
+ items.push({ label: "主机", value: s.chassis });
554
+
555
+ const itemsHtml = items
556
+ .map(
557
+ ({ label, value }) => `
558
+ <div class="status-sys-row">
559
+ <span class="status-sys-row__label">${escapeHtml(label)}</span>
560
+ <span class="status-sys-row__value">${escapeHtml(value)}</span>
561
+ </div>
562
+ `,
563
+ )
564
+ .join("");
565
+ return `
566
+ ${sectionTitle("系统信息")}
567
+ <div class="status-sys-list">${itemsHtml}</div>
568
+ `;
569
+ }
570
+
571
+ function renderRankingBars(
572
+ items: Array<{ name: string; value: number }>,
573
+ formatValue: (n: number) => string,
574
+ color: string,
575
+ ): string {
576
+ if (items.length === 0) {
577
+ return `<div class="status-empty">暂无数据</div>`;
578
+ }
579
+ const max = Math.max(1, ...items.map((i) => i.value));
580
+ return items
581
+ .map((item) => {
582
+ const percent = Math.max(2, (item.value / max) * 100);
583
+ return `
584
+ <div class="status-rank-row">
585
+ <div class="status-rank-row__name">${escapeHtml(item.name)}</div>
586
+ <div class="status-rank-row__bar">
587
+ <div class="status-rank-row__fill" style="width:${percent}%;background:${color};"></div>
588
+ </div>
589
+ <div class="status-rank-row__value">${escapeHtml(formatValue(item.value))}</div>
590
+ </div>
591
+ `;
592
+ })
593
+ .join("");
594
+ }
595
+
596
+ function renderAISection(
597
+ ai: AIUsageStatsLite,
598
+ theme: ReturnType<typeof getHelpTheme>,
599
+ ): string {
600
+ if (!ai.available) {
601
+ return `
602
+ ${sectionTitle("AI 统计 · 近 7 天")}
603
+ <div class="status-empty">AI 统计暂不可用</div>
604
+ `;
605
+ }
606
+ const totals = [
607
+ { label: "请求数", value: fmtNumber(ai.totalRequests) },
608
+ { label: "错误率", value: fmtPercent(ai.errorRate * 100) },
609
+ { label: "缓存命中", value: fmtPercent(ai.cacheHitRate * 100) },
610
+ { label: "输入 Token", value: fmtNumber(ai.inputTokens) },
611
+ { label: "输出 Token", value: fmtNumber(ai.outputTokens) },
612
+ { label: "总 Token", value: fmtNumber(ai.totalTokens) },
613
+ ];
614
+ const totalsHtml = totals
615
+ .map(
616
+ (t) => `
617
+ <div class="status-kv">
618
+ <div class="status-kv__label">${escapeHtml(t.label)}</div>
619
+ <div class="status-kv__value">${escapeHtml(t.value)}</div>
620
+ </div>
621
+ `,
622
+ )
623
+ .join("");
624
+ const toolsColor = theme.isNightMode
625
+ ? "linear-gradient(90deg, #38bdf8, #7ee7dd)"
626
+ : "linear-gradient(90deg, #22d3ee, #38bdf8)";
627
+ const toolsHtml = renderRankingBars(
628
+ ai.topTools.map((t) => ({ name: t.name, value: t.count })),
629
+ (n) => fmtNumber(n),
630
+ toolsColor,
631
+ );
632
+ return `
633
+ ${sectionTitle("AI 统计 · 近 7 天")}
634
+ <div class="status-kv-grid">${totalsHtml}</div>
635
+ <div class="status-rank-block">
636
+ <div class="status-rank-block__title">工具调用排名</div>
637
+ ${toolsHtml}
638
+ </div>
639
+ `;
640
+ }
641
+
642
+ function buildStyle(theme: ReturnType<typeof getHelpTheme>): string {
643
+ return `
644
+ <style>
645
+ .status-sheet {
646
+ position: relative;
647
+ padding: 18px;
648
+ display: flex;
649
+ flex-direction: column;
650
+ gap: 14px;
651
+ background: ${theme.pageBg};
652
+ color: ${theme.panelTitle};
653
+ font-family: "SF Pro Display", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Hiragino Sans GB", sans-serif;
654
+ overflow: hidden;
655
+ }
656
+ .status-sheet::before,
657
+ .status-sheet::after {
658
+ content: "";
659
+ position: absolute;
660
+ inset: 0;
661
+ pointer-events: none;
662
+ }
663
+ .status-sheet::before { background: ${theme.pageAccent}; }
664
+ .status-sheet::after {
665
+ background-image: ${theme.pageGrid};
666
+ background-size: 28px 28px;
667
+ opacity: ${theme.isNightMode ? "0.55" : "0.35"};
668
+ }
669
+ .status-sheet__scene,
670
+ .status-sheet__scene-image,
671
+ .status-sheet__scene-overlay {
672
+ position: absolute;
673
+ inset: 0;
674
+ pointer-events: none;
675
+ }
676
+ .status-sheet__scene { z-index: 0; overflow: hidden; }
677
+ .status-sheet__scene-image {
678
+ background-image: url("${HELP_BACKGROUND_IMAGE_URL}");
679
+ background-size: cover;
680
+ background-position: center center;
681
+ opacity: ${theme.sceneOpacity};
682
+ filter: ${theme.sceneFilter};
683
+ transform: scale(1.06);
684
+ }
685
+ .status-sheet__scene-overlay {
686
+ background: ${theme.sceneGlow}, ${theme.sceneMask};
687
+ }
688
+ .status-shell {
689
+ position: relative;
690
+ z-index: 1;
691
+ display: flex;
692
+ flex-direction: column;
693
+ gap: 14px;
694
+ border-radius: 30px;
695
+ border: 1px solid ${theme.shellBorder};
696
+ box-shadow: ${theme.shellShadow};
697
+ padding: 14px;
698
+ background: ${theme.shellBg};
699
+ backdrop-filter: blur(10px) saturate(1.06);
700
+ }
701
+ .status-hero {
702
+ position: relative;
703
+ display: block;
704
+ padding: 18px;
705
+ border-radius: 24px;
706
+ border: 1px solid ${theme.heroBorder};
707
+ background: ${theme.heroBg};
708
+ overflow: hidden;
709
+ }
710
+ .status-hero::before {
711
+ content: "";
712
+ position: absolute;
713
+ inset: auto auto -42px -32px;
714
+ width: 180px;
715
+ height: 180px;
716
+ border-radius: 999px;
717
+ background: ${theme.heroGlow};
718
+ filter: blur(4px);
719
+ }
720
+ .status-hero__content { position: relative; z-index: 1; }
721
+ .status-hero__eyebrow {
722
+ margin-bottom: 12px;
723
+ padding-bottom: 10px;
724
+ font-size: 18px;
725
+ font-weight: 900;
726
+ letter-spacing: 0.18em;
727
+ text-transform: uppercase;
728
+ color: ${theme.eyebrow};
729
+ border-bottom: 1px solid ${theme.isNightMode ? "rgba(126, 231, 221, 0.45)" : "rgba(15, 118, 110, 0.45)"};
730
+ }
731
+ .status-hero__accounts {
732
+ display: flex;
733
+ flex-direction: column;
734
+ gap: 12px;
735
+ }
736
+ .status-hero__account {
737
+ display: flex;
738
+ flex-direction: column;
739
+ gap: 8px;
740
+ padding: 10px 0;
741
+ /* Light-green hairline separator between accounts. The first account
742
+ * stays flush (no top line); each subsequent one gets the divider. */
743
+ border-top: 1px solid ${theme.isNightMode ? "rgba(126, 231, 221, 0.45)" : "rgba(15, 118, 110, 0.45)"};
744
+ }
745
+ .status-hero__account:first-child {
746
+ border-top: 0;
747
+ padding-top: 4px;
748
+ }
749
+ .status-hero__account-name {
750
+ font-size: 18px;
751
+ font-weight: 800;
752
+ line-height: 1.15;
753
+ color: ${theme.title};
754
+ letter-spacing: -0.005em;
755
+ word-break: break-word;
756
+ }
757
+ .status-hero__account-body {
758
+ display: flex;
759
+ align-items: center;
760
+ gap: 14px;
761
+ }
762
+ .status-hero__account-avatar-wrap {
763
+ position: relative;
764
+ width: 88px;
765
+ height: 88px;
766
+ flex-shrink: 0;
767
+ display: flex;
768
+ align-items: center;
769
+ justify-content: center;
770
+ }
771
+ .status-hero__account-avatar {
772
+ width: 84px;
773
+ height: 84px;
774
+ border-radius: 50%;
775
+ aspect-ratio: 1 / 1;
776
+ object-fit: cover;
777
+ border: 2px solid ${theme.tagBorder};
778
+ background: ${theme.panelBg};
779
+ box-shadow: 0 0 0 3px ${theme.panelBg}, 0 0 0 4px ${theme.eyebrow}33;
780
+ display: block;
781
+ }
782
+ .status-hero__account-avatar-dot {
783
+ position: absolute;
784
+ right: 4px;
785
+ bottom: 4px;
786
+ width: 20px;
787
+ height: 20px;
788
+ border-radius: 999px;
789
+ border: 2px solid ${theme.panelBg};
790
+ box-sizing: border-box;
791
+ }
792
+ .status-hero__account-tags {
793
+ flex: 1 1 auto;
794
+ min-width: 0;
795
+ display: flex;
796
+ flex-wrap: wrap;
797
+ gap: 5px;
798
+ }
799
+ .status-hero__empty {
800
+ font-size: 12px;
801
+ color: ${theme.emptyText};
802
+ text-align: center;
803
+ padding: 8px 0;
804
+ }
805
+ .status-dot {
806
+ display: inline-block;
807
+ width: 8px; height: 8px;
808
+ border-radius: 999px;
809
+ }
810
+ .status-chip {
811
+ display: inline-block;
812
+ padding: 3px 10px;
813
+ border-radius: 999px;
814
+ font-size: 11.5px;
815
+ font-weight: 700;
816
+ font-family: "SF Mono", monospace;
817
+ background: ${theme.tagBg};
818
+ color: ${theme.tagText};
819
+ border: 1px solid ${theme.tagBorder};
820
+ }
821
+ .status-chip--ok {
822
+ background: rgba(16, 185, 129, 0.16);
823
+ color: #10b981;
824
+ border-color: rgba(16, 185, 129, 0.32);
825
+ }
826
+ .status-chip--danger {
827
+ background: rgba(239, 68, 68, 0.16);
828
+ color: #ef4444;
829
+ border-color: rgba(239, 68, 68, 0.32);
830
+ }
831
+ .status-section-title {
832
+ display: flex;
833
+ align-items: center;
834
+ margin: 6px 4px 0;
835
+ }
836
+ .status-section-title span {
837
+ font-size: 12px;
838
+ font-weight: 800;
839
+ letter-spacing: 0.16em;
840
+ text-transform: uppercase;
841
+ color: ${theme.eyebrow};
842
+ }
843
+ .status-empty {
844
+ padding: 18px;
845
+ text-align: center;
846
+ font-size: 12px;
847
+ color: ${theme.emptyText};
848
+ border-radius: 16px;
849
+ background: ${theme.panelBg};
850
+ border: 1px dashed ${theme.panelBorder};
851
+ }
852
+ .status-kv-grid {
853
+ display: grid;
854
+ grid-template-columns: repeat(3, 1fr);
855
+ gap: 8px;
856
+ }
857
+ .status-kv {
858
+ padding: 12px 14px;
859
+ border-radius: 16px;
860
+ background: ${theme.panelBg};
861
+ border: 1px solid ${theme.panelBorder};
862
+ }
863
+ .status-kv__label {
864
+ font-size: 10px;
865
+ font-weight: 700;
866
+ letter-spacing: 0.1em;
867
+ text-transform: uppercase;
868
+ color: ${theme.eyebrow};
869
+ }
870
+ .status-kv__value {
871
+ margin-top: 6px;
872
+ font-family: "SF Mono", monospace;
873
+ font-size: 16px;
874
+ font-weight: 800;
875
+ color: ${theme.panelTitle};
876
+ }
877
+ .status-pie-grid {
878
+ display: grid;
879
+ grid-template-columns: repeat(3, 1fr);
880
+ gap: 10px;
881
+ }
882
+ .status-pie-card {
883
+ display: flex;
884
+ flex-direction: column;
885
+ align-items: center;
886
+ gap: 6px;
887
+ padding: 16px 12px 14px;
888
+ border-radius: 18px;
889
+ background: ${theme.panelBg};
890
+ border: 1px solid ${theme.panelBorder};
891
+ }
892
+ .status-pie { width: 100px; height: 100px; }
893
+ .status-pie-card__label {
894
+ margin-top: 2px;
895
+ font-size: 16px;
896
+ font-weight: 800;
897
+ letter-spacing: 0.08em;
898
+ text-transform: uppercase;
899
+ color: ${theme.panelTitle};
900
+ }
901
+ .status-pie-card__lines {
902
+ width: 100%;
903
+ display: flex;
904
+ flex-direction: column;
905
+ align-items: center;
906
+ gap: 2px;
907
+ margin-top: 2px;
908
+ text-align: center;
909
+ }
910
+ .status-pie-card__line {
911
+ font-size: 10.5px;
912
+ line-height: 1.4;
913
+ color: ${theme.panelDesc};
914
+ font-family: "SF Mono", "JetBrains Mono", "Fira Code", monospace;
915
+ max-width: 100%;
916
+ overflow: hidden;
917
+ text-overflow: ellipsis;
918
+ white-space: nowrap;
919
+ }
920
+ .status-bar {
921
+ height: 6px;
922
+ border-radius: 999px;
923
+ background: ${theme.commandBg};
924
+ overflow: hidden;
925
+ }
926
+ .status-bar__fill { height: 100%; border-radius: 999px; }
927
+ .status-runtime-grid {
928
+ display: grid;
929
+ grid-template-columns: repeat(4, 1fr);
930
+ gap: 8px;
931
+ }
932
+ .status-runtime-block {
933
+ padding: 12px 14px;
934
+ border-radius: 14px;
935
+ background: ${theme.panelBg};
936
+ border: 1px solid ${theme.panelBorder};
937
+ display: flex;
938
+ flex-direction: column;
939
+ gap: 4px;
940
+ }
941
+ .status-runtime-block__label {
942
+ font-size: 10px;
943
+ font-weight: 700;
944
+ letter-spacing: 0.1em;
945
+ text-transform: uppercase;
946
+ color: ${theme.eyebrow};
947
+ }
948
+ .status-runtime-block__value {
949
+ font-family: "SF Mono", monospace;
950
+ font-size: 14px;
951
+ font-weight: 800;
952
+ color: ${theme.panelTitle};
953
+ }
954
+ .status-sys-list {
955
+ display: flex;
956
+ flex-direction: column;
957
+ gap: 6px;
958
+ padding: 12px 14px;
959
+ border-radius: 16px;
960
+ background: ${theme.panelBg};
961
+ border: 1px solid ${theme.panelBorder};
962
+ }
963
+ .status-sys-row {
964
+ display: flex;
965
+ align-items: baseline;
966
+ gap: 12px;
967
+ font-size: 12px;
968
+ line-height: 1.45;
969
+ }
970
+ .status-sys-row__label {
971
+ flex: 0 0 64px;
972
+ font-size: 11px;
973
+ font-weight: 700;
974
+ color: ${theme.eyebrow};
975
+ letter-spacing: 0.04em;
976
+ }
977
+ .status-sys-row__value {
978
+ flex: 1 1 auto;
979
+ min-width: 0;
980
+ font-family: "SF Mono", "JetBrains Mono", "Fira Code", monospace;
981
+ font-size: 12px;
982
+ font-weight: 700;
983
+ color: ${theme.panelTitle};
984
+ word-break: break-all;
985
+ }
986
+ .status-disk-list {
987
+ display: flex;
988
+ flex-direction: column;
989
+ gap: 6px;
990
+ padding: 12px 14px;
991
+ border-radius: 16px;
992
+ background: ${theme.panelBg};
993
+ border: 1px solid ${theme.panelBorder};
994
+ }
995
+ .status-chart-wrap {
996
+ padding: 8px 12px;
997
+ border-radius: 16px;
998
+ background: ${theme.panelBg};
999
+ border: 1px solid ${theme.panelBorder};
1000
+ }
1001
+ .status-chart { display: block; width: 100%; height: 120px; }
1002
+ .status-net-grid {
1003
+ display: grid;
1004
+ grid-template-columns: repeat(4, 1fr);
1005
+ gap: 8px;
1006
+ margin-top: 8px;
1007
+ }
1008
+ .status-net-cell {
1009
+ padding: 10px 12px;
1010
+ border-radius: 14px;
1011
+ background: ${theme.panelBg};
1012
+ border: 1px solid ${theme.panelBorder};
1013
+ }
1014
+ .status-net-cell__label {
1015
+ font-size: 10px;
1016
+ font-weight: 700;
1017
+ color: ${theme.eyebrow};
1018
+ letter-spacing: 0.08em;
1019
+ }
1020
+ .status-net-cell__value {
1021
+ margin-top: 4px;
1022
+ font-family: "SF Mono", monospace;
1023
+ font-size: 13px;
1024
+ font-weight: 800;
1025
+ color: ${theme.panelTitle};
1026
+ }
1027
+ .status-disk-row {
1028
+ display: flex;
1029
+ flex-direction: column;
1030
+ gap: 6px;
1031
+ padding: 8px 0;
1032
+ border-bottom: 1px dashed ${theme.divider};
1033
+ }
1034
+ .status-disk-row:last-child { border-bottom: none; }
1035
+ .status-disk-row__head {
1036
+ display: flex;
1037
+ justify-content: space-between;
1038
+ font-size: 12px;
1039
+ }
1040
+ .status-disk-row__mount {
1041
+ font-family: "SF Mono", monospace;
1042
+ color: ${theme.panelTitle};
1043
+ font-weight: 700;
1044
+ }
1045
+ .status-disk-row__usage {
1046
+ color: ${theme.panelDesc};
1047
+ font-family: "SF Mono", monospace;
1048
+ }
1049
+ .status-rank-block {
1050
+ padding: 12px 14px;
1051
+ border-radius: 16px;
1052
+ background: ${theme.panelBg};
1053
+ border: 1px solid ${theme.panelBorder};
1054
+ margin-top: 8px;
1055
+ }
1056
+ .status-rank-block__title {
1057
+ font-size: 11px;
1058
+ font-weight: 800;
1059
+ letter-spacing: 0.1em;
1060
+ text-transform: uppercase;
1061
+ color: ${theme.eyebrow};
1062
+ margin-bottom: 8px;
1063
+ }
1064
+ .status-rank-row {
1065
+ display: flex;
1066
+ align-items: center;
1067
+ gap: 10px;
1068
+ padding: 5px 0;
1069
+ }
1070
+ .status-rank-row__name {
1071
+ flex: 0 0 130px;
1072
+ font-size: 12px;
1073
+ color: ${theme.panelTitle};
1074
+ white-space: nowrap;
1075
+ overflow: hidden;
1076
+ text-overflow: ellipsis;
1077
+ }
1078
+ .status-rank-row__bar {
1079
+ flex: 1;
1080
+ height: 8px;
1081
+ background: ${theme.commandBg};
1082
+ border-radius: 999px;
1083
+ overflow: hidden;
1084
+ }
1085
+ .status-rank-row__fill { height: 100%; border-radius: 999px; }
1086
+ .status-rank-row__value {
1087
+ flex: 0 0 70px;
1088
+ text-align: right;
1089
+ font-family: "SF Mono", monospace;
1090
+ font-size: 11px;
1091
+ color: ${theme.panelDesc};
1092
+ }
1093
+ .status-footer {
1094
+ display: flex;
1095
+ align-items: stretch;
1096
+ gap: 0;
1097
+ border-radius: 20px;
1098
+ border: 1px solid ${theme.footerBorder};
1099
+ background: ${theme.footerBg};
1100
+ overflow: hidden;
1101
+ }
1102
+ .status-footer__item {
1103
+ flex: 1;
1104
+ display: flex;
1105
+ align-items: center;
1106
+ gap: 12px;
1107
+ padding: 14px 16px;
1108
+ }
1109
+ .status-footer__item + .status-footer__item {
1110
+ border-left: 1px solid ${theme.divider};
1111
+ }
1112
+ .status-footer__icon {
1113
+ width: 36px;
1114
+ height: 36px;
1115
+ flex-shrink: 0;
1116
+ display: grid;
1117
+ place-items: center;
1118
+ border-radius: 12px;
1119
+ background: ${theme.isNightMode ? "rgba(126, 231, 221, 0.08)" : "rgba(15, 118, 110, 0.08)"};
1120
+ color: ${theme.eyebrow};
1121
+ font-size: 18px;
1122
+ }
1123
+ .status-footer__text {
1124
+ display: flex;
1125
+ flex-direction: column;
1126
+ min-width: 0;
1127
+ }
1128
+ .status-footer__label {
1129
+ font-size: 11px;
1130
+ line-height: 1.4;
1131
+ color: ${theme.footerLabel};
1132
+ }
1133
+ .status-footer__value {
1134
+ margin-top: 2px;
1135
+ font-family: "SF Mono", "JetBrains Mono", "Fira Code", monospace;
1136
+ font-size: 12px;
1137
+ line-height: 1.45;
1138
+ font-weight: 700;
1139
+ color: ${theme.footerText};
1140
+ }
1141
+ </style>
1142
+ `;
1143
+ }
1144
+
1145
+ function renderFooter(
1146
+ snapshot: StatusSnapshot,
1147
+ theme: ReturnType<typeof getHelpTheme>,
1148
+ ): string {
1149
+ const fw = snapshot.framework;
1150
+ const runtimeLabel = `${fw.runtime} ${fw.runtimeVersion}`;
1151
+ return `
1152
+ <footer class="status-footer">
1153
+ <div class="status-footer__item">
1154
+ <div class="status-footer__icon">⚡</div>
1155
+ <div class="status-footer__text">
1156
+ <div class="status-footer__label">Framework</div>
1157
+ <div class="status-footer__value">Mioki ${escapeHtml(fw.miokiVersion)}</div>
1158
+ </div>
1159
+ </div>
1160
+ <div class="status-footer__item">
1161
+ <div class="status-footer__icon">🚀</div>
1162
+ <div class="status-footer__text">
1163
+ <div class="status-footer__label">Platform</div>
1164
+ <div class="status-footer__value">Mioku ${escapeHtml(fw.miokuVersion)}</div>
1165
+ </div>
1166
+ </div>
1167
+ <div class="status-footer__item">
1168
+ <div class="status-footer__icon">🥟</div>
1169
+ <div class="status-footer__text">
1170
+ <div class="status-footer__label">Runtime</div>
1171
+ <div class="status-footer__value">${escapeHtml(runtimeLabel)}</div>
1172
+ </div>
1173
+ </div>
1174
+ </footer>
1175
+ `;
1176
+ }
1177
+
1178
+ export function renderStatusHtml(snapshot: StatusSnapshot): string {
1179
+ const theme = getHelpTheme(snapshot.isNightMode);
1180
+ const body = [
1181
+ renderResourcesSection(snapshot, theme),
1182
+ renderNetworkSection(snapshot, theme),
1183
+ renderRuntimeSection(snapshot, theme),
1184
+ renderDiskSection(snapshot, theme),
1185
+ renderAISection(snapshot.ai, theme),
1186
+ renderSystemSection(snapshot, theme),
1187
+ ].join("\n");
1188
+ return `
1189
+ ${buildStyle(theme)}
1190
+ <div class="status-sheet">
1191
+ <div class="status-sheet__scene">
1192
+ <div class="status-sheet__scene-image"></div>
1193
+ <div class="status-sheet__scene-overlay"></div>
1194
+ </div>
1195
+ <div class="status-shell">
1196
+ ${renderHero(snapshot, theme)}
1197
+ ${body}
1198
+ ${renderFooter(snapshot, theme)}
1199
+ </div>
1200
+ </div>
1201
+ `;
1202
+ }