1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 04:38:53 +03:00
This commit is contained in:
Anton Afanasyeu
2026-06-08 22:37:05 +02:00
parent 1e08efb9a4
commit df79962dee
12 changed files with 653 additions and 36 deletions

View File

@@ -5,8 +5,11 @@ public final class BackendEndpoints {
public static final String APPS_HOST = "apps.f0xx.org";
public static final String CRASHES_BASE =
"https://" + APPS_HOST + "/app/androidcast_project/crashes";
public static final String GRAPHS_BASE =
"https://" + APPS_HOST + "/app/androidcast_project/graphs";
public static final String HEARTBEAT_URL = CRASHES_BASE + "/api/heartbeat.php";
public static final String UPLOAD_URL = CRASHES_BASE + "/api/upload.php";
public static final String GRAPH_UPLOAD_URL = GRAPHS_BASE + "/api/graph_upload.php";
private BackendEndpoints() {}
@@ -26,4 +29,16 @@ public final class BackendEndpoints {
}
return trimmed;
}
/** Maps a crashes upload URL to the graphs session ingest endpoint. */
public static String graphUploadUrlFromCrashesUrl(String crashesUrl) {
if (crashesUrl == null || crashesUrl.isEmpty()) {
return GRAPH_UPLOAD_URL;
}
String trimmed = normalizeCrashesUrl(crashesUrl.trim());
if (trimmed.contains("/crashes/")) {
return trimmed.replace("/crashes/", "/graphs/").replace("/api/upload.php", "/api/graph_upload.php");
}
return GRAPH_UPLOAD_URL;
}
}

View File

@@ -0,0 +1,106 @@
package com.foxx.androidcast.stats;
import android.content.Context;
import android.os.Build;
import com.foxx.androidcast.AppPreferences;
import com.foxx.androidcast.BuildConfig;
import com.foxx.androidcast.DeviceInfo;
import com.foxx.androidcast.crash.CrashSettingsStore;
import com.foxx.androidcast.crash.CrashUploadClient;
import com.foxx.androidcast.network.BackendEndpoints;
import org.json.JSONObject;
import java.util.Locale;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** POST anonymous session aggregates to the graphs ingest API ({@code graph_upload.php}). */
public final class GraphSessionUploader {
private static final ExecutorService EXEC = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "graph-session-upload");
t.setDaemon(true);
return t;
});
private GraphSessionUploader() {}
public static void uploadAsync(Context context, JSONObject sessionRoot) {
if (context == null || sessionRoot == null || !AppPreferences.isGrabSessionStats(context)) {
return;
}
final Context app = context.getApplicationContext();
EXEC.execute(() -> upload(app, sessionRoot));
}
static boolean upload(Context context, JSONObject sessionRoot) {
try {
JSONObject payload = toGraphPayload(context, sessionRoot);
if (payload == null) {
return false;
}
String url = BackendEndpoints.graphUploadUrlFromCrashesUrl(
CrashSettingsStore.load(context).uploadUrl);
return CrashUploadClient.upload(url, payload);
} catch (Exception ignored) {
return false;
}
}
static JSONObject toGraphPayload(Context context, JSONObject root) throws Exception {
long started = root.optLong("started_at_epoch_ms", 0L);
long ended = root.optLong("ended_at_epoch_ms", 0L);
if (started <= 0 || ended <= 0 || ended < started) {
return null;
}
String direction = root.optString("direction", "send");
String ingestDir = "recv".equalsIgnoreCase(direction) ? "receiver" : "sender";
String transport = root.optString("transport", "wifi").toLowerCase(Locale.US);
if (transport.isEmpty()) {
transport = "wifi";
}
JSONObject payload = new JSONObject();
payload.put("schema_version", 1);
payload.put("ingest_kind", "graph_session");
payload.put("session_id", sessionId(started, direction));
payload.put("device_id", DeviceInfo.getDeviceUuid(context));
payload.put("app_version", BuildConfig.VERSION_NAME);
payload.put("sdk_int", Build.VERSION.SDK_INT);
payload.put("started_at_epoch_ms", started);
payload.put("ended_at_epoch_ms", ended);
payload.put("duration_s", Math.max(0, (int) ((ended - started) / 1000L)));
payload.put("direction", ingestDir);
payload.put("transport", transport);
payload.put("install_source", "direct");
payload.put("ntp_source", AppPreferences.isDevBackendNtpSyncEnabled(context) ? "backend" : "device");
if (root.has("avg_video_bitrate_kbps")) {
payload.put("avg_kbps", Math.max(0, (int) Math.round(root.optDouble("avg_video_bitrate_kbps", 0))));
}
if (root.has("avg_loss_ratio")) {
payload.put("packet_loss_pct", Math.max(0.0, root.optDouble("avg_loss_ratio", 0) * 100.0));
}
JSONObject globals = root.optJSONObject("globals");
String reason = globals != null ? globals.optString("reason", "") : "";
payload.put("completed", reason.isEmpty() || !"crash".equalsIgnoreCase(reason));
JSONObject meta = new JSONObject();
meta.put("capture_mode", root.optString("capture_mode", ""));
meta.put("video_codec", root.optString("video_codec", ""));
meta.put("role", root.optString("role", ""));
if (root.has("avg_encode_fps")) {
meta.put("avg_encode_fps", root.optDouble("avg_encode_fps", 0));
}
payload.put("meta", meta);
return payload;
}
private static String sessionId(long startedMs, String direction) {
String seed = startedMs + ":" + direction + ":" + UUID.randomUUID();
return "mob-" + Integer.toHexString(seed.hashCode()) + "-" + startedMs;
}
}

View File

@@ -274,6 +274,7 @@ public final class SessionStatsRecorder {
try {
JSONObject root = buildSessionJson(endedMs, reason);
SessionStatsStore.saveSession(context, startedMs, direction, root);
GraphSessionUploader.uploadAsync(context, root);
} catch (Exception ignored) {
}
}

View File

@@ -17,4 +17,10 @@ public class BackendEndpointsTest {
String legacy = "https://f0xx.org/app/androidcast_project/crashes/api/upload.php";
assertEquals(BackendEndpoints.UPLOAD_URL, BackendEndpoints.normalizeCrashesUrl(legacy));
}
@Test
public void graphUploadUrlFromCrashesUrl() {
assertEquals(BackendEndpoints.GRAPH_UPLOAD_URL,
BackendEndpoints.graphUploadUrlFromCrashesUrl(BackendEndpoints.UPLOAD_URL));
}
}

View 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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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