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

befroe mdb mig

This commit is contained in:
Anton Afanasyeu
2026-05-22 22:13:52 +02:00
parent d008afca73
commit 24b5a49d08
17 changed files with 1409 additions and 118 deletions

View File

@@ -13,10 +13,15 @@ Anonymous crash JSON receiver and web console for
Minimal `php81-fpm` does **not** include sessions or PDO. Install modules explicitly:
```sh
# SQLite only (dev / small installs)
apk add php81-fpm php81-session php81-pdo php81-pdo_sqlite php81-sqlite3
# MariaDB (recommended on BE)
apk add mariadb mariadb-client php81-pdo_mysql
rc-service php-fpm81 restart
# package names may be php82-* on newer Alpine; adjust to your php -v
php -m | grep -E 'session|pdo_sqlite|PDO'
php -m | grep -E 'session|pdo_sqlite|pdo_mysql|PDO'
```
If `session_name()` is undefined, `php81-session` is missing or FPM was not restarted after install.
@@ -52,9 +57,16 @@ curl -X POST http://127.0.0.1:8080/api/upload.php \
2. Point nginx `SCRIPT_FILENAME` at `.../backend/public/` (see `nginx.vm.conf`). Do not mix `proxy_*`, `try_files`, and broken alias/try_files combos in one block.
3. Set `base_path` in `config.php` to match the URL prefix exactly.
3. Choose DB:
- **SQLite (default):** run `sql/schema.sqlite.sql`, set `DB_DRIVER=sqlite` in config
- **MariaDB:** create DB, run `sql/schema.mariadb.sql`, set `DB_DRIVER=mysql`
4. `chown -R www-data:www-data data storage`
- **SQLite (default):** `sqlite3 data/crashes.sqlite < sql/schema.sqlite.sql`, `db.driver` => `sqlite`
- **MariaDB (fresh, recommended on BE):**
```sh
rc-service mariadb start
mysql -u root -p < sql/schema.mariadb.sql
# optional: CRASH_DB_PASS='…' ./scripts/init-mariadb.sh # creates app user
```
In `config/config.php`: `db.driver` => `mysql`, fill `db.mysql` (host `127.0.0.1`).
PHP auto-creates missing tables on first request if schema was not applied manually.
4. `chown -R www-data:www-data data storage` (SQLite only needs writable `data/`; MariaDB uses mysqld)
5. TLS terminate at nginx; restrict `/api/upload.php` by firewall if needed
## Android app configuration

View File

@@ -15,7 +15,8 @@ return [
// Production: '/app/androidcast_project/crashes' — Local php -S: use '' (empty string)
'base_path' => '/app/androidcast_project/crashes',
'db' => [
'driver' => 'sqlite', // sqlite | mysql
// sqlite = default file DB; mysql = MariaDB/MySQL (recommended on BE for concurrency)
'driver' => 'sqlite', // sqlite | mysql | mariadb (alias)
'sqlite_path' => __DIR__ . '/../data/crashes.sqlite',
'mysql' => [
'host' => '127.0.0.1',

View File

@@ -50,6 +50,14 @@
fastcgi_param REQUEST_URI $request_uri;
}
location = /app/androidcast_project/crashes/api/report_tags.php {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/report_tags.php;
fastcgi_param SCRIPT_NAME /app/androidcast_project/crashes/api/report_tags.php;
fastcgi_param REQUEST_URI $request_uri;
}
location ^~ /app/androidcast_project/crashes/ {
include fastcgi_params;
fastcgi_pass unix:/run/php-fpm.socket;

View File

@@ -6,37 +6,53 @@ if (!cfg('debug')) {
json_out(['ok' => false, 'error' => 'diag disabled'], 403);
}
$driver = Database::driver();
$path = cfg('db.sqlite_path');
$out = [
'ok' => true,
'db_driver' => $driver,
'php_user' => get_current_user(),
'sqlite_path' => $path,
'sqlite_realpath' => is_string($path) ? realpath($path) : null,
'sqlite_exists' => is_string($path) && is_file($path),
'sqlite_writable' => is_string($path) && is_writable($path),
'data_dir_writable' => is_string($path) && is_writable(dirname($path)),
'storage_writable' => is_writable(dirname(__DIR__) . '/../storage'),
'tables' => [],
'reports_columns' => [],
'test_insert' => null,
];
if ($driver === 'sqlite') {
$out['sqlite_path'] = $path;
$out['sqlite_realpath'] = is_string($path) ? realpath($path) : null;
$out['sqlite_exists'] = is_string($path) && is_file($path);
$out['sqlite_writable'] = is_string($path) && is_writable($path);
$out['data_dir_writable'] = is_string($path) && is_writable(dirname($path));
} else {
$m = cfg('db.mysql', []);
$out['mysql'] = [
'host' => $m['host'] ?? '127.0.0.1',
'port' => (int) ($m['port'] ?? 3306),
'database' => $m['database'] ?? 'androidcast_crashes',
'username' => $m['username'] ?? '',
];
}
try {
$pdo = Database::pdo();
$tables = $pdo->query(
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"
)->fetchAll(PDO::FETCH_COLUMN);
$tables = Database::listTables($pdo);
$out['tables'] = $tables;
if (in_array('reports', $tables, true)) {
$out['reports_columns'] = $pdo->query('PRAGMA table_info(reports)')->fetchAll(PDO::FETCH_ASSOC);
$cols = Database::columnNames($pdo, 'reports');
$out['reports_columns'] = array_map(
static fn (string $name) => ['name' => $name],
$cols
);
}
$pdo->beginTransaction();
$stmt = $pdo->prepare(
'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, device_model, app_version, payload_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms,
device_model, app_version, payload_json, tags_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
$rid = 'diag-' . time();
$stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}']);
$stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}', '[]']);
$pdo->rollBack();
$out['test_insert'] = 'ok (rolled back)';
} catch (Throwable $e) {

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../src/bootstrap.php';
if (!Auth::user()) {
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
}
$id = (int) ($_GET['id'] ?? 0);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'invalid id'], 400);
}
$report = ReportRepository::getById($id);
if (!$report) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
json_out([
'ok' => true,
'id' => $id,
'tags' => $report['tags'] ?? [],
'presets' => ReportRepository::listTagPresets(),
'can_edit' => Auth::canEditTags(),
]);
}
if (!Auth::canEditTags()) {
json_out(['ok' => false, 'error' => 'forbidden'], 403);
}
$raw = file_get_contents('php://input');
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : $_POST;
if (!is_array($data)) {
json_out(['ok' => false, 'error' => 'invalid json'], 400);
}
$id = (int) ($data['id'] ?? $id);
if ($id <= 0) {
json_out(['ok' => false, 'error' => 'invalid id'], 400);
}
$tagsIn = $data['tags'] ?? [];
if (!is_array($tagsIn)) {
json_out(['ok' => false, 'error' => 'tags must be array'], 400);
}
$report = ReportRepository::getById($id);
if (!$report) {
json_out(['ok' => false, 'error' => 'not found'], 404);
}
try {
ReportRepository::setCustomTags($id, $tagsIn);
$updated = ReportRepository::getById($id);
json_out([
'ok' => true,
'id' => $id,
'tags' => $updated['tags'] ?? [],
]);
} catch (Throwable $e) {
error_log('report_tags: ' . $e->getMessage());
json_out(['ok' => false, 'error' => 'save failed'], 500);
}

