1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 07:20:00 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-09 13:38:09 +02:00
parent df79962dee
commit 507924229d
22 changed files with 609 additions and 246 deletions

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -106,14 +106,13 @@ unset($__platformDir);
<option value="light">Light</option>
</select>
</label>
<div class="locale-picker" data-i18n-title="lang.label" title="Language">
<label class="toolbar-select locale-toolbar-select hub-locale" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" id="locale-flag" aria-hidden="true">🇬🇧</span>
<span class="locale-code" id="locale-code" aria-hidden="true">EN</span>
<select class="lang-select locale-picker__select" id="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<select class="lang-select" id="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</div>
</label>
</div>
</header>

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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",

View File

@@ -148,6 +148,7 @@
"nav.security": "Безопасность",
"footer.copyright": "© Anton Afanaasyeu, {year}",
"nav.tickets": "Тикеты",
"issue.new": "+ Новая задача",
"ticket.lifecycle": "Жизненный цикл",
"ticket.assignees": "Исполнители",
"ticket.assignee_add": "Добавить",

View File

@@ -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);

View File

@@ -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);
}
})();

View File

@@ -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();
}

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Import graph_session NDJSON directly into graph_sessions (no HTTP).
* Usage: php scripts/bulk_import_graph_sessions.php --file=/tmp/fake_graph_100dev.ndjson
*/
$opts = getopt('', ['file::', 'limit::']);
$file = $opts['file'] ?? '/tmp/fake_graph_sessions.ndjson';
$limit = isset($opts['limit']) ? max(0, (int) $opts['limit']) : 0;
if (!is_readable($file)) {
fwrite(STDERR, "File not found: $file\n");
exit(1);
}
require_once __DIR__ . '/../src/bootstrap.php';
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$ok = 0;
$fail = 0;
foreach ($lines as $i => $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);

View File

@@ -2,12 +2,20 @@
<?php
declare(strict_types=1);
$opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::']);
$count = max(10, (int) ($opts['count'] ?? 180));
$days = max(1, (int) ($opts['days'] ?? 14));
$opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::', 'devices::', 'per-device::']);
$devices = max(1, (int) ($opts['devices'] ?? 0));
$perDevice = max(1, (int) ($opts['per-device'] ?? 5));
$count = isset($opts['count']) ? max(1, (int) $opts['count']) : 0;
if ($count <= 0) {
$count = $devices > 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";

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/** Convert graph_session NDJSON to MySQL bulk INSERT SQL. */
$opts = getopt('', ['file::', 'out::']);
$file = $opts['file'] ?? '/tmp/fake_graph_sessions.ndjson';
$out = $opts['out'] ?? '/tmp/fake_graph_sessions.sql';
if (!is_readable($file)) {
fwrite(STDERR, "File not found: $file\n");
exit(1);
}
function sqlQuote(?string $s): string {
if ($s === null) {
return 'NULL';
}
return "'" . str_replace(["\\", "'"], ["\\\\", "''"], $s) . "'";
}
$fh = fopen($out, 'wb');
if (!$fh) {
fwrite(STDERR, "Cannot write $out\n");
exit(1);
}
fwrite($fh, "SET NAMES utf8mb4;\nSTART TRANSACTION;\n");
$batch = [];
$batchSize = 50;
$total = 0;
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$p = json_decode($line, true);
if (!is_array($p)) {
continue;
}
$dir = strtolower((string) ($p['direction'] ?? 'sender'));
if ($dir !== 'sender' && $dir !== 'receiver') {
$dir = 'sender';
}
$completed = !empty($p['completed']) ? 1 : 0;
$meta = isset($p['meta']) && is_array($p['meta'])
? json_encode($p['meta'], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
: null;
$batch[] = '(' . implode(', ', [
sqlQuote((string) ($p['session_id'] ?? '')),
(int) ($p['company_id'] ?? 1),
(int) ($p['user_id'] ?? 1),
sqlQuote((string) ($p['device_id'] ?? '')),
sqlQuote((string) ($p['app_version'] ?? '')),
(int) ($p['sdk_int'] ?? 0),
(int) ($p['started_at_epoch_ms'] ?? 0),
(int) ($p['ended_at_epoch_ms'] ?? 0),
(int) ($p['duration_s'] ?? 0),
$completed,
sqlQuote($dir),
sqlQuote((string) ($p['transport'] ?? 'wifi')),
(int) ($p['avg_kbps'] ?? 0),
(int) ($p['recv_kbps'] ?? 0),
number_format((float) ($p['packet_loss_pct'] ?? 0), 3, '.', ''),
sqlQuote((string) ($p['ntp_source'] ?? 'device')),
isset($p['ntp_correction_s']) ? (int) $p['ntp_correction_s'] : 'NULL',
sqlQuote((string) ($p['install_source'] ?? 'direct')),
$meta !== null ? sqlQuote($meta) : 'NULL',
]) . ')';
$total++;
if (count($batch) >= $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";

View File

@@ -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');

View File

@@ -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;
}

View File

@@ -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<string> */
public static function columnNames(PDO $pdo, string $table): array {
if (self::isMysql()) {

View File

@@ -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) {

View File

@@ -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})";
}
}

