1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 07:58:10 +03:00

be stuff, fe reqs

This commit is contained in:
Anton Afanasyeu
2026-05-22 23:56:07 +02:00
parent 24b5a49d08
commit 7900441ff6
12 changed files with 1245 additions and 112 deletions

View File

@@ -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',

View File

@@ -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'] ?? '',
];

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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; }

View File

@@ -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(' · ') +
' — <a href="' +
escapeHtml(basePath() + '/?view=reports') +
'">Clear filters</a>';
}
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 +=
'<th class="' +
@@ -552,7 +660,11 @@
'"' +
w +
' scope="col"><span class="th-inner">' +
(grouped ? '' : '<span class="th-drag" title="Drag to reorder column" aria-hidden="true">⋮⋮</span>') +
(grouped
? ''
: '<span class="th-drag" draggable="true" data-col-key="' +
escapeHtml(c.key) +
'" title="Drag to reorder column" aria-hidden="true">⋮⋮</span>') +
'<span class="th-label">' +
escapeHtml(c.label) +
'</span> <span class="sort-ind" aria-hidden="true">' +
@@ -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 = '<div class="pagination-inner">';
html +=
'<button type="button" class="btn page-btn" data-page="' +
(prev || '') +
'" ' +
(prev ? '' : 'disabled') +
'>← Previous</button>';
'>' +
escapeHtml(prevLabel) +
'</button>';
html +=
'<span class="page-info">Page ' +
page +
@@ -609,7 +741,9 @@
(next || '') +
'" ' +
(next ? '' : 'disabled') +
'>Next →</button>';
'>' +
escapeHtml(nextLabel) +
'</button>';
html += '</div>';
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) =>
'<li role="option"><button type="button" class="suggest-item" data-suggest="' +
escapeHtml(s) +
'">' +
escapeHtml(s) +
'</button></li>'
)
.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 || [];

View File

@@ -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 <<EOF
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
CREATE USER IF NOT EXISTS '${DB_USER}'@'127.0.0.1' IDENTIFIED BY '${DB_PASS}';
GRANT SELECT, INSERT, UPDATE, DELETE ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE ON ${DB_NAME}.* TO '${DB_USER}'@'127.0.0.1';
FLUSH PRIVILEGES;
EOF
else
echo "Skip app user: set CRASH_DB_PASS to create '${DB_USER}'@127.0.0.1"
echo "Skip app user: set CRASH_DB_PASS to create '${DB_USER}'@localhost and @127.0.0.1"
fi
echo "Done. Set config.php: db.driver = mysql, db.mysql.* then restart php-fpm."

View File

