From 7900441ff6b8c7367c0972195b6fc049f400f52a Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Fri, 22 May 2026 23:56:07 +0200 Subject: [PATCH] be stuff, fe reqs --- .../backend/config/config.example.php | 2 + .../backend/public/api/diag.php | 3 + .../backend/public/api/report_tags.php | 14 +- .../backend/public/api/reports.php | 55 ++- .../backend/public/assets/css/app.css | 116 +++++ .../backend/public/assets/js/app.js | 444 ++++++++++++++---- .../backend/scripts/init-mariadb.sh | 6 +- .../crash_reporter/backend/src/Database.php | 22 +- .../backend/src/ReportRepository.php | 416 +++++++++++++++- .../crash_reporter/backend/src/bootstrap.php | 185 +++++++- .../crash_reporter/backend/views/layout.php | 10 + .../backend/views/report_detail.php | 84 +++- 12 files changed, 1245 insertions(+), 112 deletions(-) diff --git a/examples/crash_reporter/backend/config/config.example.php b/examples/crash_reporter/backend/config/config.example.php index 546d8ce..853638a 100644 --- a/examples/crash_reporter/backend/config/config.example.php +++ b/examples/crash_reporter/backend/config/config.example.php @@ -19,8 +19,10 @@ return [ 'driver' => 'sqlite', // sqlite | mysql | mariadb (alias) 'sqlite_path' => __DIR__ . '/../data/crashes.sqlite', 'mysql' => [ + // Alpine: if TCP fails with "Connection refused", set socket (see /etc/my.cnf.d/*.cnf) 'host' => '127.0.0.1', 'port' => 3306, + 'socket' => '/run/mysqld/mysqld.sock', // leave '' to use host+port only 'database' => 'androidcast_crashes', 'username' => 'androidcast', 'password' => 'change-me', diff --git a/examples/crash_reporter/backend/public/api/diag.php b/examples/crash_reporter/backend/public/api/diag.php index ccdbbc1..fe8733b 100644 --- a/examples/crash_reporter/backend/public/api/diag.php +++ b/examples/crash_reporter/backend/public/api/diag.php @@ -26,9 +26,12 @@ if ($driver === 'sqlite') { $out['data_dir_writable'] = is_string($path) && is_writable(dirname($path)); } else { $m = cfg('db.mysql', []); + $socket = trim((string) ($m['socket'] ?? '')); $out['mysql'] = [ 'host' => $m['host'] ?? '127.0.0.1', 'port' => (int) ($m['port'] ?? 3306), + 'socket' => $socket, + 'socket_exists' => $socket !== '' && file_exists($socket), 'database' => $m['database'] ?? 'androidcast_crashes', 'username' => $m['username'] ?? '', ]; diff --git a/examples/crash_reporter/backend/public/api/report_tags.php b/examples/crash_reporter/backend/public/api/report_tags.php index 9ab47c9..f012031 100644 --- a/examples/crash_reporter/backend/public/api/report_tags.php +++ b/examples/crash_reporter/backend/public/api/report_tags.php @@ -6,8 +6,10 @@ if (!Auth::user()) { json_out(['ok' => false, 'error' => 'unauthorized'], 401); } +$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'); $id = (int) ($_GET['id'] ?? 0); -if ($_SERVER['REQUEST_METHOD'] === 'GET') { + +if ($method === 'GET') { if ($id <= 0) { json_out(['ok' => false, 'error' => 'invalid id'], 400); } @@ -28,6 +30,10 @@ if (!Auth::canEditTags()) { json_out(['ok' => false, 'error' => 'forbidden'], 403); } +if ($method !== 'PUT' && $method !== 'POST') { + json_out(['ok' => false, 'error' => 'method not allowed'], 405); +} + $raw = file_get_contents('php://input'); $data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST; if (!is_array($data)) { @@ -58,5 +64,9 @@ try { ]); } catch (Throwable $e) { error_log('report_tags: ' . $e->getMessage()); - json_out(['ok' => false, 'error' => 'save failed'], 500); + $out = ['ok' => false, 'error' => 'save failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + } + json_out($out, 500); } diff --git a/examples/crash_reporter/backend/public/api/reports.php b/examples/crash_reporter/backend/public/api/reports.php index de0efc2..d0b785c 100644 --- a/examples/crash_reporter/backend/public/api/reports.php +++ b/examples/crash_reporter/backend/public/api/reports.php @@ -16,11 +16,62 @@ if (!in_array($perPage, $allowedPerPage, true)) { $sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'priority')); $dir = (string) ($_GET['dir'] ?? 'desc'); $sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0)); +$similarTo = max(0, (int) ($_GET['similar_to'] ?? 0)); +$searchQ = trim((string) ($_GET['q'] ?? '')); +$suggest = isset($_GET['suggest']) && $_GET['suggest'] === '1'; + +$filters = []; +$device = trim((string) ($_GET['filter_device'] ?? '')); +if ($device !== '') { + $filters['device'] = $device; +} +$abi = trim((string) ($_GET['filter_abi'] ?? '')); +if ($abi !== '') { + $filters['abi'] = $abi; +} +$osSdk = max(0, (int) ($_GET['filter_os_sdk'] ?? 0)); +if ($osSdk > 0) { + $filters['os_sdk'] = $osSdk; +} +$osRelease = trim((string) ($_GET['filter_os_release'] ?? '')); +if ($osRelease !== '') { + $filters['os_release'] = $osRelease; +} +$appVersion = trim((string) ($_GET['filter_app_version'] ?? '')); +if ($appVersion !== '') { + $filters['app_version'] = $appVersion; +} +$build = trim((string) ($_GET['filter_build'] ?? '')); +if ($build !== '') { + $filters['build'] = $build; +} try { - $data = ReportRepository::listPage($grouped, $page, $perPage, $sort, $dir, $sinceMs); + if ($suggest) { + json_out([ + 'ok' => true, + 'suggestions' => ReportRepository::searchSuggestions($searchQ), + 'q' => $searchQ, + ]); + } + if ($searchQ !== '') { + $data = ReportRepository::search($searchQ, $page, $perPage, $sinceMs); + } elseif ($similarTo > 0) { + $data = ReportRepository::listSimilar($similarTo, $page, $perPage, $sinceMs); + } elseif ($filters !== []) { + $data = ReportRepository::listFiltered($filters, $page, $perPage, $sort, $dir, $sinceMs); + } else { + $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); + $out = ['ok' => false, 'error' => 'list failed']; + if (cfg('debug')) { + $out['hint'] = $e->getMessage(); + if (str_contains($e->getMessage(), '2002')) { + $out['hint'] .= ' — use db.mysql.socket (/run/mysqld/mysqld.sock on Alpine); TCP 3306 is closed'; + } + } + json_out($out, 500); } diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index 06d1639..91ded25 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -537,6 +537,122 @@ a:hover { text-decoration: underline; } color: #fff; background: var(--tag-bg, #5c6b82); } +.reports-filter-banner { + margin: 8px 0 0; + padding: 10px 12px; + border-radius: 8px; + background: var(--surface2); + border: 1px solid var(--border); +} +.reports-filter-banner a { font-weight: 600; } + +.reports-search-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 14px; + margin: 12px 0 8px; +} +.reports-search-label { + font-size: 13px; + color: var(--muted); + flex: 0 0 auto; +} +.reports-search-field { + position: relative; + flex: 1 1 280px; + min-width: 200px; +} +.reports-search-input { + width: 100%; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font-size: 14px; +} +.reports-search-input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +.reports-search-suggest { + position: absolute; + z-index: 20; + left: 0; + right: 0; + top: calc(100% + 4px); + margin: 0; + padding: 4px 0; + list-style: none; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .25); + max-height: 240px; + overflow-y: auto; +} +.reports-search-suggest[hidden] { display: none; } +.suggest-item { + display: block; + width: 100%; + text-align: left; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--text); + font-size: 13px; + cursor: pointer; +} +.suggest-item:hover, +.suggest-item:focus-visible { + background: var(--row-hover); + outline: none; +} + +.stack-block-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.stack-block-head h2 { margin: 0; } +.btn-similar { + margin-left: 0; + white-space: nowrap; + font-size: 13px; +} + +.detail-filter-link { + color: var(--text); + text-decoration: none; + border-radius: 6px; + padding: 2px 4px; + margin: -2px -4px; + transition: background .12s ease, color .12s ease; +} +.detail-filter-link:hover, +.detail-filter-link:focus-visible { + background: var(--row-hover); + color: var(--accent); + outline: none; +} +.detail-filter-link--tag { + display: inline-block; + padding: 2px 8px; + border-radius: 999px; + background: var(--surface2); + border: 1px solid var(--border); + font-size: 12px; + font-weight: 600; +} +.detail-filter-link--tag:hover { + border-color: var(--accent); +} +.detail-abi-row { line-height: 1.8; } + .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; } diff --git a/examples/crash_reporter/backend/public/assets/js/app.js b/examples/crash_reporter/backend/public/assets/js/app.js index c99cac8..a762cdd 100644 --- a/examples/crash_reporter/backend/public/assets/js/app.js +++ b/examples/crash_reporter/backend/public/assets/js/app.js @@ -46,6 +46,7 @@ const GROUP_COLUMNS = [ { key: 'cnt', label: 'Count' }, + { key: 'rating', label: 'Rating', fmt: 'stars' }, { key: 'fingerprint', label: 'Fingerprint', fmt: 'fp' }, { key: 'crash_type', label: 'Type' }, { key: 'last_generated_ms', label: 'Last generated', fmt: 'time' }, @@ -330,71 +331,159 @@ 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; + function bindReportsTableLayout(table, thead, ctx) { + if (!table || !thead || table.dataset.layoutBound === '1') return; + table.dataset.layoutBound = '1'; + let dragKey = null; - 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); - }); + thead.addEventListener('dragstart', (e) => { + const handle = e.target.closest('.th-drag'); + if (!handle || ctx.getGrouped()) return; + dragKey = handle.getAttribute('data-col-key'); + const th = handle.closest('th'); + if (th) th.classList.add('th-dragging'); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', dragKey || ''); + } + }); + + thead.addEventListener('dragend', (e) => { + const th = e.target.closest('th'); + if (th) th.classList.remove('th-dragging'); + dragKey = null; + }); + + thead.addEventListener('dragover', (e) => { + if (ctx.getGrouped()) return; + if (!e.target.closest('th[data-col-key]')) return; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + }); + + thead.addEventListener('drop', (e) => { + if (ctx.getGrouped()) return; + const th = e.target.closest('th[data-col-key]'); + if (!th) return; + e.preventDefault(); + const sourceKey = + (e.dataTransfer && e.dataTransfer.getData('text/plain')) || dragKey; + const targetKey = th.getAttribute('data-col-key'); + if (!sourceKey || !targetKey || sourceKey === targetKey) return; + const order = getOrderedColumnKeys(); + const from = order.indexOf(sourceKey); + const to = order.indexOf(targetKey); + if (from < 0 || to < 0) return; + order.splice(from, 1); + order.splice(to, 0, sourceKey); + saveColumnOrder(order); + dragKey = null; + ctx.refreshLayout(); + }); + + table.addEventListener('mousedown', (e) => { + if (ctx.getGrouped()) return; + const handle = e.target.closest('.col-resizer'); + if (!handle) return; + e.preventDefault(); + e.stopPropagation(); + const colKey = handle.getAttribute('data-resize-col'); + const th = handle.closest('th'); + if (!colKey || !th) return; + const widths = getColWidths(); + 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'; + th.style.minWidth = w + 'px'; + th.style.maxWidth = w + 'px'; + const cg = document.getElementById('reports-colgroup'); + const col = cg && cg.querySelector('col[data-col="' + colKey + '"]'); + if (col) col.style.width = w + 'px'; + else if (colKey === 'tags') { + const tagCol = cg && cg.querySelector('col.col-tags'); + if (tagCol) tagCol.style.width = w + 'px'; + } + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + saveColWidths(widths); + applyColgroup(false); + }; + 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 parseListContext() { + const p = new URLSearchParams(window.location.search); + return { + similarTo: Number(p.get('similar_to')) || 0, + filterDevice: p.get('filter_device') || '', + filterAbi: p.get('filter_abi') || '', + filterOsSdk: Number(p.get('filter_os_sdk')) || 0, + filterOsRelease: p.get('filter_os_release') || '', + filterAppVersion: p.get('filter_app_version') || '', + filterBuild: p.get('filter_build') || '', + searchQuery: p.get('q') || '', + }; + } + + function listContextActive(ctx) { + return ( + ctx.similarTo > 0 || + !!ctx.filterDevice || + !!ctx.filterAbi || + ctx.filterOsSdk > 0 || + !!ctx.filterOsRelease || + !!ctx.filterAppVersion || + !!ctx.filterBuild || + !!ctx.searchQuery + ); + } + + function updateFilterBanner(ctx, bannerEl) { + if (!bannerEl) return; + const parts = []; + if (ctx.similarTo > 0) { + parts.push('Similar reports for #' + ctx.similarTo); + } + if (ctx.filterDevice) { + parts.push('Device: “' + ctx.filterDevice + '”'); + } + if (ctx.filterAbi) { + parts.push('ABI: ' + ctx.filterAbi); + } + if (ctx.filterOsSdk > 0) { + parts.push('SDK ' + ctx.filterOsSdk); + } + if (ctx.filterOsRelease) { + parts.push('Android ' + ctx.filterOsRelease); + } + if (ctx.filterAppVersion) { + parts.push('App ' + ctx.filterAppVersion); + } + if (ctx.filterBuild) { + parts.push('Build ' + ctx.filterBuild); + } + if (ctx.searchQuery) { + parts.push('Search: “' + ctx.searchQuery + '”'); + } + if (!parts.length) { + bannerEl.hidden = true; + bannerEl.textContent = ''; + return; + } + bannerEl.hidden = false; + bannerEl.innerHTML = + parts.join(' · ') + + ' — Clear filters'; } function navigateTo(href) { @@ -449,9 +538,14 @@ const perPageSel = document.getElementById('per-page-select'); const table = document.getElementById('reports-tree'); - let grouped = app.getAttribute('data-grouped') === '1'; + const filterBanner = document.getElementById('reports-filter-banner'); + const listContext = parseListContext(); + let grouped = listContextActive(listContext) + ? false + : app.getAttribute('data-grouped') === '1'; let page = 1; let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50; + let listTotal = 0; const saved = loadSavedSort(grouped); let sort = saved.sort; let dir = saved.dir; @@ -471,14 +565,29 @@ return apiSortKey(sort) === apiSortKey(colKey); } + const layoutCtx = { + getGrouped: () => grouped, + refreshLayout: () => { + applyColgroup(grouped); + renderHead(); + if (lastItems.length) renderRows(lastItems); + }, + }; + function refreshTableChrome() { - applyColgroup(grouped); - renderHead(); - setupColumnResize(table, grouped); - setupColumnDrag(thead, grouped, refreshTableChrome); - if (lastItems.length) { - renderRows(lastItems); - } + layoutCtx.refreshLayout(); + } + + bindReportsTableLayout(table, thead, layoutCtx); + updateFilterBanner(listContext, filterBanner); + + if (listContextActive(listContext)) { + app.setAttribute('data-grouped', '0'); + app.querySelectorAll('.reports-mode-btn').forEach((b) => { + b.classList.toggle('active', b.getAttribute('data-grouped') === '0'); + b.disabled = true; + b.title = 'Disabled while a filter is active'; + }); } if (perPageSel) { @@ -539,8 +648,7 @@ let thDrag = ''; if (!grouped) { thClass += ' th-draggable'; - thDrag = - ' draggable="true" data-col-key="' + escapeHtml(c.key); + thDrag = ' data-col-key="' + escapeHtml(c.key) + '"'; } html += '' + - (grouped ? '' : '') + + (grouped + ? '' + : '') + '' + escapeHtml(c.label) + '