mirror of
git://f0xx.org/android_cast
synced 2026-07-29 03:57:50 +03:00
be sync
This commit is contained in:
65
examples/crash_reporter/backend/public/api/ticket_create.php
Normal file
65
examples/crash_reporter/backend/public/api/ticket_create.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
if (!Auth::user()) {
|
||||
json_out(['ok' => false, 'error' => 'unauthorized'], 401);
|
||||
}
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw !== false ? $raw : '', true);
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
$title = trim((string) ($data['title'] ?? ''));
|
||||
$body = trim((string) ($data['body'] ?? ''));
|
||||
$brief = trim((string) ($data['brief'] ?? ''));
|
||||
if ($title === '') {
|
||||
json_out(['ok' => false, 'error' => 'title required'], 400);
|
||||
}
|
||||
if ($body === '' && $brief !== '') {
|
||||
$body = $brief;
|
||||
}
|
||||
|
||||
$tags = is_array($data['tags'] ?? null) ? $data['tags'] : [];
|
||||
if ($tags === []) {
|
||||
$tags = [['id' => 'open', 'label' => 'open', 'bg' => '#22c55e']];
|
||||
}
|
||||
|
||||
$now = (int) round(microtime(true) * 1000);
|
||||
$payload = [
|
||||
'schema_version' => 1,
|
||||
'ingest_kind' => 'ticket',
|
||||
'ticket_id' => 'web-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)),
|
||||
'title' => mb_substr($title, 0, 256),
|
||||
'brief' => $brief !== '' ? mb_substr($brief, 0, 512) : mb_substr($title, 0, 512),
|
||||
'body' => $body !== '' ? $body : $title,
|
||||
'opened_at_epoch_ms' => $now,
|
||||
'rating' => max(0, min(5, (int) ($data['rating'] ?? 3))),
|
||||
'owner' => (string) ($user['username'] ?? 'admin'),
|
||||
'tags' => $tags,
|
||||
'device' => [
|
||||
'manufacturer' => 'web',
|
||||
'model' => 'console',
|
||||
'sdk_int' => 0,
|
||||
'release' => 'n/a',
|
||||
],
|
||||
'app' => [
|
||||
'package' => 'com.foxx.androidcast',
|
||||
'version_name' => 'console',
|
||||
'version_code' => 0,
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
Database::requireTicketsTable();
|
||||
TicketRepository::insert($payload);
|
||||
json_out(['ok' => true, 'ticket_id' => $payload['ticket_id']]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
error_log('ticket_create: ' . $e->getMessage());
|
||||
json_out(['ok' => false, 'error' => 'create failed'], 500);
|
||||
}
|
||||
@@ -38,7 +38,14 @@ body {
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.shell { display: flex; min-height: calc(100vh - 40px); flex: 1 0 auto; }
|
||||
.shell {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: calc(100vh - var(--footer-h, 40px));
|
||||
max-height: calc(100vh - var(--footer-h, 40px));
|
||||
overflow: hidden;
|
||||
}
|
||||
.shell.single .main-pane { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||
.shell.shell--graphs-full .main-pane {
|
||||
max-width: none;
|
||||
@@ -53,12 +60,16 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
.nav-pane {
|
||||
width: 56px;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
transition: width .22s ease;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.nav-pane.open { width: 200px; }
|
||||
.nav-handle {
|
||||
@@ -94,6 +105,9 @@ a:hover { text-decoration: underline; }
|
||||
list-style: none;
|
||||
margin: 56px 0 0;
|
||||
padding: 0 8px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.nav-list li { margin: 6px 0; }
|
||||
.nav-link {
|
||||
@@ -394,16 +408,14 @@ a:hover { text-decoration: underline; }
|
||||
border-left: 7px solid currentColor;
|
||||
}
|
||||
.nav-user {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
position: static;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
padding: 0 8px;
|
||||
padding: 8px 8px 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.nav-pane.open .nav-user {
|
||||
@@ -434,9 +446,17 @@ a:hover { text-decoration: underline; }
|
||||
}
|
||||
.nav-link--logout { color: var(--muted); }
|
||||
.nav-link--logout:hover { color: var(--danger); background: rgba(248, 113, 113, .12); }
|
||||
.main-pane { flex: 1; padding: 24px 28px; }
|
||||
.main-pane {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
body { --footer-h: 40px; }
|
||||
.bottom-bar {
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
height: var(--footer-h);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -674,6 +694,8 @@ a:hover { text-decoration: underline; }
|
||||
.reports-toolbar .toolbar-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.toolbar-select { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
|
||||
.nav-locale {
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
padding: 8px 10px 6px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
@@ -1710,6 +1732,59 @@ button.report-tag--filter:hover {
|
||||
color: var(--muted, #94a3b8);
|
||||
font-weight: 400;
|
||||
}
|
||||
.graphs-grid .card.card--lift.graph-brick--empty {
|
||||
display: none;
|
||||
}
|
||||
.graphs-grid .card.card--lift.graph-brick--dragging {
|
||||
opacity: 0.55;
|
||||
outline: 2px dashed var(--accent);
|
||||
}
|
||||
.graph-brick-drag-handle {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--muted);
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
.graph-brick-tip {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
margin: 0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.8rem;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.modal-dialog {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
max-width: 32rem;
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
.modal-dialog::backdrop { background: rgba(8, 12, 20, 0.72); }
|
||||
.modal-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.modal-form label { display: flex; flex-direction: column; gap: 6px; font-size: 0.9rem; }
|
||||
.modal-form input,
|
||||
.modal-form textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
}
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
|
||||
.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; min-height: 120px; }
|
||||
.graph-detail-overlay {
|
||||
position: fixed;
|
||||
|
||||
@@ -146,6 +146,12 @@
|
||||
"nav.security": "Security",
|
||||
"footer.copyright": "© Anton Afanaasyeu, {year}",
|
||||
"tickets.title": "Tickets",
|
||||
"tickets.new": "New ticket",
|
||||
"ticket.create": "Create",
|
||||
"ticket.cancel": "Cancel",
|
||||
"ticket.title": "Title",
|
||||
"ticket.brief": "Brief",
|
||||
"ticket.body": "Description",
|
||||
"tickets.empty": "No tickets yet.",
|
||||
"tickets.col_issue": "Issue",
|
||||
"tickets.col_opened": "Opened",
|
||||
|
||||
@@ -95,13 +95,30 @@
|
||||
return graphScopes.platform;
|
||||
}
|
||||
|
||||
const BRICK_ORDER_KEY = 'graphs_brick_order_v1';
|
||||
|
||||
function prepareCanvas(canvas, fallbackW, fallbackH) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = Math.max(280, Math.round(rect.width || fallbackW || canvas.width || 520));
|
||||
const h = Math.max(120, Math.round(rect.height || fallbackH || canvas.height || 160));
|
||||
const dpr = Math.min(2.5, window.devicePixelRatio || 1);
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = h + 'px';
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
return { w, h, dpr };
|
||||
}
|
||||
|
||||
function drawLineChart(canvas, series, color, opts) {
|
||||
if (!canvas) return null;
|
||||
const interactive = opts && opts.interactive;
|
||||
const sized = prepareCanvas(canvas, 520, interactive ? 380 : 160);
|
||||
const w = sized.w;
|
||||
const h = sized.h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = 'rgba(90,110,140,0.12)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
@@ -196,6 +213,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
const highlightIdx = opts && opts.highlightIdx;
|
||||
if (highlightIdx != null && plotPoints[highlightIdx]) {
|
||||
const hi = plotPoints[highlightIdx];
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = '#f8fafc';
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.arc(hi.x, hi.y, 7, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
return {
|
||||
points: plotPoints,
|
||||
padL,
|
||||
@@ -255,10 +284,11 @@
|
||||
if (!canvas) return null;
|
||||
const interactive = opts && opts.interactive;
|
||||
const highlightIdx = opts && opts.highlightIdx;
|
||||
const sized = prepareCanvas(canvas, interactive ? 640 : 520, interactive ? 380 : 160);
|
||||
const w = sized.w;
|
||||
const h = sized.h;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (interactive) {
|
||||
ctx.fillStyle = 'rgba(90,110,140,0.12)';
|
||||
@@ -420,9 +450,13 @@
|
||||
document.querySelectorAll('[data-graph-scope]').forEach((section) => {
|
||||
const need = section.getAttribute('data-graph-scope');
|
||||
let show = false;
|
||||
if (need === 'user') show = true;
|
||||
if (need === 'slug_admin' && (viewer === 'slug_admin' || viewer === 'platform_admin')) show = true;
|
||||
if (need === 'platform_admin' && viewer === 'platform_admin') show = true;
|
||||
if (viewer === 'platform_admin') {
|
||||
show = need === 'platform_admin';
|
||||
} else if (viewer === 'slug_admin') {
|
||||
show = need === 'slug_admin' || need === 'user';
|
||||
} else {
|
||||
show = need === 'user';
|
||||
}
|
||||
section.hidden = !show;
|
||||
});
|
||||
}
|
||||
@@ -445,6 +479,209 @@
|
||||
card.setAttribute('role', 'button');
|
||||
const title = card.querySelector('h3');
|
||||
if (title) card.setAttribute('aria-label', 'Open full view: ' + title.textContent);
|
||||
if (canvas && canvas.id) {
|
||||
card.setAttribute('data-brick-id', canvas.id.replace(/^graph-/, ''));
|
||||
} else if (breakdown && breakdown.id) {
|
||||
card.setAttribute('data-brick-id', breakdown.id.replace(/^graph-/, ''));
|
||||
} else if (kpi && kpi.id) {
|
||||
card.setAttribute('data-brick-id', kpi.id.replace(/^graph-/, ''));
|
||||
}
|
||||
if (title && !title.querySelector('.graph-brick-drag-handle')) {
|
||||
const handle = document.createElement('span');
|
||||
handle.className = 'graph-brick-drag-handle';
|
||||
handle.textContent = '⋮⋮';
|
||||
handle.title = 'Drag to reorder';
|
||||
handle.setAttribute('draggable', 'true');
|
||||
title.appendChild(handle);
|
||||
}
|
||||
if (canvas && !card.querySelector('.graph-brick-tip')) {
|
||||
const tip = document.createElement('p');
|
||||
tip.className = 'graph-brick-tip';
|
||||
tip.hidden = true;
|
||||
card.style.position = 'relative';
|
||||
card.appendChild(tip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fieldHasData(def, scope) {
|
||||
if (!def || !scope) return false;
|
||||
const field = scope[def.field];
|
||||
if (def.type === 'line') {
|
||||
return Array.isArray(field) && field.some((p) => Number(p.y || 0) > 0);
|
||||
}
|
||||
if (def.type === 'pie' || def.type === 'breakdown' || def.type === 'toplist') {
|
||||
return Array.isArray(field) && field.some((r) => Number(r.value || 0) > 0);
|
||||
}
|
||||
if (def.type === 'kpi') {
|
||||
return field != null && field !== '' && String(field) !== '—';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hideEmptyBricks() {
|
||||
document.querySelectorAll('.graphs-grid .card.card--lift').forEach((card) => {
|
||||
const canvas = card.querySelector('canvas');
|
||||
const breakdown = card.querySelector('.graph-breakdown');
|
||||
const kpi = card.querySelector('.graph-kpi');
|
||||
let empty = true;
|
||||
const brickId = card.getAttribute('data-brick-id') || '';
|
||||
const m = brickId.match(/^(user|slug|platform)-(.+)$/);
|
||||
if (m) {
|
||||
const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform';
|
||||
const def = BRICK_DEFS[m[2]];
|
||||
empty = !fieldHasData(def, scopeData(scopeKey));
|
||||
} else if (canvas && canvas.id) {
|
||||
const parsed = parseCanvasId(canvas.id);
|
||||
empty = !fieldHasData(parsed && parsed.def, scopeData(parsed && parsed.scopeKey));
|
||||
} else if (breakdown) {
|
||||
empty = breakdown.textContent.trim() === 'No data' || breakdown.innerHTML.trim() === '';
|
||||
} else if (kpi) {
|
||||
empty = (kpi.textContent || '').trim() === '—';
|
||||
}
|
||||
card.classList.toggle('graph-brick--empty', empty);
|
||||
});
|
||||
}
|
||||
|
||||
function sessionMetricsDeficit(scope) {
|
||||
if (!scope) return true;
|
||||
const sessionFields = [
|
||||
'sessions_per_day',
|
||||
'unique_devices_per_day',
|
||||
'avg_duration_s_per_day',
|
||||
'send_sessions_per_day',
|
||||
'recv_sessions_per_day',
|
||||
'avg_bitrate_kbps_per_day',
|
||||
];
|
||||
return !sessionFields.some((key) => {
|
||||
const rows = scope[key];
|
||||
return Array.isArray(rows) && rows.some((p) => Number(p.y || 0) > 0);
|
||||
});
|
||||
}
|
||||
|
||||
function readBrickOrder(scopeKey) {
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(BRICK_ORDER_KEY) || '{}');
|
||||
return Array.isArray(all[scopeKey]) ? all[scopeKey] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveBrickOrder(scopeKey, ids) {
|
||||
try {
|
||||
const all = JSON.parse(localStorage.getItem(BRICK_ORDER_KEY) || '{}');
|
||||
all[scopeKey] = ids;
|
||||
localStorage.setItem(BRICK_ORDER_KEY, JSON.stringify(all));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function applyBrickOrder() {
|
||||
document.querySelectorAll('.graphs-scope').forEach((section) => {
|
||||
if (section.hidden) return;
|
||||
const scopeKey = section.getAttribute('data-graph-scope') || 'user';
|
||||
const grid = section.querySelector('.graphs-grid');
|
||||
if (!grid) return;
|
||||
const order = readBrickOrder(scopeKey);
|
||||
if (!order.length) return;
|
||||
const cards = Array.from(grid.querySelectorAll('.card.card--lift'));
|
||||
const byId = new Map();
|
||||
cards.forEach((c) => {
|
||||
const id = c.getAttribute('data-brick-id');
|
||||
if (id) byId.set(id, c);
|
||||
});
|
||||
order.forEach((id) => {
|
||||
const card = byId.get(id);
|
||||
if (card) grid.appendChild(card);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let brickDragMoved = false;
|
||||
|
||||
function initBrickDragDrop() {
|
||||
let dragCard = null;
|
||||
document.querySelectorAll('.graphs-grid').forEach((grid) => {
|
||||
grid.addEventListener('dragstart', (ev) => {
|
||||
const handle = ev.target.closest('.graph-brick-drag-handle');
|
||||
if (!handle) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
const card = handle.closest('.card.card--lift');
|
||||
if (!card || !grid.contains(card)) return;
|
||||
dragCard = card;
|
||||
brickDragMoved = false;
|
||||
card.classList.add('graph-brick--dragging');
|
||||
if (ev.dataTransfer) {
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
ev.dataTransfer.setData('text/plain', card.getAttribute('data-brick-id') || '');
|
||||
}
|
||||
});
|
||||
grid.addEventListener('dragend', () => {
|
||||
if (dragCard) dragCard.classList.remove('graph-brick--dragging');
|
||||
dragCard = null;
|
||||
const section = grid.closest('.graphs-scope');
|
||||
const scopeKey = section ? section.getAttribute('data-graph-scope') || 'user' : 'user';
|
||||
const ids = Array.from(grid.querySelectorAll('.card.card--lift'))
|
||||
.map((c) => c.getAttribute('data-brick-id'))
|
||||
.filter(Boolean);
|
||||
saveBrickOrder(scopeKey, ids);
|
||||
});
|
||||
grid.addEventListener('dragover', (ev) => {
|
||||
ev.preventDefault();
|
||||
if (!dragCard) return;
|
||||
brickDragMoved = true;
|
||||
const over = ev.target.closest('.card.card--lift');
|
||||
if (!over || over === dragCard || !grid.contains(over)) return;
|
||||
const rect = over.getBoundingClientRect();
|
||||
const before = ev.clientY < rect.top + rect.height / 2;
|
||||
grid.insertBefore(dragCard, before ? over : over.nextSibling);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function attachBrickPreviewHover() {
|
||||
document.querySelectorAll('.graphs-grid .card.card--lift canvas').forEach((canvas) => {
|
||||
const card = canvas.closest('.card');
|
||||
const tip = card ? card.querySelector('.graph-brick-tip') : null;
|
||||
if (!card || !tip) return;
|
||||
const parsed = parseCanvasId(canvas.id);
|
||||
if (!parsed || !parsed.def || parsed.def.type !== 'line') return;
|
||||
const scope = scopeData(parsed.scopeKey);
|
||||
if (!scope || !Array.isArray(scope[parsed.def.field])) return;
|
||||
const series = scope[parsed.def.field];
|
||||
const meta = drawLineChart(canvas, series, parsed.def.color, { interactive: false });
|
||||
if (!meta || !meta.points.length) return;
|
||||
canvas.onmousemove = function (ev) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = meta.w / rect.width;
|
||||
const x = (ev.clientX - rect.left) * scaleX;
|
||||
let best = null;
|
||||
let bestD = Infinity;
|
||||
meta.points.forEach((pt) => {
|
||||
const d = Math.abs(pt.x - x);
|
||||
if (d < bestD) {
|
||||
bestD = d;
|
||||
best = pt;
|
||||
}
|
||||
});
|
||||
if (!best || bestD > 32) {
|
||||
tip.hidden = true;
|
||||
drawLineChart(canvas, series, parsed.def.color, { interactive: false });
|
||||
return;
|
||||
}
|
||||
const raw = best.raw || {};
|
||||
tip.hidden = false;
|
||||
tip.textContent = parsed.def.title + ': ' + String(raw.x || '') + ' → ' + Number(raw.y || 0);
|
||||
tip.style.left = Math.min(card.clientWidth - 12, ev.clientX - card.getBoundingClientRect().left + 10) + 'px';
|
||||
tip.style.top = Math.max(8, ev.clientY - card.getBoundingClientRect().top - 28) + 'px';
|
||||
drawLineChart(canvas, series, parsed.def.color, { interactive: false, highlightIdx: best.idx });
|
||||
};
|
||||
canvas.onmouseleave = function () {
|
||||
tip.hidden = true;
|
||||
drawLineChart(canvas, series, parsed.def.color, { interactive: false });
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -726,7 +963,13 @@
|
||||
applyColumns(2);
|
||||
}
|
||||
|
||||
initBrickDragDrop();
|
||||
|
||||
app.addEventListener('click', (ev) => {
|
||||
if (ev.target.closest('.graph-brick-drag-handle') || brickDragMoved) {
|
||||
brickDragMoved = false;
|
||||
return;
|
||||
}
|
||||
const card = ev.target.closest('.graph-brick--clickable');
|
||||
if (card && app.contains(card)) openGraphDetail(card);
|
||||
});
|
||||
@@ -770,8 +1013,18 @@
|
||||
if (graphScopes.slug) renderScope('slug', graphScopes.slug);
|
||||
if (graphScopes.platform) renderScope('platform', graphScopes.platform);
|
||||
markGraphBricks();
|
||||
hideEmptyBricks();
|
||||
applyBrickOrder();
|
||||
attachBrickPreviewHover();
|
||||
const cols = colsSel && colsSel.value ? colsSel.value : '2';
|
||||
status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd · columns=' + cols;
|
||||
let statusText = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd · columns=' + cols;
|
||||
const visibleScope =
|
||||
viewer === 'platform_admin' ? graphScopes.platform : viewer === 'slug_admin' ? graphScopes.slug : graphScopes.user;
|
||||
if (sessionMetricsDeficit(visibleScope)) {
|
||||
statusText +=
|
||||
' · Session metrics empty — cast with Developer “Grab session stats” on; app uploads graph_session to graph_upload.php';
|
||||
}
|
||||
status.textContent = statusText;
|
||||
openGraphFromPermalink();
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
|
||||
@@ -349,6 +349,60 @@
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
load();
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ if ($route === '/api/ticket_upload.php' || str_ends_with($route, '/api/ticket_up
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/ticket_create.php' || str_ends_with($route, '/api/ticket_create.php')) {
|
||||
require __DIR__ . '/api/ticket_create.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/tickets.php' || str_ends_with($route, '/api/tickets.php')) {
|
||||
require __DIR__ . '/api/tickets.php';
|
||||
exit;
|
||||
@@ -234,32 +239,43 @@ if ($route === '/account-security' && $_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$uid = (int) ($user['id'] ?? 0);
|
||||
$action = (string) ($_POST['action'] ?? '');
|
||||
if ($action === 'start_totp') {
|
||||
$secret = AuthTotp::generateSecret();
|
||||
$_SESSION['totp_enroll_secret'] = $secret;
|
||||
$_SESSION['totp_enroll_exp'] = time() + 900;
|
||||
} elseif ($action === 'confirm_totp') {
|
||||
$secret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
|
||||
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
||||
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
||||
$code = trim($_POST['code'] ?? '');
|
||||
if ($secret === '' || $exp < time() || !AuthTotp::verify($secret, $code)) {
|
||||
$securityError = 'Invalid code — try setup again.';
|
||||
if (AuthFactors::hasTotp($uid)) {
|
||||
$securityError = 'Remove the current authenticator before enrolling a new one.';
|
||||
} else {
|
||||
AuthFactors::enrollTotp($uid, $secret);
|
||||
$securitySuccess = 'Authenticator enrolled.';
|
||||
$secret = AuthTotp::generateSecret();
|
||||
$_SESSION['totp_enroll_secret'] = $secret;
|
||||
$_SESSION['totp_enroll_exp'] = time() + 900;
|
||||
}
|
||||
} elseif ($action === 'confirm_totp') {
|
||||
if (AuthFactors::hasTotp($uid)) {
|
||||
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
||||
$securityError = 'Authenticator is already enrolled.';
|
||||
} else {
|
||||
$secret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
|
||||
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
||||
$code = trim($_POST['code'] ?? '');
|
||||
if ($secret === '' || $exp < time()) {
|
||||
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
||||
$securityError = 'Setup expired — start again.';
|
||||
} elseif (!AuthTotp::verify($secret, $code, 2)) {
|
||||
$securityError = 'Invalid code — check device time and try again.';
|
||||
} else {
|
||||
unset($_SESSION['totp_enroll_secret'], $_SESSION['totp_enroll_exp']);
|
||||
AuthFactors::enrollTotp($uid, $secret);
|
||||
$securitySuccess = 'Authenticator enrolled.';
|
||||
}
|
||||
}
|
||||
} elseif ($action === 'remove_totp') {
|
||||
AuthFactors::removeTotp($uid);
|
||||
$securitySuccess = 'Authenticator removed.';
|
||||
}
|
||||
$totpSecret = '';
|
||||
if (!AuthFactors::hasTotp($uid)) {
|
||||
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
||||
if ($exp >= time()) {
|
||||
$totpSecret = (string) ($_SESSION['totp_enroll_secret'] ?? '');
|
||||
}
|
||||
if (!empty($securityError)) {
|
||||
$_SESSION['security_flash_error'] = $securityError;
|
||||
}
|
||||
require __DIR__ . '/../views/account_security.php';
|
||||
if (!empty($securitySuccess)) {
|
||||
$_SESSION['security_flash_success'] = $securitySuccess;
|
||||
}
|
||||
header('Location: ' . Auth::basePath() . '/account-security', true, 303);
|
||||
exit;
|
||||
}
|
||||
|
||||
@@ -267,6 +283,9 @@ if ($route === '/account-security') {
|
||||
Auth::check();
|
||||
$user = Auth::user();
|
||||
$uid = (int) ($user['id'] ?? 0);
|
||||
$securityError = (string) ($_SESSION['security_flash_error'] ?? '');
|
||||
$securitySuccess = (string) ($_SESSION['security_flash_success'] ?? '');
|
||||
unset($_SESSION['security_flash_error'], $_SESSION['security_flash_success']);
|
||||
$totpSecret = '';
|
||||
if (!AuthFactors::hasTotp($uid)) {
|
||||
$exp = (int) ($_SESSION['totp_enroll_exp'] ?? 0);
|
||||
|
||||
@@ -24,7 +24,7 @@ final class AuthTotp {
|
||||
. '&data=' . rawurlencode($otpauthUri);
|
||||
}
|
||||
|
||||
public static function verify(string $secret, string $code, int $window = 1): bool {
|
||||
public static function verify(string $secret, string $code, int $window = 2): bool {
|
||||
$code = preg_replace('/\s+/', '', $code) ?? '';
|
||||
if (!preg_match('/^\d{6}$/', $code)) {
|
||||
return false;
|
||||
|
||||
@@ -175,6 +175,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>
|
||||
<label class="toolbar-select">
|
||||
<span data-i18n="theme.label">Theme</span>
|
||||
<select id="theme-select" aria-label="UI theme">
|
||||
@@ -222,6 +223,22 @@
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user