View File

@@ -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 {

View File

@@ -28,6 +28,7 @@
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
<script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.js" defer></script>
<script src="<?= h(Auth::basePath()) ?>/assets/js/ticket_create.js" defer></script>
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
<?php endif; ?>
@@ -57,6 +58,14 @@
<span class="nav-icon nav-icon--menu" aria-hidden="true"></span>
</button>
<ul class="nav-list">
<li>
<button type="button" class="nav-link nav-link--create js-new-issue-btn"
data-i18n-aria="issue.new" data-i18n-title="issue.new"
aria-label="New issue" title="New issue">
<span class="nav-icon nav-icon--plus" aria-hidden="true"></span>
<span class="nav-label" data-i18n="issue.new">New issue</span>
</button>
</li>
<li>
<a href="<?= h(Auth::basePath()) ?>/?view=home"
class="nav-link <?= ($view ?? '') === 'home' ? 'active' : '' ?>"
@@ -129,14 +138,13 @@
</li>
</ul>
<div class="nav-locale">
<div class="locale-picker" data-i18n-title="lang.label" title="Language">
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" id="locale-flag" aria-hidden="true">🇬🇧</span>
<span class="locale-code" id="locale-code" aria-hidden="true">EN</span>
<select class="lang-select locale-picker__select" id="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<select class="lang-select" id="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</div>
</label>
</div>
<div class="nav-user">
<span class="nav-user-name" title="<?= h(Auth::user()['username'] ?? '') ?>">
@@ -163,7 +171,12 @@
</nav>
<main class="main-pane">
<?php if (($view ?? 'home') === 'home'): ?>
<h1 data-i18n="home.title">Issues console</h1>
<div class="toolbar reports-toolbar home-toolbar">
<h1 data-i18n="home.title">Issues console</h1>
<div class="toolbar-actions">
<button type="button" class="btn btn-primary js-new-issue-btn" data-i18n="issue.new">+ New issue</button>
</div>
</div>
<p data-i18n="home.intro">Operator console for crash triage and tickets — not the public upload API. Open <strong>Issues</strong> to browse and group fingerprints, or use the shortcuts below.</p>
<?php $skip_home_link = true; require __DIR__ . '/partials/console_quick_links.php'; ?>
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
@@ -175,7 +188,7 @@
<div class="toolbar reports-toolbar">
<h1 data-i18n="tickets.title">Tickets</h1>
<div class="toolbar-actions">
<button type="button" class="btn btn-primary" id="tickets-new-btn" data-i18n="tickets.new">New ticket</button>
<button type="button" class="btn btn-primary js-new-issue-btn" data-i18n="issue.new">+ New issue</button>
<label class="toolbar-select">
<span data-i18n="theme.label">Theme</span>
<select id="theme-select" aria-label="UI theme">
@@ -223,28 +236,13 @@
</table>
</div>
<nav class="reports-pagination" id="tickets-pagination" aria-label="Tickets pages"></nav>
<dialog id="ticket-create-dialog" class="modal-dialog">
<form method="dialog" id="ticket-create-form" class="modal-form">
<h2 data-i18n="tickets.new">New ticket</h2>
<label><span data-i18n="ticket.title">Title</span>
<input name="title" id="ticket-create-title" required maxlength="256"></label>
<label><span data-i18n="ticket.brief">Brief</span>
<input name="brief" id="ticket-create-brief" maxlength="512"></label>
<label><span data-i18n="ticket.body">Description</span>
<textarea name="body" id="ticket-create-body" rows="8" required></textarea></label>
<div class="modal-actions">
<button type="button" class="btn" id="ticket-create-cancel" data-i18n="ticket.cancel">Cancel</button>
<button type="submit" class="btn btn-primary" data-i18n="ticket.create">Create</button>
</div>
<p id="ticket-create-status" class="muted" aria-live="polite"></p>
</form>
</dialog>
</div>
<?php elseif (($view ?? '') === 'reports'): ?>
<div id="reports-app" class="reports-app" data-grouped="0">
<div class="toolbar reports-toolbar">
<h1 data-i18n="reports.title">Crash reports</h1>
<h1 data-i18n="reports.title">Issues</h1>
<div class="toolbar-actions">
<button type="button" class="btn btn-primary js-new-issue-btn" data-i18n="issue.new">+ New issue</button>
<button type="button" class="btn reports-mode-btn active" data-grouped="0" data-i18n="reports.by_time">By time</button>
<button type="button" class="btn reports-mode-btn" data-grouped="1" data-i18n="reports.grouped">Grouped</button>
<label class="toolbar-select">
@@ -549,6 +547,22 @@
</div>
</div>
<?php endif; ?>
<dialog id="ticket-create-dialog" class="modal-dialog">
<form method="dialog" id="ticket-create-form" class="modal-form">
<h2 data-i18n="issue.new">New issue</h2>
<label><span data-i18n="ticket.title">Title</span>
<input name="title" id="ticket-create-title" required maxlength="256" autofocus></label>
<label><span data-i18n="ticket.brief">Brief</span>
<input name="brief" id="ticket-create-brief" maxlength="512"></label>
<label><span data-i18n="ticket.body">Description</span>
<textarea name="body" id="ticket-create-body" rows="6"></textarea></label>
<div class="modal-actions">
<button type="button" class="btn" id="ticket-create-cancel" data-i18n="ticket.cancel">Cancel</button>
<button type="submit" class="btn btn-primary" data-i18n="ticket.create">Create</button>
</div>
<p id="ticket-create-status" class="muted" aria-live="polite"></p>
</form>
</dialog>
<?php platform_render_footer(); ?>
</body>
</html>

View File

@@ -30,14 +30,13 @@ $bp = Auth::basePath();
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-locale">
<div class="locale-picker" data-i18n-title="lang.label" title="Language">
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" aria-hidden="true">🇬🇧</span>
<span class="locale-code" aria-hidden="true">EN</span>
<select class="lang-select locale-picker__select" data-i18n-aria="lang.label" aria-label="Language">
<select class="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</div>
</label>
</div>
<form class="login-card" method="post" action="<?= h($bp) ?>/login">
<h1><?= h(cfg('app_name')) ?></h1>

View File

@@ -19,14 +19,13 @@ $bp = Auth::basePath();
</head>
<body class="login-page" data-base-path="<?= h($bp) ?>">
<div class="login-locale">
<div class="locale-picker" data-i18n-title="lang.label" title="Language">
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
<span class="locale-flag" aria-hidden="true">🇬🇧</span>
<span class="locale-code" aria-hidden="true">EN</span>
<select class="lang-select locale-picker__select" data-i18n-aria="lang.label" aria-label="Language">
<select class="lang-select" data-i18n-aria="lang.label" aria-label="Language">
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</div>
</label>
</div>
<form class="login-card" method="post" action="<?= h($bp) ?>/register">
<h1 data-i18n="register.title">Create account</h1>