aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--api/debug/dashboard.php2
-rw-r--r--api/debug/ingest.php10
-rw-r--r--api/debug/report.php99
-rw-r--r--downloads/bugs/BUG-REPORT.html44
-rw-r--r--main.html6
-rw-r--r--modules/core/core-bundle.js46
-rw-r--r--modules/core/module-debug-supervisor.js3
-rw-r--r--modules/core/module-loader.js2
-rw-r--r--modules/core/module-system.js43
-rw-r--r--scripts/gen-bug-report.py132
10 files changed, 380 insertions, 7 deletions
diff --git a/api/debug/dashboard.php b/api/debug/dashboard.php
index 7a46661..c235ec0 100644
--- a/api/debug/dashboard.php
+++ b/api/debug/dashboard.php
@@ -151,7 +151,7 @@ if ($lastSync): ?>
<td><?= $i++ ?></td>
<td class="max-w-300"><a href="error.php?hash=<?= urlencode($e['hash'] ?? '') ?>" style="color:var(--text);text-decoration:none;" title="View details"><?= htmlspecialchars(substr($e['message'] ?? '', 0, 80)) ?></a></td>
<td><span class="tag"><?= htmlspecialchars($e['type'] ?? '?') ?></span></td>
- <td><?= $e['count'] ?></td>
+ <td><?= (int)($e['count'] ?? 0) ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($topErrors)): ?>
diff --git a/api/debug/ingest.php b/api/debug/ingest.php
index 363dd09..b513593 100644
--- a/api/debug/ingest.php
+++ b/api/debug/ingest.php
@@ -17,6 +17,12 @@ $errors = $payload['errors'];
$session = $payload['session'] ?? [];
$clientVersion = $payload['version'] ?? 'unknown';
+// Client-supplied counts are untrusted: clamp so a bad client cannot store 38,495× rows.
+function clamp_count($v) {
+ $n = (int)$v;
+ return $n < 1 ? 1 : ($n > 1000 ? 1000 : $n);
+}
+
// Validate batch size
if (count($errors) > MAX_ERRORS_PER_BATCH) {
$errors = array_slice($errors, 0, MAX_ERRORS_PER_BATCH);
@@ -65,7 +71,7 @@ foreach ($errors as $e) {
if (isset($existingHashes[$hash])) {
// Update existing entry
$idx = $existingHashes[$hash];
- $allErrors[$idx]['count'] += $e['count'] ?? 1;
+ $allErrors[$idx]['count'] += clamp_count($e['count'] ?? 1);
$allErrors[$idx]['last_seen'] = $e['lastSeen'] ?? $now;
$allErrors[$idx]['ingested_at'] = $now;
if (!empty($e['stack'])) {
@@ -85,7 +91,7 @@ foreach ($errors as $e) {
'module' => $e['module'] ?? null,
'stack' => isset($e['stack']) ? substr($e['stack'], 0, 2000) : null,
'session_id' => $sessionId,
- 'count' => $e['count'] ?? 1,
+ 'count' => clamp_count($e['count'] ?? 1),
'first_seen' => $e['firstSeen'] ?? $now,
'last_seen' => $e['lastSeen'] ?? $now,
'ingested_at' => $now,
diff --git a/api/debug/report.php b/api/debug/report.php
new file mode 100644
index 0000000..a4cf9f1
--- /dev/null
+++ b/api/debug/report.php
@@ -0,0 +1,99 @@
+<?php
+// api/debug/report.php — structured bug-report intake (Part A)
+// POST { title, body?, diagnostics{...} } -> { success, id, duplicate, url }
+require_once __DIR__ . '/config.php';
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ error_response('Method not allowed', 405);
+}
+
+define('REPORTS_DIR', __DIR__ . '/../../data/reports');
+define('RATE_FILE', DEBUG_DATA_DIR . '/report_rate.json');
+define('MAX_REPORT_KB', 512);
+define('RATE_MAX', 5); // reports per IP …
+define('RATE_WINDOW', 600); // … per 10 minutes
+
+$raw = file_get_contents('php://input');
+if (!$raw) error_response('Empty request body');
+if (strlen($raw) > MAX_REPORT_KB * 1024) {
+ error_response('Payload too large (max ' . MAX_REPORT_KB . 'KB)', 413);
+}
+
+$payload = json_decode($raw, true);
+if (!is_array($payload)) error_response('Invalid JSON');
+
+$title = trim((string)($payload['title'] ?? ''));
+if ($title === '') error_response('title is required');
+if (mb_strlen($title) > 160) $title = mb_substr($title, 0, 160);
+$body = (string)($payload['body'] ?? '');
+if (mb_strlen($body) > 4000) $body = mb_substr($body, 0, 4000);
+$diag = (isset($payload['diagnostics']) && is_array($payload['diagnostics'])) ? $payload['diagnostics'] : [];
+
+// Storage guard: data/ is not writable by php-fpm, so never pretend we wrote a report.
+if (!is_dir(REPORTS_DIR)) @mkdir(REPORTS_DIR, 0775, true);
+if (!is_dir(REPORTS_DIR) || !is_writable(REPORTS_DIR)) {
+ error_response('Report storage unavailable', 503);
+}
+
+// Per-IP rate limit
+$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+$now = time();
+$rate = load_json(RATE_FILE);
+$hits = array_values(array_filter($rate[$ip] ?? [], function ($t) use ($now) {
+ return $t > $now - RATE_WINDOW;
+}));
+if (count($hits) >= RATE_MAX) {
+ header('Retry-After: ' . RATE_WINDOW);
+ error_response('Rate limit exceeded (max ' . RATE_MAX . ' reports per 10 minutes)', 429);
+}
+$hits[] = $now;
+$rate[$ip] = $hits;
+foreach ($rate as $k => $v) {
+ $v = array_values(array_filter($v, function ($t) use ($now) { return $t > $now - 86400; }));
+ if ($v) $rate[$k] = $v; else unset($rate[$k]);
+}
+save_json(RATE_FILE, $rate);
+
+// Dedupe: same title + error set + version within 24h collapses onto the existing report.
+$fingerprint = sha1($title . '|' . json_encode($diag['errors'] ?? []) . '|' . (string)($diag['version'] ?? ''));
+$existing = glob(REPORTS_DIR . '/RPT-*.json') ?: [];
+foreach (array_slice(array_reverse($existing), 0, 100) as $f) {
+ $d = json_decode(@file_get_contents($f), true);
+ if (!is_array($d)) continue;
+ if (($d['fingerprint'] ?? '') === $fingerprint && strtotime($d['created_at'] ?? '') > $now - 86400) {
+ json_response([
+ 'success' => true,
+ 'id' => $d['id'],
+ 'duplicate' => true,
+ 'url' => '/spaces/newos4/downloads/bugs/BUG-REPORT.html',
+ ]);
+ }
+}
+
+$id = 'RPT-' . gmdate('Ymd-His') . '-' . substr(bin2hex(random_bytes(4)), 0, 8);
+$record = [
+ 'id' => $id,
+ 'created_at' => gmdate('c'),
+ 'fingerprint' => $fingerprint,
+ 'title' => $title,
+ 'body' => $body,
+ 'diagnostics' => $diag,
+ 'meta' => [
+ 'ip' => $ip,
+ 'ua' => $_SERVER['HTTP_USER_AGENT'] ?? '',
+ ],
+];
+
+$file = REPORTS_DIR . '/' . $id . '.json';
+$json = json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+if ($json === false || file_put_contents($file, $json) === false) {
+ error_response('Failed to persist report', 500);
+}
+@chmod($file, 0664);
+
+json_response([
+ 'success' => true,
+ 'id' => $id,
+ 'duplicate' => false,
+ 'url' => '/spaces/newos4/downloads/bugs/BUG-REPORT.html',
+], 201);
diff --git a/downloads/bugs/BUG-REPORT.html b/downloads/bugs/BUG-REPORT.html
new file mode 100644
index 0000000..072984d
--- /dev/null
+++ b/downloads/bugs/BUG-REPORT.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>newOS4 — Bug Reports</title>
+<style>
+ :root { --bg:#0b0f19; --panel:#141a29; --line:#243049; --text:#e6ebf5; --muted:#93a1bb; --accent:#6366f1; }
+ * { box-sizing:border-box; }
+ body { margin:0; padding:32px 20px; background:radial-gradient(circle at 20% -10%,#1b2540 0,var(--bg) 55%); color:var(--text);
+ font:15px/1.55 Inter,system-ui,-apple-system,"Segoe UI",sans-serif; }
+ main { max-width:920px; margin:0 auto; }
+ h1 { font-size:1.6rem; margin:0 0 6px; }
+ .sub { color:var(--muted); margin:0 0 26px; }
+ .stats { display:flex; gap:12px; flex-wrap:wrap; margin-bottom:24px; }
+ .stat { background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 18px; min-width:130px; }
+ .stat b { display:block; font-size:1.4rem; color:var(--accent); }
+ .stat span { color:var(--muted); font-size:.82rem; }
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:16px 18px; margin-bottom:14px; }
+ .card header { display:flex; gap:10px; align-items:center; justify-content:space-between; }
+ .card h3 { margin:0; font-size:1.02rem; }
+ .pill { font-size:.72rem; color:var(--accent); border:1px solid var(--accent); border-radius:999px; padding:2px 9px; white-space:nowrap; }
+ .meta { color:var(--muted); font-size:.82rem; margin:6px 0 0; }
+ .body { margin:10px 0 0; }
+ details { margin-top:12px; }
+ summary { cursor:pointer; color:var(--muted); }
+ pre { background:#0d1322; border:1px solid var(--line); border-radius:10px; padding:12px; overflow:auto; max-height:420px;
+ font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; }
+ .empty { color:var(--muted); }
+ code { background:#0d1322; border:1px solid var(--line); border-radius:6px; padding:1px 5px; }
+</style>
+</head>
+<body>
+<main>
+ <h1>🐞 newOS4 Bug Reports</h1>
+ <p class="sub">Generated from <code>data/reports/*.json</code> — no manual edits.</p>
+ <section class="stats">
+ <div class="stat"><b>0</b><span>reports</span></div>
+ <div class="stat"><b>—</b><span>latest report</span></div>
+ </section>
+ <p class="empty">No reports yet. Run <code>bug &lt;title&gt;</code> in newOS4.</p>
+</main>
+</body>
+</html>
diff --git a/main.html b/main.html
index 6c243ab..4bd5701 100644
--- a/main.html
+++ b/main.html
@@ -1349,13 +1349,13 @@
</script>
<!-- v4.5.63: A2A scratchpad fixes + agent-register arg fix -->
-<script src="modules/core/core-bundle.js?v=v4.6.36"></script>
+<script src="modules/core/core-bundle.js?v=v4.6.37"></script>
<!-- Async dependency-ordered module loader now only initializes terminal and loads optional ext modules -->
-<script src="modules/core/module-loader.js?v=v4.6.36"></script>
+<script src="modules/core/module-loader.js?v=v4.6.37"></script>
<!-- Critical ext bundle loads AFTER webx11 exists so registerModule calls work -->
-<script src="modules/ext/critical-ext-bundle.js?v=v4.6.36"></script>
+<script src="modules/ext/critical-ext-bundle.js?v=v4.6.37"></script>
<script>
// ═══ ONE TRUE VERSION SOURCE ═══
diff --git a/modules/core/core-bundle.js b/modules/core/core-bundle.js
index d1793b2..449a80c 100644
--- a/modules/core/core-bundle.js
+++ b/modules/core/core-bundle.js
@@ -5455,6 +5455,7 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
'errors': 'errors [n] — Show last n ERROR log entries (default: 20)',
'doctor': 'doctor — Run all health checks, rank issues, show top 3 warnings',
'bug-report': 'bug-report — Generate a markdown bug report to /tmp/ for debugging',
+ 'bug': 'bug <title> — File a structured bug report (stored server-side, visible in BUG-REPORT.html)',
'preview': 'preview <file.html> — Render an HTML file from the VFS in a preview pane',
'reset': 'reset [mem|vfs|config|all] — Global reset command (default: mem)',
'factory-reset': 'factory-reset — Alias for reset all',
@@ -5745,6 +5746,48 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
}
};
+ // v4.6.37: bug — file a structured report to the backend (Part A)
+ this.commands['bug'] = async (args) => {
+ const title = (args || []).join(' ').trim() || 'Untitled bug report';
+ const diag = {
+ version: window.NEWOS4_VERSION || 'unknown',
+ url: location.href,
+ ua: navigator.userAgent,
+ online: navigator.onLine,
+ modules: Object.keys(this.terminal.moduleManager.modules).length,
+ errors: [],
+ };
+ try {
+ const raw = JSON.parse(localStorage.getItem('webx11_debug_db') || '[]');
+ const list = Array.isArray(raw) ? raw : (raw.errors || []);
+ diag.errors = list.slice(-20).map(e => ({
+ message: String(e.message || e.msg || '').slice(0, 300),
+ source: String(e.source || e.url || '').slice(0, 200),
+ count: Number(e.count) || 1,
+ }));
+ } catch (_e) {}
+ try {
+ diag.recent_commands = JSON.parse(localStorage.getItem('webx11_command_history') || '[]')
+ .slice(-10)
+ .map(c => String(c).replace(/(password|token|secret|api[_-]?key)\S*/ig, '$1:[redacted]'));
+ } catch (_e) {}
+
+ this.terminal.print('📮 Filing bug report…', 'info');
+ let res = null, err = null;
+ try {
+ const r = await fetch('/spaces/newos4/api/debug/report.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title, diagnostics: diag }),
+ });
+ res = await r.json();
+ if (!r.ok) err = res.error || ('HTTP ' + r.status);
+ } catch (e) { err = e.message; }
+ if (err) { this.terminal.print('❌ Report failed: ' + err, 'error'); return; }
+ this.terminal.print('✅ Report filed: ' + res.id + (res.duplicate ? ' (already known)' : ''), 'success');
+ this.terminal.print(' View: /spaces/newos4/downloads/bugs/BUG-REPORT.html', 'info');
+ };
+
this.commands['preview'] = (args) => this.terminal.openPreview(args);
// v4.5.64: reset — unified reset command superseding factory-reset, reset-vfs, reset-mem, config-reset
@@ -19764,6 +19807,9 @@ class DebugSupervisorModule {
window.fetch = function() {
const args = arguments;
const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || '?');
+ // Never record failures of the debug-sync endpoint itself: that produced a
+ // self-referential loop (38,495 identical "Fetch failed …/ingest.php" entries).
+ if (url.indexOf('/api/debug/') !== -1) return self._originalFetch.apply(window, args);
return self._originalFetch.apply(window, args).catch(function(err) {
self._recordError('Fetch failed: ' + url + ' — ' + (err.message || err), url, err.stack);
throw err;
diff --git a/modules/core/module-debug-supervisor.js b/modules/core/module-debug-supervisor.js
index fc6a919..ffe08a8 100644
--- a/modules/core/module-debug-supervisor.js
+++ b/modules/core/module-debug-supervisor.js
@@ -263,6 +263,9 @@ class DebugSupervisorModule {
window.fetch = function() {
const args = arguments;
const url = typeof args[0] === 'string' ? args[0] : (args[0]?.url || '?');
+ // Never record failures of the debug-sync endpoint itself: that produced a
+ // self-referential loop (38,495 identical "Fetch failed …/ingest.php" entries).
+ if (url.indexOf('/api/debug/') !== -1) return self._originalFetch.apply(window, args);
return self._originalFetch.apply(window, args).catch(function(err) {
self._recordError('Fetch failed: ' + url + ' — ' + (err.message || err), url, err.stack);
throw err;
diff --git a/modules/core/module-loader.js b/modules/core/module-loader.js
index f060bde..05a63e9 100644
--- a/modules/core/module-loader.js
+++ b/modules/core/module-loader.js
@@ -4,7 +4,7 @@
// Core module bundle loaded synchronously in main.html.
// This loader only handles version cache-busting, splash, terminal init, and optional ext modules.
- const LATEST_VER = 'v4.6.36';
+ const LATEST_VER = 'v4.6.37';
const CACHE = '?v=' + LATEST_VER;
const stored = localStorage.getItem('webx11_cache_ver');
if (stored !== LATEST_VER) {
diff --git a/modules/core/module-system.js b/modules/core/module-system.js
index 9dee38c..1a9ad05 100644
--- a/modules/core/module-system.js
+++ b/modules/core/module-system.js
@@ -15,6 +15,7 @@
'errors': 'errors [n] — Show last n ERROR log entries (default: 20)',
'doctor': 'doctor — Run all health checks, rank issues, show top 3 warnings',
'bug-report': 'bug-report — Generate a markdown bug report to /tmp/ for debugging',
+ 'bug': 'bug <title> — File a structured bug report (stored server-side, visible in BUG-REPORT.html)',
'preview': 'preview <file.html> — Render an HTML file from the VFS in a preview pane',
'reset': 'reset [mem|vfs|config|all] — Global reset command (default: mem)',
'factory-reset': 'factory-reset — Alias for reset all',
@@ -305,6 +306,48 @@
}
};
+ // v4.6.37: bug — file a structured report to the backend (Part A)
+ this.commands['bug'] = async (args) => {
+ const title = (args || []).join(' ').trim() || 'Untitled bug report';
+ const diag = {
+ version: window.NEWOS4_VERSION || 'unknown',
+ url: location.href,
+ ua: navigator.userAgent,
+ online: navigator.onLine,
+ modules: Object.keys(this.terminal.moduleManager.modules).length,
+ errors: [],
+ };
+ try {
+ const raw = JSON.parse(localStorage.getItem('webx11_debug_db') || '[]');
+ const list = Array.isArray(raw) ? raw : (raw.errors || []);
+ diag.errors = list.slice(-20).map(e => ({
+ message: String(e.message || e.msg || '').slice(0, 300),
+ source: String(e.source || e.url || '').slice(0, 200),
+ count: Number(e.count) || 1,
+ }));
+ } catch (_e) {}
+ try {
+ diag.recent_commands = JSON.parse(localStorage.getItem('webx11_command_history') || '[]')
+ .slice(-10)
+ .map(c => String(c).replace(/(password|token|secret|api[_-]?key)\S*/ig, '$1:[redacted]'));
+ } catch (_e) {}
+
+ this.terminal.print('📮 Filing bug report…', 'info');
+ let res = null, err = null;
+ try {
+ const r = await fetch('/spaces/newos4/api/debug/report.php', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ title, diagnostics: diag }),
+ });
+ res = await r.json();
+ if (!r.ok) err = res.error || ('HTTP ' + r.status);
+ } catch (e) { err = e.message; }
+ if (err) { this.terminal.print('❌ Report failed: ' + err, 'error'); return; }
+ this.terminal.print('✅ Report filed: ' + res.id + (res.duplicate ? ' (already known)' : ''), 'success');
+ this.terminal.print(' View: /spaces/newos4/downloads/bugs/BUG-REPORT.html', 'info');
+ };
+
this.commands['preview'] = (args) => this.terminal.openPreview(args);
// v4.5.64: reset — unified reset command superseding factory-reset, reset-vfs, reset-mem, config-reset
diff --git a/scripts/gen-bug-report.py b/scripts/gen-bug-report.py
new file mode 100644
index 0000000..f995bfc
--- /dev/null
+++ b/scripts/gen-bug-report.py
@@ -0,0 +1,132 @@
+#!/usr/bin/env python3
+"""Generate a deterministic BUG-REPORT.html from data/reports/*.json.
+
+Every field is HTML-escaped; the page is self-contained (no external assets).
+Output is stable for a given set of reports — no wall-clock timestamps.
+"""
+import glob
+import html
+import json
+import os
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+REPORTS_DIR = os.path.join(ROOT, "data", "reports")
+OUT_FILE = os.path.join(ROOT, "downloads", "bugs", "BUG-REPORT.html")
+MAX_REPORTS = 200
+
+
+def esc(value):
+ return html.escape("" if value is None else str(value), quote=True)
+
+
+def load_reports():
+ rows = []
+ for path in sorted(glob.glob(os.path.join(REPORTS_DIR, "RPT-*.json"))):
+ try:
+ with open(path, encoding="utf-8") as fh:
+ rec = json.load(fh)
+ if isinstance(rec, dict) and rec.get("id"):
+ rows.append(rec)
+ except (OSError, ValueError):
+ continue
+ rows.sort(key=lambda r: (str(r.get("created_at", "")), str(r.get("id", ""))), reverse=True)
+ return rows[:MAX_REPORTS]
+
+
+def pretty(value, limit=6000):
+ try:
+ text = json.dumps(value, indent=2, ensure_ascii=False)
+ except (TypeError, ValueError):
+ text = str(value)
+ if len(text) > limit:
+ text = text[:limit] + "\n… (truncated)"
+ return esc(text)
+
+
+def render(rows):
+ total = len(rows)
+ latest = esc(rows[0].get("created_at", "—")) if rows else "—"
+ cards = []
+
+ for rec in rows:
+ diag = rec.get("diagnostics") if isinstance(rec.get("diagnostics"), dict) else {}
+ errors = diag.get("errors") if isinstance(diag.get("errors"), list) else []
+ cards.append(
+ f""" <article class="card">
+ <header>
+ <h3>{esc(rec.get('title', '(untitled)'))}</h3>
+ <span class="pill">{esc(rec.get('id', ''))}</span>
+ </header>
+ <p class="meta">{esc(rec.get('created_at', ''))} · version {esc(diag.get('version', 'unknown'))} · {len(errors)} error(s)</p>
+ {f'<p class="body">{esc(rec.get("body"))}</p>' if rec.get('body') else ''}
+ <details>
+ <summary>Diagnostics</summary>
+ <pre>{pretty(diag)}</pre>
+ </details>
+ </article>"""
+ )
+
+ if not cards:
+ cards.append(' <p class="empty">No reports yet. Run <code>bug &lt;title&gt;</code> in newOS4.</p>')
+
+ return f"""<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>newOS4 — Bug Reports</title>
+<style>
+ :root {{ --bg:#0b0f19; --panel:#141a29; --line:#243049; --text:#e6ebf5; --muted:#93a1bb; --accent:#6366f1; }}
+ * {{ box-sizing:border-box; }}
+ body {{ margin:0; padding:32px 20px; background:radial-gradient(circle at 20% -10%,#1b2540 0,var(--bg) 55%); color:var(--text);
+ font:15px/1.55 Inter,system-ui,-apple-system,"Segoe UI",sans-serif; }}
+ main {{ max-width:920px; margin:0 auto; }}
+ h1 {{ font-size:1.6rem; margin:0 0 6px; }}
+ .sub {{ color:var(--muted); margin:0 0 26px; }}
+ .stats {{ display:flex; gap:12px; flex-wrap:wrap; margin-bottom:24px; }}
+ .stat {{ background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:12px 18px; min-width:130px; }}
+ .stat b {{ display:block; font-size:1.4rem; color:var(--accent); }}
+ .stat span {{ color:var(--muted); font-size:.82rem; }}
+ .card {{ background:var(--panel); border:1px solid var(--line); border-radius:14px; padding:16px 18px; margin-bottom:14px; }}
+ .card header {{ display:flex; gap:10px; align-items:center; justify-content:space-between; }}
+ .card h3 {{ margin:0; font-size:1.02rem; }}
+ .pill {{ font-size:.72rem; color:var(--accent); border:1px solid var(--accent); border-radius:999px; padding:2px 9px; white-space:nowrap; }}
+ .meta {{ color:var(--muted); font-size:.82rem; margin:6px 0 0; }}
+ .body {{ margin:10px 0 0; }}
+ details {{ margin-top:12px; }}
+ summary {{ cursor:pointer; color:var(--muted); }}
+ pre {{ background:#0d1322; border:1px solid var(--line); border-radius:10px; padding:12px; overflow:auto; max-height:420px;
+ font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; }}
+ .empty {{ color:var(--muted); }}
+ code {{ background:#0d1322; border:1px solid var(--line); border-radius:6px; padding:1px 5px; }}
+</style>
+</head>
+<body>
+<main>
+ <h1>🐞 newOS4 Bug Reports</h1>
+ <p class="sub">Generated from <code>data/reports/*.json</code> — no manual edits.</p>
+ <section class="stats">
+ <div class="stat"><b>{total}</b><span>reports</span></div>
+ <div class="stat"><b>{esc(latest)}</b><span>latest report</span></div>
+ </section>
+{chr(10).join(cards)}
+</main>
+</body>
+</html>
+"""
+
+
+def main():
+ rows = load_reports()
+ page = render(rows)
+ os.makedirs(os.path.dirname(OUT_FILE), exist_ok=True)
+ with open(OUT_FILE, "w", encoding="utf-8") as fh:
+ fh.write(page)
+ os.chmod(OUT_FILE, 0o664)
+ print(f"wrote {OUT_FILE} ({len(rows)} report(s), {len(page)} bytes)")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())