View File

@@ -13,7 +13,7 @@ $allowedPerPage = [25, 50, 75, 100, 200];
if (!in_array($perPage, $allowedPerPage, true)) {
$perPage = 50;
}
$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'generated_at_ms'));
$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'priority'));
$dir = (string) ($_GET['dir'] ?? 'desc');
$sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0));

View File

@@ -345,6 +345,9 @@ a:hover { text-decoration: underline; }
border-radius: 8px;
border: 1px solid var(--border);
margin-left: 8px;
cursor: pointer;
background: var(--surface);
color: var(--text);
}
.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
@@ -443,6 +446,55 @@ a:hover { text-decoration: underline; }
.reports-mode-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.reports-status { margin: 8px 0 0; min-height: 1.2em; }
.reports-table-wrap { overflow-x: auto; }
.reports-table--cols {
table-layout: fixed;
width: 100%;
}
.reports-table--cols th,
.reports-table--cols td {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: relative;
vertical-align: middle;
}
.reports-table--cols .col-tags,
.reports-table--cols .report-tags {
white-space: normal;
}
.th-inner {
display: flex;
align-items: center;
gap: 4px;
padding-right: 8px;
min-height: 1.5rem;
}
.th-drag {
cursor: grab;
color: var(--muted);
font-size: 11px;
letter-spacing: -2px;
user-select: none;
flex-shrink: 0;
}
.th-drag:active { cursor: grabbing; }
.th-label { flex: 1; min-width: 0; }
.th-draggable.th-dragging { opacity: .55; }
.col-resizer {
position: absolute;
top: 0;
right: 0;
width: 7px;
height: 100%;
cursor: col-resize;
user-select: none;
z-index: 2;
}
.col-resizer:hover,
.col-resizer:active {
background: rgba(61, 139, 253, .35);
}
.report-tree-head .col-resizer { display: none; }
.reports-pagination { margin-top: 16px; }
.pagination-inner {
display: flex;
@@ -578,3 +630,189 @@ a:hover { text-decoration: underline; }
[data-theme="light"] .star--on { background: #d97706; }
.muted { color: var(--muted); }
.alert { background: rgba(248,113,113,.15); color: var(--danger); padding: 10px; border-radius: 8px; }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover { filter: brightness(1.08); }
.tag-edit-btn {
flex-shrink: 0;
margin-left: 2px;
padding: 0 5px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--muted);
font-size: 13px;
line-height: 1.2;
cursor: pointer;
vertical-align: middle;
}
.tag-edit-btn:hover,
.tag-edit-btn:focus-visible {
background: rgba(61, 139, 253, .2);
color: var(--accent);
outline: none;
}
.tag-editor {
margin: 20px 0 24px;
padding: 16px 18px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
}
.tag-editor-head h2 { margin: 0 0 4px; font-size: 1.1rem; }
.tag-editor-hint { margin: 0 0 12px; font-size: 13px; }
.tag-editor-preview {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 1.5rem;
margin-bottom: 12px;
}
.tag-editor-list {
list-style: none;
margin: 0 0 12px;
padding: 0;
}
.tag-editor-item {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.tag-editor-item:last-child { border-bottom: none; }
.tag-color-edit {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--muted);
}
.tag-color-edit input[type="color"] {
width: 2rem;
height: 1.6rem;
padding: 0;
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.tag-editor-add {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px 14px;
margin-bottom: 12px;
}
.tag-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: var(--muted);
}
.tag-field input[type="text"] {
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
min-width: 10rem;
}
.tag-editor-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.tag-editor-status--err { color: var(--danger); }
.tag-preset-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 13px;
}
.tag-preset-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.tag-preset-chip {
padding: 4px 10px;
border: 1px solid var(--border);
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: #fff;
background: var(--tag-bg, #5c6b82);
cursor: pointer;
}
.tag-preset-chip:hover { filter: brightness(1.12); }
.report-tag {
position: relative;
padding-right: 8px;
}
.report-tag .tag-remove {
margin-left: 4px;
padding: 0 3px;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, .25);
color: #fff;
font-size: 14px;
line-height: 1;
cursor: pointer;
vertical-align: middle;
}
.report-tag .tag-remove:hover { background: rgba(0, 0, 0, .45); }
.tag-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.tag-modal[hidden] { display: none; }
.tag-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, .55);
}
.tag-modal-panel {
position: relative;
width: min(420px, 100%);
max-height: 90vh;
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0, 0, 0, .4);
}
.tag-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.tag-modal-header h2 { margin: 0; font-size: 1.05rem; }
.tag-modal-close {
border: none;
background: transparent;
color: var(--muted);
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
}
.tag-modal-close:hover { color: var(--text); background: var(--row-hover); }
.tag-editor--modal { margin: 0; border: none; border-radius: 0; }

View File

