commit bff43ff5d19357fbbd60fc534f2de3714224dcd3 Author: Anton Afanasyeu Date: Tue Jun 23 12:29:36 2026 +0200 initial diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d64ab0 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-be-graphs + +Thin BE UI. Remote: `git://f0xx.org/ac/ac-be-graphs` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..d70c429 --- /dev/null +++ b/composer.json @@ -0,0 +1,19 @@ +{ + "name": "androidcast/be-graphs", + "description": "Graphs dashboard UI", + "require": { + "php": ">=8.1", + "androidcast/platform-php": "dev-next", + "androidcast/platform-web": "dev-next" + }, + "repositories": [ + { + "type": "vcs", + "url": "git://f0xx.org/ac/ac-platform-php" + }, + { + "type": "vcs", + "url": "git://f0xx.org/ac/ac-platform-web" + } + ] +} diff --git a/public/assets/js/graphs.js b/public/assets/js/graphs.js new file mode 100644 index 0000000..01fcd2f --- /dev/null +++ b/public/assets/js/graphs.js @@ -0,0 +1,1093 @@ +(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: 'Issues / 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 issue fingerprints' }, + 'crash-versions': { type: 'pie', field: 'crashes_by_app_version', title: 'Issues by app version' }, + 'live-casts': { type: 'line', field: 'casts_per_day', title: 'Live casts / day', color: COLORS[0] }, + 'live-avg-length': { type: 'line', field: 'avg_length_s_per_day', title: 'Avg cast length (s) / day', color: COLORS[2] }, + 'live-platform-pie': { type: 'pie', field: 'platform_mix', title: 'Cast platforms' }, + 'live-join-platform-pie': { type: 'pie', field: 'joins_platform_mix', title: 'Join platforms' }, + 'live-video-codec-pie': { type: 'pie', field: 'video_codec_mix', title: 'Video codecs' }, + 'live-audio-codec-pie': { type: 'pie', field: 'audio_codec_mix', title: 'Audio codecs' }, + 'live-bw-pie': { type: 'pie', field: 'bw_mode_mix', title: 'Bandwidth modes' }, + 'live-top-users': { type: 'toplist', field: 'top_users', title: 'Top casters' }, + }; + + let graphScopes = { user: null, slug: null, platform: null }; + let liveCastScope = null; + let detailState = null; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function el(id) { + return document.getElementById(id); + } + + function escapeHtml(s) { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function graphPermalink(scopeKey, suffix, days) { + const url = new URL(window.location.href); + url.searchParams.set('graph', scopeKey + '-' + suffix); + if (days) { + url.searchParams.set('days', String(days)); + } + return url.toString(); + } + + function syncGraphPermalink(scopeKey, suffix) { + const daysSel = el('graphs-days'); + const days = daysSel && daysSel.value ? daysSel.value : '14'; + const link = graphPermalink(scopeKey, suffix, days); + if (window.history && window.history.replaceState) { + window.history.replaceState(null, '', link); + } + const share = el('graph-detail-share'); + if (share) { + share.href = link; + } + } + + function clearGraphPermalink() { + const url = new URL(window.location.href); + url.searchParams.delete('graph'); + if (window.history && window.history.replaceState) { + window.history.replaceState(null, '', url.toString()); + } + } + + function openGraphFromPermalink() { + const params = new URLSearchParams(window.location.search); + const token = params.get('graph'); + if (!token) return; + const node = document.getElementById('graph-' + token); + if (!node) return; + const card = node.closest('.card.card--lift'); + if (card) { + openGraphDetail(card); + } + } + + function parseCanvasId(id) { + const m = String(id || '').match(/^graph-(user|slug|platform)-(.+)$/); + if (m) { + const scopeKey = m[1] === 'user' ? 'user' : m[1] === 'slug' ? 'slug' : 'platform'; + return { scopeKey, suffix: m[2], def: BRICK_DEFS[m[2]] || null }; + } + const live = String(id || '').match(/^graph-live-(.+)$/); + if (live) { + const suffix = 'live-' + live[1]; + return { scopeKey: 'live_cast', suffix: suffix, def: BRICK_DEFS[suffix] || null }; + } + return null; + } + + function scopeData(scopeKey) { + if (scopeKey === 'live_cast') return liveCastScope; + if (scopeKey === 'user') return graphScopes.user; + if (scopeKey === 'slug') return graphScopes.slug; + 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; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = 'rgba(90,110,140,0.12)'; + ctx.fillRect(0, 0, w, h); + const points = Array.isArray(series) ? series : []; + if (!points.length) { + ctx.fillStyle = '#94a3b8'; + ctx.font = '12px system-ui, sans-serif'; + ctx.fillText('No data', 24, h / 2); + return { points: [], padL: 36, padT: 12, plotW: w - 48, plotH: h - 40, maxY: 1 }; + } + let maxY = 0; + points.forEach((p) => { + const y = Number(p.y || 0); + if (y > maxY) maxY = y; + }); + if (maxY <= 0) maxY = 1; + const padL = interactive ? 56 : 36; + const padR = interactive ? 148 : 12; + const padT = interactive ? 24 : 16; + const padB = interactive ? 40 : 32; + const plotW = w - padL - padR; + const plotH = h - padT - padB; + const lineColor = color || '#3d8bfd'; + const title = (opts && opts.title) || ''; + + ctx.strokeStyle = 'rgba(120,140,170,0.45)'; + ctx.lineWidth = 1; + ctx.fillStyle = '#94a3b8'; + ctx.font = interactive ? '11px system-ui, sans-serif' : '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), 8, 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, idx }; + }); + + ctx.strokeStyle = lineColor; + ctx.lineWidth = interactive ? 2.5 : 2; + ctx.beginPath(); + 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 = lineColor; + ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2); + ctx.fill(); + }); + if (title) { + const lx = w - padR + 12; + ctx.fillStyle = lineColor; + ctx.fillRect(lx, padT, 12, 12); + ctx.fillStyle = '#e2e8f0'; + ctx.font = '12px system-ui, sans-serif'; + const legend = title.length > 18 ? title.slice(0, 17) + '…' : title; + ctx.fillText(legend, lx + 18, padT + 10); + ctx.fillStyle = '#94a3b8'; + ctx.font = '10px system-ui, sans-serif'; + ctx.fillText('Y: value · X: date', lx, padT + 28); + } + const xTicks = Math.min(6, points.length); + for (let t = 0; t < xTicks; t++) { + const idx = xTicks === 1 ? 0 : Math.round((t * (points.length - 1)) / (xTicks - 1)); + const pt = plotPoints[idx]; + const label = String(points[idx].x || ''); + ctx.fillStyle = '#94a3b8'; + ctx.font = '10px system-ui, sans-serif'; + ctx.fillText(label, Math.max(padL, pt.x - 20), h - 12); + } + } else { + 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]; + ctx.fillText(String(last.x || ''), padL + plotW - 48, h - 8); + } + } + + 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, + padT, + padR, + plotW, + plotH, + maxY, + w, + h, + series: points, + color: lineColor, + title, + }; + } + + function detailCanvasSize() { + const wrap = el('graph-detail-canvas-wrap'); + const maxW = wrap ? wrap.clientWidth - 8 : window.innerWidth - 48; + return { + w: Math.max(640, Math.min(1280, maxW)), + h: Math.max(380, window.innerHeight - 200), + }; + } + + function redrawDetailChart(highlightIdx) { + const state = detailState; + if (!state || state.type !== 'line' || !state.canvas) return; + const canvas = state.canvas; + const meta = drawLineChart(canvas, state.series, state.def.color, { + interactive: true, + title: state.def.title, + }); + if (meta && highlightIdx != null && meta.points[highlightIdx]) { + const ctx = canvas.getContext('2d'); + const pt = meta.points[highlightIdx]; + ctx.strokeStyle = 'rgba(148,163,184,0.55)'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(pt.x, meta.padT); + ctx.lineTo(pt.x, meta.h - meta.padT); + ctx.stroke(); + ctx.setLineDash([]); + ctx.beginPath(); + ctx.fillStyle = state.def.color || '#3d8bfd'; + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 2; + ctx.arc(pt.x, pt.y, 7, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + detailState.meta = meta; + } + + function drawPie(canvas, rows, opts) { + 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; + ctx.clearRect(0, 0, w, h); + if (interactive) { + ctx.fillStyle = 'rgba(90,110,140,0.12)'; + ctx.fillRect(0, 0, w, h); + } + const data = Array.isArray(rows) ? rows.filter((r) => Number(r.value) > 0) : []; + if (!data.length) { + ctx.fillStyle = '#94a3b8'; + ctx.font = '12px system-ui, sans-serif'; + ctx.fillText('No data', 24, h / 2); + return { slices: [], cx: 0, cy: 0, r: 0, data: [], total: 0, w, h }; + } + const total = data.reduce((s, r) => s + Number(r.value || 0), 0); + const cx = w * (interactive ? 0.38 : 0.35); + const cy = h * 0.5; + const r = Math.min(w, h) * (interactive ? 0.36 : 0.32); + let angle = -Math.PI / 2; + const slices = []; + data.forEach((row, i) => { + const slice = (Number(row.value) / total) * Math.PI * 2; + const start = angle; + const end = angle + slice; + slices.push({ start, end, idx: i, row, color: COLORS[i % COLORS.length] }); + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.fillStyle = COLORS[i % COLORS.length]; + if (interactive && highlightIdx === i) { + ctx.shadowColor = 'rgba(0,0,0,0.35)'; + ctx.shadowBlur = 10; + } else { + ctx.shadowBlur = 0; + } + ctx.arc(cx, cy, r, start, end); + ctx.closePath(); + ctx.fill(); + ctx.shadowBlur = 0; + angle = end; + }); + const legendX = w * (interactive ? 0.58 : 0.62); + let ly = interactive ? 28 : 16; + data.forEach((row, i) => { + const color = COLORS[i % COLORS.length]; + ctx.fillStyle = color; + ctx.fillRect(legendX, ly, interactive ? 14 : 10, interactive ? 14 : 10); + ctx.fillStyle = highlightIdx === i && interactive ? '#f8fafc' : '#cbd5e1'; + ctx.font = interactive ? '13px system-ui, sans-serif' : '11px system-ui, sans-serif'; + const pct = total > 0 ? Math.round((100 * Number(row.value)) / total) : 0; + const label = String(row.label || '') + ' (' + pct + '%)'; + ctx.fillText(label, legendX + (interactive ? 22 : 16), ly + (interactive ? 12 : 9)); + ly += interactive ? 22 : 18; + }); + return { slices, cx, cy, r, data, total, w, h }; + } + + function redrawDetailPie(highlightIdx) { + const state = detailState; + if (!state || state.type !== 'pie' || !state.canvas) return; + const meta = drawPie(state.canvas, state.series, { interactive: true, highlightIdx: highlightIdx }); + detailState.meta = meta; + } + + function pieSliceAt(meta, mx, my) { + if (!meta || !meta.slices.length || meta.total <= 0) return -1; + const dx = mx - meta.cx; + const dy = my - meta.cy; + if (Math.sqrt(dx * dx + dy * dy) > meta.r) return -1; + let rel = Math.atan2(dy, dx) + Math.PI / 2; + if (rel < 0) rel += Math.PI * 2; + if (rel >= Math.PI * 2) rel -= Math.PI * 2; + let acc = 0; + for (let i = 0; i < meta.data.length; i++) { + const slice = (Number(meta.data[i].value) / meta.total) * Math.PI * 2; + if (rel >= acc && rel < acc + slice) return i; + acc += slice; + } + return -1; + } + + function renderBreakdownList(target, rows) { + if (!target) return; + if (!Array.isArray(rows) || !rows.length) { + target.textContent = 'No data'; + return; + } + const total = rows.reduce((s, r) => s + Number(r.value || 0), 0) || 1; + target.innerHTML = rows + .map((r) => { + const pct = Math.round((100 * Number(r.value || 0)) / total); + return ( + '
' + + escapeHtml(String(r.label || '')) + + '' + + Number(r.value || 0) + + ' (' + + pct + + '%)
' + ); + }) + .join(''); + } + + function renderTopList(target, rows) { + if (!target) return; + if (!Array.isArray(rows) || !rows.length) { + target.textContent = 'No data'; + return; + } + target.innerHTML = rows + .map((r) => { + const label = escapeHtml(String(r.label || '')); + const val = Number(r.value || 0); + if (r.href) { + return ( + '
' + + label + + '' + + val + + '
' + ); + } + return '
' + label + '' + val + '
'; + }) + .join(''); + } + + function renderScope(prefix, scope) { + if (!scope) return; + drawLineChart(el('graph-' + prefix + '-sessions'), scope.sessions_per_day, COLORS[0]); + drawLineChart(el('graph-' + prefix + '-devices'), scope.unique_devices_per_day, COLORS[1]); + drawLineChart(el('graph-' + prefix + '-duration'), scope.avg_duration_s_per_day, COLORS[2]); + drawLineChart(el('graph-' + prefix + '-send'), scope.send_sessions_per_day, COLORS[3]); + drawLineChart(el('graph-' + prefix + '-recv'), scope.recv_sessions_per_day, COLORS[4]); + drawLineChart(el('graph-' + prefix + '-bitrate'), scope.avg_bitrate_kbps_per_day, COLORS[0]); + drawLineChart(el('graph-' + prefix + '-recv-kbps'), scope.avg_recv_kbps_per_day, COLORS[1]); + drawLineChart(el('graph-' + prefix + '-crashes'), scope.crashes_per_day, '#f87171'); + if (scope.tickets_per_day) { + drawLineChart(el('graph-' + prefix + '-tickets'), scope.tickets_per_day, '#c084fc'); + } + drawPie(el('graph-' + prefix + '-install-pie'), scope.install_sources); + drawPie(el('graph-' + prefix + '-ntp-pie'), scope.ntp_sources); + renderBreakdownList(el('graph-' + prefix + '-transport'), scope.transport_mix); + renderBreakdownList(el('graph-' + prefix + '-versions'), scope.app_versions); + const ratioEl = el('graph-' + prefix + '-success'); + if (ratioEl) { + ratioEl.textContent = + typeof scope.success_ratio_pct === 'number' ? scope.success_ratio_pct + '%' : '—'; + } + const ntpAvg = el('graph-' + prefix + '-ntp-avg'); + if (ntpAvg && scope.ntp_correction_avg_s != null) { + ntpAvg.textContent = scope.ntp_correction_avg_s + ' s avg correction'; + } + renderTopList(el('graph-' + prefix + '-fingerprints'), scope.top_crash_fingerprints); + drawPie(el('graph-' + prefix + '-crash-versions'), scope.crashes_by_app_version); + } + + function renderLiveCast(scope) { + if (!scope) { + const section = document.getElementById('graphs-live-cast-section'); + if (section) section.hidden = true; + return; + } + const section = document.getElementById('graphs-live-cast-section'); + if (section) section.hidden = false; + drawLineChart(el('graph-live-casts'), scope.casts_per_day, COLORS[0]); + drawLineChart(el('graph-live-avg-length'), scope.avg_length_s_per_day, COLORS[2]); + drawPie(el('graph-live-platform-pie'), scope.platform_mix); + drawPie(el('graph-live-join-platform-pie'), scope.joins_platform_mix); + drawPie(el('graph-live-video-codec-pie'), scope.video_codec_mix); + drawPie(el('graph-live-audio-codec-pie'), scope.audio_codec_mix); + drawPie(el('graph-live-bw-pie'), scope.bw_mode_mix); + renderTopList(el('graph-live-top-users'), scope.top_users); + } + + function setScopeVisibility(viewer) { + document.querySelectorAll('[data-graph-scope]').forEach((section) => { + const need = section.getAttribute('data-graph-scope'); + let show = false; + 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; + }); + } + + 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); + 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')) { + title.classList.add('graph-brick-head'); + const dragLabel = + (typeof window.t === 'function' && window.t('graphs.brick_drag')) || 'Drag to reorder'; + const handle = document.createElement('button'); + handle.type = 'button'; + handle.className = 'graph-brick-drag-handle'; + handle.setAttribute('draggable', 'true'); + handle.setAttribute('aria-label', dragLabel); + handle.title = dragLabel; + handle.innerHTML = ''; + title.insertBefore(handle, title.firstChild); + } + 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 (brickId.startsWith('live-')) { + const def = BRICK_DEFS[brickId]; + empty = !fieldHasData(def, liveCastScope); + } 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 }); + }; + }); + } + + function detachDetailResize() { + if (detailState && detailState.resizeHandler) { + window.removeEventListener('resize', detailState.resizeHandler); + } + } + + function attachDetailResize(canvas, redrawFn) { + detachDetailResize(); + const handler = function () { + if (!detailState || detailState.canvas !== canvas) return; + const size = detailCanvasSize(); + canvas.width = size.w; + canvas.height = size.h; + redrawFn(null); + }; + if (detailState) detailState.resizeHandler = handler; + window.addEventListener('resize', handler, { passive: true }); + } + + function closeGraphDetail() { + detachDetailResize(); + const overlay = el('graph-detail-overlay'); + if (overlay) overlay.hidden = true; + detailState = null; + clearGraphPermalink(); + const tip = el('graph-detail-tooltip'); + if (tip) tip.hidden = true; + const statsEl = el('graph-detail-stats'); + if (statsEl) statsEl.textContent = ''; + } + + 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; + syncGraphPermalink(parsed.scopeKey, parsed.suffix); + 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; + const size = detailCanvasSize(); + canvasEl.width = size.w; + canvasEl.height = size.h; + detailState = { + type: 'line', + def, + series: fieldVal, + canvas: canvasEl, + meta: null, + }; + redrawDetailChart(null); + attachLineHover(canvasEl, def); + attachDetailResize(canvasEl, redrawDetailChart); + const statsEl = el('graph-detail-stats'); + if (statsEl) { + statsEl.textContent = + (fieldVal && fieldVal.length ? fieldVal.length + ' points · ' : '') + + 'Move pointer over chart for values'; + } + } else if (def.type === 'pie' && canvasEl) { + canvasEl.hidden = false; + const size = detailCanvasSize(); + canvasEl.width = size.w; + canvasEl.height = size.h; + detailState = { + type: 'pie', + def, + series: fieldVal, + canvas: canvasEl, + meta: null, + }; + redrawDetailPie(null); + attachPieHover(canvasEl, def); + attachDetailResize(canvasEl, redrawDetailPie); + const statsEl = el('graph-detail-stats'); + if (statsEl) { + const n = Array.isArray(fieldVal) ? fieldVal.filter((r) => Number(r.value) > 0).length : 0; + statsEl.textContent = + (n ? n + ' segments · ' : '') + 'Hover slices for value breakdown'; + } + } 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 valueUnit(def) { + if (!def || !def.title) return ''; + if (def.title.indexOf('kbps') >= 0) return ' kbps'; + if (def.title.indexOf('duration') >= 0) return ' s'; + return ''; + } + + function attachLineHover(canvas, def) { + const tip = el('graph-detail-tooltip'); + const statsEl = el('graph-detail-stats'); + const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap'); + if (!wrap) return; + + canvas.onmousemove = function (ev) { + const meta = detailState && detailState.meta; + if (!meta || !meta.points.length) return; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const x = (ev.clientX - rect.left) * scaleX; + let best = null; + let bestD = Infinity; + let bestIdx = -1; + meta.points.forEach((pt, idx) => { + const d = Math.abs(pt.x - x); + if (d < bestD) { + bestD = d; + best = pt; + bestIdx = idx; + } + }); + if (!best || bestD > 32) { + if (tip) tip.hidden = true; + redrawDetailChart(null); + return; + } + redrawDetailChart(bestIdx); + const raw = best.raw || {}; + const unit = valueUnit(def); + const label = String(raw.x || '') + ' → ' + Number(raw.y || 0) + unit; + if (tip) { + tip.hidden = false; + tip.textContent = label; + const wrapRect = wrap.getBoundingClientRect(); + tip.style.left = ev.clientX - wrapRect.left + 14 + 'px'; + tip.style.top = ev.clientY - wrapRect.top - 10 + 'px'; + } + if (statsEl) { + statsEl.textContent = def.title + ' · ' + label; + } + }; + canvas.onmouseleave = function () { + if (tip) tip.hidden = true; + redrawDetailChart(null); + if (statsEl && detailState && detailState.series) { + statsEl.textContent = + detailState.series.length + ' points · Move pointer over chart for values'; + } + }; + } + + function attachPieHover(canvas, def) { + const tip = el('graph-detail-tooltip'); + const statsEl = el('graph-detail-stats'); + const wrap = canvas && canvas.closest('.graph-detail-canvas-wrap'); + if (!wrap) return; + + canvas.onmousemove = function (ev) { + const meta = detailState && detailState.meta; + if (!meta || !meta.slices.length) return; + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const mx = (ev.clientX - rect.left) * scaleX; + const my = (ev.clientY - rect.top) * scaleY; + const idx = pieSliceAt(meta, mx, my); + if (idx < 0) { + if (tip) tip.hidden = true; + redrawDetailPie(null); + if (statsEl) { + statsEl.textContent = + meta.data.length + ' segments · Hover slices for value breakdown'; + } + return; + } + redrawDetailPie(idx); + const row = meta.data[idx] || {}; + const pct = meta.total > 0 ? Math.round((100 * Number(row.value)) / meta.total) : 0; + const label = + String(row.label || '') + ': ' + Number(row.value || 0) + ' (' + pct + '%)'; + if (tip) { + tip.hidden = false; + tip.textContent = label; + const wrapRect = wrap.getBoundingClientRect(); + tip.style.left = ev.clientX - wrapRect.left + 14 + 'px'; + tip.style.top = ev.clientY - wrapRect.top - 10 + 'px'; + } + if (statsEl) { + statsEl.textContent = def.title + ' · ' + label; + } + }; + canvas.onmouseleave = function () { + if (tip) tip.hidden = true; + redrawDetailPie(null); + if (statsEl && detailState && detailState.series) { + const n = detailState.series.filter((r) => Number(r.value) > 0).length; + statsEl.textContent = n + ' segments · Hover slices for value breakdown'; + } + }; + } + + 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); + const copyBtn = el('graph-detail-copy-link'); + if (copyBtn) { + copyBtn.addEventListener('click', function () { + const share = el('graph-detail-share'); + const link = (share && share.href && share.href !== '#') ? share.href : window.location.href; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(link).then(function () { + copyBtn.textContent = 'Copied'; + setTimeout(function () { copyBtn.textContent = 'Copy link'; }, 1500); + }).catch(function () { window.prompt('Copy graph link:', link); }); + } else { + window.prompt('Copy graph link:', link); + } + }); + } + } + document.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') closeGraphDetail(); + }); + + if (colsSel) { + applyColumns(colsSel.value); + colsSel.addEventListener('change', () => applyColumns(colsSel.value)); + } else { + 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); + }); + 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 params = new URLSearchParams(window.location.search); + const daysParam = params.get('days'); + if (daysParam && daysSel) { + daysSel.value = daysParam; + } + const days = Number(daysSel && daysSel.value ? daysSel.value : 14); + status.textContent = 'Loading…'; + const xhr = new XMLHttpRequest(); + xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true); + xhr.onload = function () { + let payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch { + status.textContent = 'Invalid response'; + return; + } + if (!payload || !payload.ok) { + status.textContent = (payload && payload.error) || 'Failed'; + return; + } + const data = payload.data || {}; + const viewer = data.viewer || 'user'; + graphScopes.user = data.user || null; + graphScopes.slug = data.slug_admin || null; + graphScopes.platform = data.platform_admin || null; + liveCastScope = data.live_cast || null; + setScopeVisibility(viewer); + renderScope('user', graphScopes.user); + if (graphScopes.slug) renderScope('slug', graphScopes.slug); + if (graphScopes.platform) renderScope('platform', graphScopes.platform); + renderLiveCast(liveCastScope); + markGraphBricks(); + hideEmptyBricks(); + applyBrickOrder(); + attachBrickPreviewHover(); + const cols = colsSel && colsSel.value ? colsSel.value : '2'; + let statusText = 'Loaded · viewer=' + viewer + ' · window=' + days + 'd · columns=' + cols; + const visibleScope = + viewer === 'platform_admin' ? graphScopes.platform : viewer === 'slug_admin' ? graphScopes.slug : graphScopes.user; + const deficitNotice = el('graphs-session-notice'); + const deficit = sessionMetricsDeficit(visibleScope); + if (deficitNotice) { + deficitNotice.hidden = !deficit; + } + status.textContent = statusText; + openGraphFromPermalink(); + }; + xhr.onerror = function () { + status.textContent = 'Network error'; + }; + xhr.send(); + } + + if (daysSel) { + daysSel.addEventListener('change', load); + } + load(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootGraphs); + } else { + bootGraphs(); + } +})(); diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..e8271bc --- /dev/null +++ b/public/index.php @@ -0,0 +1,2 @@ + + * Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8 + * Contributors: + * - Anton Afanasyeu (2 commits, 86 lines) + * - Cursor Agent (project assistant) + * Digest: SHA256 21e56cd12df6a94df41a1c0d67be530bd01454865ee1f032cef5f8d2e497509a + */ +?> + + + + + + <?= h($pageTitle ?? 'Console') ?> — <?= h(cfg('app_name')) ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + > + +
+ +
+ + + + + + + +
+
+

Tickets

+
+ + + + +
+
+

Loading…

+
+ + + + + + + + + + + + +
IssueOpenedApp / OSRatingTags
+
+ +
+ +
+
+

Issues

+
+ + + + + + +
+
+
+ Filter by tag +
+
+ +
+ +
+ + +
+ +
+

Loading…

+
+ + + + +
+
+ +
+ +
+
+

AndroidCast analytics

+
+ + + +
+
+ + +

Loading…

+ +
+

Your activity

+
+

Sessions / day

+

Unique devices / day

+

Avg duration (s) / day

+

Send sessions / day

+

Receive sessions / day

+

Success ratio

+

Outbound kbps / day

+

Inbound kbps / day

+

Issues / day

+
+
+ + + + + +
+

Live cast

+
+

Live casts / day

+

Avg cast length (s) / day

+

Cast platforms

+

Join platforms

+

Video codecs

+

Audio codecs

+

Bandwidth modes

+

Top casters

+
+
+ + +
+ +
+
+

Live cast sessions

+
+ + Education demo +
+
+ +

Loading…

+

Read-only session tree for all signed-in users. Sorted by last heartbeat (most recent first).

+
+ + + + + + + + + + + + + + +
StatusUserPlatformVideoDurationLast heartbeatJoin opensLink
+
+
+ +
+
+

Remote access

+
+ +
+
+ +

Loading…

+ +
+

Devices

+

+ Devices appear after they poll remote access (dev settings → WireGuard or RSSH). Sort is by last seen — top rows are actively polling. + Whitelist rows marked Needs whitelist (opted in, not yet allowed). + Stale rows have not polled in 7+ days. + Poll source IP is the address BE sees on the HTTP request (often a router or proxy on 10.7.x.x) — not the device WAN or LAN. +

+
+ + + + +
+
+
+ + + +
+
+ +
+

Active / pending sessions

+
+ + + + + + + + + + + + +
SessionDeviceStatusTunnelEndpointActions
+
+
+ +
+

Inactive / closed sessions

+
+ + + + + + + + + + + +
SessionDeviceStatusClosedReason
+
+
+ +
+

Audit log

+
+ + + + + + + + + + +
TimeDeviceActionReason
+
+
+
+ + + +
+
+

Access control

+
+

Loading…

+

+
+

Users & company roles

+
+ + + + + + + + + + + + +
UserGlobal roleCompanyCompany rolePrivilege setAuth lockouts
+
+
+

+ API: /api/rbac.php · + Root edits global roles; company owner/admin edits memberships and remote-access privilege sets. +

+
+ +
+
+ + + + +
+
+

New ticket

+ +
+
+ + + +
+
+

+
+ + +
+
+
+
+ + + + diff --git a/views/partials/console_home_landing.php b/views/partials/console_home_landing.php new file mode 100644 index 0000000..b8d660a --- /dev/null +++ b/views/partials/console_home_landing.php @@ -0,0 +1,58 @@ + +
+
+

Console

+
+ +
+
+

Issue triage, tickets, and session metrics — pick a workspace below.

+ + + +

All services

+ +
diff --git a/views/partials/console_quick_links.php b/views/partials/console_quick_links.php new file mode 100644 index 0000000..9106fd4 --- /dev/null +++ b/views/partials/console_quick_links.php @@ -0,0 +1,37 @@ + + * @var bool $skip_home_link omit "Console home" (use on home view) + */ +$bp = Auth::basePath(); +$proj = '/app/androidcast_project'; +$navClass = 'console-quick-links graphs-quick-links' . (isset($extra_class) ? ' ' . $extra_class : ''); +$items = []; +if (empty($skip_home_link)) { + $items[] = ['Console home', $bp . '/?view=home', 'nav-icon--home']; +} +$items[] = ['Issues', $bp . '/?view=reports', 'nav-icon--reports']; +$items[] = ['Tickets', $bp . '/?view=tickets', 'nav-icon--tickets']; +if (Rbac::can('remote_access_view')) { + $items[] = ['Remote access', $bp . '/?view=remote_access', 'nav-icon--remote']; +} +if (Rbac::can('short_links_view')) { + $items[] = ['Short links', $bp . '/?view=short_links', 'nav-icon--link']; +} +if (Rbac::canManageRbac()) { + $items[] = ['Access control', $bp . '/?view=rbac', 'nav-icon--tickets']; +} +$items[] = ['Graphs', $proj . '/graphs/', 'nav-icon--graphs']; +$items[] = ['Builder', $proj . '/build/', 'nav-icon--builder']; +$items[] = ['Hub', $proj . '/', 'nav-icon--home']; +?> + diff --git a/views/partials/cookie_consent.php b/views/partials/cookie_consent.php new file mode 100644 index 0000000..0a0e5ed --- /dev/null +++ b/views/partials/cookie_consent.php @@ -0,0 +1,15 @@ + +