mirror of
git://f0xx.org/android_cast
synced 2026-07-29 07:39:15 +03:00
be stuff, fe reqs
This commit is contained in:
@@ -19,8 +19,10 @@ return [
|
|||||||
'driver' => 'sqlite', // sqlite | mysql | mariadb (alias)
|
'driver' => 'sqlite', // sqlite | mysql | mariadb (alias)
|
||||||
'sqlite_path' => __DIR__ . '/../data/crashes.sqlite',
|
'sqlite_path' => __DIR__ . '/../data/crashes.sqlite',
|
||||||
'mysql' => [
|
'mysql' => [
|
||||||
|
// Alpine: if TCP fails with "Connection refused", set socket (see /etc/my.cnf.d/*.cnf)
|
||||||
'host' => '127.0.0.1',
|
'host' => '127.0.0.1',
|
||||||
'port' => 3306,
|
'port' => 3306,
|
||||||
|
'socket' => '/run/mysqld/mysqld.sock', // leave '' to use host+port only
|
||||||
'database' => 'androidcast_crashes',
|
'database' => 'androidcast_crashes',
|
||||||
'username' => 'androidcast',
|
'username' => 'androidcast',
|
||||||
'password' => 'change-me',
|
'password' => 'change-me',
|
||||||
|
|||||||
@@ -26,9 +26,12 @@ if ($driver === 'sqlite') {
|
|||||||
$out['data_dir_writable'] = is_string($path) && is_writable(dirname($path));
|
$out['data_dir_writable'] = is_string($path) && is_writable(dirname($path));
|
||||||
} else {
|
} else {
|
||||||
$m = cfg('db.mysql', []);
|
$m = cfg('db.mysql', []);
|
||||||
|
$socket = trim((string) ($m['socket'] ?? ''));
|
||||||
$out['mysql'] = [
|
$out['mysql'] = [
|
||||||
'host' => $m['host'] ?? '127.0.0.1',
|
'host' => $m['host'] ?? '127.0.0.1',
|
||||||
'port' => (int) ($m['port'] ?? 3306),
|
'port' => (int) ($m['port'] ?? 3306),
|
||||||
|
'socket' => $socket,
|
||||||
|
'socket_exists' => $socket !== '' && file_exists($socket),
|
||||||
'database' => $m['database'] ?? 'androidcast_crashes',
|
'database' => $m['database'] ?? 'androidcast_crashes',
|
||||||
'username' => $m['username'] ?? '',
|
'username' => $m['username'] ?? '',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ if (!Auth::user()) {
|
|||||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
|
||||||
$id = (int) ($_GET['id'] ?? 0);
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
|
||||||
|
if ($method === 'GET') {
|
||||||
if ($id <= 0) {
|
if ($id <= 0) {
|
||||||
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
json_out(['ok' => false, 'error' => 'invalid id'], 400);
|
||||||
}
|
}
|
||||||
@@ -28,6 +30,10 @@ if (!Auth::canEditTags()) {
|
|||||||
json_out(['ok' => false, 'error' => 'forbidden'], 403);
|
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');
|
$raw = file_get_contents('php://input');
|
||||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
@@ -58,5 +64,9 @@ try {
|
|||||||
]);
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log('report_tags: ' . $e->getMessage());
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,62 @@ if (!in_array($perPage, $allowedPerPage, true)) {
|
|||||||
$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'priority'));
|
$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'priority'));
|
||||||
$dir = (string) ($_GET['dir'] ?? 'desc');
|
$dir = (string) ($_GET['dir'] ?? 'desc');
|
||||||
$sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0));
|
$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 {
|
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);
|
json_out(['ok' => true] + $data);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
error_log('reports api: ' . $e->getMessage());
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -537,6 +537,122 @@ a:hover { text-decoration: underline; }
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
background: var(--tag-bg, #5c6b82);
|
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; }
|
.exc { color: var(--danger); font-weight: 600; }
|
||||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; }
|
.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; }
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
|
|
||||||
const GROUP_COLUMNS = [
|
const GROUP_COLUMNS = [
|
||||||
{ key: 'cnt', label: 'Count' },
|
{ key: 'cnt', label: 'Count' },
|
||||||
|
{ key: 'rating', label: 'Rating', fmt: 'stars' },
|
||||||
{ key: 'fingerprint', label: 'Fingerprint', fmt: 'fp' },
|
{ key: 'fingerprint', label: 'Fingerprint', fmt: 'fp' },
|
||||||
{ key: 'crash_type', label: 'Type' },
|
{ key: 'crash_type', label: 'Type' },
|
||||||
{ key: 'last_generated_ms', label: 'Last generated', fmt: 'time' },
|
{ key: 'last_generated_ms', label: 'Last generated', fmt: 'time' },
|
||||||
@@ -330,71 +331,159 @@
|
|||||||
cg.innerHTML = html;
|
cg.innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupColumnResize(table, isGrouped, onLayoutChange) {
|
function bindReportsTableLayout(table, thead, ctx) {
|
||||||
if (isGrouped) return;
|
if (!table || !thead || table.dataset.layoutBound === '1') return;
|
||||||
const widths = getColWidths();
|
table.dataset.layoutBound = '1';
|
||||||
table.querySelectorAll('.col-resizer').forEach((handle) => {
|
let dragKey = null;
|
||||||
handle.addEventListener('mousedown', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const colKey = handle.getAttribute('data-resize-col');
|
|
||||||
const th = handle.closest('th');
|
|
||||||
if (!colKey || !th) return;
|
|
||||||
const startX = e.clientX;
|
|
||||||
const startW = th.getBoundingClientRect().width;
|
|
||||||
|
|
||||||
const onMove = (ev) => {
|
thead.addEventListener('dragstart', (e) => {
|
||||||
const w = Math.max(48, Math.round(startW + (ev.clientX - startX)));
|
const handle = e.target.closest('.th-drag');
|
||||||
widths[colKey] = w;
|
if (!handle || ctx.getGrouped()) return;
|
||||||
th.style.width = w + 'px';
|
dragKey = handle.getAttribute('data-col-key');
|
||||||
applyColgroup(false);
|
const th = handle.closest('th');
|
||||||
};
|
if (th) th.classList.add('th-dragging');
|
||||||
const onUp = () => {
|
if (e.dataTransfer) {
|
||||||
document.removeEventListener('mousemove', onMove);
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
document.removeEventListener('mouseup', onUp);
|
e.dataTransfer.setData('text/plain', dragKey || '');
|
||||||
saveColWidths(widths);
|
}
|
||||||
if (onLayoutChange) onLayoutChange();
|
});
|
||||||
};
|
|
||||||
document.addEventListener('mousemove', onMove);
|
thead.addEventListener('dragend', (e) => {
|
||||||
document.addEventListener('mouseup', onUp);
|
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) {
|
function parseListContext() {
|
||||||
if (isGrouped) return;
|
const p = new URLSearchParams(window.location.search);
|
||||||
let dragKey = null;
|
return {
|
||||||
thead.querySelectorAll('th.th-draggable').forEach((th) => {
|
similarTo: Number(p.get('similar_to')) || 0,
|
||||||
th.addEventListener('dragstart', (e) => {
|
filterDevice: p.get('filter_device') || '',
|
||||||
dragKey = th.getAttribute('data-col-key');
|
filterAbi: p.get('filter_abi') || '',
|
||||||
th.classList.add('th-dragging');
|
filterOsSdk: Number(p.get('filter_os_sdk')) || 0,
|
||||||
if (e.dataTransfer) {
|
filterOsRelease: p.get('filter_os_release') || '',
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
filterAppVersion: p.get('filter_app_version') || '',
|
||||||
e.dataTransfer.setData('text/plain', dragKey || '');
|
filterBuild: p.get('filter_build') || '',
|
||||||
}
|
searchQuery: p.get('q') || '',
|
||||||
});
|
};
|
||||||
th.addEventListener('dragend', () => {
|
}
|
||||||
th.classList.remove('th-dragging');
|
|
||||||
dragKey = null;
|
function listContextActive(ctx) {
|
||||||
});
|
return (
|
||||||
th.addEventListener('dragover', (e) => {
|
ctx.similarTo > 0 ||
|
||||||
e.preventDefault();
|
!!ctx.filterDevice ||
|
||||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
!!ctx.filterAbi ||
|
||||||
});
|
ctx.filterOsSdk > 0 ||
|
||||||
th.addEventListener('drop', (e) => {
|
!!ctx.filterOsRelease ||
|
||||||
e.preventDefault();
|
!!ctx.filterAppVersion ||
|
||||||
const targetKey = th.getAttribute('data-col-key');
|
!!ctx.filterBuild ||
|
||||||
if (!dragKey || !targetKey || dragKey === targetKey) return;
|
!!ctx.searchQuery
|
||||||
const order = getOrderedColumnKeys();
|
);
|
||||||
const from = order.indexOf(dragKey);
|
}
|
||||||
const to = order.indexOf(targetKey);
|
|
||||||
if (from < 0 || to < 0) return;
|
function updateFilterBanner(ctx, bannerEl) {
|
||||||
order.splice(from, 1);
|
if (!bannerEl) return;
|
||||||
order.splice(to, 0, dragKey);
|
const parts = [];
|
||||||
saveColumnOrder(order);
|
if (ctx.similarTo > 0) {
|
||||||
if (onReorder) onReorder();
|
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) {
|
function navigateTo(href) {
|
||||||
@@ -449,9 +538,14 @@
|
|||||||
const perPageSel = document.getElementById('per-page-select');
|
const perPageSel = document.getElementById('per-page-select');
|
||||||
|
|
||||||
const table = document.getElementById('reports-tree');
|
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 page = 1;
|
||||||
let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50;
|
let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50;
|
||||||
|
let listTotal = 0;
|
||||||
const saved = loadSavedSort(grouped);
|
const saved = loadSavedSort(grouped);
|
||||||
let sort = saved.sort;
|
let sort = saved.sort;
|
||||||
let dir = saved.dir;
|
let dir = saved.dir;
|
||||||
@@ -471,14 +565,29 @@
|
|||||||
return apiSortKey(sort) === apiSortKey(colKey);
|
return apiSortKey(sort) === apiSortKey(colKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const layoutCtx = {
|
||||||
|
getGrouped: () => grouped,
|
||||||
|
refreshLayout: () => {
|
||||||
|
applyColgroup(grouped);
|
||||||
|
renderHead();
|
||||||
|
if (lastItems.length) renderRows(lastItems);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function refreshTableChrome() {
|
function refreshTableChrome() {
|
||||||
applyColgroup(grouped);
|
layoutCtx.refreshLayout();
|
||||||
renderHead();
|
}
|
||||||
setupColumnResize(table, grouped);
|
|
||||||
setupColumnDrag(thead, grouped, refreshTableChrome);
|
bindReportsTableLayout(table, thead, layoutCtx);
|
||||||
if (lastItems.length) {
|
updateFilterBanner(listContext, filterBanner);
|
||||||
renderRows(lastItems);
|
|
||||||
}
|
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) {
|
if (perPageSel) {
|
||||||
@@ -539,8 +648,7 @@
|
|||||||
let thDrag = '';
|
let thDrag = '';
|
||||||
if (!grouped) {
|
if (!grouped) {
|
||||||
thClass += ' th-draggable';
|
thClass += ' th-draggable';
|
||||||
thDrag =
|
thDrag = ' data-col-key="' + escapeHtml(c.key) + '"';
|
||||||
' draggable="true" data-col-key="' + escapeHtml(c.key);
|
|
||||||
}
|
}
|
||||||
html +=
|
html +=
|
||||||
'<th class="' +
|
'<th class="' +
|
||||||
@@ -552,7 +660,11 @@
|
|||||||
'"' +
|
'"' +
|
||||||
w +
|
w +
|
||||||
' scope="col"><span class="th-inner">' +
|
' 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">' +
|
'<span class="th-label">' +
|
||||||
escapeHtml(c.label) +
|
escapeHtml(c.label) +
|
||||||
'</span> <span class="sort-ind" aria-hidden="true">' +
|
'</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) {
|
function renderPagination(total) {
|
||||||
|
listTotal = total;
|
||||||
const pages = Math.max(1, Math.ceil(total / perPage));
|
const pages = Math.max(1, Math.ceil(total / perPage));
|
||||||
if (page > pages) page = pages;
|
if (page > pages) page = pages;
|
||||||
const prev = page > 1 ? page - 1 : null;
|
const prev = page > 1 ? page - 1 : null;
|
||||||
const next = page < pages ? 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">';
|
let html = '<div class="pagination-inner">';
|
||||||
html +=
|
html +=
|
||||||
'<button type="button" class="btn page-btn" data-page="' +
|
'<button type="button" class="btn page-btn" data-page="' +
|
||||||
(prev || '') +
|
(prev || '') +
|
||||||
'" ' +
|
'" ' +
|
||||||
(prev ? '' : 'disabled') +
|
(prev ? '' : 'disabled') +
|
||||||
'>← Previous</button>';
|
'>' +
|
||||||
|
escapeHtml(prevLabel) +
|
||||||
|
'</button>';
|
||||||
html +=
|
html +=
|
||||||
'<span class="page-info">Page ' +
|
'<span class="page-info">Page ' +
|
||||||
page +
|
page +
|
||||||
@@ -609,7 +741,9 @@
|
|||||||
(next || '') +
|
(next || '') +
|
||||||
'" ' +
|
'" ' +
|
||||||
(next ? '' : 'disabled') +
|
(next ? '' : 'disabled') +
|
||||||
'>Next →</button>';
|
'>' +
|
||||||
|
escapeHtml(nextLabel) +
|
||||||
|
'</button>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
pagination.innerHTML = html;
|
pagination.innerHTML = html;
|
||||||
pagination.querySelectorAll('.page-btn').forEach((btn) => {
|
pagination.querySelectorAll('.page-btn').forEach((btn) => {
|
||||||
@@ -699,7 +833,7 @@
|
|||||||
if (loading) return;
|
if (loading) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
statusEl.textContent = 'Loading…';
|
statusEl.textContent = 'Loading…';
|
||||||
const qs =
|
let qs =
|
||||||
'page=' +
|
'page=' +
|
||||||
page +
|
page +
|
||||||
'&per_page=' +
|
'&per_page=' +
|
||||||
@@ -709,8 +843,32 @@
|
|||||||
'&dir=' +
|
'&dir=' +
|
||||||
encodeURIComponent(dir) +
|
encodeURIComponent(dir) +
|
||||||
'&since_ms=' +
|
'&since_ms=' +
|
||||||
encodeURIComponent(String(lastVisitMs())) +
|
encodeURIComponent(String(lastVisitMs()));
|
||||||
(grouped ? '&group=1' : '');
|
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();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.open('GET', basePath() + '/api/reports.php?' + qs, true);
|
xhr.open('GET', basePath() + '/api/reports.php?' + qs, true);
|
||||||
xhr.onload = function () {
|
xhr.onload = function () {
|
||||||
@@ -745,6 +903,134 @@
|
|||||||
|
|
||||||
initTagEditButtons(tbody, load);
|
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);
|
applyColgroup(grouped);
|
||||||
load();
|
load();
|
||||||
}
|
}
|
||||||
@@ -899,7 +1185,7 @@
|
|||||||
els.saveBtn.addEventListener('click', () => {
|
els.saveBtn.addEventListener('click', () => {
|
||||||
setStatus('Saving…', true);
|
setStatus('Saving…', true);
|
||||||
const xhr = new XMLHttpRequest();
|
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.setRequestHeader('Content-Type', 'application/json');
|
||||||
xhr.onload = function () {
|
xhr.onload = function () {
|
||||||
let data;
|
let data;
|
||||||
@@ -910,7 +1196,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
setStatus(data.error || 'Save failed', false);
|
setStatus(data.hint || data.error || 'Save failed', false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.tags = data.tags || [];
|
state.tags = data.tags || [];
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ echo "Applying schema from $SCHEMA ..."
|
|||||||
mysql -u root -p < "$SCHEMA"
|
mysql -u root -p < "$SCHEMA"
|
||||||
|
|
||||||
if [ -n "$DB_PASS" ]; then
|
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
|
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}';
|
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';
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ${DB_NAME}.* TO '${DB_USER}'@'127.0.0.1';
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
EOF
|
EOF
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
echo "Done. Set config.php: db.driver = mysql, db.mysql.* then restart php-fpm."
|
echo "Done. Set config.php: db.driver = mysql, db.mysql.* then restart php-fpm."
|
||||||
|
|||||||
@@ -25,13 +25,21 @@ final class Database {
|
|||||||
}
|
}
|
||||||
if (self::isMysql()) {
|
if (self::isMysql()) {
|
||||||
$m = cfg('db.mysql', []);
|
$m = cfg('db.mysql', []);
|
||||||
$dsn = sprintf(
|
$db = $m['database'] ?? 'androidcast_crashes';
|
||||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
$charset = $m['charset'] ?? 'utf8mb4';
|
||||||
$m['host'] ?? '127.0.0.1',
|
$socket = trim((string) ($m['socket'] ?? ''));
|
||||||
(int) ($m['port'] ?? 3306),
|
// Prefer explicit socket from config (Alpine: TCP 3306 often closed).
|
||||||
$m['database'] ?? 'androidcast_crashes',
|
if ($socket !== '') {
|
||||||
$m['charset'] ?? 'utf8mb4'
|
$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'] ?? '', [
|
self::$pdo = new PDO($dsn, $m['username'] ?? '', $m['password'] ?? '', [
|
||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
|||||||
@@ -123,14 +123,7 @@ final class ReportRepository {
|
|||||||
$stmt = $pdo->prepare($sql);
|
$stmt = $pdo->prepare($sql);
|
||||||
$stmt->execute([$sinceMs, $userId]);
|
$stmt->execute([$sinceMs, $userId]);
|
||||||
$items = $stmt->fetchAll();
|
$items = $stmt->fetchAll();
|
||||||
foreach ($items as &$row) {
|
self::finishListRows($items, $sinceMs, false);
|
||||||
$row['viewed'] = (bool) ($row['viewed'] ?? false);
|
|
||||||
$row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
|
|
||||||
report_enrich_list_row($row, $sinceMs);
|
|
||||||
$row['brief'] = report_brief_lines($row, false);
|
|
||||||
unset($row['payload_json'], $row['tags_json']);
|
|
||||||
}
|
|
||||||
unset($row);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'items' => $items,
|
'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 {
|
private static function buildListOrder(string $sort, string $dir): string {
|
||||||
$dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC';
|
$dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC';
|
||||||
if ($sort === 'priority') {
|
if ($sort === 'priority') {
|
||||||
|
|||||||
@@ -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. */
|
/** Human-readable ABI list from device payload. */
|
||||||
function format_device_abis(mixed $abis): string {
|
function format_device_abis(mixed $abis): string {
|
||||||
if (!is_array($abis)) {
|
if (!is_array($abis)) {
|
||||||
@@ -152,6 +151,96 @@ function format_device_abis(mixed $abis): string {
|
|||||||
return implode(', ', $parts);
|
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 {
|
function device_os_name(array $device): string {
|
||||||
if (!empty($device['os_name'])) {
|
if (!empty($device['os_name'])) {
|
||||||
return trim((string) $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 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). */
|
/** System tags — not stored in tags_json (computed in UI). */
|
||||||
function report_reserved_tag_ids(): array {
|
function report_reserved_tag_ids(): array {
|
||||||
return ['new', 'java', 'ndk', 'android'];
|
return ['new', 'java', 'ndk', 'android'];
|
||||||
|
|||||||
@@ -106,6 +106,16 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<p id="reports-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||||
<div class="reports-table-wrap">
|
<div class="reports-table-wrap">
|
||||||
<table class="data-table reports-tree reports-table--cols" id="reports-tree">
|
<table class="data-table reports-tree reports-table--cols" id="reports-tree">
|
||||||
|
|||||||
@@ -9,9 +9,32 @@ $rawJson = safe_json_encode($p, $jsonFlags);
|
|||||||
$sessionJson = !empty($p['session_context'])
|
$sessionJson = !empty($p['session_context'])
|
||||||
? safe_json_encode($p['session_context'], $jsonFlags)
|
? 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">
|
<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>
|
<span class="back-icon" aria-hidden="true"></span>
|
||||||
</a>
|
</a>
|
||||||
<div>
|
<div>
|
||||||
@@ -20,7 +43,7 @@ $sessionJson = !empty($p['session_context'])
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="tag-editor-head">
|
||||||
<h2>Tags</h2>
|
<h2>Tags</h2>
|
||||||
<p class="muted tag-editor-hint">Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.</p>
|
<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>
|
?></script>
|
||||||
<div class="cards cards--lift">
|
<div class="cards cards--lift">
|
||||||
<div class="card card--lift"><h3>Device</h3>
|
<div class="card card--lift"><h3>Device</h3>
|
||||||
<p><?= h(trim(($device['manufacturer'] ?? '') . ' ' . ($device['model'] ?? ''))) ?></p>
|
<?php if ($deviceName !== ''): ?>
|
||||||
<p>Android <?= h($device['release'] ?? '') ?> (SDK <?= (int)($device['sdk_int'] ?? 0) ?>)</p>
|
<p><a class="detail-filter-link" href="<?= h($filterDeviceUrl) ?>" title="Show reports for similar devices"><?= h($deviceName) ?></a></p>
|
||||||
<?php if (!empty($device['abis'])): ?>
|
<?php else: ?>
|
||||||
<p class="muted">ABIs: <?= h(format_device_abis($device['abis'] ?? null)) ?></p>
|
<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; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card--lift"><h3>App</h3>
|
<div class="card card--lift"><h3>App</h3>
|
||||||
<p><?= h($app['package'] ?? '') ?></p>
|
<p><?= h($app['package'] ?? '') ?></p>
|
||||||
<p>v<?= h($app['version_name'] ?? '') ?> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
|
<?php
|
||||||
<?php if (!empty($p['build'])): $b = $p['build']; ?>
|
$versionName = (string) ($app['version_name'] ?? '');
|
||||||
<p class="muted">Build: <?= h($b['git_commit'] ?? $b['git'] ?? '') ?></p>
|
$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; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card--lift"><h3>Timing</h3>
|
<div class="card card--lift"><h3>Timing</h3>
|
||||||
@@ -77,7 +133,10 @@ $sessionJson = !empty($p['session_context'])
|
|||||||
</div>
|
</div>
|
||||||
<?php if (!empty($p['java'])): $j = $p['java']; ?>
|
<?php if (!empty($p['java'])): $j = $p['java']; ?>
|
||||||
<section class="stack-block">
|
<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="exc"><?= h($j['exception'] ?? '') ?><?= !empty($j['message']) ? ': ' . h($j['message']) : '' ?></p>
|
||||||
<p class="muted">Thread: <?= h($j['thread'] ?? '') ?></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>
|
<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 endif; ?>
|
||||||
<?php if (!empty($p['native'])): $n = $p['native']; ?>
|
<?php if (!empty($p['native'])): $n = $p['native']; ?>
|
||||||
<section class="stack-block">
|
<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>
|
<p class="exc">Signal: <?= h($n['signal'] ?? '') ?></p>
|
||||||
<pre class="stack"><?php foreach ($n['backtrace'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
|
<pre class="stack"><?php foreach ($n['backtrace'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user