diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-18 17:01:52 +0200 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-18 17:01:52 +0200 |
| commit | d7a1198aa7ac77cbe9d3f0c844245dd08a8ccd12 (patch) | |
| tree | 0e6851e95a62d05f51966fe29eb784108df0fe4b | |
| parent | 824d6cd3c61488c120c0226d1929324f2741fafc (diff) | |
newOS4 v4.6.37: bug-report pipeline (report.php, bug command, HTML generator, telemetry-loop fix)
| -rw-r--r-- | api/debug/dashboard.php | 2 | ||||
| -rw-r--r-- | api/debug/ingest.php | 10 | ||||
| -rw-r--r-- | api/debug/report.php | 99 | ||||
| -rw-r--r-- | downloads/bugs/BUG-REPORT.html | 44 | ||||
| -rw-r--r-- | main.html | 6 | ||||
| -rw-r--r-- | modules/core/core-bundle.js | 46 | ||||
| -rw-r--r-- | modules/core/module-debug-supervisor.js | 3 | ||||
| -rw-r--r-- | modules/core/module-loader.js | 2 | ||||
| -rw-r--r-- | modules/core/module-system.js | 43 | ||||
| -rw-r--r-- | scripts/gen-bug-report.py | 132 |
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 <title></code> in newOS4.</p> +</main> +</body> +</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 <title></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()) |