@@ -6,6 +6,19 @@
viewed: 'crash_console_viewed_ids',
sort: 'crash_console_sort',
skipStamp: 'crash_skip_visit_stamp',
colWidths: 'crash_console_col_widths',
colOrder: 'crash_console_col_order',
};
const DEFAULT_COL_WIDTHS = {
expand: 40,
device_model: 128,
app_version: 80,
os_name: 88,
os_version: 120,
received_at_ms: 152,
rating: 92,
tags: 128,
};
const TAG_NEW = { label: 'new', bg: '#3d8bfd' };
@@ -21,13 +34,14 @@
{ key: 'os_name', label: 'OS' },
{ key: 'os_version', label: 'OS ver' },
{ key: 'received_at_ms', label: 'Received', fmt: 'time' },
{ key: 'rating', label: 'Rating', fmt: 'stars', noSort: true },
{ key: 'rating', label: 'Rating', fmt: 'stars' },
];
const SORT_API_MAP = {
priority: 'priority',
rating: 'fingerprint_cnt',
os_name: 'device_model',
os_version: 'app_version',
rating: 'fingerprint_cnt',
};
const GROUP_COLUMNS = [
@@ -239,6 +253,150 @@
return SORT_API_MAP[key] || key;
}
function loadSavedSort(isGrouped) {
try {
const s = JSON.parse(lsGet(STORAGE.sort, ''));
if (!s || !s.sort) {
return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' };
}
if (!!s.grouped !== !!isGrouped) {
return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' };
}
return { sort: s.sort, dir: s.dir || 'desc' };
} catch {
return { sort: isGrouped ? 'cnt' : 'priority', dir: 'desc' };
}
}
function getColWidths() {
try {
const w = JSON.parse(lsGet(STORAGE.colWidths, '{}'));
return { ...DEFAULT_COL_WIDTHS, ...(w && typeof w === 'object' ? w : {}) };
} catch {
return { ...DEFAULT_COL_WIDTHS };
}
}
function saveColWidths(widths) {
lsSet(STORAGE.colWidths, JSON.stringify(widths));
}
function getOrderedColumnKeys() {
const defaults = LIST_COLUMNS.map((c) => c.key);
try {
const saved = JSON.parse(lsGet(STORAGE.colOrder, '[]'));
if (Array.isArray(saved) && saved.length) {
const out = saved.filter((k) => defaults.includes(k));
defaults.forEach((k) => {
if (!out.includes(k)) out.push(k);
});
return out;
}
} catch { /* ignore */ }
return defaults;
}
function saveColumnOrder(keys) {
lsSet(STORAGE.colOrder, JSON.stringify(keys));
}
function getDisplayColumns(isGrouped) {
if (isGrouped) return GROUP_COLUMNS;
return getOrderedColumnKeys()
.map((k) => LIST_COLUMNS.find((c) => c.key === k))
.filter(Boolean);
}
function applyColgroup(isGrouped) {
const cg = document.getElementById('reports-colgroup');
if (!cg || isGrouped) {
if (cg) cg.innerHTML = '';
return;
}
const widths = getColWidths();
const order = getOrderedColumnKeys();
let html =
'<col class="col-expand" style="width:' + (widths.expand || 40) + 'px" />';
order.forEach((k) => {
html +=
'<col data-col="' +
escapeHtml(k) +
'" style="width:' +
(widths[k] || 100) +
'px" />';
});
html +=
'<col class="col-tags" style="width:' + (widths.tags || 128) + 'px" />';
cg.innerHTML = html;
}
function setupColumnResize(table, isGrouped, onLayoutChange) {
if (isGrouped) return;
const widths = getColWidths();
table.querySelectorAll('.col-resizer').forEach((handle) => {
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const colKey = handle.getAttribute('data-resize-col');
const th = handle.closest('th');
if (!colKey || !th) return;
const startX = e.clientX;
const startW = th.getBoundingClientRect().width;
const onMove = (ev) => {
const w = Math.max(48, Math.round(startW + (ev.clientX - startX)));
widths[colKey] = w;
th.style.width = w + 'px';
applyColgroup(false);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveColWidths(widths);
if (onLayoutChange) onLayoutChange();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
});
}
function setupColumnDrag(thead, isGrouped, onReorder) {
if (isGrouped) return;
let dragKey = null;
thead.querySelectorAll('th.th-draggable').forEach((th) => {
th.addEventListener('dragstart', (e) => {
dragKey = th.getAttribute('data-col-key');
th.classList.add('th-dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', dragKey || '');
}
});
th.addEventListener('dragend', () => {
th.classList.remove('th-dragging');
dragKey = null;
});
th.addEventListener('dragover', (e) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
});
th.addEventListener('drop', (e) => {
e.preventDefault();
const targetKey = th.getAttribute('data-col-key');
if (!dragKey || !targetKey || dragKey === targetKey) return;
const order = getOrderedColumnKeys();
const from = order.indexOf(dragKey);
const to = order.indexOf(targetKey);
if (from < 0 || to < 0) return;
order.splice(from, 1);
order.splice(to, 0, dragKey);
saveColumnOrder(order);
if (onReorder) onReorder();
});
});
}
function navigateTo(href) {
try {
sessionStorage.setItem(STORAGE.skipStamp, '1');
@@ -290,20 +448,37 @@
const statusEl = document.getElementById('reports-status');
const perPageSel = document.getElementById('per-page-select');
const table = document.getElementById('reports-tree');
let grouped = app.getAttribute('data-grouped') === '1';
let page = 1;
let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50;
let sort = grouped ? 'cnt' : 'generated_at_ms';
let dir = 'desc';
const saved = loadSavedSort(grouped);
let sort = saved.sort;
let dir = saved.dir;
let loading = false;
let lastItems = [];
const savedSort = lsGet(STORAGE.sort, '');
if (savedSort) {
try {
const s = JSON.parse(savedSort);
if (s && s.sort) sort = s.sort;
if (s && s.dir) dir = s.dir;
} catch { /* ignore */ }
function saveSortState() {
lsSet(
STORAGE.sort,
JSON.stringify({ sort, dir, grouped: !!grouped })
);
}
function sortActive(colKey) {
if (sort === 'priority' && colKey === 'rating') return true;
if (sort === colKey) return true;
return apiSortKey(sort) === apiSortKey(colKey);
}
function refreshTableChrome() {
applyColgroup(grouped);
renderHead();
setupColumnResize(table, grouped);
setupColumnDrag(thead, grouped, refreshTableChrome);
if (lastItems.length) {
renderRows(lastItems);
}
}
if (perPageSel) {
@@ -323,7 +498,9 @@
app.querySelectorAll('.reports-mode-btn').forEach((b) => {
b.classList.toggle('active', b === btn);
});
sort = grouped ? 'cnt' : 'generated_at_ms';
const next = loadSavedSort(grouped);
sort = next.sort;
dir = next.dir;
page = 1;
load();
});
@@ -341,7 +518,7 @@
window.addEventListener('pagehide', maybeStampVisit);
function columns() {
return grouped ? GROUP_COLUMNS : LIST_COLUMNS;
return getDisplayColumns(grouped);
}
function colspan() {
@@ -350,30 +527,48 @@
function renderHead() {
const cols = columns();
let html = '<tr><th class="report-tree-head" aria-label="Expand"></th>';
const widths = getColWidths();
let html =
'<tr><th class="report-tree-head" aria-label="Expand"><span class="col-resizer" data-resize-col="expand"></span></th>';
cols.forEach((c) => {
const active = sort === c.key || apiSortKey(sort) === apiSortKey(c.key);
if (c.noSort) {
html += '<th scope="col">' + escapeHtml(c.label) + '</th>';
return;
}
const active = sortActive(c.key);
const arrow =
active ? (dir === 'asc' ? '▲' : '▼') : '<span class="sort-idle">↕</span>';
const w = widths[c.key] ? ' style="width:' + widths[c.key] + 'px"' : '';
let thClass = 'sortable' + (active ? ' sortable--active' : '');
let thDrag = '';
if (!grouped) {
thClass += ' th-draggable';
thDrag =
' draggable="true" data-col-key="' + escapeHtml(c.key);
}
html +=
'<th class="sortable' +
(active ? ' sortable--active' : '') +
'" data-sort="' +
'<th class="' +
thClass +
'"' +
thDrag +
' data-sort="' +
escapeHtml(c.key) +
'" scope="col">' +
'"' +
w +
' scope="col"><span class="th-inner">' +
(grouped ? '' : '<span class="th-drag" title="Drag to reorder column" aria-hidden="true">⋮⋮</span>') +
'<span class="th-label">' +
escapeHtml(c.label) +
' <span class="sort-ind" aria-hidden="true">' +
'</span> <span class="sort-ind" aria-hidden="true">' +
arrow +
'</span></th>';
'</span></span>' +
(grouped ? '' : '<span class="col-resizer" data-resize-col="' + escapeHtml(c.key) + '"></span>') +
'</th>';
});
html += '<th class="col-tags">Tags</th></tr>';
html +=
'<th class="col-tags" scope="col" style="width:' +
(widths.tags || 128) +
'px">Tags<span class="col-resizer" data-resize-col="tags"></span></th></tr>';
thead.innerHTML = html;
thead.querySelectorAll('.sortable').forEach((th) => {
th.addEventListener('click', () => {
th.addEventListener('click', (e) => {
if (e.target.closest('.col-resizer') || e.target.closest('.th-drag')) return;
const key = th.getAttribute('data-sort');
if (!key) return;
if (sort === key) {
@@ -382,7 +577,7 @@
sort = key;
dir = 'desc';
}
lsSet(STORAGE.sort, JSON.stringify({ sort, dir, grouped }));
saveSortState();
page = 1;
load();
});
@@ -459,7 +654,17 @@
html += '<td>' + formatCell(c, row) + '</td>';
}
});
html += '<td class="col-tags"><div class="report-tags">' + buildTags(row, grouped) + '</div></td>';
const tagEditBtn =
!grouped && row.id && canTagEdit()
? '<button type="button" class="tag-edit-btn" data-report-id="' +
row.id +
'" title="Edit tags" aria-label="Edit tags">✎</button>'
: '';
html +=
'<td class="col-tags"><div class="report-tags">' +
buildTags(row, grouped) +
tagEditBtn +
'</div></td>';
html += '</tr>';
const briefLines = row.brief || [];
@@ -521,8 +726,8 @@
statusEl.textContent = data.error || 'Load failed';
return;
}
renderHead();
renderRows(data.items || []);
lastItems = data.items || [];
refreshTableChrome();
renderPagination(Number(data.total) || 0);
statusEl.textContent =
'Showing ' +
@@ -538,14 +743,335 @@
xhr.send();
}
renderHead();
initTagEditButtons(tbody, load);
applyColgroup(grouped);
load();
}
const TAG_QUICK_COLORS = [
'#5c6b82', '#3d8bfd', '#6d28d9', '#b45309', '#047857',
'#dc2626', '#d97706', '#0891b2', '#be185d', '#4f46e5',
];
function canTagEdit() {
return document.body.getAttribute('data-can-tag-edit') === '1';
}
function slugTagId(label) {
return String(label)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 32);
}
function renderTagPill(tag, removable) {
let html =
'<span class="report-tag" style="--tag-bg:' +
escapeHtml(tag.bg || '#5c6b82') +
'">' +
escapeHtml(tag.label || tag.id);
if (removable) {
html +=
'<button type="button" class="tag-remove" data-remove-id="' +
escapeHtml(tag.id) +
'" aria-label="Remove tag">×</button>';
}
return html + '</span>';
}
function createTagEditor(cfg) {
const state = { tags: [...(cfg.initialTags || [])], presets: [], reportId: cfg.reportId };
const els = cfg.elements;
function renderList() {
if (!els.list) return;
if (!state.tags.length) {
els.list.innerHTML = '<li class="muted">No custom tags yet.</li>';
return;
}
els.list.innerHTML = state.tags
.map(
(t, i) =>
'<li class="tag-editor-item">' +
renderTagPill(t, true) +
'<label class="tag-color-edit">Color <input type="color" data-color-idx="' +
i +
'" value="' +
escapeHtml(t.bg || '#5c6b82') +
'"></label></li>'
)
.join('');
els.list.querySelectorAll('.tag-remove').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-remove-id');
state.tags = state.tags.filter((t) => t.id !== id);
renderAll();
});
});
els.list.querySelectorAll('input[data-color-idx]').forEach((inp) => {
inp.addEventListener('input', () => {
const idx = Number(inp.getAttribute('data-color-idx'));
if (state.tags[idx]) state.tags[idx].bg = inp.value;
renderPreview();
});
});
}
function renderPreview() {
if (!els.preview) return;
els.preview.innerHTML = state.tags.length
? state.tags.map((t) => renderTagPill(t, false)).join('')
: '<span class="muted">No custom tags</span>';
}
function renderPresets() {
if (!els.presetBar || !els.presetChips) return;
if (!state.presets.length) {
els.presetBar.hidden = true;
return;
}
els.presetBar.hidden = false;
els.presetChips.innerHTML = state.presets
.map(
(p) =>
'<button type="button" class="tag-preset-chip" data-preset-id="' +
escapeHtml(p.id) +
'" style="--tag-bg:' +
escapeHtml(p.bg) +
'">' +
escapeHtml(p.label) +
'</button>'
)
.join('');
els.presetChips.querySelectorAll('.tag-preset-chip').forEach((btn) => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-preset-id');
const p = state.presets.find((x) => x.id === id);
if (!p || state.tags.some((t) => t.id === p.id)) return;
state.tags.push({ ...p });
renderAll();
});
});
}
function renderAll() {
renderList();
renderPreview();
}
function setStatus(msg, ok) {
if (!els.status) return;
els.status.textContent = msg || '';
els.status.classList.toggle('tag-editor-status--err', ok === false);
}
function addFromForm(form) {
const label = (form.label?.value || '').trim();
const bg = form.bg?.value || '#5c6b82';
if (!label) return;
const id = slugTagId(label);
if (!id) return;
const reserved = ['new', 'java', 'ndk', 'android'];
if (reserved.includes(id)) {
setStatus('Reserved tag id: ' + id, false);
return;
}
const existing = state.tags.findIndex((t) => t.id === id);
const entry = { id, label, bg };
if (existing >= 0) state.tags[existing] = entry;
else state.tags.push(entry);
form.label.value = '';
renderAll();
setStatus('', true);
}
if (els.form) {
els.form.addEventListener('submit', (e) => {
e.preventDefault();
addFromForm(els.form);
});
}
if (els.saveBtn) {
els.saveBtn.addEventListener('click', () => {
setStatus('Saving…', true);
const xhr = new XMLHttpRequest();
xhr.open('PUT', basePath() + '/api/report_tags.php', true);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
setStatus('Invalid response', false);
return;
}
if (!data.ok) {
setStatus(data.error || 'Save failed', false);
return;
}
state.tags = data.tags || [];
renderAll();
setStatus('Saved', true);
if (cfg.onSaved) cfg.onSaved(state.tags, state.reportId);
};
xhr.onerror = function () {
setStatus('Network error', false);
};
xhr.send(JSON.stringify({ id: state.reportId, tags: state.tags }));
});
}
function loadFromApi() {
const xhr = new XMLHttpRequest();
xhr.open('GET', basePath() + '/api/report_tags.php?id=' + state.reportId, true);
xhr.onload = function () {
let data;
try {
data = JSON.parse(xhr.responseText);
} catch {
return;
}
if (data.ok) {
state.tags = data.tags || [];
state.presets = data.presets || [];
renderAll();
renderPresets();
}
};
xhr.send();
}
renderAll();
if (cfg.fetchPresets) loadFromApi();
return {
setTags(tags) {
state.tags = tags || [];
renderAll();
},
getTags() {
return state.tags;
},
reload() {
loadFromApi();
},
};
}
let tagModalEditor = null;
function openTagModal(reportId) {
const modal = document.getElementById('tag-modal');
const editorEl = document.getElementById('tag-editor-modal');
if (!modal || !editorEl || !canTagEdit()) return;
editorEl.setAttribute('data-report-id', String(reportId));
modal.hidden = false;
modal.setAttribute('aria-hidden', 'false');
tagModalEditor = createTagEditor({
reportId: Number(reportId),
initialTags: [],
fetchPresets: true,
elements: {
list: document.getElementById('tag-modal-list'),
preview: document.getElementById('tag-modal-preview'),
form: document.getElementById('tag-modal-add'),
presetBar: document.getElementById('tag-modal-presets'),
presetChips: document.getElementById('tag-modal-preset-chips'),
saveBtn: document.getElementById('tag-modal-save'),
status: document.getElementById('tag-modal-status'),
},
onSaved() {
if (typeof reportsReloadRef === 'function') reportsReloadRef();
},
});
}
function closeTagModal() {
const modal = document.getElementById('tag-modal');
if (!modal) return;
modal.hidden = true;
modal.setAttribute('aria-hidden', 'true');
tagModalEditor = null;
}
let reportsReloadRef = null;
function initTagModal() {
document.querySelectorAll('[data-tag-modal-close]').forEach((el) => {
el.addEventListener('click', closeTagModal);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeTagModal();
});
}
function initTagEditorReadOnly() {
const preview = document.getElementById('tag-editor-preview');
const dataEl = document.getElementById('report-custom-tags-data');
if (!preview || !dataEl || canTagEdit()) return;
try {
const tags = JSON.parse(dataEl.textContent || '[]');
preview.innerHTML = tags.length
? tags.map((t) => renderTagPill(t, false)).join('')
: '<span class="muted">No custom tags</span>';
} catch {
preview.innerHTML = '<span class="muted">—</span>';
}
}
function initTagEditorDetail() {
const root = document.getElementById('tag-editor');
if (!root) return;
if (!canTagEdit()) {
initTagEditorReadOnly();
return;
}
const reportId = Number(root.getAttribute('data-report-id'));
let initial = [];
const dataEl = document.getElementById('report-custom-tags-data');
if (dataEl) {
try {
initial = JSON.parse(dataEl.textContent || '[]');
} catch { /* ignore */ }
}
createTagEditor({
reportId,
initialTags: initial,
fetchPresets: true,
elements: {
list: document.getElementById('tag-editor-list'),
preview: document.getElementById('tag-editor-preview'),
form: document.getElementById('tag-editor-add'),
presetBar: document.getElementById('tag-preset-bar'),
presetChips: document.getElementById('tag-preset-chips'),
saveBtn: document.getElementById('tag-editor-save'),
status: document.getElementById('tag-editor-status'),
},
});
}
function initTagEditButtons(tbody, reloadFn) {
reportsReloadRef = reloadFn;
if (!tbody) return;
tbody.addEventListener('click', (e) => {
const btn = e.target.closest('.tag-edit-btn');
if (!btn) return;
e.stopPropagation();
e.preventDefault();
openTagModal(Number(btn.getAttribute('data-report-id')));
});
}
document.addEventListener('DOMContentLoaded', () => {
initTheme();
initNav();
initTagModal();
initReportDetail();
initTagEditorDetail();
initReportsApp();
});
})();

