diff --git a/examples/crash_reporter/backend/README.md b/examples/crash_reporter/backend/README.md
index 1237513..466cbdc 100644
--- a/examples/crash_reporter/backend/README.md
+++ b/examples/crash_reporter/backend/README.md
@@ -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
diff --git a/examples/crash_reporter/backend/config/config.example.php b/examples/crash_reporter/backend/config/config.example.php
index 6dd193d..546d8ce 100644
--- a/examples/crash_reporter/backend/config/config.example.php
+++ b/examples/crash_reporter/backend/config/config.example.php
@@ -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',
diff --git a/examples/crash_reporter/backend/nginx.apps.conf.fragment b/examples/crash_reporter/backend/nginx.apps.conf.fragment
index 620152c..32aa2a6 100644
--- a/examples/crash_reporter/backend/nginx.apps.conf.fragment
+++ b/examples/crash_reporter/backend/nginx.apps.conf.fragment
@@ -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;
diff --git a/examples/crash_reporter/backend/public/api/diag.php b/examples/crash_reporter/backend/public/api/diag.php
index d54bb0c..ccdbbc1 100644
--- a/examples/crash_reporter/backend/public/api/diag.php
+++ b/examples/crash_reporter/backend/public/api/diag.php
@@ -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) {
diff --git a/examples/crash_reporter/backend/public/api/report_tags.php b/examples/crash_reporter/backend/public/api/report_tags.php
new file mode 100644
index 0000000..9ab47c9
--- /dev/null
+++ b/examples/crash_reporter/backend/public/api/report_tags.php
@@ -0,0 +1,62 @@
+ 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);
+}
diff --git a/examples/crash_reporter/backend/public/api/reports.php b/examples/crash_reporter/backend/public/api/reports.php
index ff64e0e..de0efc2 100644
--- a/examples/crash_reporter/backend/public/api/reports.php
+++ b/examples/crash_reporter/backend/public/api/reports.php
@@ -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));
diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css
index ac09ec6..06d1639 100644
--- a/examples/crash_reporter/backend/public/assets/css/app.css
+++ b/examples/crash_reporter/backend/public/assets/css/app.css
@@ -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; }
diff --git a/examples/crash_reporter/backend/public/assets/js/app.js b/examples/crash_reporter/backend/public/assets/js/app.js
index 6f13954..c99cac8 100644
--- a/examples/crash_reporter/backend/public/assets/js/app.js
+++ b/examples/crash_reporter/backend/public/assets/js/app.js
@@ -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 =
+ '
';
+ order.forEach((k) => {
+ html +=
+ '';
+ });
+ html +=
+ '';
+ 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 = ' | ';
+ const widths = getColWidths();
+ let html =
+ '
|---|
| ';
cols.forEach((c) => {
- const active = sort === c.key || apiSortKey(sort) === apiSortKey(c.key);
- if (c.noSort) {
- html += '' + escapeHtml(c.label) + ' | ';
- return;
- }
+ const active = sortActive(c.key);
const arrow =
active ? (dir === 'asc' ? '▲' : '▼') : '↕';
+ 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 +=
- '' +
+ '"' +
+ w +
+ ' scope="col">' +
+ (grouped ? '' : '⋮⋮') +
+ '' +
escapeHtml(c.label) +
- ' ' +
+ ' ' +
arrow +
- ' | ';
+ '' +
+ (grouped ? '' : '') +
+ '';
});
- html += 'Tags |
';
+ html +=
+ 'Tags | ';
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 += '' + formatCell(c, row) + ' | ';
}
});
- html += '' + buildTags(row, grouped) + ' | ';
+ const tagEditBtn =
+ !grouped && row.id && canTagEdit()
+ ? ''
+ : '';
+ html +=
+ '' +
+ buildTags(row, grouped) +
+ tagEditBtn +
+ ' | ';
html += '';
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 =
+ '' +
+ escapeHtml(tag.label || tag.id);
+ if (removable) {
+ html +=
+ '';
+ }
+ return html + '';
+ }
+
+ 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 = 'No custom tags yet.';
+ return;
+ }
+ els.list.innerHTML = state.tags
+ .map(
+ (t, i) =>
+ '' +
+ renderTagPill(t, true) +
+ ''
+ )
+ .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('')
+ : 'No custom tags';
+ }
+
+ 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) =>
+ ''
+ )
+ .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('')
+ : 'No custom tags';
+ } catch {
+ preview.innerHTML = '—';
+ }
+ }
+
+ 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();
});
})();
diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php
index d7ebe5f..4ee5724 100644
--- a/examples/crash_reporter/backend/public/index.php
+++ b/examples/crash_reporter/backend/public/index.php
@@ -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;
}
diff --git a/examples/crash_reporter/backend/scripts/init-mariadb.sh b/examples/crash_reporter/backend/scripts/init-mariadb.sh
new file mode 100755
index 0000000..719fdf7
--- /dev/null
+++ b/examples/crash_reporter/backend/scripts/init-mariadb.sh
@@ -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 <prepare('SELECT * FROM users WHERE username = ? LIMIT 1');
diff --git a/examples/crash_reporter/backend/src/Database.php b/examples/crash_reporter/backend/src/Database.php
index e506dfc..6a9dfef 100644
--- a/examples/crash_reporter/backend/src/Database.php
+++ b/examples/crash_reporter/backend/src/Database.php
@@ -1,27 +1,29 @@
- * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
- * Contributors:
- * - Anton Afanasyeu (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 */
+ 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 */
+ 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;
diff --git a/examples/crash_reporter/backend/src/ReportRepository.php b/examples/crash_reporter/backend/src/ReportRepository.php
index a1d6d84..6168e76 100644
--- a/examples/crash_reporter/backend/src/ReportRepository.php
+++ b/examples/crash_reporter/backend/src/ReportRepository.php
@@ -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;
diff --git a/examples/crash_reporter/backend/src/bootstrap.php b/examples/crash_reporter/backend/src/bootstrap.php
index 3e61475..35718d5 100644
--- a/examples/crash_reporter/backend/src/bootstrap.php
+++ b/examples/crash_reporter/backend/src/bootstrap.php
@@ -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 $tags
+ * @return list
+ */
+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 */
function parse_report_tags(?string $json): array {
if ($json === null || $json === '') {
diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php
index e6d192c..b85dbd8 100644
--- a/examples/crash_reporter/backend/views/layout.php
+++ b/examples/crash_reporter/backend/views/layout.php
@@ -28,6 +28,7 @@
>
@@ -107,7 +108,8 @@
Loading…
-
+
@@ -117,6 +119,34 @@
+
+
+