diff --git a/examples/crash_reporter/backend/nginx.apps.conf.fragment b/examples/crash_reporter/backend/nginx.apps.conf.fragment index 122a1fc..620152c 100644 --- a/examples/crash_reporter/backend/nginx.apps.conf.fragment +++ b/examples/crash_reporter/backend/nginx.apps.conf.fragment @@ -26,6 +26,30 @@ client_max_body_size 4m; } + location = /app/androidcast_project/crashes/api/diag.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/diag.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/diag.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/crashes/api/reports.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/reports.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/reports.php; + fastcgi_param REQUEST_URI $request_uri; + } + + location = /app/androidcast_project/crashes/api/report_viewed.php { + include fastcgi_params; + fastcgi_pass unix:/run/php-fpm.socket; + fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/report_viewed.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/report_viewed.php; + fastcgi_param REQUEST_URI $request_uri; + } + location ^~ /app/androidcast_project/crashes/ { include fastcgi_params; fastcgi_pass unix:/run/php-fpm.socket; diff --git a/examples/crash_reporter/backend/public/api/diag.php b/examples/crash_reporter/backend/public/api/diag.php new file mode 100644 index 0000000..d54bb0c --- /dev/null +++ b/examples/crash_reporter/backend/public/api/diag.php @@ -0,0 +1,47 @@ + false, 'error' => 'diag disabled'], 403); +} + +$path = cfg('db.sqlite_path'); +$out = [ + 'ok' => true, + 'php_user' => get_current_user(), + 'sqlite_path' => $path, + 'sqlite_realpath' => is_string($path) ? realpath($path) : null, + 'sqlite_exists' => is_string($path) && is_file($path), + 'sqlite_writable' => is_string($path) && is_writable($path), + 'data_dir_writable' => is_string($path) && is_writable(dirname($path)), + 'storage_writable' => is_writable(dirname(__DIR__) . '/../storage'), + 'tables' => [], + 'reports_columns' => [], + 'test_insert' => null, +]; + +try { + $pdo = Database::pdo(); + $tables = $pdo->query( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name" + )->fetchAll(PDO::FETCH_COLUMN); + $out['tables'] = $tables; + if (in_array('reports', $tables, true)) { + $out['reports_columns'] = $pdo->query('PRAGMA table_info(reports)')->fetchAll(PDO::FETCH_ASSOC); + } + $pdo->beginTransaction(); + $stmt = $pdo->prepare( + 'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, device_model, app_version, payload_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + ); + $rid = 'diag-' . time(); + $stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}']); + $pdo->rollBack(); + $out['test_insert'] = 'ok (rolled back)'; +} catch (Throwable $e) { + $out['ok'] = false; + $out['test_insert'] = $e->getMessage(); +} + +json_out($out); diff --git a/examples/crash_reporter/backend/public/api/report_viewed.php b/examples/crash_reporter/backend/public/api/report_viewed.php new file mode 100644 index 0000000..fa18d8f --- /dev/null +++ b/examples/crash_reporter/backend/public/api/report_viewed.php @@ -0,0 +1,25 @@ + false, 'error' => 'unauthorized'], 401); +} +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + json_out(['ok' => false, 'error' => 'POST required'], 405); +} + +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; +$id = (int) ($data['id'] ?? 0); +if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); +} + +try { + ReportRepository::markViewed($id, (int) Auth::user()['id']); + json_out(['ok' => true, 'id' => $id]); +} catch (Throwable $e) { + error_log('report_viewed: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'failed'], 500); +} diff --git a/examples/crash_reporter/backend/public/api/reports.php b/examples/crash_reporter/backend/public/api/reports.php new file mode 100644 index 0000000..ff64e0e --- /dev/null +++ b/examples/crash_reporter/backend/public/api/reports.php @@ -0,0 +1,26 @@ + false, 'error' => 'unauthorized'], 401); +} + +$grouped = isset($_GET['group']) && $_GET['group'] === '1'; +$page = max(1, (int) ($_GET['page'] ?? 1)); +$perPage = (int) ($_GET['per_page'] ?? 50); +$allowedPerPage = [25, 50, 75, 100, 200]; +if (!in_array($perPage, $allowedPerPage, true)) { + $perPage = 50; +} +$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'generated_at_ms')); +$dir = (string) ($_GET['dir'] ?? 'desc'); +$sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0)); + +try { + $data = ReportRepository::listPage($grouped, $page, $perPage, $sort, $dir, $sinceMs); + json_out(['ok' => true] + $data); +} catch (Throwable $e) { + error_log('reports api: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'list failed'], 500); +} diff --git a/examples/crash_reporter/backend/public/api/upload.php b/examples/crash_reporter/backend/public/api/upload.php index 4c1633c..84fb233 100644 --- a/examples/crash_reporter/backend/public/api/upload.php +++ b/examples/crash_reporter/backend/public/api/upload.php @@ -33,6 +33,7 @@ try { json_out(['ok' => false, 'error' => 'duplicate report_id'], 409); } error_log('crash upload PDO: ' . $msg); + log_crash_event('upload', 'PDO: ' . $msg); $out = ['ok' => false, 'error' => 'store failed']; if (cfg('debug')) { $out['hint'] = $msg; @@ -40,6 +41,7 @@ try { json_out($out, 500); } catch (Throwable $e) { error_log('crash upload: ' . $e->getMessage()); + log_crash_event('upload', $e->getMessage()); $out = ['ok' => false, 'error' => 'store failed']; if (cfg('debug')) { $out['hint'] = $e->getMessage(); diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index 202610a..ac09ec6 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -1,12 +1,30 @@ -:root { +:root, +[data-theme="dark"] { --bg: #0f1419; --surface: #1a2332; + --surface2: #0a0e14; --border: #2d3a4f; --text: #e7ecf3; --muted: #8b9cb3; --accent: #3d8bfd; --accent2: #5eead4; --danger: #f87171; + --row-hover: rgba(61, 139, 253, .12); + --row-open: rgba(61, 139, 253, .08); +} + +[data-theme="light"] { + --bg: #f0f2f6; + --surface: #ffffff; + --surface2: #e8ecf2; + --border: #c5d0e0; + --text: #1a2332; + --muted: #5a6b82; + --accent: #2563eb; + --accent2: #0d9488; + --danger: #dc2626; + --row-hover: rgba(37, 99, 235, .1); + --row-open: rgba(37, 99, 235, .06); } * { box-sizing: border-box; } body { @@ -331,10 +349,232 @@ a:hover { text-decoration: underline; } .btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } .data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; } .data-table th, .data-table td { border-bottom: 1px solid var(--border); padding: 10px 8px; text-align: left; } +.reports-tree .report-tree-head { width: 2.5rem; padding: 0; } +.reports-tree .report-tree-cell { + width: 2.5rem; + padding: 6px 4px; + vertical-align: middle; +} +.report-tree-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin: 0; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: background .12s ease, color .12s ease; +} +.report-tree-toggle:hover, +.report-tree-toggle:focus-visible { + background: rgba(61, 139, 253, .18); + color: var(--accent); + outline: none; +} +.report-tree-arrow { + display: block; + width: 0; + height: 0; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 7px solid currentColor; + transition: transform .18s ease; +} +.report-tree-toggle[aria-expanded="true"] .report-tree-arrow { + transform: rotate(90deg); +} +.report-row { + transition: background .12s ease; +} +.report-row--nav { cursor: pointer; } +.report-row:hover, +.report-row:focus-within, +.report-brief-panel--nav:hover, +.report-brief-panel--nav:focus-within { + background: var(--row-hover); +} +.report-row.report-row--open { + background: var(--row-open); +} +.report-brief-panel--nav { + cursor: pointer; + border-radius: 0 8px 8px 0; + margin: 0; + padding: 0; +} +.report-brief-panel--nav:focus { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.report-brief-row[hidden] { display: none; } +.report-brief-cell { + padding: 0 12px 12px 2.75rem !important; + border-bottom: 1px solid var(--border); + background: var(--surface2); +} +.report-brief { + margin: 0; + padding: 10px 14px; + border-left: 3px solid var(--accent); + border-radius: 0 8px 8px 0; + font-size: 13px; + line-height: 1.45; +} +.report-brief p { margin: 0 0 6px; } +.report-brief p:last-child { margin-bottom: 0; } +.report-brief-hint { font-size: 12px; margin-top: 8px !important; } .badge { background: var(--accent2); color: #042; padding: 2px 8px; border-radius: 999px; font-weight: 700; } -.stack { background: #0a0e14; padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; } +.stack { background: var(--surface2); padding: 16px; border-radius: 8px; overflow-x: auto; font-size: 13px; line-height: 1.5; } +.reports-toolbar .toolbar-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.toolbar-select { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); } +.toolbar-select select { + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); +} +.reports-mode-btn { margin-left: 0; } +.reports-mode-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } +.reports-status { margin: 8px 0 0; min-height: 1.2em; } +.reports-table-wrap { overflow-x: auto; } +.reports-pagination { margin-top: 16px; } +.pagination-inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.page-btn:disabled { opacity: .45; cursor: not-allowed; } +.page-info { font-size: 13px; color: var(--muted); } +.data-table th.sortable { + cursor: pointer; + user-select: none; + white-space: nowrap; +} +.data-table th.sortable:hover { color: var(--accent); } +.data-table th.sortable--active { color: var(--accent); } +.sort-ind { display: inline-block; min-width: 1em; font-size: 11px; opacity: .85; } +.sort-idle { opacity: .35; } +.col-tags { + min-width: 6rem; + max-width: 11rem; + text-align: right; + vertical-align: middle; +} +.col-tags .report-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: flex-end; +} +.data-table th.col-tags { text-align: right; } +.report-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1.4; + color: #fff; + background: var(--tag-bg, #5c6b82); +} .exc { color: var(--danger); font-weight: 600; } .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; } .card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; } +.cards--lift .card--lift { + transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease; +} +.cards--lift .card--lift:hover { + transform: translateY(-4px); + box-shadow: 0 10px 24px rgba(0, 0, 0, .22); + border-color: var(--accent); +} +[data-theme="light"] .cards--lift .card--lift:hover { + box-shadow: 0 10px 22px rgba(26, 35, 50, .14); +} +.detail-header { + display: flex; + align-items: flex-start; + gap: 14px; + margin-bottom: 8px; +} +.back-link { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + border-radius: 10px; + border: 1px solid var(--border); + color: var(--text); + text-decoration: none; + flex-shrink: 0; + transition: background .15s ease, border-color .15s ease, color .15s ease; +} +.back-link:hover { + background: var(--row-hover); + border-color: var(--accent); + color: var(--accent); + text-decoration: none; +} +.back-icon { + display: block; + width: 10px; + height: 10px; + margin-left: 3px; + border-left: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); +} +.detail-tree { + margin-top: 20px; + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; +} +.detail-tree-item + .detail-tree-item { + border-top: 1px solid var(--border); +} +.detail-tree-row { + display: flex; + align-items: stretch; + width: 100%; + cursor: pointer; + transition: background .12s ease; +} +.detail-tree-row:hover, +.detail-tree-row:focus-within, +.detail-tree-row.report-row--open { + background: var(--row-hover); +} +.detail-tree-caption { + flex: 1; + display: flex; + align-items: center; + padding: 10px 12px; + font-weight: 600; +} +.detail-tree-body { + padding: 0 14px 14px 2.75rem; + background: var(--surface2); + border-top: 1px solid var(--border); +} +.detail-tree-body[hidden] { display: none; } +.star-rating { display: inline-flex; gap: 2px; line-height: 1; } +.star { + width: 14px; + height: 14px; + clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); + background: var(--border); +} +.star--on { background: #f59e0b; } +[data-theme="light"] .star--on { background: #d97706; } .muted { color: var(--muted); } .alert { background: rgba(248,113,113,.15); color: var(--danger); padding: 10px; border-radius: 8px; } diff --git a/examples/crash_reporter/backend/public/assets/js/app.js b/examples/crash_reporter/backend/public/assets/js/app.js index 96cab61..6f13954 100644 --- a/examples/crash_reporter/backend/public/assets/js/app.js +++ b/examples/crash_reporter/backend/public/assets/js/app.js @@ -1,10 +1,551 @@ -document.addEventListener('DOMContentLoaded', () => { - const nav = document.getElementById('nav-pane'); - const handle = document.getElementById('nav-handle'); - if (!nav || !handle) return; - handle.addEventListener('click', () => { - const open = nav.classList.toggle('open'); - handle.setAttribute('aria-expanded', open ? 'true' : 'false'); +(function () { + const STORAGE = { + theme: 'crash_console_theme', + perPage: 'crash_console_per_page', + lastVisit: 'crash_console_last_visit_ms', + viewed: 'crash_console_viewed_ids', + sort: 'crash_console_sort', + skipStamp: 'crash_skip_visit_stamp', + }; + + const TAG_NEW = { label: 'new', bg: '#3d8bfd' }; + const CRASH_KIND_TAGS = { + native: { label: 'NDK', bg: '#b45309' }, + java: { label: 'Java', bg: '#6d28d9' }, + android: { label: 'Android', bg: '#047857' }, + }; + + const LIST_COLUMNS = [ + { key: 'device_model', label: 'Device' }, + { key: 'app_version', label: 'App' }, + { key: 'os_name', label: 'OS' }, + { key: 'os_version', label: 'OS ver' }, + { key: 'received_at_ms', label: 'Received', fmt: 'time' }, + { key: 'rating', label: 'Rating', fmt: 'stars', noSort: true }, + ]; + + const SORT_API_MAP = { + os_name: 'device_model', + os_version: 'app_version', + rating: 'fingerprint_cnt', + }; + + const GROUP_COLUMNS = [ + { key: 'cnt', label: 'Count' }, + { key: 'fingerprint', label: 'Fingerprint', fmt: 'fp' }, + { key: 'crash_type', label: 'Type' }, + { key: 'last_generated_ms', label: 'Last generated', fmt: 'time' }, + { key: 'last_received_ms', label: 'Last received', fmt: 'time' }, + ]; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function lsGet(key, fallback) { + try { + const v = localStorage.getItem(key); + return v === null ? fallback : v; + } catch { + return fallback; + } + } + + function lsSet(key, value) { + try { + localStorage.setItem(key, value); + } catch { /* ignore */ } + } + + function getViewedIds() { + try { + const raw = lsGet(STORAGE.viewed, '[]'); + const arr = JSON.parse(raw); + return new Set(Array.isArray(arr) ? arr.map(Number).filter(Boolean) : []); + } catch { + return new Set(); + } + } + + function addViewedId(id) { + const set = getViewedIds(); + set.add(Number(id)); + lsSet(STORAGE.viewed, JSON.stringify([...set])); + } + + function lastVisitMs() { + return Number(lsGet(STORAGE.lastVisit, '0')) || 0; + } + + function stampLastVisit() { + lsSet(STORAGE.lastVisit, String(Date.now())); + } + + function applyTheme(theme) { + const t = theme === 'light' ? 'light' : 'dark'; + document.documentElement.setAttribute('data-theme', t); + lsSet(STORAGE.theme, t); + const sel = document.getElementById('theme-select'); + if (sel) sel.value = t; + } + + function initTheme() { + applyTheme(lsGet(STORAGE.theme, 'dark')); + const sel = document.getElementById('theme-select'); + if (sel) { + sel.addEventListener('change', () => applyTheme(sel.value)); + } + } + + function initNav() { + const nav = document.getElementById('nav-pane'); + const handle = document.getElementById('nav-handle'); + if (!nav || !handle) return; + handle.addEventListener('click', () => { + const open = nav.classList.toggle('open'); + handle.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + handle.setAttribute('aria-expanded', nav.classList.contains('open') ? 'true' : 'false'); + } + + function markReportViewed(id) { + if (!id) return; + addViewedId(id); + const xhr = new XMLHttpRequest(); + xhr.open('POST', basePath() + '/api/report_viewed.php', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(JSON.stringify({ id: Number(id) })); + } + + function bindDetailTree(root) { + root.querySelectorAll('[data-tree-toggle]').forEach((row) => { + const panelId = row.getAttribute('data-controls'); + const panel = panelId ? document.getElementById(panelId) : null; + const btn = row.querySelector('.report-tree-toggle'); + if (!panel) return; + const toggle = (e) => { + if (e && e.target.closest('.report-tree-toggle') && e.type === 'click') { + e.stopPropagation(); + } + const open = panel.hidden; + panel.hidden = !open; + if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + row.classList.toggle('report-row--open', open); + }; + if (btn) { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + toggle(e); + }); + } + row.addEventListener('click', () => toggle()); + row.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + }); + }); + } + + function initReportDetail() { + const id = document.body.getAttribute('data-report-id'); + if (!id) return; + markReportViewed(id); + const tree = document.getElementById('detail-tree'); + if (tree) bindDetailTree(tree); + } + + function escapeHtml(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function formatTime(ms) { + if (!ms) return '—'; + const d = new Date(Number(ms)); + return d.toISOString().replace('T', ' ').slice(0, 19); + } + + function formatCell(col, row) { + const v = row[col.key]; + if (col.fmt === 'time') return formatTime(v); + if (col.fmt === 'stars') return formatStars(Number(row.rating ?? 0)); + if (col.fmt === 'fp') { + const s = String(v || ''); + return '' + escapeHtml(s.slice(0, 12)) + (s.length > 12 ? '…' : '') + ''; + } + return escapeHtml(v ?? '') || ''; + } + + function formatStars(n) { + const score = Math.max(0, Math.min(5, Number(n) || 0)); + let html = + ''; + for (let i = 1; i <= 5; i++) { + html += ''; + } + return html + ''; + } + + function crashKindTag(crashType) { + const t = String(crashType || '').toLowerCase(); + return CRASH_KIND_TAGS[t] || { label: t || '?', bg: '#5c6b82' }; + } + + function renderTagPills(tags) { + if (!tags.length) return ''; + return tags + .map( + (t) => + '' + + escapeHtml(t.label) + + '' + ) + .join(''); + } + + function buildTags(row, grouped) { + const tags = []; + const kind = crashKindTag(row.crash_type); + tags.push(kind); + if (!grouped && row.id) { + const viewed = row.viewed || getViewedIds().has(Number(row.id)); + const sinceVisit = Number(row.received_at_ms) > lastVisitMs(); + if (!viewed && (lastVisitMs() === 0 || sinceVisit)) { + tags.push(TAG_NEW); + } + } + if (Array.isArray(row.tags)) { + row.tags.forEach((t) => { + if (!t || !t.id) return; + const id = String(t.id).toLowerCase(); + if (id === 'new' || id === 'java' || id === 'ndk' || id === 'android') return; + tags.push({ + label: t.label || t.id, + bg: t.bg || '#5c6b82', + }); + }); + } + return renderTagPills(tags); + } + + function apiSortKey(key) { + return SORT_API_MAP[key] || key; + } + + function navigateTo(href) { + try { + sessionStorage.setItem(STORAGE.skipStamp, '1'); + } catch { /* ignore */ } + window.location.href = href; + } + + function bindTreeInteractions(root) { + root.querySelectorAll('.report-tree-toggle').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const id = btn.getAttribute('aria-controls'); + const briefRow = id ? document.getElementById(id) : null; + const row = btn.closest('.report-row'); + if (!briefRow || !row) return; + const open = briefRow.hidden; + briefRow.hidden = !open; + btn.setAttribute('aria-expanded', open ? 'true' : 'false'); + row.classList.toggle('report-row--open', open); + }); + }); + + root.querySelectorAll('[data-href]').forEach((el) => { + const href = el.getAttribute('data-href'); + if (!href) return; + const go = () => navigateTo(href); + const onActivate = (e) => { + if (e.target.closest('.report-tree-toggle')) return; + go(); + }; + el.addEventListener('click', onActivate); + el.addEventListener('keydown', (e) => { + if (e.target.closest('.report-tree-toggle')) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + go(); + } + }); + }); + } + + function initReportsApp() { + const app = document.getElementById('reports-app'); + if (!app) return; + + const thead = document.getElementById('reports-thead'); + const tbody = document.getElementById('reports-tbody'); + const pagination = document.getElementById('reports-pagination'); + const statusEl = document.getElementById('reports-status'); + const perPageSel = document.getElementById('per-page-select'); + + let grouped = app.getAttribute('data-grouped') === '1'; + let page = 1; + let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50; + let sort = grouped ? 'cnt' : 'generated_at_ms'; + let dir = 'desc'; + let loading = false; + + const savedSort = lsGet(STORAGE.sort, ''); + if (savedSort) { + try { + const s = JSON.parse(savedSort); + if (s && s.sort) sort = s.sort; + if (s && s.dir) dir = s.dir; + } catch { /* ignore */ } + } + + if (perPageSel) { + perPageSel.value = String(perPage); + perPageSel.addEventListener('change', () => { + perPage = Number(perPageSel.value) || 50; + lsSet(STORAGE.perPage, String(perPage)); + page = 1; + load(); + }); + } + + app.querySelectorAll('.reports-mode-btn').forEach((btn) => { + btn.addEventListener('click', () => { + grouped = btn.getAttribute('data-grouped') === '1'; + app.setAttribute('data-grouped', grouped ? '1' : '0'); + app.querySelectorAll('.reports-mode-btn').forEach((b) => { + b.classList.toggle('active', b === btn); + }); + sort = grouped ? 'cnt' : 'generated_at_ms'; + page = 1; + load(); + }); + }); + + const maybeStampVisit = () => { + try { + if (sessionStorage.getItem(STORAGE.skipStamp)) { + sessionStorage.removeItem(STORAGE.skipStamp); + return; + } + } catch { /* ignore */ } + stampLastVisit(); + }; + window.addEventListener('pagehide', maybeStampVisit); + + function columns() { + return grouped ? GROUP_COLUMNS : LIST_COLUMNS; + } + + function colspan() { + return columns().length + 2; + } + + function renderHead() { + const cols = columns(); + let html = ''; + cols.forEach((c) => { + const active = sort === c.key || apiSortKey(sort) === apiSortKey(c.key); + if (c.noSort) { + html += '' + escapeHtml(c.label) + ''; + return; + } + const arrow = + active ? (dir === 'asc' ? '▲' : '▼') : ''; + html += + '' + + escapeHtml(c.label) + + ' '; + }); + html += 'Tags'; + thead.innerHTML = html; + thead.querySelectorAll('.sortable').forEach((th) => { + th.addEventListener('click', () => { + const key = th.getAttribute('data-sort'); + if (!key) return; + if (sort === key) { + dir = dir === 'asc' ? 'desc' : 'asc'; + } else { + sort = key; + dir = 'desc'; + } + lsSet(STORAGE.sort, JSON.stringify({ sort, dir, grouped })); + page = 1; + load(); + }); + }); + } + + function renderPagination(total) { + const pages = Math.max(1, Math.ceil(total / perPage)); + if (page > pages) page = pages; + const prev = page > 1 ? page - 1 : null; + const next = page < pages ? page + 1 : null; + let html = '
'; + html += + ''; + html += + 'Page ' + + page + + ' / ' + + pages + + ' · ' + + total + + ' reports'; + html += + ''; + html += '
'; + pagination.innerHTML = html; + pagination.querySelectorAll('.page-btn').forEach((btn) => { + btn.addEventListener('click', () => { + const p = Number(btn.getAttribute('data-page')); + if (!p) return; + page = p; + load(); + }); + }); + } + + function renderRows(items) { + const cols = columns(); + if (!items.length) { + tbody.innerHTML = + 'No reports yet.'; + return; + } + let html = ''; + items.forEach((row, i) => { + const rowKey = grouped ? 'g' + i + '-' + page : 'r' + row.id; + const briefId = 'brief-' + rowKey; + const openUrl = !grouped && row.id + ? basePath() + '/?view=report&id=' + row.id + : ''; + const navClass = openUrl ? ' report-row--nav' : ''; + const hrefAttr = openUrl ? ' data-href="' + escapeHtml(openUrl) + '"' : ''; + const tabAttr = openUrl ? ' tabindex="0" role="link"' : ''; + + html += ''; + html += + ''; + cols.forEach((c) => { + if (c.key === 'cnt') { + html += '' + escapeHtml(row.cnt) + ''; + } else { + html += '' + formatCell(c, row) + ''; + } + }); + html += '
' + buildTags(row, grouped) + '
'; + html += ''; + + const briefLines = row.brief || []; + let panelOpen = '
'; + if (openUrl) { + panelOpen = + '
'; + } + html += + '' + + panelOpen + + '
'; + briefLines.forEach((line) => { + html += '

' + escapeHtml(line) + '

'; + }); + if (openUrl) { + html += '

Click to open full report

'; + } + html += '
'; + }); + tbody.innerHTML = html; + bindTreeInteractions(tbody); + } + + function load() { + if (loading) return; + loading = true; + statusEl.textContent = 'Loading…'; + const qs = + 'page=' + + page + + '&per_page=' + + perPage + + '&sort=' + + encodeURIComponent(apiSortKey(sort)) + + '&dir=' + + encodeURIComponent(dir) + + '&since_ms=' + + encodeURIComponent(String(lastVisitMs())) + + (grouped ? '&group=1' : ''); + const xhr = new XMLHttpRequest(); + xhr.open('GET', basePath() + '/api/reports.php?' + qs, true); + xhr.onload = function () { + loading = false; + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + statusEl.textContent = 'Invalid response'; + return; + } + if (!data.ok) { + statusEl.textContent = data.error || 'Load failed'; + return; + } + renderHead(); + renderRows(data.items || []); + renderPagination(Number(data.total) || 0); + statusEl.textContent = + 'Showing ' + + (data.items?.length || 0) + + ' of ' + + (data.total || 0) + + (grouped ? ' (grouped)' : ''); + }; + xhr.onerror = function () { + loading = false; + statusEl.textContent = 'Network error'; + }; + xhr.send(); + } + + renderHead(); + load(); + } + + document.addEventListener('DOMContentLoaded', () => { + initTheme(); + initNav(); + initReportDetail(); + initReportsApp(); }); - handle.setAttribute('aria-expanded', nav.classList.contains('open') ? 'true' : 'false'); -}); +})(); diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php index 81d2cb5..d7ebe5f 100644 --- a/examples/crash_reporter/backend/public/index.php +++ b/examples/crash_reporter/backend/public/index.php @@ -25,6 +25,21 @@ if ($route === '/api/upload.php' || str_ends_with($route, '/api/upload.php')) { exit; } +if ($route === '/api/diag.php' || str_ends_with($route, '/api/diag.php')) { + require __DIR__ . '/api/diag.php'; + exit; +} + +if ($route === '/api/reports.php' || str_ends_with($route, '/api/reports.php')) { + require __DIR__ . '/api/reports.php'; + exit; +} + +if ($route === '/api/report_viewed.php' || str_ends_with($route, '/api/report_viewed.php')) { + require __DIR__ . '/api/report_viewed.php'; + exit; +} + if ($route === '/logout') { Auth::logout(); header('Location: ' . $base . '/login'); @@ -60,11 +75,11 @@ if ($view === 'report' && isset($_GET['id'])) { echo 'Not found'; exit; } + ReportRepository::markViewed((int) $report['id'], (int) Auth::user()['id']); $pageTitle = 'Report'; require __DIR__ . '/../views/layout.php'; exit; } $pageTitle = $view === 'home' ? 'Home' : 'Crash reports'; -$reports = $view === 'reports' ? ReportRepository::listReports($grouped) : []; require __DIR__ . '/../views/layout.php'; diff --git a/examples/crash_reporter/backend/sql/schema.sqlite.sql b/examples/crash_reporter/backend/sql/schema.sqlite.sql index 15cd97a..8d6a6ed 100644 --- a/examples/crash_reporter/backend/sql/schema.sqlite.sql +++ b/examples/crash_reporter/backend/sql/schema.sqlite.sql @@ -17,7 +17,16 @@ CREATE TABLE IF NOT EXISTS reports ( received_at_ms INTEGER NOT NULL, device_model TEXT, app_version TEXT, - payload_json TEXT NOT NULL + payload_json TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]' +); + +CREATE TABLE IF NOT EXISTS report_views ( + user_id INTEGER NOT NULL, + report_id INTEGER NOT NULL, + viewed_at_ms INTEGER NOT NULL, + PRIMARY KEY (user_id, report_id), + FOREIGN KEY (report_id) REFERENCES reports(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_reports_generated ON reports(generated_at_ms DESC); diff --git a/examples/crash_reporter/backend/src/Database.php b/examples/crash_reporter/backend/src/Database.php index 9fffc89..e506dfc 100644 --- a/examples/crash_reporter/backend/src/Database.php +++ b/examples/crash_reporter/backend/src/Database.php @@ -67,13 +67,56 @@ final class Database { $stmt->execute([$table]); return $stmt->fetchColumn() === false; }; - if (!$need('users') && !$need('reports')) { + if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) { + self::ensureReportColumns($pdo); + self::ensureReportViewsTable($pdo); return; } $schemaFile = __DIR__ . '/../sql/schema.sqlite.sql'; if (!is_readable($schemaFile)) { throw new RuntimeException('schema file missing: ' . $schemaFile); } + if (!$need('reports') && !self::reportsSchemaOk($pdo)) { + $pdo->exec('DROP TABLE IF EXISTS reports'); + } $pdo->exec((string) file_get_contents($schemaFile)); + self::ensureReportColumns($pdo); + self::ensureReportViewsTable($pdo); + } + + private static function ensureReportColumns(PDO $pdo): void { + $names = array_column($pdo->query('PRAGMA table_info(reports)')->fetchAll(PDO::FETCH_ASSOC), 'name'); + if (!in_array('tags_json', $names, true)) { + $pdo->exec("ALTER TABLE reports ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'"); + } + } + + private static function ensureReportViewsTable(PDO $pdo): void { + $pdo->exec( + 'CREATE TABLE IF NOT EXISTS report_views ( + user_id INTEGER NOT NULL, + report_id INTEGER NOT NULL, + viewed_at_ms INTEGER NOT NULL, + PRIMARY KEY (user_id, report_id) + )' + ); + } + + private static function reportsSchemaOk(PDO $pdo): bool { + try { + $cols = $pdo->query('PRAGMA table_info(reports)')->fetchAll(PDO::FETCH_ASSOC); + } catch (PDOException) { + return false; + } + if ($cols === []) { + return false; + } + $names = array_column($cols, 'name'); + foreach (['report_id', 'fingerprint', 'crash_type', 'generated_at_ms', 'received_at_ms', 'payload_json'] as $col) { + if (!in_array($col, $names, true)) { + return false; + } + } + return true; } } diff --git a/examples/crash_reporter/backend/src/ReportRepository.php b/examples/crash_reporter/backend/src/ReportRepository.php index c3dd49f..a1d6d84 100644 --- a/examples/crash_reporter/backend/src/ReportRepository.php +++ b/examples/crash_reporter/backend/src/ReportRepository.php @@ -1,25 +1,37 @@ - * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 - * Contributors: - * - Anton Afanasyeu (2 commits, 61 lines) - * - Cursor Agent (project assistant) - * Digest: SHA256 5da7d17c78a5f2dbddfbabcbf0d2b4f391edb314902c758ce1c0df59c9140bd5 - */ declare(strict_types=1); final class ReportRepository { + private const LIST_SORT = [ + 'generated_at_ms', + 'received_at_ms', + 'crash_type', + 'device_model', + 'app_version', + 'fingerprint', + 'fingerprint_cnt', + ]; + private const GROUP_SORT = [ + 'cnt', + 'fingerprint', + 'crash_type', + 'last_generated_ms', + 'last_received_ms', + ]; + public static function insert(array $payload): void { $pdo = Database::pdo(); $device = $payload['device'] ?? []; $app = $payload['app'] ?? []; + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($json === false) { + throw new RuntimeException('payload json_encode failed: ' . json_last_error_msg()); + } + $tags = json_encode($payload['tags'] ?? [], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $stmt = $pdo->prepare( - 'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, device_model, app_version, payload_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, + device_model, app_version, payload_json, tags_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ); $stmt->execute([ $payload['report_id'] ?? uniqid('rpt_', true), @@ -29,14 +41,36 @@ final class ReportRepository { (int) round(microtime(true) * 1000), $device['model'] ?? null, $app['version_name'] ?? null, - json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + $json, + $tags ?: '[]', ]); } public static function listReports(bool $grouped): array { + return self::listPage($grouped, 1, 500, 'generated_at_ms', 'desc')['items']; + } + + public static function listPage( + bool $grouped, + int $page, + int $perPage, + string $sort, + string $dir, + int $sinceMs = 0 + ): array { $pdo = Database::pdo(); + $page = max(1, $page); + $perPage = max(1, min(200, $perPage)); + $dir = strtolower($dir) === 'asc' ? 'ASC' : 'DESC'; + $offset = ($page - 1) * $perPage; + $userId = (int) (Auth::user()['id'] ?? 0); + if ($grouped) { - $sql = 'SELECT fingerprint, crash_type, + $sortCol = in_array($sort, self::GROUP_SORT, true) ? $sort : 'cnt'; + $total = (int) $pdo->query( + 'SELECT COUNT(*) FROM (SELECT 1 FROM reports GROUP BY fingerprint, crash_type)' + )->fetchColumn(); + $sql = "SELECT fingerprint, crash_type, MIN(device_model) AS device_model, MAX(app_version) AS app_version, COUNT(*) AS cnt, @@ -44,25 +78,93 @@ final class ReportRepository { MAX(received_at_ms) AS last_received_ms FROM reports GROUP BY fingerprint, crash_type - ORDER BY cnt DESC, last_generated_ms DESC'; - return $pdo->query($sql)->fetchAll(); + ORDER BY $sortCol $dir + LIMIT $perPage OFFSET $offset"; + $items = $pdo->query($sql)->fetchAll(); + foreach ($items as &$row) { + $row['fingerprint_cnt'] = (int) ($row['cnt'] ?? 0); + $row['fingerprint_recent_cnt'] = $sinceMs > 0 && (int) ($row['last_received_ms'] ?? 0) > $sinceMs + ? (int) $row['cnt'] : 0; + $row['rating'] = report_rating_score((int) $row['cnt'], (int) $row['fingerprint_recent_cnt'], (int) $row['cnt']); + $row['os_name'] = ''; + $row['os_version'] = ''; + $row['brief'] = report_brief_lines($row, true); + } + unset($row); + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => $sortCol, + 'dir' => strtolower($dir), + 'grouped' => true, + ]; } - $sql = 'SELECT id, report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, device_model, app_version - FROM reports - ORDER BY generated_at_ms DESC, received_at_ms DESC - LIMIT 500'; - return $pdo->query($sql)->fetchAll(); + + $sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'generated_at_ms'; + if ($sortCol === 'fingerprint_cnt') { + $sortCol = 'fingerprint_cnt'; + } + $total = (int) $pdo->query('SELECT COUNT(*) FROM reports')->fetchColumn(); + $sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms, + r.device_model, r.app_version, r.payload_json, r.tags_json, + CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed, + (SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint) AS fingerprint_cnt, + (SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint + AND r2.received_at_ms > ?) AS fingerprint_recent_cnt + FROM reports r + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + ORDER BY " . ($sortCol === 'fingerprint_cnt' ? 'fingerprint_cnt' : "r.$sortCol") . " $dir + LIMIT $perPage OFFSET $offset"; + $stmt = $pdo->prepare($sql); + $stmt->execute([$sinceMs, $userId]); + $items = $stmt->fetchAll(); + foreach ($items as &$row) { + $row['viewed'] = (bool) ($row['viewed'] ?? false); + $row['tags'] = parse_report_tags($row['tags_json'] ?? '[]'); + report_enrich_list_row($row, $sinceMs); + $row['brief'] = report_brief_lines($row, false); + unset($row['payload_json'], $row['tags_json']); + } + unset($row); + + return [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'sort' => $sortCol, + 'dir' => strtolower($dir), + 'grouped' => false, + ]; + } + + public static function markViewed(int $reportId, int $userId): void { + $pdo = Database::pdo(); + $stmt = $pdo->prepare( + 'INSERT OR REPLACE INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)' + ); + $stmt->execute([$userId, $reportId, (int) round(microtime(true) * 1000)]); } public static function getById(int $id): ?array { $pdo = Database::pdo(); - $stmt = $pdo->prepare('SELECT * FROM reports WHERE id = ?'); - $stmt->execute([$id]); + $userId = (int) (Auth::user()['id'] ?? 0); + $stmt = $pdo->prepare( + 'SELECT r.*, CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed + FROM reports r + LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? + WHERE r.id = ?' + ); + $stmt->execute([$userId, $id]); $row = $stmt->fetch(); if (!$row) { return null; } $row['payload'] = json_decode($row['payload_json'], true); + $row['tags'] = parse_report_tags($row['tags_json'] ?? '[]'); + $row['viewed'] = (bool) ($row['viewed'] ?? false); return $row; } } diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php index 7550135..3e61475 100644 --- a/examples/crash_reporter/backend/src/bootstrap.php +++ b/examples/crash_reporter/backend/src/bootstrap.php @@ -59,3 +59,154 @@ function json_out(array $data, int $code = 200): void { echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); exit; } + +/** Short lines for expandable list preview (not full report detail). */ +function report_brief_lines(array $row, bool $grouped): array { + if ($grouped) { + return array_filter([ + sprintf('%d reports · %s', (int) ($row['cnt'] ?? 0), $row['crash_type'] ?? ''), + 'Fingerprint: ' . ($row['fingerprint'] ?? ''), + trim(sprintf( + '%s · app %s', + $row['device_model'] ?? '', + $row['app_version'] ?? '' + ), ' ·'), + ]); + } + $p = json_decode($row['payload_json'] ?? '', true); + if (!is_array($p)) { + $p = []; + } + $lines = [ + sprintf( + '%s · %s · %s', + $row['crash_type'] ?? ($p['crash_type'] ?? ''), + $p['scenario'] ?? '—', + substr($row['fingerprint'] ?? ($p['fingerprint'] ?? ''), 0, 16) + ), + trim(sprintf( + '%s %s · %s v%s', + $p['device']['manufacturer'] ?? '', + $row['device_model'] ?? ($p['device']['model'] ?? ''), + $p['app']['package'] ?? 'com.foxx.androidcast', + $row['app_version'] ?? ($p['app']['version_name'] ?? '') + )), + ]; + if (!empty($p['java'])) { + $j = $p['java']; + $exc = trim(($j['exception'] ?? '') . (!empty($j['message']) ? ': ' . $j['message'] : '')); + $lines[] = $exc !== '' ? $exc : 'Java crash'; + $frames = $j['stack_frames'] ?? []; + if (!empty($frames[0])) { + $lines[] = (string) $frames[0]; + } + } elseif (!empty($p['native'])) { + $lines[] = 'Native: ' . ($p['native']['signal'] ?? 'crash'); + $bt = $p['native']['backtrace'] ?? []; + if (!empty($bt[0])) { + $lines[] = (string) $bt[0]; + } + } + if (!empty($p['runtime_context']['last_activity'])) { + $lines[] = 'Activity: ' . $p['runtime_context']['last_activity']; + } + return array_values(array_filter($lines, static fn ($l) => trim($l) !== '' && trim($l) !== '·')); +} + +/** @return array{label:string,bg:string} */ +function crash_kind_tag(string $crashType): array { + $t = strtolower($crashType); + return match ($t) { + 'native' => ['label' => 'NDK', 'bg' => '#b45309'], + 'java' => ['label' => 'Java', 'bg' => '#6d28d9'], + 'android' => ['label' => 'Android', 'bg' => '#047857'], + default => ['label' => ucfirst($t ?: '?'), 'bg' => '#5c6b82'], + }; +} + +/** Enrich list row with OS fields and rating inputs (keeps payload_json until unset). */ +function report_enrich_list_row(array &$row, int $sinceMs = 0): void { + $p = json_decode($row['payload_json'] ?? '', true); + if (!is_array($p)) { + $p = []; + } + $d = $p['device'] ?? []; + $row['os_name'] = trim((string) ($d['manufacturer'] ?? $d['brand'] ?? '')); + $rel = (string) ($d['release'] ?? ''); + $sdk = (int) ($d['sdk_int'] ?? 0); + if ($rel !== '' && $sdk > 0) { + $row['os_version'] = $rel . ' (API ' . $sdk . ')'; + } elseif ($rel !== '') { + $row['os_version'] = $rel; + } elseif ($sdk > 0) { + $row['os_version'] = 'API ' . $sdk; + } else { + $row['os_version'] = ''; + } + $row['scenario'] = (string) ($p['scenario'] ?? ''); + $total = (int) ($row['fingerprint_cnt'] ?? 1); + $recent = (int) ($row['fingerprint_recent_cnt'] ?? 0); + if ($sinceMs > 0 && $recent === 0 && !empty($row['received_at_ms'])) { + $recent = (int) $row['received_at_ms'] > $sinceMs ? 1 : 0; + } + $row['rating'] = report_rating_score($total, $recent, (int) ($row['cnt'] ?? 0)); +} + +function report_rating_score(int $fingerprintTotal, int $fingerprintRecent, int $groupCnt = 0): int { + $t = $groupCnt > 0 ? $groupCnt : max(1, $fingerprintTotal); + $r = $fingerprintRecent; + $score = 0; + if ($t >= 40) { + $score = 5; + } elseif ($t >= 20) { + $score = 4; + } elseif ($t >= 10) { + $score = 3; + } elseif ($t >= 4) { + $score = 2; + } elseif ($t >= 2) { + $score = 1; + } + if ($r >= 8) { + $score = min(5, $score + 2); + } elseif ($r >= 3) { + $score = min(5, $score + 1); + } + return max(0, min(5, $score)); +} + +/** @return list */ +function parse_report_tags(?string $json): array { + if ($json === null || $json === '') { + return []; + } + $raw = json_decode($json, true); + if (!is_array($raw)) { + return []; + } + $out = []; + foreach ($raw as $tag) { + if (!is_array($tag)) { + continue; + } + $id = (string) ($tag['id'] ?? $tag['label'] ?? ''); + if ($id === '') { + continue; + } + $out[] = [ + 'id' => $id, + 'label' => (string) ($tag['label'] ?? $id), + 'bg' => (string) ($tag['bg'] ?? '#5c6b82'), + ]; + } + return $out; +} + +function log_crash_event(string $channel, string $message): void { + $dir = dirname(__DIR__) . '/storage'; + if (!is_dir($dir)) { + @mkdir($dir, 0775, true); + } + $line = date('c') . " [$channel] $message\n"; + @file_put_contents($dir . '/crash.log', $line, FILE_APPEND | LOCK_EX); +} diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index 2e7443b..e6d192c 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -12,16 +12,23 @@ */ ?> - + <?= h($pageTitle ?? 'Console') ?> — <?= h(cfg('app_name')) ?> + - - +>