@@ -25,13 +25,21 @@ final class Database {
}
if (self::isMysql()) {
$m = cfg('db.mysql', []);
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$m['host'] ?? '127.0.0.1',
(int) ($m['port'] ?? 3306),
$m['database'] ?? 'androidcast_crashes',
$m['charset'] ?? 'utf8mb4'
);
$db = $m['database'] ?? 'androidcast_crashes';
$charset = $m['charset'] ?? 'utf8mb4';
$socket = trim((string) ($m['socket'] ?? ''));
// Prefer explicit socket from config (Alpine: TCP 3306 often closed).
if ($socket !== '') {
$dsn = sprintf('mysql:unix_socket=%s;dbname=%s;charset=%s', $socket, $db, $charset);
} else {
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$m['host'] ?? '127.0.0.1',
(int) ($m['port'] ?? 3306),
$db,
$charset
);
}
self::$pdo = new PDO($dsn, $m['username'] ?? '', $m['password'] ?? '', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,

View File

@@ -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<string> */
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<array<string,mixed>> $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') {

View File

@@ -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<string> */
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<string> $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'];

View File

@@ -106,6 +106,16 @@
</label>
</div>
</div>
<p id="reports-filter-banner" class="reports-filter-banner muted" hidden></p>
<div class="reports-search-row">
<label class="reports-search-label" for="reports-search-input">Search</label>
<div class="reports-search-field">
<input type="search" id="reports-search-input" class="reports-search-input"
placeholder="Keywords (exceptions, devices, stack traces…)" autocomplete="off" spellcheck="false">
<ul id="reports-search-suggest" class="reports-search-suggest" hidden role="listbox"></ul>
</div>
<button type="button" class="btn" id="reports-search-clear" hidden>Clear</button>
</div>
<p id="reports-status" class="reports-status muted" aria-live="polite">Loading…</p>
<div class="reports-table-wrap">
<table class="data-table reports-tree reports-table--cols" id="reports-tree">

View File

@@ -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);
}
}
}
?>
<div class="detail-header">
<a href="<?= h(Auth::basePath()) ?>/?view=reports" class="back-link" aria-label="Back to reports list" title="Back to list">
<a href="<?= h($bp) ?>/?view=reports" class="back-link" aria-label="Back to reports list" title="Back to list">
<span class="back-icon" aria-hidden="true"></span>
</a>
<div>
@@ -20,7 +43,7 @@ $sessionJson = !empty($p['session_context'])
</div>
</div>
<section class="tag-editor" id="tag-editor" data-report-id="<?= (int) ($report['id'] ?? 0) ?>">
<section class="tag-editor" id="tag-editor" data-report-id="<?= $reportDbId ?>">
<div class="tag-editor-head">
<h2>Tags</h2>
<p class="muted tag-editor-hint">Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.</p>
@@ -56,17 +79,50 @@ $sessionJson = !empty($p['session_context'])
?></script>
<div class="cards cards--lift">
<div class="card card--lift"><h3>Device</h3>
<p><?= h(trim(($device['manufacturer'] ?? '') . ' ' . ($device['model'] ?? ''))) ?></p>
<p>Android <?= h($device['release'] ?? '') ?> (SDK <?= (int)($device['sdk_int'] ?? 0) ?>)</p>
<?php if (!empty($device['abis'])): ?>
<p class="muted">ABIs: <?= h(format_device_abis($device['abis'] ?? null)) ?></p>
<?php if ($deviceName !== ''): ?>
<p><a class="detail-filter-link" href="<?= h($filterDeviceUrl) ?>" title="Show reports for similar devices"><?= h($deviceName) ?></a></p>
<?php else: ?>
<p class="muted"></p>
<?php endif; ?>
<?php if ($filterOsUrl !== ''): ?>
<p><a class="detail-filter-link" href="<?= h($filterOsUrl) ?>" title="Show reports on this OS">Android <?= h($release) ?> (SDK <?= $sdk ?>)</a></p>
<?php else: ?>
<p>Android <?= h($release) ?> (SDK <?= $sdk ?>)</p>
<?php endif; ?>
<?php if ($abiList !== []): ?>
<p class="muted detail-abi-row">ABIs:
<?php foreach ($abiList as $i => $abi): ?>
<?php if ($i > 0): ?>, <?php endif; ?>
<a class="detail-filter-link detail-filter-link--tag" href="<?= h($bp . '/?view=reports&filter_abi=' . rawurlencode($abi)) ?>"><?= h($abi) ?></a>
<?php endforeach; ?>
</p>
<?php endif; ?>
</div>
<div class="card card--lift"><h3>App</h3>
<p><?= h($app['package'] ?? '') ?></p>
<p>v<?= h($app['version_name'] ?? '') ?> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
<?php if (!empty($p['build'])): $b = $p['build']; ?>
<p class="muted">Build: <?= h($b['git_commit'] ?? $b['git'] ?? '') ?></p>
<?php
$versionName = (string) ($app['version_name'] ?? '');
$filterAppUrl = $versionName !== ''
? $bp . '/?view=reports&filter_app_version=' . rawurlencode($versionName)
: '';
?>
<?php if ($filterAppUrl !== ''): ?>
<p><a class="detail-filter-link" href="<?= h($filterAppUrl) ?>" title="Show reports for this app version">v<?= h($versionName) ?></a> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
<?php else: ?>
<p>v<?= h($versionName) ?> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
<?php endif; ?>
<?php if (!empty($p['build'])):
$b = $p['build'];
$buildId = trim((string) ($b['git_commit'] ?? $b['git'] ?? ''));
$filterBuildUrl = $buildId !== ''
? $bp . '/?view=reports&filter_build=' . rawurlencode($buildId)
: '';
?>
<?php if ($filterBuildUrl !== ''): ?>
<p class="muted">Build: <a class="detail-filter-link" href="<?= h($filterBuildUrl) ?>" title="Show reports from this build"><?= h($buildId) ?></a></p>
<?php else: ?>
<p class="muted">Build: <?= h($buildId) ?></p>
<?php endif; ?>
<?php endif; ?>
</div>
<div class="card card--lift"><h3>Timing</h3>
@@ -77,7 +133,10 @@ $sessionJson = !empty($p['session_context'])
</div>
<?php if (!empty($p['java'])): $j = $p['java']; ?>
<section class="stack-block">
<h2>Java exception</h2>
<div class="stack-block-head">
<h2>Java exception</h2>
<a class="btn btn-similar" href="<?= h($similarUrl) ?>">Find similar reports</a>
</div>
<p class="exc"><?= h($j['exception'] ?? '') ?><?= !empty($j['message']) ? ': ' . h($j['message']) : '' ?></p>
<p class="muted">Thread: <?= h($j['thread'] ?? '') ?></p>
<pre class="stack"><?php foreach ($j['stack_frames'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
@@ -85,7 +144,10 @@ $sessionJson = !empty($p['session_context'])
<?php endif; ?>
<?php if (!empty($p['native'])): $n = $p['native']; ?>
<section class="stack-block">
<h2>Native crash</h2>
<div class="stack-block-head">
<h2>Native crash</h2>
<a class="btn btn-similar" href="<?= h($similarUrl) ?>">Find similar reports</a>
</div>
<p class="exc">Signal: <?= h($n['signal'] ?? '') ?></p>
<pre class="stack"><?php foreach ($n['backtrace'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
</section>