diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md index 1237513..466cbdc 100644 --- a/examples/crash_reporter/backend/README.md +++ b/examples/crash_reporter/backend/README.md @@ -13,10 +13,15 @@ Anonymous crash JSON receiver and web console for Minimal `php81-fpm` does **not** include sessions or PDO. Install modules explicitly: ```sh +# SQLite only (dev / small installs) apk add php81-fpm php81-session php81-pdo php81-pdo_sqlite php81-sqlite3 + +# MariaDB (recommended on BE) +apk add mariadb mariadb-client php81-pdo_mysql + rc-service php-fpm81 restart # package names may be php82-* on newer Alpine; adjust to your php -v -php -m | grep -E 'session|pdo_sqlite|PDO' +php -m | grep -E 'session|pdo_sqlite|pdo_mysql|PDO' ``` If `session_name()` is undefined, `php81-session` is missing or FPM was not restarted after install. @@ -52,9 +57,16 @@ curl -X POST http://127.0.0.1:8080/api/upload.php \ 2. Point nginx `SCRIPT_FILENAME` at `.../backend/public/` (see `nginx.vm.conf`). Do not mix `proxy_*`, `try_files`, and broken alias/try_files combos in one block. 3. Set `base_path` in `config.php` to match the URL prefix exactly. 3. Choose DB: - - **SQLite (default):** run `sql/schema.sqlite.sql`, set `DB_DRIVER=sqlite` in config - - **MariaDB:** create DB, run `sql/schema.mariadb.sql`, set `DB_DRIVER=mysql` -4. `chown -R www-data:www-data data storage` + - **SQLite (default):** `sqlite3 data/crashes.sqlite < sql/schema.sqlite.sql`, `db.driver` => `sqlite` + - **MariaDB (fresh, recommended on BE):** + ```sh + rc-service mariadb start + mysql -u root -p < sql/schema.mariadb.sql + # optional: CRASH_DB_PASS='…' ./scripts/init-mariadb.sh # creates app user + ``` + In `config/config.php`: `db.driver` => `mysql`, fill `db.mysql` (host `127.0.0.1`). + PHP auto-creates missing tables on first request if schema was not applied manually. +4. `chown -R www-data:www-data data storage` (SQLite only needs writable `data/`; MariaDB uses mysqld) 5. TLS terminate at nginx; restrict `/api/upload.php` by firewall if needed ## Android app configuration diff --git a/examples/crash_reporter/backend/config/config.example.php b/examples/crash_reporter/backend/config/config.example.php index 6dd193d..546d8ce 100644 --- a/examples/crash_reporter/backend/config/config.example.php +++ b/examples/crash_reporter/backend/config/config.example.php @@ -15,7 +15,8 @@ return [ // Production: '/app/androidcast_project/crashes' — Local php -S: use '' (empty string) 'base_path' => '/app/androidcast_project/crashes', 'db' => [ - 'driver' => 'sqlite', // sqlite | mysql + // sqlite = default file DB; mysql = MariaDB/MySQL (recommended on BE for concurrency) + 'driver' => 'sqlite', // sqlite | mysql | mariadb (alias) 'sqlite_path' => __DIR__ . '/../data/crashes.sqlite', 'mysql' => [ 'host' => '127.0.0.1', diff --git a/examples/crash_reporter/backend/nginx.apps.conf.fragment b/examples/crash_reporter/backend/nginx.apps.conf.fragment index 620152c..32aa2a6 100644 --- a/examples/crash_reporter/backend/nginx.apps.conf.fragment +++ b/examples/crash_reporter/backend/nginx.apps.conf.fragment @@ -50,6 +50,14 @@ fastcgi_param REQUEST_URI $request_uri; } + location = /app/androidcast_project/crashes/api/report_tags.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_tags.php; + fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/report_tags.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 index d54bb0c..ccdbbc1 100644 --- a/examples/crash_reporter/backend/public/api/diag.php +++ b/examples/crash_reporter/backend/public/api/diag.php @@ -6,37 +6,53 @@ if (!cfg('debug')) { json_out(['ok' => false, 'error' => 'diag disabled'], 403); } +$driver = Database::driver(); $path = cfg('db.sqlite_path'); $out = [ 'ok' => true, + 'db_driver' => $driver, '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, ]; +if ($driver === 'sqlite') { + $out['sqlite_path'] = $path; + $out['sqlite_realpath'] = is_string($path) ? realpath($path) : null; + $out['sqlite_exists'] = is_string($path) && is_file($path); + $out['sqlite_writable'] = is_string($path) && is_writable($path); + $out['data_dir_writable'] = is_string($path) && is_writable(dirname($path)); +} else { + $m = cfg('db.mysql', []); + $out['mysql'] = [ + 'host' => $m['host'] ?? '127.0.0.1', + 'port' => (int) ($m['port'] ?? 3306), + 'database' => $m['database'] ?? 'androidcast_crashes', + 'username' => $m['username'] ?? '', + ]; +} + try { $pdo = Database::pdo(); - $tables = $pdo->query( - "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name" - )->fetchAll(PDO::FETCH_COLUMN); + $tables = Database::listTables($pdo); $out['tables'] = $tables; if (in_array('reports', $tables, true)) { - $out['reports_columns'] = $pdo->query('PRAGMA table_info(reports)')->fetchAll(PDO::FETCH_ASSOC); + $cols = Database::columnNames($pdo, 'reports'); + $out['reports_columns'] = array_map( + static fn (string $name) => ['name' => $name], + $cols + ); } $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 (?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, + device_model, app_version, payload_json, tags_json) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ); $rid = 'diag-' . time(); - $stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}']); + $stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}', '[]']); $pdo->rollBack(); $out['test_insert'] = 'ok (rolled back)'; } catch (Throwable $e) { diff --git a/examples/crash_reporter/backend/public/api/report_tags.php b/examples/crash_reporter/backend/public/api/report_tags.php new file mode 100644 index 0000000..9ab47c9 --- /dev/null +++ b/examples/crash_reporter/backend/public/api/report_tags.php @@ -0,0 +1,62 @@ + false, 'error' => 'unauthorized'], 401); +} + +$id = (int) ($_GET['id'] ?? 0); +if ($_SERVER['REQUEST_METHOD'] === 'GET') { + if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); + } + $report = ReportRepository::getById($id); + if (!$report) { + json_out(['ok' => false, 'error' => 'not found'], 404); + } + json_out([ + 'ok' => true, + 'id' => $id, + 'tags' => $report['tags'] ?? [], + 'presets' => ReportRepository::listTagPresets(), + 'can_edit' => Auth::canEditTags(), + ]); +} + +if (!Auth::canEditTags()) { + json_out(['ok' => false, 'error' => 'forbidden'], 403); +} + +$raw = file_get_contents('php://input'); +$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; +if (!is_array($data)) { + json_out(['ok' => false, 'error' => 'invalid json'], 400); +} +$id = (int) ($data['id'] ?? $id); +if ($id <= 0) { + json_out(['ok' => false, 'error' => 'invalid id'], 400); +} + +$tagsIn = $data['tags'] ?? []; +if (!is_array($tagsIn)) { + json_out(['ok' => false, 'error' => 'tags must be array'], 400); +} + +$report = ReportRepository::getById($id); +if (!$report) { + json_out(['ok' => false, 'error' => 'not found'], 404); +} + +try { + ReportRepository::setCustomTags($id, $tagsIn); + $updated = ReportRepository::getById($id); + json_out([ + 'ok' => true, + 'id' => $id, + 'tags' => $updated['tags'] ?? [], + ]); +} catch (Throwable $e) { + error_log('report_tags: ' . $e->getMessage()); + json_out(['ok' => false, 'error' => 'save failed'], 500); +} diff --git a/examples/crash_reporter/backend/public/api/reports.php b/examples/crash_reporter/backend/public/api/reports.php index ff64e0e..de0efc2 100644 --- a/examples/crash_reporter/backend/public/api/reports.php +++ b/examples/crash_reporter/backend/public/api/reports.php @@ -13,7 +13,7 @@ $allowedPerPage = [25, 50, 75, 100, 200]; if (!in_array($perPage, $allowedPerPage, true)) { $perPage = 50; } -$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'generated_at_ms')); +$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'priority')); $dir = (string) ($_GET['dir'] ?? 'desc'); $sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0)); diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index ac09ec6..06d1639 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -345,6 +345,9 @@ a:hover { text-decoration: underline; } border-radius: 8px; border: 1px solid var(--border); margin-left: 8px; + cursor: pointer; + background: var(--surface); + color: var(--text); } .btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } .data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; } @@ -443,6 +446,55 @@ a:hover { text-decoration: underline; } .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-table--cols { + table-layout: fixed; + width: 100%; +} +.reports-table--cols th, +.reports-table--cols td { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; + vertical-align: middle; +} +.reports-table--cols .col-tags, +.reports-table--cols .report-tags { + white-space: normal; +} +.th-inner { + display: flex; + align-items: center; + gap: 4px; + padding-right: 8px; + min-height: 1.5rem; +} +.th-drag { + cursor: grab; + color: var(--muted); + font-size: 11px; + letter-spacing: -2px; + user-select: none; + flex-shrink: 0; +} +.th-drag:active { cursor: grabbing; } +.th-label { flex: 1; min-width: 0; } +.th-draggable.th-dragging { opacity: .55; } +.col-resizer { + position: absolute; + top: 0; + right: 0; + width: 7px; + height: 100%; + cursor: col-resize; + user-select: none; + z-index: 2; +} +.col-resizer:hover, +.col-resizer:active { + background: rgba(61, 139, 253, .35); +} +.report-tree-head .col-resizer { display: none; } .reports-pagination { margin-top: 16px; } .pagination-inner { display: flex; @@ -578,3 +630,189 @@ a:hover { text-decoration: underline; } [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; } + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.btn-primary:hover { filter: brightness(1.08); } + +.tag-edit-btn { + flex-shrink: 0; + margin-left: 2px; + padding: 0 5px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--muted); + font-size: 13px; + line-height: 1.2; + cursor: pointer; + vertical-align: middle; +} +.tag-edit-btn:hover, +.tag-edit-btn:focus-visible { + background: rgba(61, 139, 253, .2); + color: var(--accent); + outline: none; +} + +.tag-editor { + margin: 20px 0 24px; + padding: 16px 18px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; +} +.tag-editor-head h2 { margin: 0 0 4px; font-size: 1.1rem; } +.tag-editor-hint { margin: 0 0 12px; font-size: 13px; } +.tag-editor-preview { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 1.5rem; + margin-bottom: 12px; +} +.tag-editor-list { + list-style: none; + margin: 0 0 12px; + padding: 0; +} +.tag-editor-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.tag-editor-item:last-child { border-bottom: none; } +.tag-color-edit { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); +} +.tag-color-edit input[type="color"] { + width: 2rem; + height: 1.6rem; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; +} +.tag-editor-add { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 14px; + margin-bottom: 12px; +} +.tag-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--muted); +} +.tag-field input[type="text"] { + padding: 8px 10px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + min-width: 10rem; +} +.tag-editor-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +.tag-editor-status--err { color: var(--danger); } +.tag-preset-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-bottom: 12px; + font-size: 13px; +} +.tag-preset-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.tag-preset-chip { + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 12px; + font-weight: 600; + color: #fff; + background: var(--tag-bg, #5c6b82); + cursor: pointer; +} +.tag-preset-chip:hover { filter: brightness(1.12); } + +.report-tag { + position: relative; + padding-right: 8px; +} +.report-tag .tag-remove { + margin-left: 4px; + padding: 0 3px; + border: none; + border-radius: 4px; + background: rgba(0, 0, 0, .25); + color: #fff; + font-size: 14px; + line-height: 1; + cursor: pointer; + vertical-align: middle; +} +.report-tag .tag-remove:hover { background: rgba(0, 0, 0, .45); } + +.tag-modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} +.tag-modal[hidden] { display: none; } +.tag-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, .55); +} +.tag-modal-panel { + position: relative; + width: min(420px, 100%); + max-height: 90vh; + overflow-y: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 16px 48px rgba(0, 0, 0, .4); +} +.tag-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} +.tag-modal-header h2 { margin: 0; font-size: 1.05rem; } +.tag-modal-close { + border: none; + background: transparent; + color: var(--muted); + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; +} +.tag-modal-close:hover { color: var(--text); background: var(--row-hover); } +.tag-editor--modal { margin: 0; border: none; border-radius: 0; } diff --git a/examples/crash_reporter/backend/public/assets/js/app.js b/examples/crash_reporter/backend/public/assets/js/app.js index 6f13954..c99cac8 100644 --- a/examples/crash_reporter/backend/public/assets/js/app.js +++ b/examples/crash_reporter/backend/public/assets/js/app.js @@ -6,6 +6,19 @@ viewed: 'crash_console_viewed_ids', sort: 'crash_console_sort', skipStamp: 'crash_skip_visit_stamp', + colWidths: 'crash_console_col_widths', + colOrder: 'crash_console_col_order', + }; + + const DEFAULT_COL_WIDTHS = { + expand: 40, + device_model: 128, + app_version: 80, + os_name: 88, + os_version: 120, + received_at_ms: 152, + rating: 92, + tags: 128, }; const TAG_NEW = { label: 'new', bg: '#3d8bfd' }; @@ -21,13 +34,14 @@ { 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 }, + { key: 'rating', label: 'Rating', fmt: 'stars' }, ]; const SORT_API_MAP = { + priority: 'priority', + rating: 'fingerprint_cnt', os_name: 'device_model', os_version: 'app_version', - rating: 'fingerprint_cnt', }; const GROUP_COLUMNS = [ @@ -239,6 +253,150 @@ return SORT_API_MAP[key] || key; } + function loadSavedSort(isGrouped) { + try { + const s = JSON.parse(lsGet(STORAGE.sort, '')); + if (!s || !s.sort) { + return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' }; + } + if (!!s.grouped !== !!isGrouped) { + return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' }; + } + return { sort: s.sort, dir: s.dir || 'desc' }; + } catch { + return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' }; + } + } + + function getColWidths() { + try { + const w = JSON.parse(lsGet(STORAGE.colWidths, '{}')); + return { ...DEFAULT_COL_WIDTHS, ...(w && typeof w === 'object' ? w : {}) }; + } catch { + return { ...DEFAULT_COL_WIDTHS }; + } + } + + function saveColWidths(widths) { + lsSet(STORAGE.colWidths, JSON.stringify(widths)); + } + + function getOrderedColumnKeys() { + const defaults = LIST_COLUMNS.map((c) => c.key); + try { + const saved = JSON.parse(lsGet(STORAGE.colOrder, '[]')); + if (Array.isArray(saved) && saved.length) { + const out = saved.filter((k) => defaults.includes(k)); + defaults.forEach((k) => { + if (!out.includes(k)) out.push(k); + }); + return out; + } + } catch { /* ignore */ } + return defaults; + } + + function saveColumnOrder(keys) { + lsSet(STORAGE.colOrder, JSON.stringify(keys)); + } + + function getDisplayColumns(isGrouped) { + if (isGrouped) return GROUP_COLUMNS; + return getOrderedColumnKeys() + .map((k) => LIST_COLUMNS.find((c) => c.key === k)) + .filter(Boolean); + } + + function applyColgroup(isGrouped) { + const cg = document.getElementById('reports-colgroup'); + if (!cg || isGrouped) { + if (cg) cg.innerHTML = ''; + return; + } + const widths = getColWidths(); + const order = getOrderedColumnKeys(); + let html = + ''; + order.forEach((k) => { + html += + ''; + }); + html += + ''; + cg.innerHTML = html; + } + + function setupColumnResize(table, isGrouped, onLayoutChange) { + if (isGrouped) return; + const widths = getColWidths(); + table.querySelectorAll('.col-resizer').forEach((handle) => { + handle.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + const colKey = handle.getAttribute('data-resize-col'); + const th = handle.closest('th'); + if (!colKey || !th) return; + const startX = e.clientX; + const startW = th.getBoundingClientRect().width; + + const onMove = (ev) => { + const w = Math.max(48, Math.round(startW + (ev.clientX - startX))); + widths[colKey] = w; + th.style.width = w + 'px'; + applyColgroup(false); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + saveColWidths(widths); + if (onLayoutChange) onLayoutChange(); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }); + }); + } + + function setupColumnDrag(thead, isGrouped, onReorder) { + if (isGrouped) return; + let dragKey = null; + thead.querySelectorAll('th.th-draggable').forEach((th) => { + th.addEventListener('dragstart', (e) => { + dragKey = th.getAttribute('data-col-key'); + th.classList.add('th-dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', dragKey || ''); + } + }); + th.addEventListener('dragend', () => { + th.classList.remove('th-dragging'); + dragKey = null; + }); + th.addEventListener('dragover', (e) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + }); + th.addEventListener('drop', (e) => { + e.preventDefault(); + const targetKey = th.getAttribute('data-col-key'); + if (!dragKey || !targetKey || dragKey === targetKey) return; + const order = getOrderedColumnKeys(); + const from = order.indexOf(dragKey); + const to = order.indexOf(targetKey); + if (from < 0 || to < 0) return; + order.splice(from, 1); + order.splice(to, 0, dragKey); + saveColumnOrder(order); + if (onReorder) onReorder(); + }); + }); + } + function navigateTo(href) { try { sessionStorage.setItem(STORAGE.skipStamp, '1'); @@ -290,20 +448,37 @@ const statusEl = document.getElementById('reports-status'); const perPageSel = document.getElementById('per-page-select'); + const table = document.getElementById('reports-tree'); 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'; + const saved = loadSavedSort(grouped); + let sort = saved.sort; + let dir = saved.dir; let loading = false; + let lastItems = []; - 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 */ } + function saveSortState() { + lsSet( + STORAGE.sort, + JSON.stringify({ sort, dir, grouped: !!grouped }) + ); + } + + function sortActive(colKey) { + if (sort === 'priority' && colKey === 'rating') return true; + if (sort === colKey) return true; + return apiSortKey(sort) === apiSortKey(colKey); + } + + function refreshTableChrome() { + applyColgroup(grouped); + renderHead(); + setupColumnResize(table, grouped); + setupColumnDrag(thead, grouped, refreshTableChrome); + if (lastItems.length) { + renderRows(lastItems); + } } if (perPageSel) { @@ -323,7 +498,9 @@ app.querySelectorAll('.reports-mode-btn').forEach((b) => { b.classList.toggle('active', b === btn); }); - sort = grouped ? 'cnt' : 'generated_at_ms'; + const next = loadSavedSort(grouped); + sort = next.sort; + dir = next.dir; page = 1; load(); }); @@ -341,7 +518,7 @@ window.addEventListener('pagehide', maybeStampVisit); function columns() { - return grouped ? GROUP_COLUMNS : LIST_COLUMNS; + return getDisplayColumns(grouped); } function colspan() { @@ -350,30 +527,48 @@ function renderHead() { const cols = columns(); - let html = ''; + const widths = getColWidths(); + let html = + ''; cols.forEach((c) => { - const active = sort === c.key || apiSortKey(sort) === apiSortKey(c.key); - if (c.noSort) { - html += '' + escapeHtml(c.label) + ''; - return; - } + const active = sortActive(c.key); const arrow = active ? (dir === 'asc' ? '▲' : '▼') : ''; + const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : ''; + let thClass = 'sortable' + (active ? ' sortable--active' : ''); + let thDrag = ''; + if (!grouped) { + thClass += ' th-draggable'; + thDrag = + ' draggable="true" data-col-key="' + escapeHtml(c.key); + } html += - '' + + '"' + + w + + ' scope="col">' + + (grouped ? '' : '') + + '' + escapeHtml(c.label) + - ' '; + '' + + (grouped ? '' : '') + + ''; }); - html += 'Tags'; + html += + 'Tags'; thead.innerHTML = html; thead.querySelectorAll('.sortable').forEach((th) => { - th.addEventListener('click', () => { + th.addEventListener('click', (e) => { + if (e.target.closest('.col-resizer') || e.target.closest('.th-drag')) return; const key = th.getAttribute('data-sort'); if (!key) return; if (sort === key) { @@ -382,7 +577,7 @@ sort = key; dir = 'desc'; } - lsSet(STORAGE.sort, JSON.stringify({ sort, dir, grouped })); + saveSortState(); page = 1; load(); }); @@ -459,7 +654,17 @@ html += '' + formatCell(c, row) + ''; } }); - html += '
' + buildTags(row, grouped) + '
'; + const tagEditBtn = + !grouped && row.id && canTagEdit() + ? '' + : ''; + html += + '
' + + buildTags(row, grouped) + + tagEditBtn + + '
'; html += ''; const briefLines = row.brief || []; @@ -521,8 +726,8 @@ statusEl.textContent = data.error || 'Load failed'; return; } - renderHead(); - renderRows(data.items || []); + lastItems = data.items || []; + refreshTableChrome(); renderPagination(Number(data.total) || 0); statusEl.textContent = 'Showing ' + @@ -538,14 +743,335 @@ xhr.send(); } - renderHead(); + initTagEditButtons(tbody, load); + + applyColgroup(grouped); load(); } + const TAG_QUICK_COLORS = [ + '#5c6b82', '#3d8bfd', '#6d28d9', '#b45309', '#047857', + '#dc2626', '#d97706', '#0891b2', '#be185d', '#4f46e5', + ]; + + function canTagEdit() { + return document.body.getAttribute('data-can-tag-edit') === '1'; + } + + function slugTagId(label) { + return String(label) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 32); + } + + function renderTagPill(tag, removable) { + let html = + '' + + escapeHtml(tag.label || tag.id); + if (removable) { + html += + ''; + } + return html + ''; + } + + function createTagEditor(cfg) { + const state = { tags: [...(cfg.initialTags || [])], presets: [], reportId: cfg.reportId }; + const els = cfg.elements; + + function renderList() { + if (!els.list) return; + if (!state.tags.length) { + els.list.innerHTML = '
  • No custom tags yet.
  • '; + return; + } + els.list.innerHTML = state.tags + .map( + (t, i) => + '
  • ' + + renderTagPill(t, true) + + '
  • ' + ) + .join(''); + els.list.querySelectorAll('.tag-remove').forEach((btn) => { + btn.addEventListener('click', () => { + const id = btn.getAttribute('data-remove-id'); + state.tags = state.tags.filter((t) => t.id !== id); + renderAll(); + }); + }); + els.list.querySelectorAll('input[data-color-idx]').forEach((inp) => { + inp.addEventListener('input', () => { + const idx = Number(inp.getAttribute('data-color-idx')); + if (state.tags[idx]) state.tags[idx].bg = inp.value; + renderPreview(); + }); + }); + } + + function renderPreview() { + if (!els.preview) return; + els.preview.innerHTML = state.tags.length + ? state.tags.map((t) => renderTagPill(t, false)).join('') + : 'No custom tags'; + } + + function renderPresets() { + if (!els.presetBar || !els.presetChips) return; + if (!state.presets.length) { + els.presetBar.hidden = true; + return; + } + els.presetBar.hidden = false; + els.presetChips.innerHTML = state.presets + .map( + (p) => + '' + ) + .join(''); + els.presetChips.querySelectorAll('.tag-preset-chip').forEach((btn) => { + btn.addEventListener('click', () => { + const id = btn.getAttribute('data-preset-id'); + const p = state.presets.find((x) => x.id === id); + if (!p || state.tags.some((t) => t.id === p.id)) return; + state.tags.push({ ...p }); + renderAll(); + }); + }); + } + + function renderAll() { + renderList(); + renderPreview(); + } + + function setStatus(msg, ok) { + if (!els.status) return; + els.status.textContent = msg || ''; + els.status.classList.toggle('tag-editor-status--err', ok === false); + } + + function addFromForm(form) { + const label = (form.label?.value || '').trim(); + const bg = form.bg?.value || '#5c6b82'; + if (!label) return; + const id = slugTagId(label); + if (!id) return; + const reserved = ['new', 'java', 'ndk', 'android']; + if (reserved.includes(id)) { + setStatus('Reserved tag id: ' + id, false); + return; + } + const existing = state.tags.findIndex((t) => t.id === id); + const entry = { id, label, bg }; + if (existing >= 0) state.tags[existing] = entry; + else state.tags.push(entry); + form.label.value = ''; + renderAll(); + setStatus('', true); + } + + if (els.form) { + els.form.addEventListener('submit', (e) => { + e.preventDefault(); + addFromForm(els.form); + }); + } + + if (els.saveBtn) { + els.saveBtn.addEventListener('click', () => { + setStatus('Saving…', true); + const xhr = new XMLHttpRequest(); + xhr.open('PUT', basePath() + '/api/report_tags.php', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + setStatus('Invalid response', false); + return; + } + if (!data.ok) { + setStatus(data.error || 'Save failed', false); + return; + } + state.tags = data.tags || []; + renderAll(); + setStatus('Saved', true); + if (cfg.onSaved) cfg.onSaved(state.tags, state.reportId); + }; + xhr.onerror = function () { + setStatus('Network error', false); + }; + xhr.send(JSON.stringify({ id: state.reportId, tags: state.tags })); + }); + } + + function loadFromApi() { + const xhr = new XMLHttpRequest(); + xhr.open('GET', basePath() + '/api/report_tags.php?id=' + state.reportId, true); + xhr.onload = function () { + let data; + try { + data = JSON.parse(xhr.responseText); + } catch { + return; + } + if (data.ok) { + state.tags = data.tags || []; + state.presets = data.presets || []; + renderAll(); + renderPresets(); + } + }; + xhr.send(); + } + + renderAll(); + if (cfg.fetchPresets) loadFromApi(); + + return { + setTags(tags) { + state.tags = tags || []; + renderAll(); + }, + getTags() { + return state.tags; + }, + reload() { + loadFromApi(); + }, + }; + } + + let tagModalEditor = null; + + function openTagModal(reportId) { + const modal = document.getElementById('tag-modal'); + const editorEl = document.getElementById('tag-editor-modal'); + if (!modal || !editorEl || !canTagEdit()) return; + editorEl.setAttribute('data-report-id', String(reportId)); + modal.hidden = false; + modal.setAttribute('aria-hidden', 'false'); + tagModalEditor = createTagEditor({ + reportId: Number(reportId), + initialTags: [], + fetchPresets: true, + elements: { + list: document.getElementById('tag-modal-list'), + preview: document.getElementById('tag-modal-preview'), + form: document.getElementById('tag-modal-add'), + presetBar: document.getElementById('tag-modal-presets'), + presetChips: document.getElementById('tag-modal-preset-chips'), + saveBtn: document.getElementById('tag-modal-save'), + status: document.getElementById('tag-modal-status'), + }, + onSaved() { + if (typeof reportsReloadRef === 'function') reportsReloadRef(); + }, + }); + } + + function closeTagModal() { + const modal = document.getElementById('tag-modal'); + if (!modal) return; + modal.hidden = true; + modal.setAttribute('aria-hidden', 'true'); + tagModalEditor = null; + } + + let reportsReloadRef = null; + + function initTagModal() { + document.querySelectorAll('[data-tag-modal-close]').forEach((el) => { + el.addEventListener('click', closeTagModal); + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeTagModal(); + }); + } + + function initTagEditorReadOnly() { + const preview = document.getElementById('tag-editor-preview'); + const dataEl = document.getElementById('report-custom-tags-data'); + if (!preview || !dataEl || canTagEdit()) return; + try { + const tags = JSON.parse(dataEl.textContent || '[]'); + preview.innerHTML = tags.length + ? tags.map((t) => renderTagPill(t, false)).join('') + : 'No custom tags'; + } catch { + preview.innerHTML = ''; + } + } + + function initTagEditorDetail() { + const root = document.getElementById('tag-editor'); + if (!root) return; + if (!canTagEdit()) { + initTagEditorReadOnly(); + return; + } + const reportId = Number(root.getAttribute('data-report-id')); + let initial = []; + const dataEl = document.getElementById('report-custom-tags-data'); + if (dataEl) { + try { + initial = JSON.parse(dataEl.textContent || '[]'); + } catch { /* ignore */ } + } + createTagEditor({ + reportId, + initialTags: initial, + fetchPresets: true, + elements: { + list: document.getElementById('tag-editor-list'), + preview: document.getElementById('tag-editor-preview'), + form: document.getElementById('tag-editor-add'), + presetBar: document.getElementById('tag-preset-bar'), + presetChips: document.getElementById('tag-preset-chips'), + saveBtn: document.getElementById('tag-editor-save'), + status: document.getElementById('tag-editor-status'), + }, + }); + } + + function initTagEditButtons(tbody, reloadFn) { + reportsReloadRef = reloadFn; + if (!tbody) return; + tbody.addEventListener('click', (e) => { + const btn = e.target.closest('.tag-edit-btn'); + if (!btn) return; + e.stopPropagation(); + e.preventDefault(); + openTagModal(Number(btn.getAttribute('data-report-id'))); + }); + } + document.addEventListener('DOMContentLoaded', () => { initTheme(); initNav(); + initTagModal(); initReportDetail(); + initTagEditorDetail(); initReportsApp(); }); })(); diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php index d7ebe5f..4ee5724 100644 --- a/examples/crash_reporter/backend/public/index.php +++ b/examples/crash_reporter/backend/public/index.php @@ -40,6 +40,11 @@ if ($route === '/api/report_viewed.php' || str_ends_with($route, '/api/report_vi exit; } +if ($route === '/api/report_tags.php' || str_ends_with($route, '/api/report_tags.php')) { + require __DIR__ . '/api/report_tags.php'; + exit; +} + if ($route === '/logout') { Auth::logout(); header('Location: ' . $base . '/login'); @@ -69,15 +74,33 @@ $grouped = isset($_GET['group']) && $_GET['group'] === '1'; $view = $_GET['view'] ?? 'home'; if ($view === 'report' && isset($_GET['id'])) { - $report = ReportRepository::getById((int) $_GET['id']); - if (!$report) { - http_response_code(404); - echo 'Not found'; - exit; + try { + $report = ReportRepository::getById((int) $_GET['id']); + if (!$report) { + http_response_code(404); + echo 'Not found'; + exit; + } + $uid = (int) (Auth::user()['id'] ?? 0); + if ($uid > 0) { + try { + ReportRepository::markViewed((int) $report['id'], $uid); + } catch (Throwable $e) { + error_log('markViewed: ' . $e->getMessage()); + } + } + $pageTitle = 'Report'; + require __DIR__ . '/../views/layout.php'; + } catch (Throwable $e) { + error_log('report view: ' . $e->getMessage()); + http_response_code(500); + if (cfg('debug')) { + header('Content-Type: text/plain; charset=utf-8'); + echo 'Report view failed: ' . $e->getMessage(); + } else { + echo 'Report view failed'; + } } - ReportRepository::markViewed((int) $report['id'], (int) Auth::user()['id']); - $pageTitle = 'Report'; - require __DIR__ . '/../views/layout.php'; exit; } diff --git a/examples/crash_reporter/backend/scripts/init-mariadb.sh b/examples/crash_reporter/backend/scripts/init-mariadb.sh new file mode 100755 index 0000000..719fdf7 --- /dev/null +++ b/examples/crash_reporter/backend/scripts/init-mariadb.sh @@ -0,0 +1,33 @@ +#!/bin/sh +# Fresh MariaDB setup for crash reporter (run on BE as root or with mysql admin). +# Usage: +# ./scripts/init-mariadb.sh +# CRASH_DB_PASS='secret' ./scripts/init-mariadb.sh +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SCHEMA="$ROOT/sql/schema.mariadb.sql" +DB_NAME="${CRASH_DB_NAME:-androidcast_crashes}" +DB_USER="${CRASH_DB_USER:-androidcast}" +DB_PASS="${CRASH_DB_PASS:-}" + +if [ ! -f "$SCHEMA" ]; then + echo "Missing $SCHEMA" >&2 + exit 1 +fi + +echo "Applying schema from $SCHEMA ..." +mysql -u root -p < "$SCHEMA" + +if [ -n "$DB_PASS" ]; then + echo "Creating app user $DB_USER@127.0.0.1 ..." + mysql -u root -p <prepare('SELECT * FROM users WHERE username = ? LIMIT 1'); diff --git a/examples/crash_reporter/backend/src/Database.php b/examples/crash_reporter/backend/src/Database.php index e506dfc..6a9dfef 100644 --- a/examples/crash_reporter/backend/src/Database.php +++ b/examples/crash_reporter/backend/src/Database.php @@ -1,27 +1,29 @@ - * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 - * Contributors: - * - Anton Afanasyeu (2 commits, 43 lines) - * - Cursor Agent (project assistant) - * Digest: SHA256 6e0c3bc4b7a544839aa7961fa9b6adb0da4968c13825a10159d791270a26d4a1 - */ declare(strict_types=1); final class Database { private static ?PDO $pdo = null; private static bool $schemaChecked = false; + private static ?string $driver = null; + + public static function driver(): string { + if (self::$driver !== null) { + return self::$driver; + } + $d = cfg('db.driver', 'sqlite'); + self::$driver = ($d === 'mysql' || $d === 'mariadb') ? 'mysql' : 'sqlite'; + return self::$driver; + } + + public static function isMysql(): bool { + return self::driver() === 'mysql'; + } public static function pdo(): PDO { if (self::$pdo !== null) { return self::$pdo; } - $driver = cfg('db.driver', 'sqlite'); - if ($driver === 'mysql') { + if (self::isMysql()) { $m = cfg('db.mysql', []); $dsn = sprintf( 'mysql:host=%s;port=%d;dbname=%s;charset=%s', @@ -34,6 +36,7 @@ final class Database { PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, ]); + self::ensureSchema(); return self::$pdo; } $path = cfg('db.sqlite_path'); @@ -50,7 +53,20 @@ final class Database { return self::$pdo; } - /** Idempotent: applies sql/schema.sqlite.sql if core tables are missing. */ + /** Upsert into report_views (portable). */ + public static function upsertReportView(int $userId, int $reportId, int $viewedAtMs): void { + $pdo = self::pdo(); + if (self::isMysql()) { + $sql = 'INSERT INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE viewed_at_ms = VALUES(viewed_at_ms)'; + } else { + $sql = 'INSERT OR REPLACE INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)'; + } + $stmt = $pdo->prepare($sql); + $stmt->execute([$userId, $reportId, $viewedAtMs]); + } + + /** Idempotent schema setup for sqlite and mysql. */ public static function ensureSchema(): void { if (self::$schemaChecked) { return; @@ -60,12 +76,16 @@ final class Database { if ($pdo === null) { return; } + if (self::isMysql()) { + self::ensureMysqlSchema($pdo); + return; + } + self::ensureSqliteSchema($pdo); + } + + private static function ensureSqliteSchema(PDO $pdo): void { $need = static function (string $table) use ($pdo): bool { - $stmt = $pdo->prepare( - "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1" - ); - $stmt->execute([$table]); - return $stmt->fetchColumn() === false; + return !self::tableExists($pdo, $table); }; if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) { self::ensureReportColumns($pdo); @@ -84,14 +104,84 @@ final class Database { self::ensureReportViewsTable($pdo); } + private static function ensureMysqlSchema(PDO $pdo): void { + if (!self::tableExists($pdo, 'users')) { + $pdo->exec( + "CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(64) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('root', 'admin', 'viewer') NOT NULL DEFAULT 'viewer', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uq_users_username (username) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + $pdo->exec( + "INSERT IGNORE INTO users (id, username, password_hash, role) + VALUES (1, 'admin', '\$2y\$10\$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root')" + ); + } + if (!self::tableExists($pdo, 'reports')) { + $pdo->exec( + "CREATE TABLE IF NOT EXISTS reports ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + report_id VARCHAR(128) NOT NULL, + fingerprint VARCHAR(64) NOT NULL, + crash_type VARCHAR(32) NOT NULL, + generated_at_ms BIGINT NOT NULL, + received_at_ms BIGINT NOT NULL, + device_model VARCHAR(128) NULL, + app_version VARCHAR(64) NULL, + payload_json LONGTEXT NOT NULL, + tags_json LONGTEXT NOT NULL, + UNIQUE KEY uq_reports_report_id (report_id), + KEY idx_reports_generated (generated_at_ms), + KEY idx_reports_received (received_at_ms), + KEY idx_reports_fingerprint (fingerprint), + KEY idx_reports_fp_received (fingerprint, received_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + } else { + self::ensureMysqlReportColumns($pdo); + } + self::ensureMysqlReportViewsTable($pdo); + } + + private static function ensureMysqlReportColumns(PDO $pdo): void { + $names = self::columnNames($pdo, 'reports'); + if (!in_array('tags_json', $names, true)) { + $pdo->exec("ALTER TABLE reports ADD COLUMN tags_json LONGTEXT NOT NULL"); + $pdo->exec("UPDATE reports SET tags_json = '[]' WHERE tags_json IS NULL OR tags_json = ''"); + } + } + + private static function ensureMysqlReportViewsTable(PDO $pdo): void { + if (self::tableExists($pdo, 'report_views')) { + return; + } + $pdo->exec( + 'CREATE TABLE IF NOT EXISTS report_views ( + user_id INT UNSIGNED NOT NULL, + report_id BIGINT UNSIGNED NOT NULL, + viewed_at_ms BIGINT NOT NULL, + PRIMARY KEY (user_id, report_id), + CONSTRAINT fk_report_views_report + FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + } + private static function ensureReportColumns(PDO $pdo): void { - $names = array_column($pdo->query('PRAGMA table_info(reports)')->fetchAll(PDO::FETCH_ASSOC), 'name'); + $names = self::columnNames($pdo, 'reports'); 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 { + if (self::tableExists($pdo, 'report_views')) { + return; + } $pdo->exec( 'CREATE TABLE IF NOT EXISTS report_views ( user_id INTEGER NOT NULL, @@ -102,16 +192,61 @@ final class Database { ); } + public static function tableExists(PDO $pdo, string $table): bool { + if (self::isMysql()) { + $db = cfg('db.mysql.database', 'androidcast_crashes'); + $stmt = $pdo->prepare( + 'SELECT 1 FROM information_schema.tables + WHERE table_schema = ? AND table_name = ? LIMIT 1' + ); + $stmt->execute([$db, $table]); + return $stmt->fetchColumn() !== false; + } + $stmt = $pdo->prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1" + ); + $stmt->execute([$table]); + return $stmt->fetchColumn() !== false; + } + + /** @return list */ + public static function columnNames(PDO $pdo, string $table): array { + if (self::isMysql()) { + $db = cfg('db.mysql.database', 'androidcast_crashes'); + $stmt = $pdo->prepare( + 'SELECT column_name AS name FROM information_schema.columns + WHERE table_schema = ? AND table_name = ?' + ); + $stmt->execute([$db, $table]); + return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'name'); + } + return array_column( + $pdo->query('PRAGMA table_info(' . preg_replace('/[^a-z0-9_]/i', '', $table) . ')')->fetchAll(PDO::FETCH_ASSOC), + 'name' + ); + } + + /** @return list */ + public static function listTables(PDO $pdo): array { + if (self::isMysql()) { + $db = cfg('db.mysql.database', 'androidcast_crashes'); + $stmt = $pdo->prepare( + 'SELECT table_name FROM information_schema.tables + WHERE table_schema = ? ORDER BY table_name' + ); + $stmt->execute([$db]); + return $stmt->fetchAll(PDO::FETCH_COLUMN); + } + return $pdo->query( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name" + )->fetchAll(PDO::FETCH_COLUMN); + } + private static function reportsSchemaOk(PDO $pdo): bool { - try { - $cols = $pdo->query('PRAGMA table_info(reports)')->fetchAll(PDO::FETCH_ASSOC); - } catch (PDOException) { + if (!self::tableExists($pdo, 'reports')) { return false; } - if ($cols === []) { - return false; - } - $names = array_column($cols, 'name'); + $names = self::columnNames($pdo, 'reports'); foreach (['report_id', 'fingerprint', 'crash_type', 'generated_at_ms', 'received_at_ms', 'payload_json'] as $col) { if (!in_array($col, $names, true)) { return false; diff --git a/examples/crash_reporter/backend/src/ReportRepository.php b/examples/crash_reporter/backend/src/ReportRepository.php index a1d6d84..6168e76 100644 --- a/examples/crash_reporter/backend/src/ReportRepository.php +++ b/examples/crash_reporter/backend/src/ReportRepository.php @@ -3,6 +3,7 @@ declare(strict_types=1); final class ReportRepository { private const LIST_SORT = [ + 'priority', 'generated_at_ms', 'received_at_ms', 'crash_type', @@ -10,6 +11,7 @@ final class ReportRepository { 'app_version', 'fingerprint', 'fingerprint_cnt', + 'rating', ]; private const GROUP_SORT = [ 'cnt', @@ -102,10 +104,11 @@ final class ReportRepository { ]; } - $sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'generated_at_ms'; - if ($sortCol === 'fingerprint_cnt') { + $sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority'; + if ($sortCol === 'rating') { $sortCol = 'fingerprint_cnt'; } + $orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir); $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, @@ -115,7 +118,7 @@ final class ReportRepository { 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 + ORDER BY $orderBy LIMIT $perPage OFFSET $offset"; $stmt = $pdo->prepare($sql); $stmt->execute([$sinceMs, $userId]); @@ -134,18 +137,55 @@ final class ReportRepository { 'total' => $total, 'page' => $page, 'per_page' => $perPage, - 'sort' => $sortCol, + 'sort' => $sort === 'priority' ? 'priority' : $sortCol, 'dir' => strtolower($dir), 'grouped' => false, ]; } - public static function markViewed(int $reportId, int $userId): void { + private static function buildListOrder(string $sort, string $dir): string { + $dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC'; + if ($sort === 'priority') { + return 'fingerprint_cnt DESC, r.received_at_ms DESC, r.app_version DESC, r.device_model ASC, r.crash_type ASC'; + } + if ($sort === 'fingerprint_cnt') { + return "fingerprint_cnt $dir, r.received_at_ms DESC"; + } + return "r.$sort $dir"; + } + + public static function setCustomTags(int $reportId, array $tags): bool { + if (self::getById($reportId) === null) { + return false; + } $pdo = Database::pdo(); - $stmt = $pdo->prepare( - 'INSERT OR REPLACE INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)' + $normalized = normalize_report_tags($tags); + $json = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $stmt = $pdo->prepare('UPDATE reports SET tags_json = ? WHERE id = ?'); + $stmt->execute([$json ?: '[]', $reportId]); + return true; + } + + /** Distinct custom tags used across reports (for suggestions). */ + public static function listTagPresets(int $limit = 40): array { + $pdo = Database::pdo(); + $rows = $pdo->query('SELECT tags_json FROM reports WHERE tags_json IS NOT NULL AND tags_json != \'[]\' LIMIT 500') + ->fetchAll(); + $map = []; + foreach ($rows as $row) { + foreach (parse_report_tags($row['tags_json'] ?? '[]') as $tag) { + $map[$tag['id']] = $tag; + } + } + return array_values($map); + } + + public static function markViewed(int $reportId, int $userId): void { + Database::upsertReportView( + $userId, + $reportId, + (int) round(microtime(true) * 1000) ); - $stmt->execute([$userId, $reportId, (int) round(microtime(true) * 1000)]); } public static function getById(int $id): ?array { @@ -162,7 +202,8 @@ final class ReportRepository { if (!$row) { return null; } - $row['payload'] = json_decode($row['payload_json'], true); + $decoded = json_decode($row['payload_json'], true); + $row['payload'] = is_array($decoded) ? $decoded : []; $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 3e61475..35718d5 100644 --- a/examples/crash_reporter/backend/src/bootstrap.php +++ b/examples/crash_reporter/backend/src/bootstrap.php @@ -49,8 +49,18 @@ function cfg(string $key, $default = null) { return $v; } -function h(?string $s): string { - return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +function h(mixed $s): string { + if ($s === null || $s === false) { + return ''; + } + return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} + +/** json_encode that never returns false (substitutes bad UTF-8). */ +function safe_json_encode(mixed $data, int $flags = 0): string { + $flags |= JSON_INVALID_UTF8_SUBSTITUTE; + $json = json_encode($data, $flags); + return is_string($json) ? $json : 'null'; } function json_out(array $data, int $code = 200): void { @@ -124,6 +134,49 @@ function crash_kind_tag(string $crashType): array { }; } +/** OS product name (Android, Linux, …) — not device manufacturer. */ +/** Human-readable ABI list from device payload. */ +function format_device_abis(mixed $abis): string { + if (!is_array($abis)) { + return is_scalar($abis) ? trim((string) $abis) : ''; + } + $parts = []; + foreach ($abis as $abi) { + if (is_scalar($abi)) { + $s = trim((string) $abi); + if ($s !== '') { + $parts[] = $s; + } + } + } + return implode(', ', $parts); +} + +function device_os_name(array $device): string { + if (!empty($device['os_name'])) { + return trim((string) $device['os_name']); + } + if (!empty($device['os'])) { + return trim((string) $device['os']); + } + $sdk = (int) ($device['sdk_int'] ?? 0); + $blob = strtolower(implode(' ', array_filter([ + (string) ($device['product'] ?? ''), + (string) ($device['device'] ?? ''), + (string) ($device['model'] ?? ''), + ]))); + if ($sdk > 0 || str_contains($blob, 'android')) { + return 'Android'; + } + if (str_contains($blob, 'linux')) { + return 'Linux'; + } + if (($device['release'] ?? '') !== '') { + return 'Android'; + } + return 'Android'; +} + /** 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); @@ -131,7 +184,7 @@ function report_enrich_list_row(array &$row, int $sinceMs = 0): void { $p = []; } $d = $p['device'] ?? []; - $row['os_name'] = trim((string) ($d['manufacturer'] ?? $d['brand'] ?? '')); + $row['os_name'] = device_os_name($d); $rel = (string) ($d['release'] ?? ''); $sdk = (int) ($d['sdk_int'] ?? 0); if ($rel !== '' && $sdk > 0) { @@ -175,6 +228,60 @@ function report_rating_score(int $fingerprintTotal, int $fingerprintRecent, int return max(0, min(5, $score)); } +/** System tags — not stored in tags_json (computed in UI). */ +function report_reserved_tag_ids(): array { + return ['new', 'java', 'ndk', 'android']; +} + +function normalize_tag_color(string $bg): string { + $bg = trim($bg); + if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $bg) !== 1) { + return '#5c6b82'; + } + if (strlen($bg) === 4) { + return '#' . $bg[1] . $bg[1] . $bg[2] . $bg[2] . $bg[3] . $bg[3]; + } + return strtolower($bg); +} + +/** + * @param list $tags + * @return list + */ +function normalize_report_tags(array $tags): array { + $reserved = array_flip(report_reserved_tag_ids()); + $out = []; + $seen = []; + foreach ($tags as $tag) { + if (!is_array($tag)) { + continue; + } + $label = trim((string) ($tag['label'] ?? $tag['id'] ?? '')); + if ($label === '' || strlen($label) > 40) { + continue; + } + $id = trim((string) ($tag['id'] ?? $label)); + $id = strtolower(preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? $id); + $id = trim($id, '-'); + if ($id === '' || isset($reserved[$id])) { + continue; + } + if (isset($seen[$id])) { + continue; + } + $seen[$id] = true; + $out[] = [ + 'id' => $id, + 'label' => $label, + 'bg' => normalize_tag_color((string) ($tag['bg'] ?? '#5c6b82')), + ]; + if (count($out) >= 24) { + break; + } + } + return $out; +} + /** @return list */ function parse_report_tags(?string $json): array { if ($json === null || $json === '') { diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index e6d192c..b85dbd8 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -28,6 +28,7 @@ >
    @@ -107,7 +108,8 @@

    Loading…

    - +
    +
    @@ -117,6 +119,34 @@
    + + +
    Android Cast crash reporter — placeholder footer
    diff --git a/examples/crash_reporter/backend/views/report_detail.php b/examples/crash_reporter/backend/views/report_detail.php index d3037b8..67b8276 100644 --- a/examples/crash_reporter/backend/views/report_detail.php +++ b/examples/crash_reporter/backend/views/report_detail.php @@ -1,12 +1,13 @@
    @@ -15,15 +16,50 @@ $sessionJson = !empty($p['session_context'])

    Crash report

    -

    Report

    +

    Report ·

    + +
    +
    +

    Tags

    +

    Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.

    +
    +
    + +
      +
      + + + +
      + +
      + + +
      + +

      Sign in as admin to edit custom tags.

      + +
      +

      Device

      Android (SDK )

      -

      ABIs:

      +

      ABIs:

      App