diff --git a/app/src/main/java/com/foxx/androidcast/network/BackendEndpoints.java b/app/src/main/java/com/foxx/androidcast/network/BackendEndpoints.java index 8367698..17416ab 100644 --- a/app/src/main/java/com/foxx/androidcast/network/BackendEndpoints.java +++ b/app/src/main/java/com/foxx/androidcast/network/BackendEndpoints.java @@ -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; + } } diff --git a/app/src/main/java/com/foxx/androidcast/stats/GraphSessionUploader.java b/app/src/main/java/com/foxx/androidcast/stats/GraphSessionUploader.java new file mode 100644 index 0000000..21e09f1 --- /dev/null +++ b/app/src/main/java/com/foxx/androidcast/stats/GraphSessionUploader.java @@ -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; + } +} diff --git a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java index 1302cc2..2535e7c 100644 --- a/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java +++ b/app/src/main/java/com/foxx/androidcast/stats/SessionStatsRecorder.java @@ -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) { } } diff --git a/app/src/test/java/com/foxx/androidcast/network/BackendEndpointsTest.java b/app/src/test/java/com/foxx/androidcast/network/BackendEndpointsTest.java index e3cd2b6..947476a 100644 --- a/app/src/test/java/com/foxx/androidcast/network/BackendEndpointsTest.java +++ b/app/src/test/java/com/foxx/androidcast/network/BackendEndpointsTest.java @@ -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)); + } } diff --git a/examples/crash_reporter/backend/public/api/ticket_create.php b/examples/crash_reporter/backend/public/api/ticket_create.php new file mode 100644 index 0000000..7810c19 --- /dev/null +++ b/examples/crash_reporter/backend/public/api/ticket_create.php @@ -0,0 +1,65 @@ + 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); +} diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index 22ae61f..5e8d138 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -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; diff --git a/examples/crash_reporter/backend/public/assets/i18n/en.json b/examples/crash_reporter/backend/public/assets/i18n/en.json index 93a5315..4286852 100644 --- a/examples/crash_reporter/backend/public/assets/i18n/en.json +++ b/examples/crash_reporter/backend/public/assets/i18n/en.json @@ -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", diff --git a/examples/crash_reporter/backend/public/assets/js/graphs.js b/examples/crash_reporter/backend/public/assets/js/graphs.js index d2503a9..b3d328b 100644 --- a/examples/crash_reporter/backend/public/assets/js/graphs.js +++ b/examples/crash_reporter/backend/public/assets/js/graphs.js @@ -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 () { diff --git a/examples/crash_reporter/backend/public/assets/js/tickets.js b/examples/crash_reporter/backend/public/assets/js/tickets.js index ce5889e..e73c308 100644 --- a/examples/crash_reporter/backend/public/assets/js/tickets.js +++ b/examples/crash_reporter/backend/public/assets/js/tickets.js @@ -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(); } diff --git a/examples/crash_reporter/backend/public/index.php b/examples/crash_reporter/backend/public/index.php index 016f62d..366086e 100644 --- a/examples/crash_reporter/backend/public/index.php +++ b/examples/crash_reporter/backend/public/index.php @@ -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); diff --git a/examples/crash_reporter/backend/src/AuthTotp.php b/examples/crash_reporter/backend/src/AuthTotp.php index dfeeaad..8656ed0 100644 --- a/examples/crash_reporter/backend/src/AuthTotp.php +++ b/examples/crash_reporter/backend/src/AuthTotp.php @@ -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; diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index 23ef01b..bd20770 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -175,6 +175,7 @@

Tickets

+ + + + +

+ +