From d5b8c54b5b5de9c5d5d9b2670d00f5429e62608c Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Tue, 23 Jun 2026 12:29:35 +0200 Subject: [PATCH] initial --- README.md | 3 + composer.json | 19 + public/assets/js/live_cast_webrtc.js | 256 ++++++++ public/assets/js/live_education.js | 210 ++++++ public/assets/js/live_join.js | 224 +++++++ public/assets/js/live_sessions.js | 113 ++++ public/assets/js/ticket_create.js | 132 ++++ public/index.php | 2 + views/layout.php | 825 ++++++++++++++++++++++++ views/live_education.php | 50 ++ views/live_join.php | 47 ++ views/partials/console_home_landing.php | 58 ++ views/partials/console_quick_links.php | 37 ++ views/partials/cookie_consent.php | 15 + views/report_detail.php | 185 ++++++ 15 files changed, 2176 insertions(+) create mode 100644 README.md create mode 100644 composer.json create mode 100644 public/assets/js/live_cast_webrtc.js create mode 100644 public/assets/js/live_education.js create mode 100644 public/assets/js/live_join.js create mode 100644 public/assets/js/live_sessions.js create mode 100644 public/assets/js/ticket_create.js create mode 100644 public/index.php create mode 100644 views/layout.php create mode 100644 views/live_education.php create mode 100644 views/live_join.php create mode 100644 views/partials/console_home_landing.php create mode 100644 views/partials/console_quick_links.php create mode 100644 views/partials/cookie_consent.php create mode 100644 views/report_detail.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..101fcd2 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# ac-be-issues + +Thin BE UI. Remote: `git://f0xx.org/ac/ac-be-issues` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1b1b9de --- /dev/null +++ b/composer.json @@ -0,0 +1,19 @@ +{ + "name": "androidcast/be-issues", + "description": "Issues console 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/live_cast_webrtc.js b/public/assets/js/live_cast_webrtc.js new file mode 100644 index 0000000..4e98a09 --- /dev/null +++ b/public/assets/js/live_cast_webrtc.js @@ -0,0 +1,256 @@ +(function (global) { + 'use strict'; + + var STUN = [{ urls: 'stun:stun.l.google.com:19302' }]; + + function signalingUrl(basePath) { + return basePath + '/api/live_cast_signaling.php'; + } + + function castApiUrl(basePath) { + return basePath + '/api/live_cast.php'; + } + + function xhrJson(method, url, body, cb) { + var xhr = new XMLHttpRequest(); + xhr.open(method, url, true); + if (body != null) { + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + } + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + cb(null); + return; + } + cb(payload); + }; + xhr.onerror = function () { cb(null); }; + xhr.send(body != null ? JSON.stringify(body) : null); + } + + function postSignal(basePath, sessionId, peerRole, msgType, payload, cb) { + xhrJson('POST', signalingUrl(basePath), { + action: 'post', + session_id: sessionId, + peer_role: peerRole, + msg_type: msgType, + payload: payload + }, cb); + } + + function pollSignals(basePath, sessionId, sinceId, fromRole, cb) { + var qs = '?action=poll&session_id=' + encodeURIComponent(sessionId) + + '&since_id=' + encodeURIComponent(String(sinceId || 0)); + if (fromRole) { + qs += '&from_role=' + encodeURIComponent(fromRole); + } + xhrJson('GET', signalingUrl(basePath) + qs, null, cb); + } + + function collectStats(pc, videoEl) { + var stats = { + ice_state: pc ? pc.iceConnectionState : 'new', + signaling_state: pc ? pc.signalingState : 'stable', + fps: 0, + inbound_kbps: 0, + outbound_kbps: 0, + rtt_ms: 0, + frames_decoded: 0 + }; + if (videoEl && videoEl.videoWidth) { + stats.frame_w = videoEl.videoWidth; + stats.frame_h = videoEl.videoHeight; + } + return stats; + } + + function enrichStatsFromRtc(pc, stats, cb) { + if (!pc || !pc.getStats) { + cb(stats); + return; + } + pc.getStats().then(function (report) { + report.forEach(function (r) { + if (r.type === 'inbound-rtp' && r.kind === 'video') { + stats.frames_decoded = r.framesDecoded || stats.frames_decoded; + if (r.bytesReceived && r.timestamp) { + stats.inbound_kbps = Math.round((r.bytesReceived * 8) / 1000); + } + } + if (r.type === 'outbound-rtp' && r.kind === 'video') { + if (r.bytesSent) { + stats.outbound_kbps = Math.round((r.bytesSent * 8) / 1000); + } + } + if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime) { + stats.rtt_ms = Math.round(r.currentRoundTripTime * 1000); + } + }); + cb(stats); + }).catch(function () { cb(stats); }); + } + + function sendHeartbeat(basePath, sessionId, status, stats, cb) { + var body = { action: 'heartbeat', session_id: sessionId, status: status || 'active' }; + if (stats) body.stats = stats; + xhrJson('POST', castApiUrl(basePath), body, cb || function () {}); + } + + /** + * @param {object} opts + * @param {string} opts.basePath + * @param {string} opts.sessionId + * @param {'caster'|'viewer'|'mobile'} opts.role + * @param {MediaStream|null} opts.localStream + * @param {function(MediaStream)} [opts.onRemoteStream] + * @param {function(object)} [opts.onStats] + * @param {function(string)} [opts.onStatus] + */ + function connect(opts) { + var basePath = opts.basePath || ''; + var sessionId = opts.sessionId || ''; + var role = opts.role || 'viewer'; + var localStream = opts.localStream || null; + var onRemoteStream = opts.onRemoteStream || function () {}; + var onStats = opts.onStats || function () {}; + var onStatus = opts.onStatus || function () {}; + var remoteRole = role === 'caster' ? 'viewer' : 'caster'; + var sinceId = 0; + var pc = new RTCPeerConnection({ iceServers: STUN }); + var pollTimer = null; + var hbTimer = null; + var remoteVideo = null; + var stopped = false; + + function status(msg) { + onStatus(msg); + } + + function teardown() { + stopped = true; + if (pollTimer) clearInterval(pollTimer); + if (hbTimer) clearInterval(hbTimer); + pollTimer = null; + hbTimer = null; + try { pc.close(); } catch (e) { /* ignore */ } + } + + pc.onicecandidate = function (ev) { + if (!ev.candidate) return; + postSignal(basePath, sessionId, role, 'candidate', { + candidate: ev.candidate.candidate, + sdpMid: ev.candidate.sdpMid, + sdpMLineIndex: ev.candidate.sdpMLineIndex + }, function () {}); + }; + + pc.ontrack = function (ev) { + var stream = ev.streams && ev.streams[0] ? ev.streams[0] : new MediaStream([ev.track]); + onRemoteStream(stream); + }; + + pc.oniceconnectionstatechange = function () { + status('ICE: ' + pc.iceConnectionState); + }; + + if (localStream) { + localStream.getTracks().forEach(function (t) { pc.addTrack(t, localStream); }); + } + + function applyRemoteDescription(payload, cb) { + if (!payload || !payload.type || !payload.sdp) { + cb(false); + return; + } + pc.setRemoteDescription(payload).then(function () { + cb(true); + }).catch(function () { cb(false); }); + } + + function handlePollMessages(messages) { + if (!messages || !messages.length) return; + messages.forEach(function (msg) { + sinceId = Math.max(sinceId, msg.id || 0); + var payload = msg.payload || {}; + if (msg.msg_type === 'offer' && role === 'viewer') { + applyRemoteDescription(payload, function (ok) { + if (!ok) return; + pc.createAnswer().then(function (answer) { + return pc.setLocalDescription(answer); + }).then(function () { + postSignal(basePath, sessionId, role, 'answer', pc.localDescription, function () {}); + status('Answer sent — connecting…'); + }).catch(function () { + status('Could not create WebRTC answer.'); + }); + }); + } else if (msg.msg_type === 'answer' && role === 'caster') { + applyRemoteDescription(payload, function (ok) { + if (ok) status('Viewer connected — streaming.'); + }); + } else if (msg.msg_type === 'candidate' && payload.candidate) { + pc.addIceCandidate({ + candidate: payload.candidate, + sdpMid: payload.sdpMid, + sdpMLineIndex: payload.sdpMLineIndex + }).catch(function () {}); + } + }); + } + + pollTimer = setInterval(function () { + if (stopped) return; + pollSignals(basePath, sessionId, sinceId, remoteRole, function (resp) { + if (!resp || !resp.ok) return; + handlePollMessages(resp.messages || []); + if (typeof resp.since_id === 'number') { + sinceId = Math.max(sinceId, resp.since_id); + } + }); + }, 800); + + hbTimer = setInterval(function () { + if (stopped) return; + enrichStatsFromRtc(pc, collectStats(pc, remoteVideo), function (stats) { + onStats(stats); + sendHeartbeat(basePath, sessionId, 'active', stats); + }); + }, 5000); + + if (role === 'caster') { + status('Publishing offer…'); + pc.createOffer().then(function (offer) { + return pc.setLocalDescription(offer); + }).then(function () { + postSignal(basePath, sessionId, role, 'offer', pc.localDescription, function (resp) { + if (resp && resp.ok) { + status('Waiting for viewer…'); + } else { + status('Signaling failed.'); + } + }); + }).catch(function () { + status('Could not create WebRTC offer.'); + }); + } else { + status('Waiting for caster offer…'); + } + + return { + setRemoteVideo: function (el) { remoteVideo = el; }, + stop: function () { + sendHeartbeat(basePath, sessionId, 'ended', null); + teardown(); + } + }; + } + + global.LiveCastWebRtc = { + connect: connect, + sendHeartbeat: sendHeartbeat, + collectStats: collectStats + }; +})(typeof window !== 'undefined' ? window : this); diff --git a/public/assets/js/live_education.js b/public/assets/js/live_education.js new file mode 100644 index 0000000..ddd51c0 --- /dev/null +++ b/public/assets/js/live_education.js @@ -0,0 +1,210 @@ +(function () { + 'use strict'; + + var MAX_DEMO_S = 300; + var timer = null; + var secondsLeft = MAX_DEMO_S; + var mediaStream = null; + var sessionId = ''; + var webrtc = null; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function el(id) { + return document.getElementById(id); + } + + function detectPlatform() { + var ua = navigator.userAgent || ''; + if (/Firefox\//i.test(ua)) return 'firefox'; + if (/Edg\//i.test(ua)) return 'edge'; + if (/Chrome\//i.test(ua)) return 'chrome'; + if (/Safari\//i.test(ua)) return 'safari'; + return 'desktop'; + } + + function apiUrl() { + return basePath() + '/api/live_cast.php'; + } + + function postJson(body, cb) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl(), true); + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + cb(null); + return; + } + cb(payload); + }; + xhr.onerror = function () { cb(null); }; + xhr.send(JSON.stringify(body)); + } + + function setStatus(text) { + var node = el('edu-status'); + if (node) node.textContent = text; + } + + function updateTimer() { + var node = el('edu-timer'); + if (!node) return; + var m = Math.floor(secondsLeft / 60); + var s = secondsLeft % 60; + node.textContent = m + ':' + (s < 10 ? '0' : '') + s; + } + + function renderShare(session) { + var box = el('edu-share'); + if (!box || !session) return; + var joinUrl = session.join_short_url || ''; + var qrUrl = session.join_qr_url || ''; + var longJoin = basePath() + '/live/join?session_id=' + encodeURIComponent(session.session_id || sessionId); + if (!joinUrl) joinUrl = longJoin; + var html = '

