mirror of
git://f0xx.org/android_cast
synced 2026-07-29 05:58:14 +03:00
be sync
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"nav.security": "Безопасность",
|
||||
"footer.copyright": "© Anton Afanaasyeu, {year}",
|
||||
"nav.tickets": "Тикеты",
|
||||
"issue.new": "+ Новая задача",
|
||||
"ticket.lifecycle": "Жизненный цикл",
|
||||
"ticket.assignees": "Исполнители",
|
||||
"ticket.assignee_add": "Добавить",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
})();
|
||||
@@ -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();
|
||||
window.__ticketsReload = function () {
|
||||
page = 1;
|
||||
load();
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
if (createStatus) createStatus.textContent = 'Network error';
|
||||
};
|
||||
xhr.send(JSON.stringify(payload));
|
||||
});
|
||||
}
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ final class AuthEmailSchema {
|
||||
self::$ensured = true;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
final class GraphRepository {
|
||||
private static bool $schemaEnsured = false;
|
||||
|
||||
public static function ensureSchema(): void {
|
||||
if (self::$schemaEnsured) {
|
||||
return;
|
||||
}
|
||||
$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);
|
||||
@@ -11,6 +17,50 @@ final class GraphRepository {
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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})";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'): ?>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user