View File

@@ -40,6 +40,11 @@ if ($route === '/api/report_viewed.php' || str_ends_with($route, '/api/report_vi
exit;
}
if ($route === '/api/report_tags.php' || str_ends_with($route, '/api/report_tags.php')) {
require __DIR__ . '/api/report_tags.php';
exit;
}
if ($route === '/logout') {
Auth::logout();
header('Location: ' . $base . '/login');
@@ -69,15 +74,33 @@ $grouped = isset($_GET['group']) && $_GET['group'] === '1';
$view = $_GET['view'] ?? 'home';
if ($view === 'report' && isset($_GET['id'])) {
$report = ReportRepository::getById((int) $_GET['id']);
if (!$report) {
http_response_code(404);
echo 'Not found';
exit;
try {
$report = ReportRepository::getById((int) $_GET['id']);
if (!$report) {
http_response_code(404);
echo 'Not found';
exit;
}
$uid = (int) (Auth::user()['id'] ?? 0);
if ($uid > 0) {
try {
ReportRepository::markViewed((int) $report['id'], $uid);
} catch (Throwable $e) {
error_log('markViewed: ' . $e->getMessage());
}
}
$pageTitle = 'Report';
require __DIR__ . '/../views/layout.php';
} catch (Throwable $e) {
error_log('report view: ' . $e->getMessage());
http_response_code(500);
if (cfg('debug')) {
header('Content-Type: text/plain; charset=utf-8');
echo 'Report view failed: ' . $e->getMessage();
} else {
echo 'Report view failed';
}
}
ReportRepository::markViewed((int) $report['id'], (int) Auth::user()['id']);
$pageTitle = 'Report';
require __DIR__ . '/../views/layout.php';
exit;
}

View File

@@ -0,0 +1,33 @@
#!/bin/sh
# Fresh MariaDB setup for crash reporter (run on BE as root or with mysql admin).
# Usage:
# ./scripts/init-mariadb.sh
# CRASH_DB_PASS='secret' ./scripts/init-mariadb.sh
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SCHEMA="$ROOT/sql/schema.mariadb.sql"
DB_NAME="${CRASH_DB_NAME:-androidcast_crashes}"
DB_USER="${CRASH_DB_USER:-androidcast}"
DB_PASS="${CRASH_DB_PASS:-}"
if [ ! -f "$SCHEMA" ]; then
echo "Missing $SCHEMA" >&2
exit 1
fi
echo "Applying schema from $SCHEMA ..."
mysql -u root -p < "$SCHEMA"
if [ -n "$DB_PASS" ]; then
echo "Creating app user $DB_USER@127.0.0.1 ..."
mysql -u root -p <<EOF
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}'@'127.0.0.1';
FLUSH PRIVILEGES;
EOF
else
echo "Skip app user: set CRASH_DB_PASS to create '${DB_USER}'@127.0.0.1"
fi
echo "Done. Set config.php: db.driver = mysql, db.mysql.* then restart php-fpm."

View File

@@ -1,30 +1,49 @@
-- Fresh MariaDB install for Android Cast crash reporter.
-- Run as root: mysql -u root -p < sql/schema.mariadb.sql
-- App user needs: SELECT, INSERT, UPDATE, DELETE on androidcast_crashes.*
CREATE DATABASE IF NOT EXISTS androidcast_crashes
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE androidcast_crashes;
CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('root','admin','viewer') NOT NULL DEFAULT 'viewer',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
role ENUM('root', 'admin', 'viewer') NOT NULL DEFAULT 'viewer',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_users_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
report_id CHAR(36) NOT NULL UNIQUE,
fingerprint CHAR(64) NOT NULL,
crash_type ENUM('java','native') NOT NULL,
report_id VARCHAR(128) NOT NULL,
fingerprint VARCHAR(64) NOT NULL,
crash_type VARCHAR(32) NOT NULL,
generated_at_ms BIGINT NOT NULL,
received_at_ms BIGINT NOT NULL,
device_model VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
payload_json JSON NOT NULL,
KEY idx_reports_generated (generated_at_ms DESC),
KEY idx_reports_received (received_at_ms DESC),
KEY idx_reports_fingerprint (fingerprint)
) ENGINE=InnoDB;
payload_json LONGTEXT NOT NULL,
tags_json LONGTEXT NOT NULL,
UNIQUE KEY uq_reports_report_id (report_id),
KEY idx_reports_generated (generated_at_ms),
KEY idx_reports_received (received_at_ms),
KEY idx_reports_fingerprint (fingerprint),
KEY idx_reports_fp_received (fingerprint, received_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS report_views (
user_id INT UNSIGNED NOT NULL,
report_id BIGINT UNSIGNED NOT NULL,
viewed_at_ms BIGINT NOT NULL,
PRIMARY KEY (user_id, report_id),
CONSTRAINT fk_report_views_report
FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO users (id, username, password_hash, role)
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');
-- default password: admin — change in production

View File

@@ -36,6 +36,10 @@ final class Auth {
return $action === 'view';
}
public static function canEditTags(): bool {
return self::can('tag_edit');
}
public static function login(string $username, string $password): bool {
$pdo = Database::pdo();
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1');

View File

@@ -1,27 +1,29 @@
<?php
/*
* package examples/crash_reporter/backend/src/Database.php
* Database.php
* Created at: Wed 20 May 2026 14:31:55 +0200
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
* Contributors:
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 43 lines)
* - Cursor Agent (project assistant)
* Digest: SHA256 6e0c3bc4b7a544839aa7961fa9b6adb0da4968c13825a10159d791270a26d4a1
*/
declare(strict_types=1);
final class Database {
private static ?PDO $pdo = null;
private static bool $schemaChecked = false;
private static ?string $driver = null;
public static function driver(): string {
if (self::$driver !== null) {
return self::$driver;
}
$d = cfg('db.driver', 'sqlite');
self::$driver = ($d === 'mysql' || $d === 'mariadb') ? 'mysql' : 'sqlite';
return self::$driver;
}
public static function isMysql(): bool {
return self::driver() === 'mysql';
}
public static function pdo(): PDO {
if (self::$pdo !== null) {
return self::$pdo;
}
$driver = cfg('db.driver', 'sqlite');
if ($driver === 'mysql') {
if (self::isMysql()) {
$m = cfg('db.mysql', []);
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
@@ -34,6 +36,7 @@ final class Database {
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
self::ensureSchema();
return self::$pdo;
}
$path = cfg('db.sqlite_path');
@@ -50,7 +53,20 @@ final class Database {
return self::$pdo;
}
/** Idempotent: applies sql/schema.sqlite.sql if core tables are missing. */
/** Upsert into report_views (portable). */
public static function upsertReportView(int $userId, int $reportId, int $viewedAtMs): void {
$pdo = self::pdo();
if (self::isMysql()) {
$sql = 'INSERT INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE viewed_at_ms = VALUES(viewed_at_ms)';
} else {
$sql = 'INSERT OR REPLACE INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)';
}
$stmt = $pdo->prepare($sql);
$stmt->execute([$userId, $reportId, $viewedAtMs]);
}
/** Idempotent schema setup for sqlite and mysql. */
public static function ensureSchema(): void {
if (self::$schemaChecked) {
return;
@@ -60,12 +76,16 @@ final class Database {
if ($pdo === null) {
return;
}
if (self::isMysql()) {
self::ensureMysqlSchema($pdo);
return;
}
self::ensureSqliteSchema($pdo);
}
private static function ensureSqliteSchema(PDO $pdo): void {
$need = static function (string $table) use ($pdo): bool {
$stmt = $pdo->prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1"
);
$stmt->execute([$table]);
return $stmt->fetchColumn() === false;
return !self::tableExists($pdo, $table);
};
if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) {
self::ensureReportColumns($pdo);
@@ -84,14 +104,84 @@ final class Database {
self::ensureReportViewsTable($pdo);
}
private static function ensureMysqlSchema(PDO $pdo): void {
if (!self::tableExists($pdo, 'users')) {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS users (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role ENUM('root', 'admin', 'viewer') NOT NULL DEFAULT 'viewer',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_users_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
$pdo->exec(
"INSERT IGNORE INTO users (id, username, password_hash, role)
VALUES (1, 'admin', '\$2y\$10\$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root')"
);
}
if (!self::tableExists($pdo, 'reports')) {
$pdo->exec(
"CREATE TABLE IF NOT EXISTS reports (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
report_id VARCHAR(128) NOT NULL,
fingerprint VARCHAR(64) NOT NULL,
crash_type VARCHAR(32) NOT NULL,
generated_at_ms BIGINT NOT NULL,
received_at_ms BIGINT NOT NULL,
device_model VARCHAR(128) NULL,
app_version VARCHAR(64) NULL,
payload_json LONGTEXT NOT NULL,
tags_json LONGTEXT NOT NULL,
UNIQUE KEY uq_reports_report_id (report_id),
KEY idx_reports_generated (generated_at_ms),
KEY idx_reports_received (received_at_ms),
KEY idx_reports_fingerprint (fingerprint),
KEY idx_reports_fp_received (fingerprint, received_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
} else {
self::ensureMysqlReportColumns($pdo);
}
self::ensureMysqlReportViewsTable($pdo);
}
private static function ensureMysqlReportColumns(PDO $pdo): void {
$names = self::columnNames($pdo, 'reports');
if (!in_array('tags_json', $names, true)) {
$pdo->exec("ALTER TABLE reports ADD COLUMN tags_json LONGTEXT NOT NULL");
$pdo->exec("UPDATE reports SET tags_json = '[]' WHERE tags_json IS NULL OR tags_json = ''");
}
}
private static function ensureMysqlReportViewsTable(PDO $pdo): void {
if (self::tableExists($pdo, 'report_views')) {
return;
}
$pdo->exec(
'CREATE TABLE IF NOT EXISTS report_views (
user_id INT UNSIGNED NOT NULL,
report_id BIGINT UNSIGNED NOT NULL,
viewed_at_ms BIGINT NOT NULL,
PRIMARY KEY (user_id, report_id),
CONSTRAINT fk_report_views_report
FOREIGN KEY (report_id) REFERENCES reports (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
}
private static function ensureReportColumns(PDO $pdo): void {
$names = array_column($pdo->query('PRAGMA table_info(reports)')->fetchAll(PDO::FETCH_ASSOC), 'name');
$names = self::columnNames($pdo, 'reports');
if (!in_array('tags_json', $names, true)) {
$pdo->exec("ALTER TABLE reports ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'");
}
}
private static function ensureReportViewsTable(PDO $pdo): void {
if (self::tableExists($pdo, 'report_views')) {
return;
}
$pdo->exec(
'CREATE TABLE IF NOT EXISTS report_views (
user_id INTEGER NOT NULL,
@@ -102,16 +192,61 @@ final class Database {
);
}
public static function tableExists(PDO $pdo, string $table): bool {
if (self::isMysql()) {
$db = cfg('db.mysql.database', 'androidcast_crashes');
$stmt = $pdo->prepare(
'SELECT 1 FROM information_schema.tables
WHERE table_schema = ? AND table_name = ? LIMIT 1'
);
$stmt->execute([$db, $table]);
return $stmt->fetchColumn() !== false;
}
$stmt = $pdo->prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1"
);
$stmt->execute([$table]);
return $stmt->fetchColumn() !== false;
}
/** @return list<string> */
public static function columnNames(PDO $pdo, string $table): array {
if (self::isMysql()) {
$db = cfg('db.mysql.database', 'androidcast_crashes');
$stmt = $pdo->prepare(
'SELECT column_name AS name FROM information_schema.columns
WHERE table_schema = ? AND table_name = ?'
);
$stmt->execute([$db, $table]);
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'name');
}
return array_column(
$pdo->query('PRAGMA table_info(' . preg_replace('/[^a-z0-9_]/i', '', $table) . ')')->fetchAll(PDO::FETCH_ASSOC),
'name'
);
}
/** @return list<string> */
public static function listTables(PDO $pdo): array {
if (self::isMysql()) {
$db = cfg('db.mysql.database', 'androidcast_crashes');
$stmt = $pdo->prepare(
'SELECT table_name FROM information_schema.tables
WHERE table_schema = ? ORDER BY table_name'
);
$stmt->execute([$db]);
return $stmt->fetchAll(PDO::FETCH_COLUMN);
}
return $pdo->query(
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"
)->fetchAll(PDO::FETCH_COLUMN);
}
private static function reportsSchemaOk(PDO $pdo): bool {
try {
$cols = $pdo->query('PRAGMA table_info(reports)')->fetchAll(PDO::FETCH_ASSOC);
} catch (PDOException) {
if (!self::tableExists($pdo, 'reports')) {
return false;
}
if ($cols === []) {
return false;
}
$names = array_column($cols, 'name');
$names = self::columnNames($pdo, 'reports');
foreach (['report_id', 'fingerprint', 'crash_type', 'generated_at_ms', 'received_at_ms', 'payload_json'] as $col) {
if (!in_array($col, $names, true)) {
return false;

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
final class ReportRepository {
private const LIST_SORT = [
'priority',
'generated_at_ms',
'received_at_ms',
'crash_type',
@@ -10,6 +11,7 @@ final class ReportRepository {
'app_version',
'fingerprint',
'fingerprint_cnt',
'rating',
];
private const GROUP_SORT = [
'cnt',
@@ -102,10 +104,11 @@ final class ReportRepository {
];
}
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'generated_at_ms';
if ($sortCol === 'fingerprint_cnt') {
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority';
if ($sortCol === 'rating') {
$sortCol = 'fingerprint_cnt';
}
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
$total = (int) $pdo->query('SELECT COUNT(*) FROM reports')->fetchColumn();
$sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms,
r.device_model, r.app_version, r.payload_json, r.tags_json,
@@ -115,7 +118,7 @@ final class ReportRepository {
AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
FROM reports r
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
ORDER BY " . ($sortCol === 'fingerprint_cnt' ? 'fingerprint_cnt' : "r.$sortCol") . " $dir
ORDER BY $orderBy
LIMIT $perPage OFFSET $offset";
$stmt = $pdo->prepare($sql);
$stmt->execute([$sinceMs, $userId]);
@@ -134,18 +137,55 @@ final class ReportRepository {
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'sort' => $sortCol,
'sort' => $sort === 'priority' ? 'priority' : $sortCol,
'dir' => strtolower($dir),
'grouped' => false,
];
}
public static function markViewed(int $reportId, int $userId): void {
private static function buildListOrder(string $sort, string $dir): string {
$dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC';
if ($sort === 'priority') {
return 'fingerprint_cnt DESC, r.received_at_ms DESC, r.app_version DESC, r.device_model ASC, r.crash_type ASC';
}
if ($sort === 'fingerprint_cnt') {
return "fingerprint_cnt $dir, r.received_at_ms DESC";
}
return "r.$sort $dir";
}
public static function setCustomTags(int $reportId, array $tags): bool {
if (self::getById($reportId) === null) {
return false;
}
$pdo = Database::pdo();
$stmt = $pdo->prepare(
'INSERT OR REPLACE INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)'
$normalized = normalize_report_tags($tags);
$json = json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$stmt = $pdo->prepare('UPDATE reports SET tags_json = ? WHERE id = ?');
$stmt->execute([$json ?: '[]', $reportId]);
return true;
}
/** Distinct custom tags used across reports (for suggestions). */
public static function listTagPresets(int $limit = 40): array {
$pdo = Database::pdo();
$rows = $pdo->query('SELECT tags_json FROM reports WHERE tags_json IS NOT NULL AND tags_json != \'[]\' LIMIT 500')
->fetchAll();
$map = [];
foreach ($rows as $row) {
foreach (parse_report_tags($row['tags_json'] ?? '[]') as $tag) {
$map[$tag['id']] = $tag;
}
}
return array_values($map);
}
public static function markViewed(int $reportId, int $userId): void {
Database::upsertReportView(
$userId,
$reportId,
(int) round(microtime(true) * 1000)
);
$stmt->execute([$userId, $reportId, (int) round(microtime(true) * 1000)]);
}
public static function getById(int $id): ?array {
@@ -162,7 +202,8 @@ final class ReportRepository {
if (!$row) {
return null;
}
$row['payload'] = json_decode($row['payload_json'], true);
$decoded = json_decode($row['payload_json'], true);
$row['payload'] = is_array($decoded) ? $decoded : [];
$row['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
$row['viewed'] = (bool) ($row['viewed'] ?? false);
return $row;

View File

@@ -49,8 +49,18 @@ function cfg(string $key, $default = null) {
return $v;
}
function h(?string $s): string {
return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
function h(mixed $s): string {
if ($s === null || $s === false) {
return '';
}
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** json_encode that never returns false (substitutes bad UTF-8). */
function safe_json_encode(mixed $data, int $flags = 0): string {
$flags |= JSON_INVALID_UTF8_SUBSTITUTE;
$json = json_encode($data, $flags);
return is_string($json) ? $json : 'null';
}
function json_out(array $data, int $code = 200): void {
@@ -124,6 +134,49 @@ function crash_kind_tag(string $crashType): array {
};
}
/** OS product name (Android, Linux, …) — not device manufacturer. */
/** Human-readable ABI list from device payload. */
function format_device_abis(mixed $abis): string {
if (!is_array($abis)) {
return is_scalar($abis) ? trim((string) $abis) : '';
}
$parts = [];
foreach ($abis as $abi) {
if (is_scalar($abi)) {
$s = trim((string) $abi);
if ($s !== '') {
$parts[] = $s;
}
}
}
return implode(', ', $parts);
}
function device_os_name(array $device): string {
if (!empty($device['os_name'])) {
return trim((string) $device['os_name']);
}
if (!empty($device['os'])) {
return trim((string) $device['os']);
}
$sdk = (int) ($device['sdk_int'] ?? 0);
$blob = strtolower(implode(' ', array_filter([
(string) ($device['product'] ?? ''),
(string) ($device['device'] ?? ''),
(string) ($device['model'] ?? ''),
])));
if ($sdk > 0 || str_contains($blob, 'android')) {
return 'Android';
}
if (str_contains($blob, 'linux')) {
return 'Linux';
}
if (($device['release'] ?? '') !== '') {
return 'Android';
}
return 'Android';
}
/** Enrich list row with OS fields and rating inputs (keeps payload_json until unset). */
function report_enrich_list_row(array &$row, int $sinceMs = 0): void {
$p = json_decode($row['payload_json'] ?? '', true);
@@ -131,7 +184,7 @@ function report_enrich_list_row(array &$row, int $sinceMs = 0): void {
$p = [];
}
$d = $p['device'] ?? [];
$row['os_name'] = trim((string) ($d['manufacturer'] ?? $d['brand'] ?? ''));
$row['os_name'] = device_os_name($d);
$rel = (string) ($d['release'] ?? '');
$sdk = (int) ($d['sdk_int'] ?? 0);
if ($rel !== '' && $sdk > 0) {
@@ -175,6 +228,60 @@ function report_rating_score(int $fingerprintTotal, int $fingerprintRecent, int
return max(0, min(5, $score));
}
/** System tags — not stored in tags_json (computed in UI). */
function report_reserved_tag_ids(): array {
return ['new', 'java', 'ndk', 'android'];
}
function normalize_tag_color(string $bg): string {
$bg = trim($bg);
if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $bg) !== 1) {
return '#5c6b82';
}
if (strlen($bg) === 4) {
return '#' . $bg[1] . $bg[1] . $bg[2] . $bg[2] . $bg[3] . $bg[3];
}
return strtolower($bg);
}
/**
* @param list<mixed> $tags
* @return list<array{id:string,label:string,bg:string}>
*/
function normalize_report_tags(array $tags): array {
$reserved = array_flip(report_reserved_tag_ids());
$out = [];
$seen = [];
foreach ($tags as $tag) {
if (!is_array($tag)) {
continue;
}
$label = trim((string) ($tag['label'] ?? $tag['id'] ?? ''));
if ($label === '' || strlen($label) > 40) {
continue;
}
$id = trim((string) ($tag['id'] ?? $label));
$id = strtolower(preg_replace('/[^a-z0-9_-]+/i', '-', $id) ?? $id);
$id = trim($id, '-');
if ($id === '' || isset($reserved[$id])) {
continue;
}
if (isset($seen[$id])) {
continue;
}
$seen[$id] = true;
$out[] = [
'id' => $id,
'label' => $label,
'bg' => normalize_tag_color((string) ($tag['bg'] ?? '#5c6b82')),
];
if (count($out) >= 24) {
break;
}
}
return $out;
}
/** @return list<array{id:string,label:string,bg:string}> */
function parse_report_tags(?string $json): array {
if ($json === null || $json === '') {

View File

@@ -28,6 +28,7 @@
</head>
<body data-base-path="<?= h(Auth::basePath()) ?>"
data-view="<?= h($view ?? 'home') ?>"
data-can-tag-edit="<?= Auth::canEditTags() ? '1' : '0' ?>"
<?= (($view ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>>
<header class="top-menu" hidden aria-hidden="true"></header>
<div class="shell">
@@ -107,7 +108,8 @@
</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" id="reports-tree">
<table class="data-table reports-tree reports-table--cols" id="reports-tree">
<colgroup id="reports-colgroup"></colgroup>
<thead id="reports-thead"></thead>
<tbody id="reports-tbody"></tbody>
</table>
@@ -117,6 +119,34 @@
<?php endif; ?>
</main>
</div>
<?php if (Auth::canEditTags()): ?>
<div id="tag-modal" class="tag-modal" hidden aria-hidden="true">
<div class="tag-modal-backdrop" data-tag-modal-close></div>
<div class="tag-modal-panel" role="dialog" aria-labelledby="tag-modal-title">
<header class="tag-modal-header">
<h2 id="tag-modal-title">Edit tags</h2>
<button type="button" class="tag-modal-close" data-tag-modal-close aria-label="Close">×</button>
</header>
<div class="tag-editor tag-editor--modal" id="tag-editor-modal" data-report-id="0">
<div class="tag-editor-preview" id="tag-modal-preview"></div>
<ul class="tag-editor-list" id="tag-modal-list"></ul>
<form class="tag-editor-add" id="tag-modal-add">
<label class="tag-field"><span>Label</span><input type="text" name="label" maxlength="40" required></label>
<label class="tag-field"><span>Color</span><input type="color" name="bg" value="#5c6b82"></label>
<button type="submit" class="btn">Add</button>
</form>
<div class="tag-preset-bar" id="tag-modal-presets" hidden>
<span class="muted">Suggestions:</span>
<div class="tag-preset-chips" id="tag-modal-preset-chips"></div>
</div>
<div class="tag-editor-actions">
<button type="button" class="btn btn-primary" id="tag-modal-save">Save</button>
<span class="tag-editor-status muted" id="tag-modal-status"></span>
</div>
</div>
</div>
</div>
<?php endif; ?>
<footer class="bottom-bar">Android Cast crash reporter — placeholder footer</footer>
</body>
</html>

View File

@@ -1,12 +1,13 @@
<?php
$p = $report['payload'] ?? [];
$device = $p['device'] ?? [];
$app = $p['app'] ?? [];
$p = is_array($report['payload'] ?? null) ? $report['payload'] : [];
$device = is_array($p['device'] ?? null) ? $p['device'] : [];
$app = is_array($p['app'] ?? null) ? $p['app'] : [];
$pageTitle = 'Report ' . ($report['report_id'] ?? '');
$view = 'reports';
$rawJson = json_encode($p, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
$rawJson = safe_json_encode($p, $jsonFlags);
$sessionJson = !empty($p['session_context'])
? json_encode($p['session_context'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
? safe_json_encode($p['session_context'], $jsonFlags)
: '';
?>
<div class="detail-header">
@@ -15,15 +16,50 @@ $sessionJson = !empty($p['session_context'])
</a>
<div>
<h1>Crash report</h1>
<p class="muted">Report <?= h($report['report_id'] ?? '') ?></p>
<p class="muted">Report <?= h($report['report_id'] ?? '') ?> · <?= h($p['crash_type'] ?? '') ?></p>
</div>
</div>
<section class="tag-editor" id="tag-editor" data-report-id="<?= (int) ($report['id'] ?? 0) ?>">
<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>
</div>
<div class="tag-editor-preview" id="tag-editor-preview" aria-live="polite"></div>
<?php if (Auth::canEditTags()): ?>
<ul class="tag-editor-list" id="tag-editor-list"></ul>
<form class="tag-editor-add" id="tag-editor-add">
<label class="tag-field">
<span>Label</span>
<input type="text" name="label" maxlength="40" required placeholder="e.g. regression">
</label>
<label class="tag-field">
<span>Color</span>
<input type="color" name="bg" value="#5c6b82">
</label>
<button type="submit" class="btn">Add tag</button>
</form>
<div class="tag-preset-bar" id="tag-preset-bar" hidden>
<span class="muted">Suggestions:</span>
<div class="tag-preset-chips" id="tag-preset-chips"></div>
</div>
<div class="tag-editor-actions">
<button type="button" class="btn btn-primary" id="tag-editor-save">Save tags</button>
<span class="tag-editor-status muted" id="tag-editor-status"></span>
</div>
<?php else: ?>
<p class="muted">Sign in as admin to edit custom tags.</p>
<?php endif; ?>
</section>
<script type="application/json" id="report-custom-tags-data"><?=
safe_json_encode($report['tags'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
?></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(implode(', ', (array)$device['abis'])) ?></p>
<p class="muted">ABIs: <?= h(format_device_abis($device['abis'] ?? null)) ?></p>
<?php endif; ?>
</div>
<div class="card card--lift"><h3>App</h3>