Share this demo

Viewers open this link in another browser (same LAN or online).

'; + html += '

' + joinUrl + '

'; + if (qrUrl) { + html += '
Viewer QR code
Scan to open viewer page
'; + } + box.hidden = false; + box.innerHTML = html; + } + + function stopDemo() { + if (timer) { + clearInterval(timer); + timer = null; + } + if (webrtc) { + webrtc.stop(); + webrtc = null; + } + if (mediaStream) { + mediaStream.getTracks().forEach(function (t) { t.stop(); }); + mediaStream = null; + } + var preview = el('edu-preview'); + if (preview) preview.srcObject = null; + if (sessionId) { + postJson({ action: 'heartbeat', session_id: sessionId, status: 'ended' }, function () {}); + sessionId = ''; + } + var share = el('edu-share'); + if (share) share.hidden = true; + el('edu-start').disabled = false; + el('edu-stop').disabled = true; + setStatus('Demo ended. Thanks for trying AndroidCast education mode.'); + } + + function startWebRtc() { + if (!window.LiveCastWebRtc || !sessionId || !mediaStream) return; + webrtc = window.LiveCastWebRtc.connect({ + basePath: basePath(), + sessionId: sessionId, + role: 'caster', + localStream: mediaStream, + onStatus: setStatus, + onStats: function (stats) { + var nerds = el('edu-stats'); + if (!nerds) return; + nerds.hidden = false; + nerds.textContent = 'Stats: ICE ' + (stats.ice_state || '—') + + ' · out ' + (stats.outbound_kbps || 0) + ' kbps' + + ' · RTT ' + (stats.rtt_ms || 0) + ' ms'; + } + }); + } + + function startDemo() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { + setStatus('Screen sharing is not supported in this browser.'); + return; + } + if (!window.RTCPeerConnection) { + setStatus('WebRTC is not available in this browser.'); + return; + } + setStatus('Requesting screen share permission…'); + navigator.mediaDevices.getDisplayMedia({ video: true, audio: false }) + .then(function (stream) { + mediaStream = stream; + var preview = el('edu-preview'); + if (preview) { + preview.hidden = false; + preview.srcObject = stream; + preview.play().catch(function () {}); + } + stream.getVideoTracks()[0].addEventListener('ended', stopDemo); + setStatus('Creating demo session…'); + postJson({ + action: 'create_intent', + platform: 'education_' + detectPlatform(), + max_duration_s: MAX_DEMO_S, + codec_video: 'browser_screen', + codec_audio: 'none', + bw_mode: 'demo' + }, function (resp) { + if (!resp || !resp.ok || !resp.session) { + setStatus('Could not register demo session. Try again later.'); + stopDemo(); + return; + } + sessionId = resp.session.session_id || ''; + renderShare(resp.session); + startWebRtc(); + secondsLeft = MAX_DEMO_S; + updateTimer(); + el('edu-start').disabled = true; + el('edu-stop').disabled = false; + setStatus('Live demo — share the link so others can watch.'); + timer = setInterval(function () { + secondsLeft -= 1; + updateTimer(); + if (secondsLeft <= 0) { + stopDemo(); + } + }, 1000); + if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) { + window.AndroidCastAnalytics.trackEvent('live_education_start', { platform: detectPlatform() }); + } + }); + }) + .catch(function () { + setStatus('Screen share permission denied or cancelled.'); + }); + } + + function boot() { + if (!el('live-education-app')) return; + updateTimer(); + var startBtn = el('edu-start'); + var stopBtn = el('edu-stop'); + if (startBtn) startBtn.addEventListener('click', startDemo); + if (stopBtn) { + stopBtn.disabled = true; + stopBtn.addEventListener('click', stopDemo); + } + var notifBtn = el('edu-notify'); + if (notifBtn && 'Notification' in window) { + notifBtn.addEventListener('click', function () { + Notification.requestPermission().then(function (perm) { + setStatus(perm === 'granted' ? 'Notifications enabled.' : 'Notifications not granted.'); + }); + }); + } else if (notifBtn) { + notifBtn.hidden = true; + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/public/assets/js/live_join.js b/public/assets/js/live_join.js new file mode 100644 index 0000000..45d4658 --- /dev/null +++ b/public/assets/js/live_join.js @@ -0,0 +1,224 @@ +(function () { + 'use strict'; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function el(id) { + return document.getElementById(id); + } + + function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function detectPlatform() { + var ua = navigator.userAgent || ''; + if (/Android/i.test(ua)) return 'android'; + if (/iPhone|iPad|iPod/i.test(ua)) return 'ios'; + if (/Firefox\//i.test(ua)) return 'firefox'; + if (/Edg\//i.test(ua)) return 'edge'; + if (/Chrome\//i.test(ua)) return 'chrome'; + if (/Safari\//i.test(ua)) return 'safari'; + return 'desktop'; + } + + function anonId() { + var key = 'ac_live_anon_id'; + try { + var existing = localStorage.getItem(key); + if (existing) return existing; + var id = 'anon-' + Math.random().toString(36).slice(2) + Date.now().toString(36); + localStorage.setItem(key, id); + return id; + } catch (e) { + return 'anon-' + Date.now(); + } + } + + function apiUrl() { + return basePath() + '/api/live_cast.php'; + } + + function postJoinEvent(sessionId, eventType, direct) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl(), true); + xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8'); + xhr.send(JSON.stringify({ + action: 'join_event', + session_id: sessionId, + event_type: eventType, + platform: detectPlatform(), + channel: direct ? 'direct_link' : 'viewer_link', + direct: !!direct, + anon_id: anonId() + })); + } + + function fetchSession(sessionId, cb) { + var xhr = new XMLHttpRequest(); + xhr.open('GET', apiUrl() + '?action=session&session_id=' + encodeURIComponent(sessionId), true); + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + cb(null); + return; + } + cb(payload && payload.ok ? payload.session : null); + }; + xhr.onerror = function () { cb(null); }; + xhr.send(); + } + + function formatStatus(session) { + if (!session) return 'Unknown'; + var st = session.status || ''; + if (st === 'live' || st === 'intent') return 'Live'; + if (st === 'closed') return 'Ended'; + if (st === 'expired') return 'Expired'; + return st; + } + + function renderStats(statsEl, stats) { + if (!statsEl || !stats) return; + statsEl.hidden = false; + statsEl.innerHTML = + 'Statistics for nerds
' + + 'ICE: ' + escapeHtml(stats.ice_state || '—') + + ' · sig: ' + escapeHtml(stats.signaling_state || '—') + + '
In: ' + (stats.inbound_kbps || 0) + ' kbps · Out: ' + (stats.outbound_kbps || 0) + ' kbps' + + '
RTT: ' + (stats.rtt_ms || 0) + ' ms · frames: ' + (stats.frames_decoded || 0) + + (stats.frame_w ? '
Frame: ' + stats.frame_w + '×' + stats.frame_h : ''); + } + + function startViewer(sessionId, session, playerEl, statusEl, statsEl) { + if (!window.RTCPeerConnection || !window.LiveCastWebRtc) { + playerEl.innerHTML = '

WebRTC is not available in this browser.

'; + return null; + } + playerEl.innerHTML = + '' + + ''; + var video = el('live-remote-video'); + var nerds = el('live-join-stats'); + var conn = window.LiveCastWebRtc.connect({ + basePath: basePath(), + sessionId: sessionId, + role: 'viewer', + onStatus: function (msg) { + statusEl.textContent = formatStatus(session) + ' — ' + msg; + }, + onRemoteStream: function (stream) { + if (video) { + video.srcObject = stream; + video.play().catch(function () {}); + } + statusEl.textContent = formatStatus(session) + ' — playing stream'; + }, + onStats: function (stats) { + renderStats(nerds || statsEl, stats); + } + }); + if (conn && video) conn.setRemoteVideo(video); + return conn; + } + + function boot() { + var root = el('live-join-app'); + if (!root) return; + + var params = new URLSearchParams(window.location.search); + var sessionId = params.get('session_id') || root.getAttribute('data-session-id') || ''; + var direct = params.get('direct') === '1' || root.getAttribute('data-direct') === '1'; + var role = params.get('role') || (direct ? 'direct' : 'viewer'); + var webrtcConn = null; + + if (!sessionId) { + el('live-join-status').textContent = 'Missing session_id in URL.'; + return; + } + + postJoinEvent(sessionId, 'open', direct); + + var statusEl = el('live-join-status'); + var metaEl = el('live-join-meta'); + var playerEl = el('live-join-player'); + var statsEl = el('live-join-stats-panel'); + + function render(session) { + if (!session) { + statusEl.textContent = 'Session not found or no longer available.'; + return; + } + var live = session.status === 'live' || session.status === 'intent'; + statusEl.textContent = formatStatus(session) + (live ? ' — connecting…' : ''); + var owner = session.owner_username || 'host'; + var lines = [ + 'Host: ' + owner, + 'Platform: ' + (session.platform || '—'), + 'Video: ' + (session.codec_video || '—'), + 'Audio: ' + (session.codec_audio || '—'), + 'Bandwidth: ' + (session.bw_mode || '—') + ]; + if (session.join_short_url) { + lines.push('Viewer link: ' + session.join_short_url); + } + metaEl.innerHTML = lines.map(function (l) { + return '
  • ' + escapeHtml(l) + '
  • '; + }).join(''); + + if (direct && /android|ios/i.test(detectPlatform())) { + playerEl.innerHTML = + '

    Open in the AndroidCast app for direct P2P on LAN.

    ' + + '

    androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1

    ' + + '

    Mobile WebRTC signaling uses the same REST API when the app transport is wired.

    '; + postJoinEvent(sessionId, 'join', true); + } else if (live) { + webrtcConn = startViewer(sessionId, session, playerEl, statusEl, statsEl); + postJoinEvent(sessionId, 'join', false); + } else { + playerEl.innerHTML = '

    Session is not live.

    '; + } + + if (window.AndroidCastAnalytics && window.AndroidCastAnalytics.trackEvent) { + window.AndroidCastAnalytics.trackEvent('live_join_view', { + session_id: sessionId, + direct: direct ? 1 : 0, + platform: detectPlatform(), + status: session.status || '' + }); + } + } + + fetchSession(sessionId, render); + setInterval(function () { + fetchSession(sessionId, function (session) { + if (!session) return; + if (!(session.status === 'live' || session.status === 'intent')) { + statusEl.textContent = formatStatus(session); + if (webrtcConn) { + webrtcConn.stop(); + webrtcConn = null; + } + } + }); + }, 30000); + + window.addEventListener('beforeunload', function () { + if (webrtcConn) webrtcConn.stop(); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/public/assets/js/live_sessions.js b/public/assets/js/live_sessions.js new file mode 100644 index 0000000..8167f4d --- /dev/null +++ b/public/assets/js/live_sessions.js @@ -0,0 +1,113 @@ +(function () { + 'use strict'; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function el(id) { + return document.getElementById(id); + } + + function escapeHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function formatMs(ms) { + if (!ms) return '—'; + try { + return new Date(ms).toLocaleString(); + } catch (e) { + return String(ms); + } + } + + function durationS(startMs, endMs) { + if (!startMs) return '—'; + var end = endMs || Date.now(); + var sec = Math.max(0, Math.round((end - startMs) / 1000)); + if (sec < 60) return sec + 's'; + return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's'; + } + + function statusClass(status) { + if (status === 'live' || status === 'intent') return 'tag-pill tag-pill--ok'; + if (status === 'expired') return 'tag-pill tag-pill--warn'; + return 'tag-pill'; + } + + function renderRows(sessions) { + var tbody = el('live-sessions-tbody'); + if (!tbody) return; + if (!sessions || !sessions.length) { + tbody.innerHTML = 'No sessions in this window.'; + return; + } + tbody.innerHTML = sessions.map(function (s) { + var joinUrl = s.join_short_url || ''; + var joinCell = joinUrl + ? 'Open' + : '—'; + return ( + '' + + '' + escapeHtml(s.status) + '' + + '' + escapeHtml(s.owner_username || '—') + '' + + '' + escapeHtml(s.platform || '—') + '' + + '' + escapeHtml(s.codec_video || '—') + '' + + '' + durationS(s.started_at_ms, s.ended_at_ms) + '' + + '' + formatMs(s.last_heartbeat_ms) + '' + + '' + Number(s.join_opens || 0) + '' + + '' + joinCell + '' + + '' + ); + }).join(''); + } + + function load() { + var status = el('live-sessions-status'); + var daysSel = el('live-sessions-days'); + var days = daysSel && daysSel.value ? daysSel.value : '14'; + if (status) status.textContent = 'Loading…'; + var xhr = new XMLHttpRequest(); + xhr.open('GET', basePath() + '/api/live_cast.php?action=list&days=' + encodeURIComponent(days), true); + xhr.onload = function () { + var payload = null; + try { + payload = JSON.parse(xhr.responseText); + } catch (e) { + if (status) status.textContent = 'Invalid response'; + return; + } + if (!payload || !payload.ok) { + if (status) status.textContent = (payload && payload.error) || 'Failed'; + return; + } + renderRows(payload.sessions || []); + if (status) { + status.textContent = 'Loaded ' + (payload.sessions || []).length + ' session(s) · ' + days + 'd window'; + } + }; + xhr.onerror = function () { + if (status) status.textContent = 'Network error'; + }; + xhr.send(); + } + + function boot() { + if (!el('live-sessions-app')) return; + var daysSel = el('live-sessions-days'); + if (daysSel) daysSel.addEventListener('change', load); + load(); + setInterval(load, 60000); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/public/assets/js/ticket_create.js b/public/assets/js/ticket_create.js new file mode 100644 index 0000000..3a8a792 --- /dev/null +++ b/public/assets/js/ticket_create.js @@ -0,0 +1,132 @@ +/** + * Create ticket/issue dialog — Issues toolbar uses "issue" type tag; Tickets uses "ticket". + */ +(function () { + const META = { + ticket: { id: 'ticket', label: 'ticket', bg: '#6366f1' }, + issue: { id: 'issue', label: 'issue', bg: '#0d9488' }, + }; + + function basePath() { + return document.body.getAttribute('data-base-path') || ''; + } + + function t(key) { + if (window.CrashI18n && window.CrashI18n.t) { + return window.CrashI18n.t(key); + } + return key; + } + + function openDialog(kind) { + const dialog = document.getElementById('ticket-create-dialog'); + const form = document.getElementById('ticket-create-form'); + const status = document.getElementById('ticket-create-status'); + const heading = document.getElementById('ticket-create-heading'); + if (!dialog || !form) return; + const createKind = kind === 'issue' ? 'issue' : 'ticket'; + form.dataset.createKind = createKind; + if (heading) { + heading.textContent = t(createKind === 'issue' ? 'issues.new' : 'tickets.new'); + } + if (status) status.textContent = ''; + form.reset(); + if (typeof dialog.showModal === 'function') { + dialog.showModal(); + } + } + + function init() { + const dialog = document.getElementById('ticket-create-dialog'); + const form = document.getElementById('ticket-create-form'); + const cancelBtn = document.getElementById('ticket-create-cancel'); + const closeBtn = document.getElementById('ticket-create-close'); + const status = document.getElementById('ticket-create-status'); + if (!dialog || !form) return; + + document.querySelectorAll('.js-new-issue-btn').forEach((btn) => { + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + openDialog('issue'); + }); + }); + document.querySelectorAll('.js-new-ticket-btn').forEach((btn) => { + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + openDialog('ticket'); + }); + }); + + function closeDialog() { + dialog.close(); + } + if (cancelBtn) cancelBtn.addEventListener('click', closeDialog); + if (closeBtn) closeBtn.addEventListener('click', closeDialog); + dialog.addEventListener('click', (ev) => { + if (ev.target === dialog) closeDialog(); + }); + + 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 kind = form.dataset.createKind === 'issue' ? 'issue' : 'ticket'; + const meta = META[kind] || META.ticket; + const payload = { + title: titleEl && titleEl.value ? titleEl.value.trim() : '', + brief: briefEl && briefEl.value ? briefEl.value.trim() : '', + body: bodyEl && bodyEl.value ? bodyEl.value.trim() : '', + tags: [ + meta, + { id: 'open', label: 'open', bg: '#22c55e' }, + ], + }; + if (!payload.title) return; + if (status) status.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 (status) status.textContent = 'Invalid response'; + return; + } + if (!data || !data.ok) { + if (status) status.textContent = (data && data.error) || 'Create failed'; + return; + } + dialog.close(); + const view = document.body.getAttribute('data-view') || ''; + if (view === 'tickets' && typeof window.__ticketsReload === 'function') { + window.__ticketsReload(); + } else if (data.id) { + window.location.href = basePath() + '/?view=ticket&id=' + encodeURIComponent(String(data.id)); + } else { + window.location.href = basePath() + '/?view=tickets'; + } + }; + xhr.onerror = function () { + if (status) status.textContent = 'Network error'; + }; + xhr.send(JSON.stringify(payload)); + }); + } + + function onReady(fn) { + if (window.CrashI18n && window.CrashI18n.ready) { + window.CrashI18n.ready.then(fn); + } else { + fn(); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => onReady(init)); + } else { + onReady(init); + } +})(); 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/live_education.php b/views/live_education.php new file mode 100644 index 0000000..c63bd94 --- /dev/null +++ b/views/live_education.php @@ -0,0 +1,50 @@ + + + + + + + Education demo — <?= h(cfg('app_name')) ?> + + + + + + + + +
    +
    + ← Back to the console + Education demo +
    +
    +

    Try screen sharing in your browser

    +

    Free demo up to 5 minutes. No install required — great for first-time visitors.

    +

    Ready when you are.

    +

    Time left: 5:00

    + + + +
    + + + +
    +

    + WebRTC P2P (no SFU): share the viewer link or QR. Same REST signaling API supports future mobile cast to browser on LAN. +

    +
    +
    + + + + diff --git a/views/live_join.php b/views/live_join.php new file mode 100644 index 0000000..954dc0b --- /dev/null +++ b/views/live_join.php @@ -0,0 +1,47 @@ + + + + + + + Join live cast — <?= h(cfg('app_name')) ?> + + + + + + + + +
    +
    + ← Back to the console + Live viewer +
    +
    +

    Join live cast

    +

    Loading session…

    +
      +
      + +
      +
      + + + + 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 @@ + + diff --git a/views/report_detail.php b/views/report_detail.php new file mode 100644 index 0000000..e9cbd17 --- /dev/null +++ b/views/report_detail.php @@ -0,0 +1,185 @@ + 0 + ? $bp . '/?view=reports&filter_os_sdk=' . $sdk + : ($release !== '' ? $bp . '/?view=reports&filter_os_release=' . rawurlencode($release) : ''); +$abiList = []; +if (!empty($device['abis']) && is_array($device['abis'])) { + foreach ($device['abis'] as $abi) { + if (is_scalar($abi) && trim((string) $abi) !== '') { + $abiList[] = trim((string) $abi); + } + } +} +?> +
      + + + +
      +

      Issue report

      +

      Report ·

      +
      +
      + +
      +
      +

      Tags

      +

      Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.

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

        Sign in as admin to edit custom tags.

        + +
        + +
        +

        Device

        + +

        + +

        + + +

        Android (SDK )

        + +

        Android (SDK )

        + + +

        ABIs: + $abi): ?> + 0): ?>, + + +

        + +
        +

        App

        +

        + + +

        v ()

        + +

        v ()

        + + + +

        Build:

        + +

        Build:

        + + +
        +

        Timing

        +

        Generated:

        +

        Received:

        +

        Fingerprint:

        +
        +
        + +
        +
        +

        Java exception

        + Find similar reports +
        +

        +

        Thread:

        +
        +
        + + +
        +
        +

        Native issue

        + Find similar reports +
        +

        Signal:

        +
        +
        + + +
        + +
        +
        + + + + Session context (issue) +
        + +
        + +
        +
        + + + + Raw JSON +
        + +
        +