diff --git a/examples/crash_reporter/backend/public/assets/css/app.css b/examples/crash_reporter/backend/public/assets/css/app.css index dcaefbc..45309c3 100644 --- a/examples/crash_reporter/backend/public/assets/css/app.css +++ b/examples/crash_reporter/backend/public/assets/css/app.css @@ -1301,11 +1301,104 @@ a:hover { text-decoration: underline; } .tag-modal-close:hover { color: var(--text); background: var(--row-hover); } .tag-editor--modal { margin: 0; border: none; border-radius: 0; } -.graphs-app .graphs-quick-links { +.graphs-app .graphs-quick-links, +.console-quick-links { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; + align-items: center; +} +.console-quick-link { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + border-radius: 8px; + text-decoration: none; + color: var(--text); + background: var(--surface-2, rgba(255, 255, 255, 0.06)); + font-size: 0.9rem; + line-height: 1.2; + border: 1px solid var(--border, rgba(255, 255, 255, 0.08)); +} +.console-quick-link:hover { + background: var(--row-hover, rgba(255, 255, 255, 0.1)); + color: var(--text); +} +.console-quick-link .nav-icon { + flex-shrink: 0; +} +.nav-icon--git { + width: 18px; + height: 18px; +} +.nav-icon--git::before { + content: ""; + position: absolute; + left: 2px; + top: 2px; + width: 7px; + height: 7px; + border: 2px solid currentColor; + border-radius: 50%; + box-sizing: border-box; +} +.nav-icon--git::after { + content: ""; + position: absolute; + left: 8px; + top: 5px; + width: 10px; + height: 10px; + border: 2px solid currentColor; + border-left: none; + border-bottom: none; + border-radius: 0 6px 0 0; + box-sizing: border-box; +} +.nav-icon--builder { + width: 18px; + height: 18px; + border: 2px solid currentColor; + border-radius: 3px; + box-sizing: border-box; + position: relative; +} +.nav-icon--builder::before { + content: ""; + position: absolute; + left: 3px; + top: 7px; + width: 10px; + height: 2px; + background: currentColor; + box-shadow: 0 -4px 0 currentColor, 0 4px 0 currentColor; +} +.nav-icon--remote { + width: 18px; + height: 18px; +} +.nav-icon--remote::before { + content: ""; + position: absolute; + left: 2px; + top: 8px; + width: 14px; + height: 2px; + background: currentColor; + border-radius: 1px; +} +.nav-icon--remote::after { + content: ""; + position: absolute; + left: 5px; + top: 3px; + width: 8px; + height: 8px; + border: 2px solid currentColor; + border-radius: 2px; + box-sizing: border-box; } .graph-quick-link { font-size: 0.9rem; } .graphs-scope { margin-top: 20px; } @@ -1315,12 +1408,98 @@ a:hover { text-decoration: underline; } font-weight: 600; color: var(--text); } -.graphs-grid { +.graphs-app .graphs-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 14px; + grid-template-columns: repeat(var(--graphs-columns, 2), minmax(0, 1fr)); + gap: 16px; + width: 100%; +} +.graphs-grid .card.card--lift.graph-brick--clickable { + cursor: pointer; + min-height: 12rem; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +.graphs-grid .card.card--lift.graph-brick--clickable:hover, +.graphs-grid .card.card--lift.graph-brick--clickable:focus-visible { + box-shadow: 0 0 0 1px var(--accent, #3d8bfd); + outline: none; +} +.graphs-grid .card.card--lift.graph-brick--clickable h3::after { + content: " ↗"; + font-size: 0.75em; + color: var(--muted, #94a3b8); + font-weight: 400; +} +.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; min-height: 120px; } +.graph-detail-overlay { + position: fixed; + inset: 0; + z-index: 2000; + background: rgba(8, 12, 20, 0.92); + display: flex; + flex-direction: column; +} +.graph-detail-overlay[hidden] { + display: none !important; +} +.graph-detail-panel { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + max-height: 100vh; + background: var(--bg, #0f1419); +} +.graph-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 20px; + border-bottom: 1px solid var(--border, rgba(255, 255, 255, 0.1)); + flex-shrink: 0; +} +.graph-detail-header h2 { + margin: 0; + font-size: 1.15rem; + font-weight: 600; +} +.graph-detail-canvas-wrap { + position: relative; + flex: 1; + overflow: auto; + padding: 16px 20px 24px; +} +.graph-detail-canvas-wrap canvas { + display: block; + width: 100%; + max-width: 100%; + height: auto; +} +.graph-detail-body { + max-width: 960px; +} +.graph-detail-kpi { + font-size: 3rem; +} +.graph-detail-tooltip { + position: absolute; + z-index: 3; + margin: 0; + padding: 6px 10px; + border-radius: 6px; + background: var(--surface-2, #1e293b); + border: 1px solid var(--border, rgba(255, 255, 255, 0.15)); + color: var(--text); + font-size: 0.85rem; + pointer-events: none; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} +.graph-detail-table { + font-size: 1rem; + max-width: 720px; } -.graphs-grid canvas { width: 100%; height: auto; max-width: 100%; } .graph-kpi { font-size: 2rem; font-weight: 700; diff --git a/examples/crash_reporter/backend/public/assets/js/graphs.js b/examples/crash_reporter/backend/public/assets/js/graphs.js index 97471ae..fafaf7d 100644 --- a/examples/crash_reporter/backend/public/assets/js/graphs.js +++ b/examples/crash_reporter/backend/public/assets/js/graphs.js @@ -1,6 +1,29 @@ (function () { const COLORS = ['#3d8bfd', '#5eead4', '#f59e0b', '#34d399', '#a78bfa', '#f472b6', '#94a3b8']; + const BRICK_DEFS = { + sessions: { type: 'line', field: 'sessions_per_day', title: 'Sessions / day', color: COLORS[0] }, + devices: { type: 'line', field: 'unique_devices_per_day', title: 'Unique devices / day', color: COLORS[1] }, + duration: { type: 'line', field: 'avg_duration_s_per_day', title: 'Avg duration (s) / day', color: COLORS[2] }, + send: { type: 'line', field: 'send_sessions_per_day', title: 'Send sessions / day', color: COLORS[3] }, + recv: { type: 'line', field: 'recv_sessions_per_day', title: 'Receive sessions / day', color: COLORS[4] }, + bitrate: { type: 'line', field: 'avg_bitrate_kbps_per_day', title: 'Outbound kbps / day', color: COLORS[0] }, + 'recv-kbps': { type: 'line', field: 'avg_recv_kbps_per_day', title: 'Inbound kbps / day', color: COLORS[1] }, + crashes: { type: 'line', field: 'crashes_per_day', title: 'Crashes / day', color: '#f87171' }, + tickets: { type: 'line', field: 'tickets_per_day', title: 'Tickets / day', color: '#c084fc' }, + 'install-pie': { type: 'pie', field: 'install_sources', title: 'Install sources' }, + 'ntp-pie': { type: 'pie', field: 'ntp_sources', title: 'NTP sources' }, + transport: { type: 'breakdown', field: 'transport_mix', title: 'Transport mix' }, + versions: { type: 'breakdown', field: 'app_versions', title: 'App versions' }, + success: { type: 'kpi', field: 'success_ratio_pct', title: 'Success ratio', suffix: '%' }, + 'ntp-avg': { type: 'kpi', field: 'ntp_correction_avg_s', title: 'NTP correction', suffix: ' s avg correction' }, + fingerprints: { type: 'toplist', field: 'top_crash_fingerprints', title: 'Top crash fingerprints' }, + 'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Crashes by app version' }, + }; + + let graphScopes = { user: null, slug: null, platform: null }; + let detailState = null; + function basePath() { return document.body.getAttribute('data-base-path') || ''; } @@ -9,10 +32,32 @@ return document.getElementById(id); } - function drawLineChart(canvas, series, color) { - if (!canvas) return; + function escapeHtml(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function parseCanvasId(id) { + const m = String(id || '').match(/^graph-(user|slug|platform)-(.+)$/); + if (!m) return null; + const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform'; + return { scopeKey, suffix: m[2], def: BRICK_DEFS[m[2]] || null }; + } + + function scopeData(scopeKey) { + if (scopeKey === 'user') return graphScopes.user; + if (scopeKey === 'slug') return graphScopes.slug; + return graphScopes.platform; + } + + function drawLineChart(canvas, series, color, opts) { + if (!canvas) return null; + const interactive = opts && opts.interactive; const ctx = canvas.getContext('2d'); - if (!ctx) return; + if (!ctx) return null; const w = canvas.width; const h = canvas.height; ctx.clearRect(0, 0, w, h); @@ -23,7 +68,7 @@ ctx.fillStyle = '#94a3b8'; ctx.font = '12px system-ui, sans-serif'; ctx.fillText('No data', 24, h / 2); - return; + return { points: [], padL: 36, padT: 12, plotW: w - 48, plotH: h - 40, maxY: 1 }; } let maxY = 0; points.forEach((p) => { @@ -31,42 +76,62 @@ if (y > maxY) maxY = y; }); if (maxY <= 0) maxY = 1; - const padL = 36; - const padR = 12; - const padT = 12; - const padB = 28; + const padL = interactive ? 52 : 36; + const padR = interactive ? 120 : 12; + const padT = 16; + const padB = 32; const plotW = w - padL - padR; const plotH = h - padT - padB; ctx.strokeStyle = 'rgba(120,140,170,0.45)'; ctx.lineWidth = 1; + ctx.fillStyle = '#94a3b8'; + ctx.font = '10px system-ui, sans-serif'; for (let i = 0; i <= 4; i++) { const y = padT + (plotH * i) / 4; ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke(); + if (interactive) { + const tickVal = Math.round((maxY * (4 - i)) / 4); + ctx.fillText(String(tickVal), 4, y + 4); + } } + const plotPoints = points.map((p, idx) => { + const x = padL + (plotW * idx) / Math.max(1, points.length - 1); + const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY; + return { x, y, raw: p }; + }); + ctx.strokeStyle = color || '#3d8bfd'; ctx.lineWidth = 2; ctx.beginPath(); - points.forEach((p, idx) => { - const x = padL + (plotW * idx) / Math.max(1, points.length - 1); - const y = padT + plotH - (plotH * Number(p.y || 0)) / maxY; - if (idx === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); + plotPoints.forEach((pt, idx) => { + if (idx === 0) ctx.moveTo(pt.x, pt.y); + else ctx.lineTo(pt.x, pt.y); }); ctx.stroke(); + if (interactive) { + plotPoints.forEach((pt) => { + ctx.beginPath(); + ctx.fillStyle = color || '#3d8bfd'; + ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2); + ctx.fill(); + }); + } + ctx.fillStyle = '#94a3b8'; ctx.font = '10px system-ui, sans-serif'; if (points.length) { ctx.fillText(String(points[0].x || ''), padL, h - 8); const last = points[points.length - 1]; - const lx = padL + plotW - 48; - ctx.fillText(String(last.x || ''), lx, h - 8); + ctx.fillText(String(last.x || ''), padL + plotW - 48, h - 8); } + + return { points: plotPoints, padL, padT, plotW, plotH, maxY, w, h }; } function drawPie(canvas, rows) { @@ -159,14 +224,6 @@ .join(''); } - function escapeHtml(s) { - return s - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - function renderScope(prefix, scope) { if (!scope) return; drawLineChart(el('graph-' + prefix + '-sessions'), scope.sessions_per_day, COLORS[0]); @@ -197,29 +254,6 @@ drawPie(el('graph-' + prefix + '-crash-versions'), scope.crashes_by_app_version); } - function renderLinks(links) { - const nav = el('graphs-quick-links'); - if (!nav || !links) return; - const items = [ - ['Hub', links.hub], - ['Crashes', links.crashes], - ['Tickets', links.tickets], - ['Builder', links.build], - ['Git', links.git], - ]; - nav.innerHTML = items - .filter((pair) => pair[1]) - .map( - (pair) => - '' + - escapeHtml(pair[0]) + - '' - ) - .join(''); - } - function setScopeVisibility(viewer) { document.querySelectorAll('[data-graph-scope]').forEach((section) => { const need = section.getAttribute('data-graph-scope'); @@ -231,11 +265,178 @@ }); } + function applyColumns(cols) { + const app = el('graphs-app'); + if (!app) return; + const n = Math.min(4, Math.max(1, Number(cols) || 2)); + app.style.setProperty('--graphs-columns', String(n)); + } + + function markGraphBricks() { + 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'); + if (!canvas && !breakdown && !kpi) return; + card.classList.add('graph-brick--clickable'); + card.setAttribute('tabindex', '0'); + card.setAttribute('role', 'button'); + const title = card.querySelector('h3'); + if (title) card.setAttribute('aria-label', 'Open full view: ' + title.textContent); + }); + } + + function closeGraphDetail() { + const overlay = el('graph-detail-overlay'); + if (overlay) overlay.hidden = true; + detailState = null; + const tip = el('graph-detail-tooltip'); + if (tip) tip.hidden = true; + } + + function openGraphDetail(card) { + const canvas = card.querySelector('canvas'); + const breakdown = card.querySelector('.graph-breakdown'); + const kpi = card.querySelector('.graph-kpi'); + let parsed = null; + if (canvas && canvas.id) parsed = parseCanvasId(canvas.id); + if (!parsed && breakdown && breakdown.id) parsed = parseCanvasId(breakdown.id); + if (!parsed && kpi && kpi.id) parsed = parseCanvasId(kpi.id); + if (!parsed || !parsed.def) return; + + const scope = scopeData(parsed.scopeKey); + if (!scope) return; + const def = parsed.def; + const fieldVal = scope[def.field]; + const titleEl = el('graph-detail-title'); + const canvasEl = el('graph-detail-canvas'); + const bodyEl = el('graph-detail-body'); + const overlay = el('graph-detail-overlay'); + if (!overlay || !titleEl || !bodyEl) return; + + titleEl.textContent = def.title; + bodyEl.innerHTML = ''; + if (canvasEl) canvasEl.hidden = true; + + if (def.type === 'line' && canvasEl) { + canvasEl.hidden = false; + canvasEl.width = 960; + canvasEl.height = 420; + const meta = drawLineChart(canvasEl, fieldVal, def.color, { interactive: true }); + detailState = { type: 'line', meta, def }; + attachLineHover(canvasEl, meta, def); + } else if (def.type === 'pie' && canvasEl) { + canvasEl.hidden = false; + canvasEl.width = 720; + canvasEl.height = 400; + drawPie(canvasEl, fieldVal); + detailState = { type: 'pie' }; + } else if (def.type === 'breakdown' || def.type === 'toplist') { + const wrap = document.createElement('div'); + wrap.className = 'graph-detail-table graph-breakdown'; + if (!Array.isArray(fieldVal) || !fieldVal.length) { + wrap.textContent = 'No data'; + } else if (def.type === 'toplist') { + renderTopList(wrap, fieldVal); + } else { + renderBreakdownList(wrap, fieldVal); + } + bodyEl.appendChild(wrap); + detailState = { type: def.type }; + } else if (def.type === 'kpi') { + const p = document.createElement('p'); + p.className = 'graph-kpi graph-detail-kpi'; + let text = '—'; + if (typeof fieldVal === 'number') { + text = String(fieldVal) + (def.suffix || ''); + } else if (fieldVal != null) { + text = String(fieldVal); + } + p.textContent = text; + bodyEl.appendChild(p); + detailState = { type: 'kpi' }; + } + + overlay.hidden = false; + } + + function attachLineHover(canvas, meta, def) { + const tip = el('graph-detail-tooltip'); + const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap'); + if (!tip || !wrap || !meta || !meta.points.length) return; + + canvas.onmousemove = function (ev) { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / 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 > 28) { + tip.hidden = true; + return; + } + const raw = best.raw || {}; + tip.hidden = false; + const unit = + def && def.title && def.title.indexOf('kbps') >= 0 + ? ' kbps' + : def && def.title && def.title.indexOf('duration') >= 0 + ? ' s' + : ''; + tip.textContent = String(raw.x || '') + ': ' + Number(raw.y || 0) + unit; + const wrapRect = wrap.getBoundingClientRect(); + tip.style.left = ev.clientX - wrapRect.left + 12 + 'px'; + tip.style.top = ev.clientY - wrapRect.top - 8 + 'px'; + }; + canvas.onmouseleave = function () { + tip.hidden = true; + }; + } + function bootGraphs() { const app = el('graphs-app'); if (!app) return; const status = el('graphs-status'); const daysSel = el('graphs-days'); + const colsSel = el('graphs-columns'); + + const overlay = el('graph-detail-overlay'); + if (overlay) { + overlay.addEventListener('click', (ev) => { + if (ev.target === overlay) closeGraphDetail(); + }); + const closeBtn = el('graph-detail-close'); + if (closeBtn) closeBtn.addEventListener('click', closeGraphDetail); + } + document.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') closeGraphDetail(); + }); + + if (colsSel) { + applyColumns(colsSel.value); + colsSel.addEventListener('change', () => applyColumns(colsSel.value)); + } else { + applyColumns(2); + } + + app.addEventListener('click', (ev) => { + const card = ev.target.closest('.graph-brick--clickable'); + if (card && app.contains(card)) openGraphDetail(card); + }); + app.addEventListener('keydown', (ev) => { + const card = ev.target.closest('.graph-brick--clickable'); + if (card && (ev.key === 'Enter' || ev.key === ' ')) { + ev.preventDefault(); + openGraphDetail(card); + } + }); function load() { const days = Number(daysSel && daysSel.value ? daysSel.value : 14); @@ -256,12 +457,16 @@ } const data = payload.data || {}; const viewer = data.viewer || 'user'; - renderLinks(data.links || {}); + graphScopes.user = data.user || null; + graphScopes.slug = data.slug_admin || null; + graphScopes.platform = data.platform_admin || null; setScopeVisibility(viewer); - renderScope('user', data.user); - if (data.slug_admin) renderScope('slug', data.slug_admin); - if (data.platform_admin) renderScope('platform', data.platform_admin); - status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd'; + renderScope('user', graphScopes.user); + if (graphScopes.slug) renderScope('slug', graphScopes.slug); + if (graphScopes.platform) renderScope('platform', graphScopes.platform); + markGraphBricks(); + const cols = colsSel && colsSel.value ? colsSel.value : '2'; + status.textContent = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd · columns=' + cols; }; xhr.onerror = function () { status.textContent = 'Network error'; diff --git a/examples/crash_reporter/backend/views/layout.php b/examples/crash_reporter/backend/views/layout.php index 266950e..12718cd 100644 --- a/examples/crash_reporter/backend/views/layout.php +++ b/examples/crash_reporter/backend/views/layout.php @@ -102,7 +102,7 @@ class="nav-link " aria-label="Remote access" title="Remote access"> - + Remote access @@ -123,7 +123,7 @@ class="nav-link" aria-label="Builder" title="Builder"> - + Builder @@ -303,9 +303,17 @@ + - +

Loading…

@@ -350,12 +358,19 @@
-

- Direct DB metrics (no ETL). Ingest: - /api/graph_upload.php - · Read: - /api/graphs.php -

+
@@ -371,19 +386,8 @@
- +

Loading…

-

- Device poll: /api/heartbeat.php (type: ra). - No nginx changes required — same crashes vhost. Configure remote_access.* in BE config.php. -

Whitelisted devices

diff --git a/examples/crash_reporter/backend/views/partials/console_quick_links.php b/examples/crash_reporter/backend/views/partials/console_quick_links.php new file mode 100644 index 0000000..48702b7 --- /dev/null +++ b/examples/crash_reporter/backend/views/partials/console_quick_links.php @@ -0,0 +1,26 @@ + + */ +$bp = Auth::basePath(); +$proj = '/app/androidcast_project'; +$navClass = 'console-quick-links graphs-quick-links' . (isset($extra_class) ? ' ' . $extra_class : ''); +$items = [ + ['Console home', $bp . '/?view=home', 'nav-icon--home'], + ['Crash reports', $bp . '/?view=reports', 'nav-icon--reports'], + ['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets'], + ['Graphs', $proj . '/graphs/', 'nav-icon--graphs'], + ['Builder', $proj . '/build/', 'nav-icon--builder'], + ['Hub', $proj . '/', 'nav-icon--home'], +]; +?> +