mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
befroe mdb mig
This commit is contained in:
@@ -13,10 +13,15 @@ Anonymous crash JSON receiver and web console for
|
|||||||
Minimal `php81-fpm` does **not** include sessions or PDO. Install modules explicitly:
|
Minimal `php81-fpm` does **not** include sessions or PDO. Install modules explicitly:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# SQLite only (dev / small installs)
|
||||||
apk add php81-fpm php81-session php81-pdo php81-pdo_sqlite php81-sqlite3
|
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
|
rc-service php-fpm81 restart
|
||||||
# package names may be php82-* on newer Alpine; adjust to your php -v
|
# 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.
|
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.
|
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. Set `base_path` in `config.php` to match the URL prefix exactly.
|
||||||
3. Choose DB:
|
3. Choose DB:
|
||||||
- **SQLite (default):** run `sql/schema.sqlite.sql`, set `DB_DRIVER=sqlite` in config
|
- **SQLite (default):** `sqlite3 data/crashes.sqlite < sql/schema.sqlite.sql`, `db.driver` => `sqlite`
|
||||||
- **MariaDB:** create DB, run `sql/schema.mariadb.sql`, set `DB_DRIVER=mysql`
|
- **MariaDB (fresh, recommended on BE):**
|
||||||
4. `chown -R www-data:www-data data storage`
|
```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
|
5. TLS terminate at nginx; restrict `/api/upload.php` by firewall if needed
|
||||||
|
|
||||||
## Android app configuration
|
## Android app configuration
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ return [
|
|||||||
// Production: '/app/androidcast_project/crashes' — Local php -S: use '' (empty string)
|
// Production: '/app/androidcast_project/crashes' — Local php -S: use '' (empty string)
|
||||||
'base_path' => '/app/androidcast_project/crashes',
|
'base_path' => '/app/androidcast_project/crashes',
|
||||||
'db' => [
|
'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',
|
'sqlite_path' => __DIR__ . '/../data/crashes.sqlite',
|
||||||
'mysql' => [
|
'mysql' => [
|
||||||
'host' => '127.0.0.1',
|
'host' => '127.0.0.1',
|
||||||
|
|||||||
@@ -50,6 +50,14 @@
|
|||||||
fastcgi_param REQUEST_URI $request_uri;
|
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/ {
|
location ^~ /app/androidcast_project/crashes/ {
|
||||||
include fastcgi_params;
|
include fastcgi_params;
|
||||||
fastcgi_pass unix:/run/php-fpm.socket;
|
fastcgi_pass unix:/run/php-fpm.socket;
|
||||||
|
|||||||
@@ -6,37 +6,53 @@ if (!cfg('debug')) {
|
|||||||
json_out(['ok' => false, 'error' => 'diag disabled'], 403);
|
json_out(['ok' => false, 'error' => 'diag disabled'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$driver = Database::driver();
|
||||||
$path = cfg('db.sqlite_path');
|
$path = cfg('db.sqlite_path');
|
||||||
$out = [
|
$out = [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
|
'db_driver' => $driver,
|
||||||
'php_user' => get_current_user(),
|
'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'),
|
'storage_writable' => is_writable(dirname(__DIR__) . '/../storage'),
|
||||||
'tables' => [],
|
'tables' => [],
|
||||||
'reports_columns' => [],
|
'reports_columns' => [],
|
||||||
'test_insert' => null,
|
'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 {
|
try {
|
||||||
$pdo = Database::pdo();
|
$pdo = Database::pdo();
|
||||||
$tables = $pdo->query(
|
$tables = Database::listTables($pdo);
|
||||||
"SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name"
|
|
||||||
)->fetchAll(PDO::FETCH_COLUMN);
|
|
||||||
$out['tables'] = $tables;
|
$out['tables'] = $tables;
|
||||||
if (in_array('reports', $tables, true)) {
|
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();
|
$pdo->beginTransaction();
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms, device_model, app_version, payload_json)
|
'INSERT INTO reports (report_id, fingerprint, crash_type, generated_at_ms, received_at_ms,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
device_model, app_version, payload_json, tags_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||||
);
|
);
|
||||||
$rid = 'diag-' . time();
|
$rid = 'diag-' . time();
|
||||||
$stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}']);
|
$stmt->execute([$rid, 'diag', 'java', 1, 1, null, null, '{}', '[]']);
|
||||||
$pdo->rollBack();
|
$pdo->rollBack();
|
||||||
$out['test_insert'] = 'ok (rolled back)';
|
$out['test_insert'] = 'ok (rolled back)';
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
|
|||||||
62
examples/crash_reporter/backend/public/api/report_tags.php
Normal file
62
examples/crash_reporter/backend/public/api/report_tags.php
Normal 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);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ $allowedPerPage = [25, 50, 75, 100, 200];
|
|||||||
if (!in_array($perPage, $allowedPerPage, true)) {
|
if (!in_array($perPage, $allowedPerPage, true)) {
|
||||||
$perPage = 50;
|
$perPage = 50;
|
||||||
}
|
}
|
||||||
$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'generated_at_ms'));
|
$sort = (string) ($_GET['sort'] ?? ($grouped ? 'cnt' : 'priority'));
|
||||||
$dir = (string) ($_GET['dir'] ?? 'desc');
|
$dir = (string) ($_GET['dir'] ?? 'desc');
|
||||||
$sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0));
|
$sinceMs = max(0, (int) ($_GET['since_ms'] ?? 0));
|
||||||
|
|
||||||
|
|||||||
@@ -345,6 +345,9 @@ a:hover { text-decoration: underline; }
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
.btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
.data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
|
.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-mode-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
.reports-status { margin: 8px 0 0; min-height: 1.2em; }
|
.reports-status { margin: 8px 0 0; min-height: 1.2em; }
|
||||||
.reports-table-wrap { overflow-x: auto; }
|
.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; }
|
.reports-pagination { margin-top: 16px; }
|
||||||
.pagination-inner {
|
.pagination-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -578,3 +630,189 @@ a:hover { text-decoration: underline; }
|
|||||||
[data-theme="light"] .star--on { background: #d97706; }
|
[data-theme="light"] .star--on { background: #d97706; }
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.alert { background: rgba(248,113,113,.15); color: var(--danger); padding: 10px; border-radius: 8px; }
|
.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; }
|
||||||
|
|||||||
@@ -6,6 +6,19 @@
|
|||||||
viewed: 'crash_console_viewed_ids',
|
viewed: 'crash_console_viewed_ids',
|
||||||
sort: 'crash_console_sort',
|
sort: 'crash_console_sort',
|
||||||
skipStamp: 'crash_skip_visit_stamp',
|
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' };
|
const TAG_NEW = { label: 'new', bg: '#3d8bfd' };
|
||||||
@@ -21,13 +34,14 @@
|
|||||||
{ key: 'os_name', label: 'OS' },
|
{ key: 'os_name', label: 'OS' },
|
||||||
{ key: 'os_version', label: 'OS ver' },
|
{ key: 'os_version', label: 'OS ver' },
|
||||||
{ key: 'received_at_ms', label: 'Received', fmt: 'time' },
|
{ 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 = {
|
const SORT_API_MAP = {
|
||||||
|
priority: 'priority',
|
||||||
|
rating: 'fingerprint_cnt',
|
||||||
os_name: 'device_model',
|
os_name: 'device_model',
|
||||||
os_version: 'app_version',
|
os_version: 'app_version',
|
||||||
rating: 'fingerprint_cnt',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const GROUP_COLUMNS = [
|
const GROUP_COLUMNS = [
|
||||||
@@ -239,6 +253,150 @@
|
|||||||
return SORT_API_MAP[key] || key;
|
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) {
|
function navigateTo(href) {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem(STORAGE.skipStamp, '1');
|
sessionStorage.setItem(STORAGE.skipStamp, '1');
|
||||||
@@ -290,20 +448,37 @@
|
|||||||
const statusEl = document.getElementById('reports-status');
|
const statusEl = document.getElementById('reports-status');
|
||||||
const perPageSel = document.getElementById('per-page-select');
|
const perPageSel = document.getElementById('per-page-select');
|
||||||
|
|
||||||
|
const table = document.getElementById('reports-tree');
|
||||||
let grouped = app.getAttribute('data-grouped') === '1';
|
let grouped = app.getAttribute('data-grouped') === '1';
|
||||||
let page = 1;
|
let page = 1;
|
||||||
let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50;
|
let perPage = Number(lsGet(STORAGE.perPage, '50')) || 50;
|
||||||
let sort = grouped ? 'cnt' : 'generated_at_ms';
|
const saved = loadSavedSort(grouped);
|
||||||
let dir = 'desc';
|
let sort = saved.sort;
|
||||||
|
let dir = saved.dir;
|
||||||
let loading = false;
|
let loading = false;
|
||||||
|
let lastItems = [];
|
||||||
|
|
||||||
const savedSort = lsGet(STORAGE.sort, '');
|
function saveSortState() {
|
||||||
if (savedSort) {
|
lsSet(
|
||||||
try {
|
STORAGE.sort,
|
||||||
const s = JSON.parse(savedSort);
|
JSON.stringify({ sort, dir, grouped: !!grouped })
|
||||||
if (s && s.sort) sort = s.sort;
|
);
|
||||||
if (s && s.dir) dir = s.dir;
|
}
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
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) {
|
if (perPageSel) {
|
||||||
@@ -323,7 +498,9 @@
|
|||||||
app.querySelectorAll('.reports-mode-btn').forEach((b) => {
|
app.querySelectorAll('.reports-mode-btn').forEach((b) => {
|
||||||
b.classList.toggle('active', b === btn);
|
b.classList.toggle('active', b === btn);
|
||||||
});
|
});
|
||||||
sort = grouped ? 'cnt' : 'generated_at_ms';
|
const next = loadSavedSort(grouped);
|
||||||
|
sort = next.sort;
|
||||||
|
dir = next.dir;
|
||||||
page = 1;
|
page = 1;
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
@@ -341,7 +518,7 @@
|
|||||||
window.addEventListener('pagehide', maybeStampVisit);
|
window.addEventListener('pagehide', maybeStampVisit);
|
||||||
|
|
||||||
function columns() {
|
function columns() {
|
||||||
return grouped ? GROUP_COLUMNS : LIST_COLUMNS;
|
return getDisplayColumns(grouped);
|
||||||
}
|
}
|
||||||
|
|
||||||
function colspan() {
|
function colspan() {
|
||||||
@@ -350,30 +527,48 @@
|
|||||||
|
|
||||||
function renderHead() {
|
function renderHead() {
|
||||||
const cols = columns();
|
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) => {
|
cols.forEach((c) => {
|
||||||
const active = sort === c.key || apiSortKey(sort) === apiSortKey(c.key);
|
const active = sortActive(c.key);
|
||||||
if (c.noSort) {
|
|
||||||
html += '<th scope="col">' + escapeHtml(c.label) + '</th>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const arrow =
|
const arrow =
|
||||||
active ? (dir === 'asc' ? '▲' : '▼') : '<span class="sort-idle">↕</span>';
|
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 +=
|
html +=
|
||||||
'<th class="sortable' +
|
'<th class="' +
|
||||||
(active ? ' sortable--active' : '') +
|
thClass +
|
||||||
'" data-sort="' +
|
'"' +
|
||||||
|
thDrag +
|
||||||
|
' data-sort="' +
|
||||||
escapeHtml(c.key) +
|
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) +
|
escapeHtml(c.label) +
|
||||||
' <span class="sort-ind" aria-hidden="true">' +
|
'</span> <span class="sort-ind" aria-hidden="true">' +
|
||||||
arrow +
|
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.innerHTML = html;
|
||||||
thead.querySelectorAll('.sortable').forEach((th) => {
|
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');
|
const key = th.getAttribute('data-sort');
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
if (sort === key) {
|
if (sort === key) {
|
||||||
@@ -382,7 +577,7 @@
|
|||||||
sort = key;
|
sort = key;
|
||||||
dir = 'desc';
|
dir = 'desc';
|
||||||
}
|
}
|
||||||
lsSet(STORAGE.sort, JSON.stringify({ sort, dir, grouped }));
|
saveSortState();
|
||||||
page = 1;
|
page = 1;
|
||||||
load();
|
load();
|
||||||
});
|
});
|
||||||
@@ -459,7 +654,17 @@
|
|||||||
html += '<td>' + formatCell(c, row) + '</td>';
|
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>';
|
html += '</tr>';
|
||||||
|
|
||||||
const briefLines = row.brief || [];
|
const briefLines = row.brief || [];
|
||||||
@@ -521,8 +726,8 @@
|
|||||||
statusEl.textContent = data.error || 'Load failed';
|
statusEl.textContent = data.error || 'Load failed';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderHead();
|
lastItems = data.items || [];
|
||||||
renderRows(data.items || []);
|
refreshTableChrome();
|
||||||
renderPagination(Number(data.total) || 0);
|
renderPagination(Number(data.total) || 0);
|
||||||
statusEl.textContent =
|
statusEl.textContent =
|
||||||
'Showing ' +
|
'Showing ' +
|
||||||
@@ -538,14 +743,335 @@
|
|||||||
xhr.send();
|
xhr.send();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHead();
|
initTagEditButtons(tbody, load);
|
||||||
|
|
||||||
|
applyColgroup(grouped);
|
||||||
load();
|
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', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
initTheme();
|
initTheme();
|
||||||
initNav();
|
initNav();
|
||||||
|
initTagModal();
|
||||||
initReportDetail();
|
initReportDetail();
|
||||||
|
initTagEditorDetail();
|
||||||
initReportsApp();
|
initReportsApp();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ if ($route === '/api/report_viewed.php' || str_ends_with($route, '/api/report_vi
|
|||||||
exit;
|
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') {
|
if ($route === '/logout') {
|
||||||
Auth::logout();
|
Auth::logout();
|
||||||
header('Location: ' . $base . '/login');
|
header('Location: ' . $base . '/login');
|
||||||
@@ -69,15 +74,33 @@ $grouped = isset($_GET['group']) && $_GET['group'] === '1';
|
|||||||
$view = $_GET['view'] ?? 'home';
|
$view = $_GET['view'] ?? 'home';
|
||||||
|
|
||||||
if ($view === 'report' && isset($_GET['id'])) {
|
if ($view === 'report' && isset($_GET['id'])) {
|
||||||
|
try {
|
||||||
$report = ReportRepository::getById((int) $_GET['id']);
|
$report = ReportRepository::getById((int) $_GET['id']);
|
||||||
if (!$report) {
|
if (!$report) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo 'Not found';
|
echo 'Not found';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
ReportRepository::markViewed((int) $report['id'], (int) Auth::user()['id']);
|
$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';
|
$pageTitle = 'Report';
|
||||||
require __DIR__ . '/../views/layout.php';
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
examples/crash_reporter/backend/scripts/init-mariadb.sh
Executable file
33
examples/crash_reporter/backend/scripts/init-mariadb.sh
Executable 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."
|
||||||
@@ -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
|
CREATE DATABASE IF NOT EXISTS androidcast_crashes
|
||||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
USE androidcast_crashes;
|
USE androidcast_crashes;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
username VARCHAR(64) NOT NULL UNIQUE,
|
username VARCHAR(64) NOT NULL,
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
role ENUM('root','admin','viewer') NOT NULL DEFAULT 'viewer',
|
role ENUM('root', 'admin', 'viewer') NOT NULL DEFAULT 'viewer',
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
) ENGINE=InnoDB;
|
UNIQUE KEY uq_users_username (username)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reports (
|
CREATE TABLE IF NOT EXISTS reports (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
report_id CHAR(36) NOT NULL UNIQUE,
|
report_id VARCHAR(128) NOT NULL,
|
||||||
fingerprint CHAR(64) NOT NULL,
|
fingerprint VARCHAR(64) NOT NULL,
|
||||||
crash_type ENUM('java','native') NOT NULL,
|
crash_type VARCHAR(32) NOT NULL,
|
||||||
generated_at_ms BIGINT NOT NULL,
|
generated_at_ms BIGINT NOT NULL,
|
||||||
received_at_ms BIGINT NOT NULL,
|
received_at_ms BIGINT NOT NULL,
|
||||||
device_model VARCHAR(128) NULL,
|
device_model VARCHAR(128) NULL,
|
||||||
app_version VARCHAR(64) NULL,
|
app_version VARCHAR(64) NULL,
|
||||||
payload_json JSON NOT NULL,
|
payload_json LONGTEXT NOT NULL,
|
||||||
KEY idx_reports_generated (generated_at_ms DESC),
|
tags_json LONGTEXT NOT NULL,
|
||||||
KEY idx_reports_received (received_at_ms DESC),
|
UNIQUE KEY uq_reports_report_id (report_id),
|
||||||
KEY idx_reports_fingerprint (fingerprint)
|
KEY idx_reports_generated (generated_at_ms),
|
||||||
) ENGINE=InnoDB;
|
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)
|
INSERT IGNORE INTO users (id, username, password_hash, role)
|
||||||
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');
|
VALUES (1, 'admin', '$2y$10$t2JyYCyjBwRxAHfmLwsnG.APoj/in0i6nxpKcQggdl.AkhFJR13o.', 'root');
|
||||||
|
-- default password: admin — change in production
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ final class Auth {
|
|||||||
return $action === 'view';
|
return $action === 'view';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function canEditTags(): bool {
|
||||||
|
return self::can('tag_edit');
|
||||||
|
}
|
||||||
|
|
||||||
public static function login(string $username, string $password): bool {
|
public static function login(string $username, string $password): bool {
|
||||||
$pdo = Database::pdo();
|
$pdo = Database::pdo();
|
||||||
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1');
|
$stmt = $pdo->prepare('SELECT * FROM users WHERE username = ? LIMIT 1');
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
<?php
|
<?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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
final class Database {
|
final class Database {
|
||||||
private static ?PDO $pdo = null;
|
private static ?PDO $pdo = null;
|
||||||
private static bool $schemaChecked = false;
|
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 {
|
public static function pdo(): PDO {
|
||||||
if (self::$pdo !== null) {
|
if (self::$pdo !== null) {
|
||||||
return self::$pdo;
|
return self::$pdo;
|
||||||
}
|
}
|
||||||
$driver = cfg('db.driver', 'sqlite');
|
if (self::isMysql()) {
|
||||||
if ($driver === 'mysql') {
|
|
||||||
$m = cfg('db.mysql', []);
|
$m = cfg('db.mysql', []);
|
||||||
$dsn = sprintf(
|
$dsn = sprintf(
|
||||||
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
||||||
@@ -34,6 +36,7 @@ final class Database {
|
|||||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
]);
|
]);
|
||||||
|
self::ensureSchema();
|
||||||
return self::$pdo;
|
return self::$pdo;
|
||||||
}
|
}
|
||||||
$path = cfg('db.sqlite_path');
|
$path = cfg('db.sqlite_path');
|
||||||
@@ -50,7 +53,20 @@ final class Database {
|
|||||||
return self::$pdo;
|
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 {
|
public static function ensureSchema(): void {
|
||||||
if (self::$schemaChecked) {
|
if (self::$schemaChecked) {
|
||||||
return;
|
return;
|
||||||
@@ -60,12 +76,16 @@ final class Database {
|
|||||||
if ($pdo === null) {
|
if ($pdo === null) {
|
||||||
return;
|
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 {
|
$need = static function (string $table) use ($pdo): bool {
|
||||||
$stmt = $pdo->prepare(
|
return !self::tableExists($pdo, $table);
|
||||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1"
|
|
||||||
);
|
|
||||||
$stmt->execute([$table]);
|
|
||||||
return $stmt->fetchColumn() === false;
|
|
||||||
};
|
};
|
||||||
if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) {
|
if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) {
|
||||||
self::ensureReportColumns($pdo);
|
self::ensureReportColumns($pdo);
|
||||||
@@ -84,14 +104,84 @@ final class Database {
|
|||||||
self::ensureReportViewsTable($pdo);
|
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 {
|
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)) {
|
if (!in_array('tags_json', $names, true)) {
|
||||||
$pdo->exec("ALTER TABLE reports ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'");
|
$pdo->exec("ALTER TABLE reports ADD COLUMN tags_json TEXT NOT NULL DEFAULT '[]'");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function ensureReportViewsTable(PDO $pdo): void {
|
private static function ensureReportViewsTable(PDO $pdo): void {
|
||||||
|
if (self::tableExists($pdo, 'report_views')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$pdo->exec(
|
$pdo->exec(
|
||||||
'CREATE TABLE IF NOT EXISTS report_views (
|
'CREATE TABLE IF NOT EXISTS report_views (
|
||||||
user_id INTEGER NOT NULL,
|
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 {
|
private static function reportsSchemaOk(PDO $pdo): bool {
|
||||||
try {
|
if (!self::tableExists($pdo, 'reports')) {
|
||||||
$cols = $pdo->query('PRAGMA table_info(reports)')->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
} catch (PDOException) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if ($cols === []) {
|
$names = self::columnNames($pdo, 'reports');
|
||||||
return false;
|
|
||||||
}
|
|
||||||
$names = array_column($cols, 'name');
|
|
||||||
foreach (['report_id', 'fingerprint', 'crash_type', 'generated_at_ms', 'received_at_ms', 'payload_json'] as $col) {
|
foreach (['report_id', 'fingerprint', 'crash_type', 'generated_at_ms', 'received_at_ms', 'payload_json'] as $col) {
|
||||||
if (!in_array($col, $names, true)) {
|
if (!in_array($col, $names, true)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
final class ReportRepository {
|
final class ReportRepository {
|
||||||
private const LIST_SORT = [
|
private const LIST_SORT = [
|
||||||
|
'priority',
|
||||||
'generated_at_ms',
|
'generated_at_ms',
|
||||||
'received_at_ms',
|
'received_at_ms',
|
||||||
'crash_type',
|
'crash_type',
|
||||||
@@ -10,6 +11,7 @@ final class ReportRepository {
|
|||||||
'app_version',
|
'app_version',
|
||||||
'fingerprint',
|
'fingerprint',
|
||||||
'fingerprint_cnt',
|
'fingerprint_cnt',
|
||||||
|
'rating',
|
||||||
];
|
];
|
||||||
private const GROUP_SORT = [
|
private const GROUP_SORT = [
|
||||||
'cnt',
|
'cnt',
|
||||||
@@ -102,10 +104,11 @@ final class ReportRepository {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'generated_at_ms';
|
$sortCol = in_array($sort, self::LIST_SORT, true) ? $sort : 'priority';
|
||||||
if ($sortCol === 'fingerprint_cnt') {
|
if ($sortCol === 'rating') {
|
||||||
$sortCol = 'fingerprint_cnt';
|
$sortCol = 'fingerprint_cnt';
|
||||||
}
|
}
|
||||||
|
$orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir);
|
||||||
$total = (int) $pdo->query('SELECT COUNT(*) FROM reports')->fetchColumn();
|
$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,
|
$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,
|
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
|
AND r2.received_at_ms > ?) AS fingerprint_recent_cnt
|
||||||
FROM reports r
|
FROM reports r
|
||||||
LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ?
|
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";
|
LIMIT $perPage OFFSET $offset";
|
||||||
$stmt = $pdo->prepare($sql);
|
$stmt = $pdo->prepare($sql);
|
||||||
$stmt->execute([$sinceMs, $userId]);
|
$stmt->execute([$sinceMs, $userId]);
|
||||||
@@ -134,18 +137,55 @@ final class ReportRepository {
|
|||||||
'total' => $total,
|
'total' => $total,
|
||||||
'page' => $page,
|
'page' => $page,
|
||||||
'per_page' => $perPage,
|
'per_page' => $perPage,
|
||||||
'sort' => $sortCol,
|
'sort' => $sort === 'priority' ? 'priority' : $sortCol,
|
||||||
'dir' => strtolower($dir),
|
'dir' => strtolower($dir),
|
||||||
'grouped' => false,
|
'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();
|
$pdo = Database::pdo();
|
||||||
$stmt = $pdo->prepare(
|
$normalized = normalize_report_tags($tags);
|
||||||
'INSERT OR REPLACE INTO report_views (user_id, report_id, viewed_at_ms) VALUES (?, ?, ?)'
|
$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 {
|
public static function getById(int $id): ?array {
|
||||||
@@ -162,7 +202,8 @@ final class ReportRepository {
|
|||||||
if (!$row) {
|
if (!$row) {
|
||||||
return null;
|
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['tags'] = parse_report_tags($row['tags_json'] ?? '[]');
|
||||||
$row['viewed'] = (bool) ($row['viewed'] ?? false);
|
$row['viewed'] = (bool) ($row['viewed'] ?? false);
|
||||||
return $row;
|
return $row;
|
||||||
|
|||||||
@@ -49,8 +49,18 @@ function cfg(string $key, $default = null) {
|
|||||||
return $v;
|
return $v;
|
||||||
}
|
}
|
||||||
|
|
||||||
function h(?string $s): string {
|
function h(mixed $s): string {
|
||||||
return htmlspecialchars($s ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
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 {
|
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). */
|
/** 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 {
|
function report_enrich_list_row(array &$row, int $sinceMs = 0): void {
|
||||||
$p = json_decode($row['payload_json'] ?? '', true);
|
$p = json_decode($row['payload_json'] ?? '', true);
|
||||||
@@ -131,7 +184,7 @@ function report_enrich_list_row(array &$row, int $sinceMs = 0): void {
|
|||||||
$p = [];
|
$p = [];
|
||||||
}
|
}
|
||||||
$d = $p['device'] ?? [];
|
$d = $p['device'] ?? [];
|
||||||
$row['os_name'] = trim((string) ($d['manufacturer'] ?? $d['brand'] ?? ''));
|
$row['os_name'] = device_os_name($d);
|
||||||
$rel = (string) ($d['release'] ?? '');
|
$rel = (string) ($d['release'] ?? '');
|
||||||
$sdk = (int) ($d['sdk_int'] ?? 0);
|
$sdk = (int) ($d['sdk_int'] ?? 0);
|
||||||
if ($rel !== '' && $sdk > 0) {
|
if ($rel !== '' && $sdk > 0) {
|
||||||
@@ -175,6 +228,60 @@ function report_rating_score(int $fingerprintTotal, int $fingerprintRecent, int
|
|||||||
return max(0, min(5, $score));
|
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}> */
|
/** @return list<array{id:string,label:string,bg:string}> */
|
||||||
function parse_report_tags(?string $json): array {
|
function parse_report_tags(?string $json): array {
|
||||||
if ($json === null || $json === '') {
|
if ($json === null || $json === '') {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
||||||
data-view="<?= h($view ?? 'home') ?>"
|
data-view="<?= h($view ?? 'home') ?>"
|
||||||
|
data-can-tag-edit="<?= Auth::canEditTags() ? '1' : '0' ?>"
|
||||||
<?= (($view ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>>
|
<?= (($view ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>>
|
||||||
<header class="top-menu" hidden aria-hidden="true"></header>
|
<header class="top-menu" hidden aria-hidden="true"></header>
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
@@ -107,7 +108,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<p id="reports-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
<p id="reports-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||||
<div class="reports-table-wrap">
|
<div class="reports-table-wrap">
|
||||||
<table class="data-table reports-tree" 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>
|
<thead id="reports-thead"></thead>
|
||||||
<tbody id="reports-tbody"></tbody>
|
<tbody id="reports-tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -117,6 +119,34 @@
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
<footer class="bottom-bar">Android Cast crash reporter — placeholder footer</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
$p = $report['payload'] ?? [];
|
$p = is_array($report['payload'] ?? null) ? $report['payload'] : [];
|
||||||
$device = $p['device'] ?? [];
|
$device = is_array($p['device'] ?? null) ? $p['device'] : [];
|
||||||
$app = $p['app'] ?? [];
|
$app = is_array($p['app'] ?? null) ? $p['app'] : [];
|
||||||
$pageTitle = 'Report ' . ($report['report_id'] ?? '');
|
$pageTitle = 'Report ' . ($report['report_id'] ?? '');
|
||||||
$view = 'reports';
|
$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'])
|
$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">
|
<div class="detail-header">
|
||||||
@@ -15,15 +16,50 @@ $sessionJson = !empty($p['session_context'])
|
|||||||
</a>
|
</a>
|
||||||
<div>
|
<div>
|
||||||
<h1>Crash report</h1>
|
<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>
|
||||||
</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="cards cards--lift">
|
||||||
<div class="card card--lift"><h3>Device</h3>
|
<div class="card card--lift"><h3>Device</h3>
|
||||||
<p><?= h(trim(($device['manufacturer'] ?? '') . ' ' . ($device['model'] ?? ''))) ?></p>
|
<p><?= h(trim(($device['manufacturer'] ?? '') . ' ' . ($device['model'] ?? ''))) ?></p>
|
||||||
<p>Android <?= h($device['release'] ?? '') ?> (SDK <?= (int)($device['sdk_int'] ?? 0) ?>)</p>
|
<p>Android <?= h($device['release'] ?? '') ?> (SDK <?= (int)($device['sdk_int'] ?? 0) ?>)</p>
|
||||||
<?php if (!empty($device['abis'])): ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="card card--lift"><h3>App</h3>
|
<div class="card card--lift"><h3>App</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user