From 507924229d4a6018f96bc26a7174df03e49003a3 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 9 Jun 2026 13:38:09 +0200 Subject: [PATCH] be sync --- examples/app_hub/assets/hub-i18n.js | 15 +- examples/app_hub/hub-landing.css | 25 +-- examples/app_hub/index.php | 7 +- .../backend/public/api/ticket_create.php | 4 +- .../backend/public/assets/css/app.css | 157 +++++++++--------- .../backend/public/assets/i18n/en.json | 1 + .../backend/public/assets/i18n/ru.json | 1 + .../backend/public/assets/js/i18n.js | 9 + .../backend/public/assets/js/ticket_create.js | 96 +++++++++++ .../backend/public/assets/js/tickets.js | 57 +------ .../scripts/bulk_import_graph_sessions.php | 42 +++++ .../scripts/generate_fake_graph_sessions.php | 25 ++- .../backend/scripts/ndjson_to_graph_sql.php | 96 +++++++++++ .../backend/sql/schema.sqlite.sql | 3 + .../backend/src/AuthEmailSchema.php | 10 +- .../crash_reporter/backend/src/Database.php | 68 ++++++++ .../backend/src/GraphRepository.php | 70 +++++++- .../backend/src/ReportRepository.php | 92 +++++----- .../backend/src/TicketRepository.php | 3 +- .../crash_reporter/backend/views/layout.php | 60 ++++--- .../crash_reporter/backend/views/login.php | 7 +- .../crash_reporter/backend/views/register.php | 7 +- 22 files changed, 609 insertions(+), 246 deletions(-) create mode 100644 examples/crash_reporter/backend/public/assets/js/ticket_create.js create mode 100644 examples/crash_reporter/backend/scripts/bulk_import_graph_sessions.php create mode 100644 examples/crash_reporter/backend/scripts/ndjson_to_graph_sql.php diff --git a/examples/app_hub/assets/hub-i18n.js b/examples/app_hub/assets/hub-i18n.js index a58a821..afd13ad 100644 --- a/examples/app_hub/assets/hub-i18n.js +++ b/examples/app_hub/assets/hub-i18n.js @@ -52,11 +52,18 @@ var key = el.getAttribute('data-i18n-title'); if (key) el.setAttribute('title', t(key)); }); - var flag = document.getElementById('locale-flag'); - var code = document.getElementById('locale-code'); var disp = LANG_DISPLAY[lang] || LANG_DISPLAY.en; - if (flag) flag.textContent = disp.flag; - if (code) code.textContent = disp.code; + document.querySelectorAll('.locale-flag').forEach(function (el) { + el.textContent = disp.flag; + }); + document.querySelectorAll('.lang-select').forEach(function (sel) { + SUPPORTED.forEach(function (code) { + var meta = LANG_DISPLAY[code] || LANG_DISPLAY.en; + var opt = sel.querySelector('option[value="' + code + '"]'); + if (opt) opt.textContent = meta.flag + ' ' + meta.code; + }); + sel.value = lang; + }); document.documentElement.setAttribute('lang', lang); var footerLeft = document.querySelector('.platform-footer__left[data-i18n]'); if (footerLeft) { diff --git a/examples/app_hub/hub-landing.css b/examples/app_hub/hub-landing.css index b84f121..52f80bf 100644 --- a/examples/app_hub/hub-landing.css +++ b/examples/app_hub/hub-landing.css @@ -416,20 +416,21 @@ .landing-rail { --landing-rail-w: 36px; } } -.landing-top-tools .locale-picker { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 4px 8px; - border-radius: 8px; - border: 1px solid var(--border); - background: rgba(26, 35, 50, 0.5); +.landing-top-tools .hub-locale { + margin: 0; } -.landing-top-tools .locale-picker__select { - border: none; - background: transparent; +.landing-top-tools .hub-locale .locale-flag { + font-size: 1rem; + line-height: 1; +} +.landing-top-tools .hub-locale select { + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); color: var(--text); - font: inherit; + font-size: 13px; + font-weight: 600; cursor: pointer; } diff --git a/examples/app_hub/index.php b/examples/app_hub/index.php index 6fabeac..74362d4 100644 --- a/examples/app_hub/index.php +++ b/examples/app_hub/index.php @@ -106,14 +106,13 @@ unset($__platformDir); -
+
+ diff --git a/examples/crash_reporter/backend/public/api/ticket_create.php b/examples/crash_reporter/backend/public/api/ticket_create.php index 7810c19..81247b3 100644 --- a/examples/crash_reporter/backend/public/api/ticket_create.php +++ b/examples/crash_reporter/backend/public/api/ticket_create.php @@ -55,8 +55,8 @@ $payload = [ try { Database::requireTicketsTable(); - TicketRepository::insert($payload); - json_out(['ok' => true, 'ticket_id' => $payload['ticket_id']]); + $id = TicketRepository::insert($payload); + json_out(['ok' => true, 'id' => $id, 'ticket_id' => $payload['ticket_id']]); } catch (InvalidArgumentException $e) { json_out(['ok' => false, 'error' => $e->getMessage()], 400); } catch (Throwable $e) { diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index 5e8d138..dc4d7a1 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -33,18 +33,25 @@ body { background: var(--bg); color: var(--text); min-height: 100vh; - display: flex; - flex-direction: column; +} +body:has(.shell) { + display: block; + overflow: hidden; + height: 100vh; + height: 100dvh; } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } .shell { display: flex; - flex: 1 1 auto; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: var(--footer-h, 40px); min-height: 0; - height: calc(100vh - var(--footer-h, 40px)); - max-height: calc(100vh - var(--footer-h, 40px)); overflow: hidden; + z-index: 1; } .shell.single .main-pane { max-width: 1100px; margin: 0 auto; padding: 24px; } .shell.shell--graphs-full .main-pane { @@ -62,6 +69,7 @@ a:hover { text-decoration: underline; } width: 56px; height: 100%; max-height: 100%; + align-self: stretch; background: var(--surface); border-right: 1px solid var(--border); transition: width .22s ease; @@ -172,6 +180,42 @@ a:hover { text-decoration: underline; } box-sizing: border-box; } /* Hamburger (nav handle) */ +.nav-link--create { + border: none; + cursor: pointer; + background: rgba(61, 139, 253, .18); + color: var(--accent); + font: inherit; +} +.nav-link--create:hover, +.nav-link--create:focus-visible { + background: rgba(61, 139, 253, .32); + color: var(--accent); + outline: none; +} +.nav-icon--plus { + width: 18px; + height: 18px; + position: relative; +} +.nav-icon--plus::before, +.nav-icon--plus::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + background: currentColor; + border-radius: 1px; + transform: translate(-50%, -50%); +} +.nav-icon--plus::before { + width: 14px; + height: 2px; +} +.nav-icon--plus::after { + width: 2px; + height: 14px; +} .nav-icon--menu { width: 18px; height: 2px; @@ -450,11 +494,19 @@ a:hover { text-decoration: underline; } flex: 1 1 auto; min-height: 0; min-width: 0; - overflow: auto; + height: 100%; + overflow-x: hidden; + overflow-y: auto; padding: 24px 28px; + -webkit-overflow-scrolling: touch; } body { --footer-h: 40px; } .bottom-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 2; flex-shrink: 0; height: var(--footer-h); border-top: 1px solid var(--border); @@ -464,8 +516,6 @@ body { --footer-h: 40px; } padding: 0 16px; font-size: 12px; color: var(--muted); - position: sticky; - bottom: 0; background: var(--surface); z-index: 40; } @@ -699,83 +749,46 @@ body { --footer-h: 40px; } padding: 8px 10px 6px; border-top: 1px solid var(--border); } -.nav-locale .locale-picker { +.nav-locale .locale-toolbar-select { width: 100%; justify-content: center; - padding: 6px 8px; - gap: 0; + margin: 0; } -.nav-pane.open .nav-locale .locale-picker { +.nav-pane.open .nav-locale .locale-toolbar-select { justify-content: flex-start; - gap: 8px; } -.locale-flag { +.locale-toolbar-select { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--muted); + cursor: pointer; +} +.locale-toolbar-select .locale-flag { flex-shrink: 0; - font-size: 1.25rem; + font-size: 1rem; line-height: 1; - width: 1.35rem; + width: 1.25rem; text-align: center; user-select: none; pointer-events: none; } -.locale-code { - flex: 0 1 auto; - font-size: 12px; - font-weight: 700; - letter-spacing: 0.04em; - line-height: 1; - white-space: nowrap; - overflow: hidden; - opacity: 0; - max-width: 0; - transition: opacity .18s ease, max-width .22s ease; -} -.nav-pane.open .locale-code { - opacity: 1; - max-width: 2.5rem; - transition-delay: .06s; -} -.locale-picker { - position: relative; - display: inline-flex; - align-items: center; - gap: 6px; - min-height: 32px; - padding: 4px 6px; - border-radius: 8px; - color: var(--muted); - box-sizing: border-box; -} -.locale-picker:hover, -.locale-picker:focus-within { - background: var(--row-hover); +.locale-toolbar-select select { + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--bg); color: var(--text); -} -.locale-picker__select { - appearance: none; - border: none; - background: transparent; - color: inherit; - font-size: 12px; + font-size: 13px; font-weight: 600; - line-height: 1; - padding: 2px 18px 2px 2px; cursor: pointer; - max-width: 4.5rem; + min-width: 4.5rem; } -.locale-picker__select:focus { - outline: 2px solid var(--accent); - outline-offset: 2px; - border-radius: 4px; -} -.nav-locale .locale-picker__select { - position: absolute; - inset: 0; - width: 100%; - height: 100%; - max-width: none; - opacity: 0; - padding: 0; +.nav-locale .locale-toolbar-select select { + flex: 1 1 auto; + min-width: 0; + max-width: 100%; } .login-locale { position: fixed; @@ -783,10 +796,6 @@ body { --footer-h: 40px; } right: 12px; z-index: 5; } -.login-locale .locale-code { - opacity: 1; - max-width: 2.5rem; -} .login-page .login-card { margin-top: 2.5rem; } .toolbar-select select { padding: 6px 8px; diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json index 4286852..f63bb64 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/en.json +++ b/examples/crash_reporter/backend/public/assets/i18n/en.json @@ -146,6 +146,7 @@ "nav.security": "Security", "footer.copyright": "© Anton Afanaasyeu, {year}", "tickets.title": "Tickets", + "issue.new": "+ New issue", "tickets.new": "New ticket", "ticket.create": "Create", "ticket.cancel": "Cancel", diff --git a/examples/crash_reporter/backend/public/assets/i18n/ru.json b/examples/crash_reporter/backend/public/assets/i18n/ru.json index f05f108..fd3af0e 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/ru.json +++ b/examples/crash_reporter/backend/public/assets/i18n/ru.json @@ -148,6 +148,7 @@ "nav.security": "Безопасность", "footer.copyright": "© Anton Afanaasyeu, {year}", "nav.tickets": "Тикеты", + "issue.new": "+ Новая задача", "ticket.lifecycle": "Жизненный цикл", "ticket.assignees": "Исполнители", "ticket.assignee_add": "Добавить", diff --git a/examples/crash_reporter/backend/public/assets/js/i18n.js b/examples/crash_reporter/backend/public/assets/js/i18n.js index 1314f73..6d17365 100644 --- a/examples/crash_reporter/backend/public/assets/js/i18n.js +++ b/examples/crash_reporter/backend/public/assets/js/i18n.js @@ -105,8 +105,17 @@ }); } + function langOptionLabel(code) { + const meta = LANG_DISPLAY[code] || LANG_DISPLAY[DEFAULT_LANG]; + return meta.flag + ' ' + meta.code; + } + function syncLangSelect() { document.querySelectorAll('.lang-select').forEach((sel) => { + SUPPORTED.forEach((code) => { + const opt = sel.querySelector('option[value="' + code + '"]'); + if (opt) opt.textContent = langOptionLabel(code); + }); if (sel.value !== lang) sel.value = lang; }); syncLocaleDisplay(lang); diff --git a/examples/crash_reporter/backend/public/assets/js/ticket_create.js b/examples/crash_reporter/backend/public/assets/js/ticket_create.js new file mode 100644 index 0000000..af398c5 --- /dev/null +++ b/examples/crash_reporter/backend/public/assets/js/ticket_create.js @@ -0,0 +1,96 @@ +/** + * Global "New issue" dialog — one click from nav or any toolbar. + */ +(function () { + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function openDialog() { + const dialog = document.getElementById('ticket-create-dialog'); + const form = document.getElementById('ticket-create-form'); + const status = document.getElementById('ticket-create-status'); + if (!dialog || !form) return; + if (status) status.textContent = ''; + form.reset(); + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } + } + + function init() { + const dialog = document.getElementById('ticket-create-dialog'); + const form = document.getElementById('ticket-create-form'); + const cancelBtn = document.getElementById('ticket-create-cancel'); + const status = document.getElementById('ticket-create-status'); + if (!dialog || !form) return; + + document.querySelectorAll('.js-new-issue-btn').forEach((btn) => { + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + openDialog(); + }); + }); + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => dialog.close()); + } + + form.addEventListener('submit', (ev) => { + ev.preventDefault(); + const titleEl = document.getElementById('ticket-create-title'); + const briefEl = document.getElementById('ticket-create-brief'); + const bodyEl = document.getElementById('ticket-create-body'); + const payload = { + title: titleEl && titleEl.value ? titleEl.value.trim() : '', + brief: briefEl && briefEl.value ? briefEl.value.trim() : '', + body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '', + }; + if (!payload.title) return; + if (status) status.textContent = 'Creating…'; + const xhr = new XMLHttpRequest(); + xhr.open('POST', basePath() + '/api/ticket_create.php', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.onload = function () { + let data = null; + try { + data = JSON.parse(xhr.responseText); + } catch { + if (status) status.textContent = 'Invalid response'; + return; + } + if (!data || !data.ok) { + if (status) status.textContent = (data && data.error) || 'Create failed'; + return; + } + dialog.close(); + const view = document.body.getAttribute('data-view') || ''; + if (view === 'tickets' && typeof window.__ticketsReload === 'function') { + window.__ticketsReload(); + } else if (data.id) { + window.location.href = basePath() + '/?view=ticket&id=' + encodeURIComponent(String(data.id)); + } else { + window.location.href = basePath() + '/?view=tickets'; + } + }; + xhr.onerror = function () { + if (status) status.textContent = 'Network error'; + }; + xhr.send(JSON.stringify(payload)); + }); + } + + function onReady(fn) { + if (window.CrashI18n && window.CrashI18n.ready) { + window.CrashI18n.ready.then(fn); + } else { + fn(); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => onReady(init)); + } else { + onReady(init); + } +})(); diff --git a/examples/crash_reporter/backend/public/assets/js/tickets.js b/examples/crash_reporter/backend/public/assets/js/tickets.js index e73c308..83e7f9a 100644 --- a/examples/crash_reporter/backend/public/assets/js/tickets.js +++ b/examples/crash_reporter/backend/public/assets/js/tickets.js @@ -349,59 +349,10 @@ }); } - const newBtn = document.getElementById('tickets-new-btn'); - const dialog = document.getElementById('ticket-create-dialog'); - const form = document.getElementById('ticket-create-form'); - const cancelBtn = document.getElementById('ticket-create-cancel'); - const createStatus = document.getElementById('ticket-create-status'); - if (newBtn && dialog && form) { - newBtn.addEventListener('click', () => { - if (createStatus) createStatus.textContent = ''; - form.reset(); - if (typeof dialog.showModal === 'function') { - dialog.showModal(); - } - }); - if (cancelBtn) { - cancelBtn.addEventListener('click', () => dialog.close()); - } - form.addEventListener('submit', (ev) => { - ev.preventDefault(); - const titleEl = document.getElementById('ticket-create-title'); - const briefEl = document.getElementById('ticket-create-brief'); - const bodyEl = document.getElementById('ticket-create-body'); - const payload = { - title: titleEl && titleEl.value ? titleEl.value.trim() : '', - brief: briefEl && briefEl.value ? briefEl.value.trim() : '', - body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '', - }; - if (!payload.title) return; - if (createStatus) createStatus.textContent = 'Creating…'; - const xhr = new XMLHttpRequest(); - xhr.open('POST', basePath() + '/api/ticket_create.php', true); - xhr.setRequestHeader('Content-Type', 'application/json'); - xhr.onload = function () { - let data = null; - try { - data = JSON.parse(xhr.responseText); - } catch { - if (createStatus) createStatus.textContent = 'Invalid response'; - return; - } - if (!data || !data.ok) { - if (createStatus) createStatus.textContent = (data && data.error) || 'Create failed'; - return; - } - dialog.close(); - page = 1; - load(); - }; - xhr.onerror = function () { - if (createStatus) createStatus.textContent = 'Network error'; - }; - xhr.send(JSON.stringify(payload)); - }); - } + window.__ticketsReload = function () { + page = 1; + load(); + }; load(); } diff --git a/examples/crash_reporter/backend/scripts/bulk_import_graph_sessions.php b/examples/crash_reporter/backend/scripts/bulk_import_graph_sessions.php new file mode 100644 index 0000000..c8f3400 --- /dev/null +++ b/examples/crash_reporter/backend/scripts/bulk_import_graph_sessions.php @@ -0,0 +1,42 @@ +#!/usr/bin/env php + $line) { + if ($limit > 0 && $i >= $limit) { + break; + } + $payload = json_decode($line, true); + if (!is_array($payload)) { + $fail++; + continue; + } + try { + GraphRepository::insertSession($payload); + $ok++; + } catch (Throwable $e) { + $fail++; + fwrite(STDERR, 'FAIL line ' . ($i + 1) . ': ' . $e->getMessage() . "\n"); + } +} + +echo "Imported ok=$ok fail=$fail total=" . count($lines) . "\n"; +exit($fail > 0 ? 1 : 0); diff --git a/examples/crash_reporter/backend/scripts/generate_fake_graph_sessions.php b/examples/crash_reporter/backend/scripts/generate_fake_graph_sessions.php index 4fad68d..586a5cb 100644 --- a/examples/crash_reporter/backend/scripts/generate_fake_graph_sessions.php +++ b/examples/crash_reporter/backend/scripts/generate_fake_graph_sessions.php @@ -2,12 +2,20 @@ 0 ? $devices * $perDevice : 180; +} +$days = max(1, (int) ($opts['days'] ?? 30)); $out = $opts['out'] ?? '/tmp/fake_graph_sessions.ndjson'; $companyId = (int) ($opts['company'] ?? 1); $userId = (int) ($opts['user'] ?? 1); +if ($devices <= 0) { + $devices = max(1, min(40, (int) ceil($count / max(1, $perDevice)))); +} $nowMs = (int) round(microtime(true) * 1000); $startFloorMs = $nowMs - ($days * 86400 * 1000); @@ -31,7 +39,7 @@ for ($i = 0; $i < $count; $i++) { 'session_id' => 'fake-' . gmdate('Ymd', (int) ($start / 1000)) . '-' . substr(sha1((string) ($start . ':' . $i)), 0, 12), 'company_id' => $companyId, 'user_id' => $userId, - 'device_id' => 'fake-dev-' . ($i % 40), + 'device_id' => 'grab-stats-dev-' . str_pad((string) ($i % $devices), 3, '0', STR_PAD_LEFT), 'app_version' => '0.1.' . ($i % 5), 'sdk_int' => 29 + ($i % 6), 'started_at_epoch_ms' => $start, @@ -46,9 +54,14 @@ for ($i = 0; $i < $count; $i++) { 'ntp_source' => $ntp[array_rand($ntp)], 'ntp_correction_s' => random_int(0, 8), 'install_source' => $installs[array_rand($installs)], - 'meta' => ['fake' => true, 'seed' => $i], + 'meta' => [ + 'fake' => true, + 'seed' => $i, + 'grab_session_stats' => true, + 'simulated' => 'bulk-100-devices', + ], ]; fwrite($fh, json_encode($payload, JSON_UNESCAPED_SLASHES) . "\n"); } fclose($fh); -echo "Generated $count rows into $out\n"; +echo "Generated $count rows ($devices devices, ~" . (int) ceil($count / $devices) . " sessions/device, {$days}d window) into $out\n"; diff --git a/examples/crash_reporter/backend/scripts/ndjson_to_graph_sql.php b/examples/crash_reporter/backend/scripts/ndjson_to_graph_sql.php new file mode 100644 index 0000000..0d901d0 --- /dev/null +++ b/examples/crash_reporter/backend/scripts/ndjson_to_graph_sql.php @@ -0,0 +1,96 @@ +#!/usr/bin/env php += $batchSize) { + fwrite($fh, "INSERT INTO graph_sessions\n"); + fwrite($fh, " (session_id, company_id, user_id, device_id, app_version, sdk_int,\n"); + fwrite($fh, " started_at_ms, ended_at_ms, duration_s, completed, direction, transport,\n"); + fwrite($fh, " avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)\n"); + fwrite($fh, "VALUES\n " . implode(",\n ", $batch) . "\n"); + fwrite($fh, "ON DUPLICATE KEY UPDATE\n"); + fwrite($fh, " ended_at_ms=VALUES(ended_at_ms), duration_s=VALUES(duration_s), completed=VALUES(completed),\n"); + fwrite($fh, " avg_kbps=VALUES(avg_kbps), recv_kbps=VALUES(recv_kbps), packet_loss_pct=VALUES(packet_loss_pct),\n"); + fwrite($fh, " meta_json=VALUES(meta_json);\n"); + $batch = []; + } +} + +if ($batch) { + fwrite($fh, "INSERT INTO graph_sessions\n"); + fwrite($fh, " (session_id, company_id, user_id, device_id, app_version, sdk_int,\n"); + fwrite($fh, " started_at_ms, ended_at_ms, duration_s, completed, direction, transport,\n"); + fwrite($fh, " avg_kbps, recv_kbps, packet_loss_pct, ntp_source, ntp_correction_s, install_source, meta_json)\n"); + fwrite($fh, "VALUES\n " . implode(",\n ", $batch) . "\n"); + fwrite($fh, "ON DUPLICATE KEY UPDATE\n"); + fwrite($fh, " ended_at_ms=VALUES(ended_at_ms), duration_s=VALUES(duration_s), completed=VALUES(completed),\n"); + fwrite($fh, " avg_kbps=VALUES(avg_kbps), recv_kbps=VALUES(recv_kbps), packet_loss_pct=VALUES(packet_loss_pct),\n"); + fwrite($fh, " meta_json=VALUES(meta_json);\n"); +} + +fwrite($fh, "COMMIT;\n"); +fclose($fh); +echo "Wrote $total rows to $out\n"; diff --git a/examples/crash_reporter/backend/sql/schema.sqlite.sql b/examples/crash_reporter/backend/sql/schema.sqlite.sql index 6bc850c..9a1fdbe 100644 --- a/examples/crash_reporter/backend/sql/schema.sqlite.sql +++ b/examples/crash_reporter/backend/sql/schema.sqlite.sql @@ -99,6 +99,9 @@ CREATE INDEX IF NOT EXISTS idx_reports_company ON reports(company_id); CREATE INDEX IF NOT EXISTS idx_reports_generated ON reports(generated_at_ms DESC); CREATE INDEX IF NOT EXISTS idx_reports_received ON reports(received_at_ms DESC); CREATE INDEX IF NOT EXISTS idx_reports_fingerprint ON reports(fingerprint); +CREATE INDEX IF NOT EXISTS idx_reports_company_fingerprint ON reports(company_id, fingerprint); +CREATE INDEX IF NOT EXISTS idx_reports_company_received ON reports(company_id, received_at_ms); +CREATE INDEX IF NOT EXISTS idx_reports_fp_received ON reports(fingerprint, received_at_ms); INSERT OR IGNORE INTO companies (id, slug, name) VALUES (1, 'default', 'Default'); diff --git a/examples/crash_reporter/backend/src/AuthEmailSchema.php b/examples/crash_reporter/backend/src/AuthEmailSchema.php index cf8bc4e..c952253 100644 --- a/examples/crash_reporter/backend/src/AuthEmailSchema.php +++ b/examples/crash_reporter/backend/src/AuthEmailSchema.php @@ -16,10 +16,12 @@ final class AuthEmailSchema { self::$ensured = true; return; } - self::ensureUserColumns($pdo); - self::ensureAuthTokens($pdo); - self::ensureAuthFactors($pdo); - self::ensureAuthAttempts($pdo); + Database::withMigrationLock($pdo, 'auth_email_schema', static function () use ($pdo): void { + self::ensureUserColumns($pdo); + self::ensureAuthTokens($pdo); + self::ensureAuthFactors($pdo); + self::ensureAuthAttempts($pdo); + }); self::$ensured = true; } diff --git a/examples/crash_reporter/backend/src/Database.php b/examples/crash_reporter/backend/src/Database.php index c5c76ee..934dd60 100644 --- a/examples/crash_reporter/backend/src/Database.php +++ b/examples/crash_reporter/backend/src/Database.php @@ -97,6 +97,7 @@ final class Database { }; if (!$need('users') && !$need('reports') && self::reportsSchemaOk($pdo)) { self::ensureReportColumns($pdo); + self::ensureReportListIndexes($pdo); self::ensureReportViewsTable($pdo); self::ensureRbacSchema($pdo); AuthEmailSchema::ensure($pdo); @@ -111,6 +112,7 @@ final class Database { } $pdo->exec((string) file_get_contents($schemaFile)); self::ensureReportColumns($pdo); + self::ensureReportListIndexes($pdo); self::ensureReportViewsTable($pdo); self::ensureRbacSchema($pdo); AuthEmailSchema::ensure($pdo); @@ -156,6 +158,7 @@ final class Database { } else { self::ensureMysqlReportColumns($pdo); } + self::ensureReportListIndexes($pdo); self::ensureMysqlReportViewsTable($pdo); self::ensureRbacSchema($pdo); AuthEmailSchema::ensure($pdo); @@ -513,6 +516,52 @@ final class Database { } } + /** Indexes for list API (fingerprint stats join + sort by received_at). */ + private static function ensureReportListIndexes(PDO $pdo): void { + if (!self::tableExists($pdo, 'reports')) { + return; + } + if (self::isMysql()) { + $existing = []; + $stmt = $pdo->query("SHOW INDEX FROM reports"); + if ($stmt !== false) { + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + $existing[(string) ($row['Key_name'] ?? '')] = true; + } + } + $add = static function (string $name, string $sql) use ($pdo, $existing): void { + if (!isset($existing[$name])) { + self::execSchemaSql($pdo, $sql, 'index reports.' . $name); + } + }; + $add('idx_reports_company_fingerprint', 'ALTER TABLE reports ADD KEY idx_reports_company_fingerprint (company_id, fingerprint)'); + $add('idx_reports_company_received', 'ALTER TABLE reports ADD KEY idx_reports_company_received (company_id, received_at_ms)'); + if (!isset($existing['idx_reports_fp_received'])) { + self::execSchemaSql( + $pdo, + 'ALTER TABLE reports ADD KEY idx_reports_fp_received (fingerprint, received_at_ms)', + 'index reports.fp_received' + ); + } + return; + } + self::execSchemaSql( + $pdo, + 'CREATE INDEX IF NOT EXISTS idx_reports_company_fingerprint ON reports(company_id, fingerprint)', + 'index reports.company_fingerprint' + ); + self::execSchemaSql( + $pdo, + 'CREATE INDEX IF NOT EXISTS idx_reports_company_received ON reports(company_id, received_at_ms)', + 'index reports.company_received' + ); + self::execSchemaSql( + $pdo, + 'CREATE INDEX IF NOT EXISTS idx_reports_fp_received ON reports(fingerprint, received_at_ms)', + 'index reports.fp_received' + ); + } + private static function ensureReportViewsTable(PDO $pdo): void { if (self::tableExists($pdo, 'report_views')) { return; @@ -557,6 +606,25 @@ final class Database { return $stmt->fetchColumn() !== false; } + /** Serialize cross-request DDL (MariaDB GET_LOCK). No-op on SQLite. */ + public static function withMigrationLock(PDO $pdo, string $name, callable $fn): void { + if (!self::isMysql()) { + $fn(); + return; + } + $lockName = 'ac_migrate_' . preg_replace('/[^a-z0-9_]/', '_', strtolower($name)); + $stmt = $pdo->query('SELECT GET_LOCK(' . $pdo->quote($lockName) . ', 90)'); + $got = $stmt !== false && (int) $stmt->fetchColumn() === 1; + if (!$got) { + return; + } + try { + $fn(); + } finally { + $pdo->query('SELECT RELEASE_LOCK(' . $pdo->quote($lockName) . ')'); + } + } + /** @return list */ public static function columnNames(PDO $pdo, string $table): array { if (self::isMysql()) { diff --git a/examples/crash_reporter/backend/src/GraphRepository.php b/examples/crash_reporter/backend/src/GraphRepository.php index d383aa1..cbbb214 100644 --- a/examples/crash_reporter/backend/src/GraphRepository.php +++ b/examples/crash_reporter/backend/src/GraphRepository.php @@ -2,15 +2,65 @@ declare(strict_types=1); final class GraphRepository { + private static bool $schemaEnsured = false; + public static function ensureSchema(): void { - $pdo = Database::pdo(); - if (!Database::tableExists($pdo, 'graph_sessions')) { - self::createTable($pdo); - self::ensureIndexes($pdo); + if (self::$schemaEnsured) { return; } - self::ensureColumns($pdo); - self::ensureIndexes($pdo); + $pdo = Database::pdo(); + Database::withMigrationLock($pdo, 'graph_sessions', static function () use ($pdo): void { + if (!Database::tableExists($pdo, 'graph_sessions')) { + self::createTable($pdo); + self::ensureIndexes($pdo); + return; + } + self::ensureColumns($pdo); + self::ensureIndexes($pdo); + }); + self::$schemaEnsured = true; + } + + private static function columnExists(PDO $pdo, string $table, string $column): bool { + if (Database::isMysql()) { + $stmt = $pdo->prepare( + 'SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1' + ); + $stmt->execute([$table, $column]); + return (bool) $stmt->fetchColumn(); + } + $stmt = $pdo->query('PRAGMA table_info(' . $table . ')'); + if ($stmt === false) { + return false; + } + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + if (($row['name'] ?? '') === $column) { + return true; + } + } + return false; + } + + private static function indexExists(PDO $pdo, string $table, string $index): bool { + if (Database::isMysql()) { + $stmt = $pdo->prepare( + 'SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1' + ); + $stmt->execute([$table, $index]); + return (bool) $stmt->fetchColumn(); + } + $stmt = $pdo->query('PRAGMA index_list(' . $table . ')'); + if ($stmt === false) { + return false; + } + while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { + if (($row['name'] ?? '') === $index) { + return true; + } + } + return false; } private static function createTable(PDO $pdo): void { @@ -85,7 +135,10 @@ final class GraphRepository { 'idx_graph_started' => 'CREATE INDEX IF NOT EXISTS idx_graph_started ON graph_sessions (started_at_ms)', 'idx_graph_device' => 'CREATE INDEX IF NOT EXISTS idx_graph_device ON graph_sessions (device_id)', ]; - foreach ($defs as $sql) { + foreach ($defs as $name => $sql) { + if (self::indexExists($pdo, 'graph_sessions', $name)) { + continue; + } try { $pdo->exec($sql); } catch (PDOException $e) { @@ -107,6 +160,9 @@ final class GraphRepository { 'ntp_correction_s' => Database::isMysql() ? 'INT NULL' : 'INTEGER NULL', ]; foreach ($cols as $name => $ddl) { + if (self::columnExists($pdo, 'graph_sessions', $name)) { + continue; + } try { $pdo->exec('ALTER TABLE graph_sessions ADD COLUMN ' . $name . ' ' . $ddl); } catch (PDOException $e) { diff --git a/examples/crash_reporter/backend/src/ReportRepository.php b/examples/crash_reporter/backend/src/ReportRepository.php index fa727df..dc5e9ec 100644 --- a/examples/crash_reporter/backend/src/ReportRepository.php +++ b/examples/crash_reporter/backend/src/ReportRepository.php @@ -140,23 +140,18 @@ final class ReportRepository { $scopeParams = []; self::appendCompanyScope('r.company_id', $where, $scopeParams); $whereSql = implode(' AND ', $where); - $fpCnt = self::fingerprintCountSql(); $countStmt = $pdo->prepare("SELECT COUNT(*) FROM reports r WHERE $whereSql"); $countStmt->execute($scopeParams); $total = (int) $countStmt->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, - CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed, - {$fpCnt} AS fingerprint_cnt, - (SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint - AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt + $sql = 'SELECT ' . self::listSelectColumns() . ' FROM reports r + ' . self::fingerprintStatsJoin() . ' LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? - WHERE $whereSql - ORDER BY $orderBy - LIMIT $perPage OFFSET $offset"; + WHERE ' . $whereSql . ' + ORDER BY ' . $orderBy . ' + LIMIT ' . $perPage . ' OFFSET ' . $offset; $stmt = $pdo->prepare($sql); - $stmt->execute(array_merge($scopeParams, [$sinceMs, $userId])); + $stmt->execute(array_merge([$sinceMs, $userId], $scopeParams)); $items = $stmt->fetchAll(); self::finishListRows($items, $sinceMs, false); @@ -237,20 +232,15 @@ final class ReportRepository { } $orderBy = self::buildListOrder($sortCol === 'priority' ? 'priority' : $sortCol, $dir); $offset = ($page - 1) * $perPage; - $fpCnt = self::fingerprintCountSql(); - $sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms, - r.device_model, r.app_version, r.payload_json, r.tags_json, - CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed, - {$fpCnt} AS fingerprint_cnt, - (SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint - AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt + $sql = 'SELECT ' . self::listSelectColumns() . ' FROM reports r + ' . self::fingerprintStatsJoin() . ' LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? - WHERE $whereSql - ORDER BY $orderBy - LIMIT $perPage OFFSET $offset"; + WHERE ' . $whereSql . ' + ORDER BY ' . $orderBy . ' + LIMIT ' . $perPage . ' OFFSET ' . $offset; $stmt = $pdo->prepare($sql); - $stmt->execute(array_merge($params, [$sinceMs, $userId])); + $stmt->execute(array_merge([$sinceMs, $userId], $params)); $items = $stmt->fetchAll(); self::finishListRows($items, $sinceMs, false); return [ @@ -327,20 +317,15 @@ final class ReportRepository { $where[] = '(' . implode(' OR ', $or) . ')'; $whereSql = implode(' AND ', $where); - $fpCnt = self::fingerprintCountSql(); - $sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms, - r.device_model, r.app_version, r.payload_json, r.tags_json, - CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed, - {$fpCnt} AS fingerprint_cnt, - (SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint - AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt + $sql = 'SELECT ' . self::listSelectColumns() . ' FROM reports r + ' . self::fingerprintStatsJoin() . ' LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? - WHERE $whereSql + WHERE ' . $whereSql . ' ORDER BY r.received_at_ms DESC - LIMIT 1200"; + LIMIT 1200'; $stmt = $pdo->prepare($sql); - $stmt->execute(array_merge($params, [$sinceMs, $userId])); + $stmt->execute(array_merge([$sinceMs, $userId], $params)); $rows = $stmt->fetchAll(); $scored = []; foreach ($rows as $row) { @@ -507,20 +492,15 @@ final class ReportRepository { } $where[] = '(' . implode(' OR ', $orParts) . ')'; $whereSql = implode(' AND ', $where); - $fpCnt = self::fingerprintCountSql(); - $sql = "SELECT r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms, - r.device_model, r.app_version, r.payload_json, r.tags_json, - CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed, - {$fpCnt} AS fingerprint_cnt, - (SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint - AND r2.company_id = r.company_id AND r2.received_at_ms > ?) AS fingerprint_recent_cnt + $sql = 'SELECT ' . self::listSelectColumns() . ' FROM reports r + ' . self::fingerprintStatsJoin() . ' LEFT JOIN report_views v ON v.report_id = r.id AND v.user_id = ? - WHERE $whereSql + WHERE ' . $whereSql . ' ORDER BY r.received_at_ms DESC - LIMIT 1500"; + LIMIT 1500'; $stmt = $pdo->prepare($sql); - $stmt->execute(array_merge($params, [$sinceMs, $userId])); + $stmt->execute(array_merge([$sinceMs, $userId], $params)); $scored = []; while ($row = $stmt->fetch()) { $payload = json_decode($row['payload_json'] ?? '', true); @@ -594,15 +574,35 @@ final class ReportRepository { private static function buildListOrder(string $sort, string $dir): string { $dir = strtoupper($dir) === 'ASC' ? 'ASC' : 'DESC'; + $fpCnt = 'COALESCE(fp.fingerprint_cnt, 1)'; if ($sort === 'priority') { - return 'fingerprint_cnt DESC, r.received_at_ms DESC, r.app_version DESC, r.device_model ASC, r.crash_type ASC'; + return "$fpCnt 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 "$fpCnt $dir, r.received_at_ms DESC"; } return "r.$sort $dir"; } + private static function listSelectColumns(): string { + return 'r.id, r.report_id, r.fingerprint, r.crash_type, r.generated_at_ms, r.received_at_ms, + r.device_model, r.app_version, r.payload_json, r.tags_json, + CASE WHEN v.viewed_at_ms IS NOT NULL THEN 1 ELSE 0 END AS viewed, + COALESCE(fp.fingerprint_cnt, 1) AS fingerprint_cnt, + COALESCE(fp.fingerprint_recent_cnt, 0) AS fingerprint_recent_cnt'; + } + + /** One grouped scan for fingerprint counts (replaces per-row correlated subqueries). */ + private static function fingerprintStatsJoin(): string { + return 'LEFT JOIN ( + SELECT company_id, fingerprint, + COUNT(*) AS fingerprint_cnt, + SUM(CASE WHEN received_at_ms > ? THEN 1 ELSE 0 END) AS fingerprint_recent_cnt + FROM reports + GROUP BY company_id, fingerprint + ) fp ON fp.company_id = r.company_id AND fp.fingerprint = r.fingerprint'; + } + public static function setCustomTags(int $reportId, array $tags): bool { if (self::getById($reportId) === null) { return false; @@ -704,8 +704,4 @@ final class ReportRepository { } } - private static function fingerprintCountSql(): string { - $scope = Rbac::fingerprintCountSql('r', 'r2'); - return "(SELECT COUNT(*) FROM reports r2 WHERE r2.fingerprint = r.fingerprint{$scope})"; - } } diff --git a/examples/crash_reporter/backend/src/TicketRepository.php b/examples/crash_reporter/backend/src/TicketRepository.php index 5d878f3..05104a1 100644 --- a/examples/crash_reporter/backend/src/TicketRepository.php +++ b/examples/crash_reporter/backend/src/TicketRepository.php @@ -13,7 +13,7 @@ final class TicketRepository { ]; private static ?array $ticketColumns = null; - public static function insert(array $payload): void { + public static function insert(array $payload): int { $err = TicketTagCatalog::validate(is_array($payload['tags'] ?? null) ? $payload['tags'] : []); if ($err !== null) { throw new InvalidArgumentException($err); @@ -69,6 +69,7 @@ final class TicketRepository { $tagsJson, $json, ]); + return (int) $pdo->lastInsertId(); } public static function listPage(int $page, int $perPage, string $sort, string $dir, string $tagFilter = ''): array { diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index bd20770..0f6aaa8 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -28,6 +28,7 @@ + @@ -57,6 +58,14 @@