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) +
' ' +
@@ -584,18 +696,38 @@
});
}
+ function pageRangeEnd(targetPage, totalCount) {
+ return Math.min(targetPage * perPage, totalCount);
+ }
+
function renderPagination(total) {
+ listTotal = 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 prevLabel = '← Previous';
+ let nextLabel = 'Next →';
+ if (prev) {
+ const t = page - 1;
+ const before = (t - 1) * perPage;
+ const through = pageRangeEnd(t, total);
+ prevLabel = '← Previous (' + before + '/' + through + ')';
+ }
+ if (next) {
+ const start = page * perPage + 1;
+ const end = pageRangeEnd(page + 1, total);
+ nextLabel = 'Next (' + start + '/' + end + ') →';
+ }
let html = '';
pagination.innerHTML = html;
pagination.querySelectorAll('.page-btn').forEach((btn) => {
@@ -699,7 +833,7 @@
if (loading) return;
loading = true;
statusEl.textContent = 'Loading…';
- const qs =
+ let qs =
'page=' +
page +
'&per_page=' +
@@ -709,8 +843,32 @@
'&dir=' +
encodeURIComponent(dir) +
'&since_ms=' +
- encodeURIComponent(String(lastVisitMs())) +
- (grouped ? '&group=1' : '');
+ encodeURIComponent(String(lastVisitMs()));
+ if (listContext.searchQuery) {
+ qs += '&q=' + encodeURIComponent(listContext.searchQuery);
+ } else if (listContext.similarTo > 0) {
+ qs += '&similar_to=' + encodeURIComponent(String(listContext.similarTo));
+ } else {
+ if (grouped) qs += '&group=1';
+ if (listContext.filterDevice) {
+ qs += '&filter_device=' + encodeURIComponent(listContext.filterDevice);
+ }
+ if (listContext.filterAbi) {
+ qs += '&filter_abi=' + encodeURIComponent(listContext.filterAbi);
+ }
+ if (listContext.filterOsSdk > 0) {
+ qs += '&filter_os_sdk=' + encodeURIComponent(String(listContext.filterOsSdk));
+ }
+ if (listContext.filterOsRelease) {
+ qs += '&filter_os_release=' + encodeURIComponent(listContext.filterOsRelease);
+ }
+ if (listContext.filterAppVersion) {
+ qs += '&filter_app_version=' + encodeURIComponent(listContext.filterAppVersion);
+ }
+ if (listContext.filterBuild) {
+ qs += '&filter_build=' + encodeURIComponent(listContext.filterBuild);
+ }
+ }
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/reports.php?' + qs, true);
xhr.onload = function () {
@@ -745,6 +903,134 @@
initTagEditButtons(tbody, load);
+ const searchInput = document.getElementById('reports-search-input');
+ const searchSuggest = document.getElementById('reports-search-suggest');
+ const searchClear = document.getElementById('reports-search-clear');
+ let suggestTimer = null;
+ let searchRunTimer = null;
+
+ function hideSuggest() {
+ if (searchSuggest) {
+ searchSuggest.hidden = true;
+ searchSuggest.innerHTML = '';
+ }
+ }
+
+ function applySearchQuery(q, pushUrl) {
+ listContext.similarTo = 0;
+ listContext.filterDevice = '';
+ listContext.filterAbi = '';
+ listContext.filterOsSdk = 0;
+ listContext.filterOsRelease = '';
+ listContext.filterAppVersion = '';
+ listContext.filterBuild = '';
+ listContext.searchQuery = (q || '').trim();
+ grouped = false;
+ app.setAttribute('data-grouped', '0');
+ page = 1;
+ updateFilterBanner(listContext, filterBanner);
+ if (searchInput) searchInput.value = listContext.searchQuery;
+ if (searchClear) searchClear.hidden = !listContext.searchQuery;
+ if (pushUrl) {
+ const url =
+ basePath() +
+ '/?view=reports' +
+ (listContext.searchQuery ? '&q=' + encodeURIComponent(listContext.searchQuery) : '');
+ history.replaceState(null, '', url);
+ }
+ load();
+ }
+
+ function fetchSuggest(q) {
+ if (!searchSuggest || q.length < 2) {
+ hideSuggest();
+ return;
+ }
+ const xhr = new XMLHttpRequest();
+ xhr.open(
+ 'GET',
+ basePath() +
+ '/api/reports.php?suggest=1&q=' +
+ encodeURIComponent(q) +
+ '&per_page=10',
+ true
+ );
+ xhr.onload = function () {
+ let data;
+ try {
+ data = JSON.parse(xhr.responseText);
+ } catch {
+ hideSuggest();
+ return;
+ }
+ if (!data.ok || !data.suggestions || !data.suggestions.length) {
+ hideSuggest();
+ return;
+ }
+ searchSuggest.innerHTML = data.suggestions
+ .map(
+ (s) =>
+ ''
+ )
+ .join('');
+ searchSuggest.hidden = false;
+ };
+ xhr.send();
+ }
+
+ if (searchInput) {
+ if (listContext.searchQuery) {
+ searchInput.value = listContext.searchQuery;
+ if (searchClear) searchClear.hidden = false;
+ }
+ searchInput.addEventListener('input', () => {
+ const q = searchInput.value.trim();
+ if (searchClear) searchClear.hidden = !q;
+ clearTimeout(suggestTimer);
+ clearTimeout(searchRunTimer);
+ if (q.length < 2) {
+ hideSuggest();
+ return;
+ }
+ suggestTimer = setTimeout(() => fetchSuggest(q), 1000);
+ searchRunTimer = setTimeout(() => applySearchQuery(q, true), 1600);
+ });
+ searchInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ clearTimeout(suggestTimer);
+ clearTimeout(searchRunTimer);
+ hideSuggest();
+ applySearchQuery(searchInput.value.trim(), true);
+ }
+ if (e.key === 'Escape') {
+ hideSuggest();
+ }
+ });
+ }
+ if (searchSuggest) {
+ searchSuggest.addEventListener('click', (e) => {
+ const btn = e.target.closest('[data-suggest]');
+ if (!btn) return;
+ const term = btn.getAttribute('data-suggest') || '';
+ if (searchInput) searchInput.value = term;
+ hideSuggest();
+ applySearchQuery(term, true);
+ });
+ }
+ if (searchClear) {
+ searchClear.addEventListener('click', () => {
+ if (searchInput) searchInput.value = '';
+ searchClear.hidden = true;
+ hideSuggest();
+ applySearchQuery('', true);
+ });
+ }
+
applyColgroup(grouped);
load();
}
@@ -899,7 +1185,7 @@
els.saveBtn.addEventListener('click', () => {
setStatus('Saving…', true);
const xhr = new XMLHttpRequest();
- xhr.open('PUT', basePath() + '/api/report_tags.php', true);
+ xhr.open('POST', basePath() + '/api/report_tags.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
@@ -910,7 +1196,7 @@
return;
}
if (!data.ok) {
- setStatus(data.error || 'Save failed', false);
+ setStatus(data.hint || data.error || 'Save failed', false);
return;
}
state.tags = data.tags || [];
diff --git a/examples/crash_reporter/backend/scripts/init-mariadb.sh b/examples/crash_reporter/backend/scripts/init-mariadb.sh
index 719fdf7..8d557d0 100755
--- a/examples/crash_reporter/backend/scripts/init-mariadb.sh
+++ b/examples/crash_reporter/backend/scripts/init-mariadb.sh
@@ -20,14 +20,16 @@ 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 ..."
+ echo "Creating app user ${DB_USER}@localhost and @127.0.0.1 ..."
mysql -u root -p < PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
diff --git a/examples/crash_reporter/backend/src/ReportRepository.php b/examples/crash_reporter/backend/src/ReportRepository.php
index 6168e76..4a721f3 100644
--- a/examples/crash_reporter/backend/src/ReportRepository.php
+++ b/examples/crash_reporter/backend/src/ReportRepository.php
@@ -123,14 +123,7 @@ final class ReportRepository {
$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);
+ self::finishListRows($items, $sinceMs, false);
return [
'items' => $items,
@@ -143,6 +136,413 @@ final class ReportRepository {
];
}
+ /**
+ * @param array{similar_to?:int,device?:string,abi?:string,os_sdk?:int,os_release?:string} $filters
+ */
+ public static function listFiltered(
+ array $filters,
+ 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';
+ $userId = (int) (Auth::user()['id'] ?? 0);
+ $where = ['1=1'];
+ $params = [];
+ $device = trim((string) ($filters['device'] ?? ''));
+ if ($device !== '') {
+ $like = '%' . $device . '%';
+ $where[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)';
+ $params[] = $like;
+ $params[] = $like;
+ }
+ $abi = trim((string) ($filters['abi'] ?? ''));
+ if ($abi !== '') {
+ $where[] = 'r.payload_json LIKE ?';
+ $params[] = '%' . $abi . '%';
+ }
+ $sdk = (int) ($filters['os_sdk'] ?? 0);
+ if ($sdk > 0) {
+ $where[] = 'r.payload_json LIKE ?';
+ $params[] = '%"sdk_int":' . $sdk . '%';
+ }
+ $release = trim((string) ($filters['os_release'] ?? ''));
+ if ($release !== '') {
+ $where[] = '(r.payload_json LIKE ? OR r.payload_json LIKE ?)';
+ $params[] = '%"release":"' . $release . '"%';
+ $params[] = '%"release": "' . $release . '"%';
+ }
+ $appVersion = trim((string) ($filters['app_version'] ?? ''));
+ if ($appVersion !== '') {
+ $like = '%' . $appVersion . '%';
+ $where[] = '(r.app_version = ? OR r.app_version LIKE ? OR r.payload_json LIKE ?)';
+ $params[] = $appVersion;
+ $params[] = $like;
+ $params[] = '%"version_name":"' . $appVersion . '"%';
+ }
+ $build = trim((string) ($filters['build'] ?? ''));
+ if ($build !== '') {
+ $where[] = 'r.payload_json LIKE ?';
+ $params[] = '%' . $build . '%';
+ }
+ $whereSql = implode(' AND ', $where);
+ $countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql");
+ $countStmt->execute($params);
+ $total = (int) $countStmt->fetchColumn();
+ $sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority';
+ if ($sortCol === 'rating') {
+ $sortCol = 'fingerprint_cnt';
+ }
+ $orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
+ $offset = ($page - 1) * $perPage;
+ $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 = ?
+ WHERE $whereSql
+ ORDER BY $orderBy
+ LIMIT $perPage OFFSET $offset";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute(array_merge([$sinceMs, $userId], $params));
+ $items = $stmt->fetchAll();
+ self::finishListRows($items, $sinceMs, false);
+ return [
+ 'items' => $items,
+ 'total' => $total,
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'sort' => $sort === 'priority' ? 'priority' : $sortCol,
+ 'dir' => strtolower($dir),
+ 'grouped' => false,
+ 'filter' => $filters,
+ ];
+ }
+
+ public static function listSimilar(
+ int $anchorId,
+ int $page,
+ int $perPage,
+ int $sinceMs = 0
+ ): array {
+ $anchor = self::getById($anchorId);
+ if ($anchor === null) {
+ return [
+ 'items' => [],
+ 'total' => 0,
+ 'page' => 1,
+ 'per_page' => $perPage,
+ 'sort' => 'similarity',
+ 'dir' => 'desc',
+ 'grouped' => false,
+ 'similar_to' => $anchorId,
+ ];
+ }
+ $pdo = Database::pdo();
+ $page = max(1, $page);
+ $perPage = max(1, min(200, $perPage));
+ $userId = (int) (Auth::user()['id'] ?? 0);
+ $payload = is_array($anchor['payload'] ?? null) ? $anchor['payload'] : [];
+ $keys = report_similarity_keys($payload);
+ $fp = $keys['fingerprint'];
+ $ct = $keys['crash_type'];
+ $familyLike = $keys['device_family'] !== '' ? '%' . $keys['device_family'] . '%' : null;
+ $excLike = $keys['exception'] !== '' ? '%' . $keys['exception'] . '%' : null;
+ $sigLike = $keys['signal'] !== '' ? '%' . $keys['signal'] . '%' : null;
+
+ $where = ['r.id != ?'];
+ $params = [$anchorId];
+ $or = [];
+ if ($fp !== '') {
+ $or[] = 'r.fingerprint = ?';
+ $params[] = $fp;
+ }
+ if ($ct !== '') {
+ $or[] = 'r.crash_type = ?';
+ $params[] = $ct;
+ }
+ if ($familyLike !== null) {
+ $or[] = '(r.device_model LIKE ? OR r.payload_json LIKE ?)';
+ $params[] = $familyLike;
+ $params[] = $familyLike;
+ }
+ if ($excLike !== null) {
+ $or[] = 'r.payload_json LIKE ?';
+ $params[] = $excLike;
+ }
+ if ($sigLike !== null) {
+ $or[] = 'r.payload_json LIKE ?';
+ $params[] = $sigLike;
+ }
+ if ($or === []) {
+ $or[] = '1=1';
+ }
+ $where[] = '(' . implode(' OR ', $or) . ')';
+ $whereSql = implode(' AND ', $where);
+
+ $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 = ?
+ WHERE $whereSql
+ ORDER BY r.received_at_ms DESC
+ LIMIT 1200";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute(array_merge([$sinceMs, $userId], $params));
+ $rows = $stmt->fetchAll();
+ $scored = [];
+ foreach ($rows as $row) {
+ $p = json_decode($row['payload_json'] ?? '', true);
+ if (!is_array($p)) {
+ $p = [];
+ }
+ $score = report_similarity_score($keys, $row, $p);
+ if ($score <= 0) {
+ continue;
+ }
+ $row['similarity_score'] = $score;
+ $row['similarity_tier'] = $score >= 1000 ? 'exact' : ($score >= 200 ? 'strong' : ($score >= 80 ? 'likely' : 'guess'));
+ $scored[] = $row;
+ }
+ usort($scored, static function (array $a, array $b): int {
+ $sa = (int) ($a['similarity_score'] ?? 0);
+ $sb = (int) ($b['similarity_score'] ?? 0);
+ if ($sa !== $sb) {
+ return $sb <=> $sa;
+ }
+ return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0));
+ });
+ $total = count($scored);
+ $offset = ($page - 1) * $perPage;
+ $slice = array_values(array_slice($scored, $offset, $perPage));
+ if ($page === 1) {
+ $anchorRow = null;
+ foreach ($scored as $row) {
+ if ((int) $row['id'] === $anchorId) {
+ $anchorRow = $row;
+ break;
+ }
+ }
+ if ($anchorRow === null) {
+ $device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
+ $app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
+ $anchorRow = [
+ 'id' => $anchorId,
+ 'report_id' => $anchor['report_id'] ?? '',
+ 'fingerprint' => $anchor['fingerprint'] ?? ($payload['fingerprint'] ?? ''),
+ 'crash_type' => $anchor['crash_type'] ?? ($payload['crash_type'] ?? ''),
+ 'generated_at_ms' => (int) ($anchor['generated_at_ms'] ?? 0),
+ 'received_at_ms' => (int) ($anchor['received_at_ms'] ?? 0),
+ 'device_model' => $anchor['device_model'] ?? ($device['model'] ?? null),
+ 'app_version' => $anchor['app_version'] ?? ($app['version_name'] ?? null),
+ 'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}',
+ 'tags_json' => json_encode($anchor['tags'] ?? [], JSON_UNESCAPED_UNICODE) ?: '[]',
+ 'viewed' => !empty($anchor['viewed']) ? 1 : 0,
+ 'fingerprint_cnt' => 1,
+ 'fingerprint_recent_cnt' => 0,
+ ];
+ }
+ $anchorRow['similarity_score'] = 10000;
+ $anchorRow['similarity_tier'] = 'anchor';
+ $slice = array_values(array_filter(
+ $slice,
+ static fn (array $r): bool => (int) ($r['id'] ?? 0) !== $anchorId
+ ));
+ array_unshift($slice, $anchorRow);
+ if (count($slice) > $perPage) {
+ $slice = array_slice($slice, 0, $perPage);
+ }
+ }
+ self::finishListRows($slice, $sinceMs, false, true);
+ return [
+ 'items' => $slice,
+ 'total' => $total + 1,
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'sort' => 'similarity',
+ 'dir' => 'desc',
+ 'grouped' => false,
+ 'similar_to' => $anchorId,
+ ];
+ }
+
+ /** @return list */
+ public static function searchSuggestions(string $q, int $limit = 10): array {
+ $tokens = search_query_tokens($q);
+ $prefix = $tokens[0] ?? mb_strtolower(trim($q));
+ if (strlen($prefix) < 2) {
+ return [];
+ }
+ $pdo = Database::pdo();
+ $stmt = $pdo->query(
+ 'SELECT payload_json, device_model, app_version, crash_type FROM reports
+ ORDER BY received_at_ms DESC LIMIT 500'
+ );
+ $freq = [];
+ while ($row = $stmt->fetch()) {
+ $payload = json_decode($row['payload_json'] ?? '', true);
+ if (!is_array($payload)) {
+ $payload = [];
+ }
+ $java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
+ $device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
+ $candidates = array_filter([
+ $java['exception'] ?? null,
+ $row['crash_type'] ?? null,
+ $row['device_model'] ?? null,
+ $device['manufacturer'] ?? null,
+ $device['model'] ?? null,
+ $row['app_version'] ?? null,
+ ]);
+ foreach ($candidates as $term) {
+ $term = trim((string) $term);
+ if ($term === '' || strlen($term) < 3) {
+ continue;
+ }
+ $low = mb_strtolower($term);
+ if (!str_contains($low, $prefix)) {
+ continue;
+ }
+ $freq[$term] = ($freq[$term] ?? 0) + 1;
+ }
+ if (!empty($java['message'])) {
+ if (preg_match_all('/[A-Za-z][A-Za-z0-9_]{3,}(?:Exception|Error)/', (string) $java['message'], $m)) {
+ foreach ($m[0] as $term) {
+ $low = mb_strtolower($term);
+ if (str_contains($low, $prefix)) {
+ $freq[$term] = ($freq[$term] ?? 0) + 2;
+ }
+ }
+ }
+ }
+ }
+ arsort($freq);
+ return array_slice(array_keys($freq), 0, max(1, min(20, $limit)));
+ }
+
+ public static function search(string $q, int $page, int $perPage, int $sinceMs = 0): array {
+ $tokens = search_query_tokens($q);
+ if ($tokens === []) {
+ return [
+ 'items' => [],
+ 'total' => 0,
+ 'page' => 1,
+ 'per_page' => $perPage,
+ 'sort' => 'relevance',
+ 'dir' => 'desc',
+ 'grouped' => false,
+ 'search' => $q,
+ ];
+ }
+ $pdo = Database::pdo();
+ $page = max(1, $page);
+ $perPage = max(1, min(200, $perPage));
+ $userId = (int) (Auth::user()['id'] ?? 0);
+ $orParts = [];
+ $params = [];
+ foreach ($tokens as $token) {
+ $like = '%' . $token . '%';
+ $orParts[] = '(r.payload_json LIKE ? OR r.device_model LIKE ? OR r.app_version LIKE ?
+ OR r.fingerprint LIKE ? OR r.crash_type LIKE ?)';
+ array_push($params, $like, $like, $like, $like, $like);
+ }
+ $whereSql = '(' . implode(' OR ', $orParts) . ')';
+ $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 = ?
+ WHERE $whereSql
+ ORDER BY r.received_at_ms DESC
+ LIMIT 1500";
+ $stmt = $pdo->prepare($sql);
+ $stmt->execute(array_merge([$sinceMs, $userId], $params));
+ $scored = [];
+ while ($row = $stmt->fetch()) {
+ $payload = json_decode($row['payload_json'] ?? '', true);
+ if (!is_array($payload)) {
+ $payload = [];
+ }
+ report_enrich_list_row($row, $sinceMs);
+ $score = report_search_score($tokens, $row, $payload);
+ if ($score <= 0) {
+ continue;
+ }
+ $row['search_score'] = $score;
+ $scored[] = $row;
+ }
+ usort($scored, static function (array $a, array $b): int {
+ $sa = (int) ($a['search_score'] ?? 0);
+ $sb = (int) ($b['search_score'] ?? 0);
+ if ($sa !== $sb) {
+ return $sb <=> $sa;
+ }
+ return ((int) ($b['received_at_ms'] ?? 0)) <=> ((int) ($a['received_at_ms'] ?? 0));
+ });
+ $total = count($scored);
+ $offset = ($page - 1) * $perPage;
+ $slice = array_slice($scored, $offset, $perPage);
+ self::finishListRows($slice, $sinceMs, false, false, true);
+ return [
+ 'items' => $slice,
+ 'total' => $total,
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'sort' => 'relevance',
+ 'dir' => 'desc',
+ 'grouped' => false,
+ 'search' => $q,
+ ];
+ }
+
+ /** @param list> $items */
+ private static function finishListRows(
+ array &$items,
+ int $sinceMs,
+ bool $grouped,
+ bool $similar = false,
+ bool $search = false
+ ): void {
+ foreach ($items as &$row) {
+ $row['viewed'] = (bool) ($row['viewed'] ?? false);
+ $row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
+ report_enrich_list_row($row, $sinceMs);
+ $brief = report_brief_lines($row, $grouped);
+ if ($similar && !empty($row['similarity_tier'])) {
+ $tier = (string) $row['similarity_tier'];
+ $labels = [
+ 'anchor' => 'This report',
+ 'exact' => 'Same fingerprint',
+ 'strong' => 'Strong match',
+ 'likely' => 'Likely similar',
+ 'guess' => 'Possible match',
+ ];
+ array_unshift($brief, ($labels[$tier] ?? $tier) . ' · score ' . (int) ($row['similarity_score'] ?? 0));
+ }
+ if ($search && !empty($row['search_score'])) {
+ array_unshift($brief, 'Search match · relevance ' . (int) $row['search_score']);
+ }
+ $row['brief'] = $brief;
+ unset($row['payload_json'], $row['tags_json']);
+ }
+ unset($row);
+ }
+
private static function buildListOrder(string $sort, string $dir): string {
$dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC';
if ($sort === 'priority') {
diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php
index 35718d5..c0bf737 100644
--- a/examples/crash_reporter/backend/src/bootstrap.php
+++ b/examples/crash_reporter/backend/src/bootstrap.php
@@ -134,7 +134,6 @@ 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)) {
@@ -152,6 +151,96 @@ function format_device_abis(mixed $abis): string {
return implode(', ', $parts);
}
+function device_display_name(array $device): string {
+ return trim(((string) ($device['manufacturer'] ?? '')) . ' ' . ((string) ($device['model'] ?? '')));
+}
+
+/** Shorter family name for “similar device” (drops last token when 3+ words). */
+function device_model_family(string $manufacturer, string $model): string {
+ $full = trim($manufacturer . ' ' . $model);
+ if ($full === '') {
+ return '';
+ }
+ $parts = preg_split('/\s+/', $full) ?: [];
+ if (count($parts) <= 2) {
+ return $full;
+ }
+ return implode(' ', array_slice($parts, 0, -1));
+}
+
+/** Keys used to rank similar crashes (from payload). */
+function report_similarity_keys(array $payload): array {
+ $java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
+ $native = is_array($payload['native'] ?? null) ? $payload['native'] : [];
+ $device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
+ $exc = (string) ($java['exception'] ?? '');
+ $msg = (string) ($java['message'] ?? '');
+ $topic = '';
+ if ($msg !== '' && preg_match('/\b([A-Za-z][A-Za-z0-9_]*(?:Exception|Error))\b/', $msg, $m)) {
+ $topic = $m[1];
+ }
+ return [
+ 'fingerprint' => (string) ($payload['fingerprint'] ?? ''),
+ 'crash_type' => strtolower((string) ($payload['crash_type'] ?? '')),
+ 'exception' => $exc,
+ 'exception_topic' => $topic,
+ 'signal' => (string) ($native['signal'] ?? ''),
+ 'device_family' => device_model_family(
+ (string) ($device['manufacturer'] ?? ''),
+ (string) ($device['model'] ?? '')
+ ),
+ 'device_model' => (string) ($device['model'] ?? ''),
+ ];
+}
+
+/** Higher = more similar. */
+function report_similarity_score(array $anchorKeys, array $row, array $rowPayload): int {
+ $score = 0;
+ $fp = (string) ($row['fingerprint'] ?? $rowPayload['fingerprint'] ?? '');
+ $ct = strtolower((string) ($row['crash_type'] ?? $rowPayload['crash_type'] ?? ''));
+ if ($anchorKeys['fingerprint'] !== '' && $fp === $anchorKeys['fingerprint']) {
+ $score += 1000;
+ }
+ if ($anchorKeys['crash_type'] !== '' && $ct === $anchorKeys['crash_type']) {
+ $score += 80;
+ }
+ $java = is_array($rowPayload['java'] ?? null) ? $rowPayload['java'] : [];
+ $native = is_array($rowPayload['native'] ?? null) ? $rowPayload['native'] : [];
+ $exc = (string) ($java['exception'] ?? '');
+ if ($anchorKeys['exception'] !== '' && $exc !== '' && $exc === $anchorKeys['exception']) {
+ $score += 120;
+ }
+ if ($anchorKeys['exception_topic'] !== '') {
+ $msg = (string) ($java['message'] ?? '');
+ if ($msg !== '' && stripos($msg, $anchorKeys['exception_topic']) !== false) {
+ $score += 60;
+ }
+ }
+ $sig = (string) ($native['signal'] ?? '');
+ if ($anchorKeys['signal'] !== '' && $sig !== '' && $sig === $anchorKeys['signal']) {
+ $score += 100;
+ }
+ $device = is_array($rowPayload['device'] ?? null) ? $rowPayload['device'] : [];
+ $family = device_model_family(
+ (string) ($device['manufacturer'] ?? ''),
+ (string) ($device['model'] ?? '')
+ );
+ if ($anchorKeys['device_family'] !== '' && $family !== '') {
+ if ($family === $anchorKeys['device_family']) {
+ $score += 50;
+ } elseif (str_starts_with($family, $anchorKeys['device_family'])
+ || str_starts_with($anchorKeys['device_family'], $family)) {
+ $score += 25;
+ }
+ }
+ $dm = (string) ($row['device_model'] ?? $device['model'] ?? '');
+ if ($anchorKeys['device_model'] !== '' && $dm !== '' && $dm === $anchorKeys['device_model']) {
+ $score += 30;
+ }
+ return $score;
+}
+
+/** OS product name (Android, Linux, …) — not device manufacturer. */
function device_os_name(array $device): string {
if (!empty($device['os_name'])) {
return trim((string) $device['os_name']);
@@ -228,6 +317,100 @@ function report_rating_score(int $fingerprintTotal, int $fingerprintRecent, int
return max(0, min(5, $score));
}
+/** @return list */
+function search_query_tokens(string $q): array {
+ $q = mb_strtolower(trim($q));
+ if ($q === '') {
+ return [];
+ }
+ $parts = preg_split('/\s+/u', $q) ?: [];
+ $out = [];
+ foreach ($parts as $part) {
+ $t = preg_replace('/[^a-z0-9_.-]+/i', '', $part) ?? '';
+ if (strlen($t) >= 2) {
+ $out[] = $t;
+ }
+ }
+ return array_values(array_unique($out));
+}
+
+/** Lowercase blob for keyword matching. */
+function report_search_blob(array $row, array $payload): string {
+ $java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
+ $native = is_array($payload['native'] ?? null) ? $payload['native'] : [];
+ $device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
+ $app = is_array($payload['app'] ?? null) ? $payload['app'] : [];
+ $build = is_array($payload['build'] ?? null) ? $payload['build'] : [];
+ $parts = [
+ $row['crash_type'] ?? '',
+ $row['fingerprint'] ?? '',
+ $row['device_model'] ?? '',
+ $row['app_version'] ?? '',
+ $payload['scenario'] ?? '',
+ $java['exception'] ?? '',
+ $java['message'] ?? '',
+ $java['thread'] ?? '',
+ $native['signal'] ?? '',
+ $device['manufacturer'] ?? '',
+ $device['brand'] ?? '',
+ $device['model'] ?? '',
+ $device['product'] ?? '',
+ $app['package'] ?? '',
+ $app['version_name'] ?? '',
+ $build['git_commit'] ?? '',
+ $build['git'] ?? '',
+ ];
+ if (!empty($java['stack_frames']) && is_array($java['stack_frames'])) {
+ $parts[] = implode(' ', array_map('strval', array_slice($java['stack_frames'], 0, 3)));
+ }
+ return mb_strtolower(implode(' ', array_filter(array_map('strval', $parts))));
+}
+
+/**
+ * @param list $tokens
+ */
+function report_search_score(array $tokens, array $row, array $payload): int {
+ if ($tokens === []) {
+ return 0;
+ }
+ $blob = report_search_blob($row, $payload);
+ $java = is_array($payload['java'] ?? null) ? $payload['java'] : [];
+ $exc = mb_strtolower((string) ($java['exception'] ?? ''));
+ $msg = mb_strtolower((string) ($java['message'] ?? ''));
+ $device = is_array($payload['device'] ?? null) ? $payload['device'] : [];
+ $mfr = mb_strtolower((string) ($device['manufacturer'] ?? ''));
+ $brand = mb_strtolower((string) ($device['brand'] ?? ''));
+ $model = mb_strtolower((string) ($device['model'] ?? ''));
+ $score = 0;
+ foreach ($tokens as $token) {
+ if ($token === '') {
+ continue;
+ }
+ if ($exc !== '' && str_contains($exc, $token)) {
+ $score += 220;
+ } elseif ($msg !== '' && str_contains($msg, $token)) {
+ $score += 140;
+ } elseif ($mfr !== '' && str_contains($mfr, $token)) {
+ $score += 70;
+ } elseif ($brand !== '' && str_contains($brand, $token)) {
+ $score += 65;
+ } elseif ($model !== '' && str_contains($model, $token)) {
+ $score += 60;
+ } elseif (str_contains($blob, $token)) {
+ $score += 45;
+ }
+ }
+ $rating = (int) ($row['rating'] ?? 0);
+ if ($rating > 0 && $score > 0) {
+ $score += $rating * 12;
+ }
+ $fpCnt = (int) ($row['fingerprint_cnt'] ?? 1);
+ if ($fpCnt > 1 && $score > 0) {
+ $score += min(40, (int) log($fpCnt + 1) * 10);
+ }
+ return $score;
+}
+
/** System tags — not stored in tags_json (computed in UI). */
function report_reserved_tag_ids(): array {
return ['new', 'java', 'ndk', 'android'];
diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php
index b85dbd8..c5ea72c 100644
--- a/examples/crash_reporter/backend/views/layout.php
+++ b/examples/crash_reporter/backend/views/layout.php
@@ -106,6 +106,16 @@
+
+
Loading…
diff --git a/examples/crash_reporter/backend/views/report_detail.php b/examples/crash_reporter/backend/views/report_detail.php
index 67b8276..55a44b1 100644
--- a/examples/crash_reporter/backend/views/report_detail.php
+++ b/examples/crash_reporter/backend/views/report_detail.php
@@ -9,9 +9,32 @@ $rawJson = safe_json_encode($p, $jsonFlags);
$sessionJson = !empty($p['session_context'])
? safe_json_encode($p['session_context'], $jsonFlags)
: '';
+$bp = Auth::basePath();
+$reportDbId = (int) ($report['id'] ?? 0);
+$deviceName = device_display_name($device);
+$deviceFamily = device_model_family(
+ (string) ($device['manufacturer'] ?? ''),
+ (string) ($device['model'] ?? '')
+);
+$filterDeviceQ = $deviceFamily !== '' ? $deviceFamily : $deviceName;
+$similarUrl = $bp . '/?view=reports&similar_to=' . $reportDbId;
+$filterDeviceUrl = $bp . '/?view=reports&filter_device=' . rawurlencode($filterDeviceQ);
+$sdk = (int) ($device['sdk_int'] ?? 0);
+$release = (string) ($device['release'] ?? '');
+$filterOsUrl = $sdk > 0
+ ? $bp . '/?view=reports&filter_os_sdk=' . $sdk
+ : ($release !== '' ? $bp . '/?view=reports&filter_os_release=' . rawurlencode($release) : '');
+$abiList = [];
+if (!empty($device['abis']) && is_array($device['abis'])) {
+ foreach ($device['abis'] as $abi) {
+ if (is_scalar($abi) && trim((string) $abi) !== '') {
+ $abiList[] = trim((string) $abi);
+ }
+ }
+}
?>
-
+
Tags
Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.
@@ -56,17 +79,50 @@ $sessionJson = !empty($p['session_context'])
?>
Device
- = h(trim(($device['manufacturer'] ?? '') . ' ' . ($device['model'] ?? ''))) ?>
- Android = h($device['release'] ?? '') ?> (SDK = (int)($device['sdk_int'] ?? 0) ?>)
-
- ABIs: = h(format_device_abis($device['abis'] ?? null)) ?>
+
+ = h($deviceName) ?>
+
+ —
+
+
+ Android = h($release) ?> (SDK = $sdk ?>)
+
+ Android = h($release) ?> (SDK = $sdk ?>)
+
+
+ ABIs:
+ $abi): ?>
+ 0): ?>,
+ = h($abi) ?>
+
+
App
= h($app['package'] ?? '') ?>
- v= h($app['version_name'] ?? '') ?> (= h((string)($app['version_code'] ?? '')) ?>)
-
- Build: = h($b['git_commit'] ?? $b['git'] ?? '') ?>
+
+
+ v= h($versionName) ?> (= h((string)($app['version_code'] ?? '')) ?>)
+
+ v= h($versionName) ?> (= h((string)($app['version_code'] ?? '')) ?>)
+
+
+
+ Build: = h($buildId) ?>
+
+ Build: = h($buildId) ?>
+
Timing
@@ -77,7 +133,10 @@ $sessionJson = !empty($p['session_context'])
- Java exception
+
= h($j['exception'] ?? '') ?>= !empty($j['message']) ? ': ' . h($j['message']) : '' ?>
Thread: = h($j['thread'] ?? '') ?>
@@ -85,7 +144,10 @@ $sessionJson = !empty($p['session_context'])
- Native crash
+
Signal: = h($n['signal'] ?? '') ?>
|