mirror of
git://f0xx.org/ac/ac-be-issues
synced 2026-07-29 07:19:00 +03:00
initial
This commit is contained in:
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# ac-be-issues
|
||||||
|
|
||||||
|
Thin BE UI. Remote: `git://f0xx.org/ac/ac-be-issues`
|
||||||
19
composer.json
Normal file
19
composer.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
256
public/assets/js/live_cast_webrtc.js
Normal file
256
public/assets/js/live_cast_webrtc.js
Normal file
@@ -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);
|
||||||
210
public/assets/js/live_education.js
Normal file
210
public/assets/js/live_education.js
Normal file
@@ -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 = '<h2>Share this demo</h2><p class="muted">Viewers open this link in another browser (same LAN or online).</p>';
|
||||||
|
html += '<p><a href="' + joinUrl + '" target="_blank" rel="noopener">' + joinUrl + '</a></p>';
|
||||||
|
if (qrUrl) {
|
||||||
|
html += '<figure class="twofa-qr-wrap"><img src="' + qrUrl + '" width="160" height="160" alt="Viewer QR code"><figcaption class="muted">Scan to open viewer page</figcaption></figure>';
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
224
public/assets/js/live_join.js
Normal file
224
public/assets/js/live_join.js
Normal file
@@ -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, '>')
|
||||||
|
.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 =
|
||||||
|
'<strong>Statistics for nerds</strong><br>'
|
||||||
|
+ 'ICE: ' + escapeHtml(stats.ice_state || '—')
|
||||||
|
+ ' · sig: ' + escapeHtml(stats.signaling_state || '—')
|
||||||
|
+ '<br>In: ' + (stats.inbound_kbps || 0) + ' kbps · Out: ' + (stats.outbound_kbps || 0) + ' kbps'
|
||||||
|
+ '<br>RTT: ' + (stats.rtt_ms || 0) + ' ms · frames: ' + (stats.frames_decoded || 0)
|
||||||
|
+ (stats.frame_w ? '<br>Frame: ' + stats.frame_w + '×' + stats.frame_h : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function startViewer(sessionId, session, playerEl, statusEl, statsEl) {
|
||||||
|
if (!window.RTCPeerConnection || !window.LiveCastWebRtc) {
|
||||||
|
playerEl.innerHTML = '<p class="muted">WebRTC is not available in this browser.</p>';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
playerEl.innerHTML =
|
||||||
|
'<video id="live-remote-video" class="live-preview" playsinline autoplay controls></video>'
|
||||||
|
+ '<div id="live-join-stats" class="live-stats-nerds" hidden></div>';
|
||||||
|
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 '<li>' + escapeHtml(l) + '</li>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (direct && /android|ios/i.test(detectPlatform())) {
|
||||||
|
playerEl.innerHTML =
|
||||||
|
'<p class="muted">Open in the AndroidCast app for direct P2P on LAN.</p>' +
|
||||||
|
'<p><code>androidcast://join?session_id=' + escapeHtml(sessionId) + '&direct=1</code></p>' +
|
||||||
|
'<p class="muted">Mobile WebRTC signaling uses the same REST API when the app transport is wired.</p>';
|
||||||
|
postJoinEvent(sessionId, 'join', true);
|
||||||
|
} else if (live) {
|
||||||
|
webrtcConn = startViewer(sessionId, session, playerEl, statusEl, statsEl);
|
||||||
|
postJoinEvent(sessionId, 'join', false);
|
||||||
|
} else {
|
||||||
|
playerEl.innerHTML = '<p class="muted">Session is not live.</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
113
public/assets/js/live_sessions.js
Normal file
113
public/assets/js/live_sessions.js
Normal file
@@ -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, '>')
|
||||||
|
.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 = '<tr><td colspan="8" class="muted">No sessions in this window.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = sessions.map(function (s) {
|
||||||
|
var joinUrl = s.join_short_url || '';
|
||||||
|
var joinCell = joinUrl
|
||||||
|
? '<a href="' + escapeHtml(joinUrl) + '" target="_blank" rel="noopener">Open</a>'
|
||||||
|
: '—';
|
||||||
|
return (
|
||||||
|
'<tr>' +
|
||||||
|
'<td><span class="' + statusClass(s.status) + '">' + escapeHtml(s.status) + '</span></td>' +
|
||||||
|
'<td>' + escapeHtml(s.owner_username || '—') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(s.platform || '—') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(s.codec_video || '—') + '</td>' +
|
||||||
|
'<td>' + durationS(s.started_at_ms, s.ended_at_ms) + '</td>' +
|
||||||
|
'<td>' + formatMs(s.last_heartbeat_ms) + '</td>' +
|
||||||
|
'<td>' + Number(s.join_opens || 0) + '</td>' +
|
||||||
|
'<td>' + joinCell + '</td>' +
|
||||||
|
'</tr>'
|
||||||
|
);
|
||||||
|
}).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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
132
public/assets/js/ticket_create.js
Normal file
132
public/assets/js/ticket_create.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
2
public/index.php
Normal file
2
public/index.php
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
require __DIR__ . '/../views/layout.php';
|
||||||
825
views/layout.php
Normal file
825
views/layout.php
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* package examples/crash_reporter/backend/views/layout.php
|
||||||
|
* layout.php
|
||||||
|
* Created at: Wed 20 May 2026 14:31:55 +0200
|
||||||
|
* Updated at: Wed 20 May 2026 15:17:13 +0200 by Anton Afanasyeu <a.afanasieff@gmail.com>
|
||||||
|
* Commit: 5d8e82d2e60a21fff3138d2a394ee4e8b4c6dcb8
|
||||||
|
* Contributors:
|
||||||
|
* - Anton Afanasyeu <a.afanasieff@gmail.com> (2 commits, 86 lines)
|
||||||
|
* - Cursor Agent (project assistant)
|
||||||
|
* Digest: SHA256 21e56cd12df6a94df41a1c0d67be530bd01454865ee1f032cef5f8d2e497509a
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><?= h($pageTitle ?? 'Console') ?> — <?= h(cfg('app_name')) ?></title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('crash_console_theme');
|
||||||
|
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
||||||
|
var l = localStorage.getItem('crash_console_lang');
|
||||||
|
if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="<?= h(Auth::basePath()) ?>/assets/css/app.css">
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/nav_shell.js" defer></script>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/i18n.js" defer></script>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/app.js" defer></script>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/cookie_consent.js" defer></script>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/ticket_create.js" defer></script>
|
||||||
|
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (($view ?? '') === 'graphs'): ?>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (($view ?? '') === 'live_sessions'): ?>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/live_sessions.js" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (($view ?? '') === 'remote_access'): ?>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/remote_access.js" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (($view ?? '') === 'short_links'): ?>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/short_links.js" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (($view ?? '') === 'rbac'): ?>
|
||||||
|
<script src="<?= h(Auth::basePath()) ?>/assets/js/rbac_admin.js" defer></script>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php AnalyticsHead::render('crashes'); ?>
|
||||||
|
</head>
|
||||||
|
<body data-base-path="<?= h(console_base_path($view ?? null)) ?>"
|
||||||
|
data-view="<?= h($view ?? 'home') ?>"
|
||||||
|
data-can-tag-edit="<?= Auth::canEditTags() ? '1' : '0' ?>"
|
||||||
|
<?= (($view ?? '') === 'report' && !empty($report['id'])) ? ' data-report-id="' . (int) $report['id'] . '"' : '' ?>
|
||||||
|
<?= (($view ?? '') === 'ticket' && !empty($ticket['id'])) ? ' data-ticket-id="' . (int) $ticket['id'] . '"' : '' ?>
|
||||||
|
<?= Rbac::can('remote_access_operate') ? ' data-can-ra-operate="1"' : '' ?>
|
||||||
|
<?= Rbac::can('remote_access_admin') ? ' data-can-ra-admin="1"' : '' ?>
|
||||||
|
<?= Rbac::can('short_links_operate') ? ' data-can-sl-operate="1"' : '' ?>
|
||||||
|
<?= Rbac::isGlobalAdmin() ? ' data-can-sl-admin="1"' : '' ?>
|
||||||
|
<?= Rbac::isRoot() ? ' data-can-rbac-root="1"' : '' ?>>
|
||||||
|
<header class="top-menu" hidden aria-hidden="true"></header>
|
||||||
|
<div class="shell<?= ($view ?? '') === 'graphs' ? ' shell--graphs-full' : '' ?>">
|
||||||
|
<nav class="nav-pane" id="nav-pane" aria-label="Console navigation">
|
||||||
|
<button type="button" class="nav-handle" id="nav-handle" data-i18n-aria="nav.toggle" data-i18n-title="nav.toggle" aria-label="Toggle navigation" title="Navigation">
|
||||||
|
<span class="nav-icon nav-icon--menu" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
<ul class="nav-list">
|
||||||
|
<li>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=home"
|
||||||
|
class="nav-link <?= ($view ?? '') === 'home' ? 'active' : '' ?>"
|
||||||
|
data-i18n-aria="nav.home" data-i18n-title="nav.home"
|
||||||
|
aria-label="Home"
|
||||||
|
title="Home">
|
||||||
|
<span class="nav-icon nav-icon--home" aria-hidden="true"></span>
|
||||||
|
<span class="nav-text">
|
||||||
|
<span class="nav-label" data-i18n="nav.home">Home</span>
|
||||||
|
<span class="nav-desc" data-i18n="nav.home_desc">Console home and workspace cards.</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=reports"
|
||||||
|
class="nav-link <?= ($view ?? '') === 'reports' || ($view ?? '') === 'report' ? 'active' : '' ?>"
|
||||||
|
data-i18n-aria="nav.reports" data-i18n-title="nav.reports"
|
||||||
|
aria-label="Reports"
|
||||||
|
title="Reports">
|
||||||
|
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
|
||||||
|
<span class="nav-text">
|
||||||
|
<span class="nav-label" data-i18n="nav.reports">Reports</span>
|
||||||
|
<span class="nav-desc" data-i18n="nav.reports_desc">Browse and triage issues.</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=tickets"
|
||||||
|
class="nav-link <?= ($view ?? '') === 'tickets' || ($view ?? '') === 'ticket' ? 'active' : '' ?>"
|
||||||
|
data-i18n-aria="nav.tickets" data-i18n-title="nav.tickets"
|
||||||
|
aria-label="Tickets"
|
||||||
|
title="Tickets">
|
||||||
|
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
|
||||||
|
<span class="nav-text">
|
||||||
|
<span class="nav-label" data-i18n="nav.tickets">Tickets</span>
|
||||||
|
<span class="nav-desc" data-i18n="nav.tickets_desc">Roadmap tasks, QA items, and tags.</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/app/androidcast_project/graphs/"
|
||||||
|
class="nav-link <?= ($view ?? '') === 'graphs' ? 'active' : '' ?>"
|
||||||
|
aria-label="Analytics"
|
||||||
|
title="Analytics">
|
||||||
|
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
|
||||||
|
<span class="nav-text">
|
||||||
|
<span class="nav-label" data-i18n="nav.graphs">Analytics</span>
|
||||||
|
<span class="nav-desc" data-i18n="nav.graphs_desc">Sessions, issues, and device activity.</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=live_sessions"
|
||||||
|
class="nav-link <?= ($view ?? '') === 'live_sessions' ? 'active' : '' ?>"
|
||||||
|
aria-label="Live sessions"
|
||||||
|
title="Live sessions">
|
||||||
|
<span class="nav-icon nav-icon--live" aria-hidden="true"></span>
|
||||||
|
<span class="nav-text">
|
||||||
|
<span class="nav-label">Live sessions</span>
|
||||||
|
<span class="nav-desc">Cast intents, join stats, and session history.</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/live/education"
|
||||||
|
class="nav-link"
|
||||||
|
aria-label="Education demo"
|
||||||
|
title="Education demo">
|
||||||
|
<span class="nav-icon nav-icon--education" aria-hidden="true"></span>
|
||||||
|
<span class="nav-text">
|
||||||
|
<span class="nav-label">Education demo</span>
|
||||||
|
<span class="nav-desc">Browser screen-share trial (5 min).</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php if (Rbac::can('remote_access_view')): ?>
|
||||||
|
<li>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=remote_access"
|
||||||
|
class="nav-link <?= ($view ?? '') === 'remote_access' ? 'active' : '' ?>"
|
||||||
|
aria-label="Remote access"
|
||||||
|
title="Remote access">
|
||||||
|
<span class="nav-icon nav-icon--remote" aria-hidden="true"></span>
|
||||||
|
<span class="nav-text">
|
||||||
|
<span class="nav-label" data-i18n="nav.remote">Remote access</span>
|
||||||
|
<span class="nav-desc" data-i18n="nav.remote_desc">WireGuard sessions and device reachability.</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (Rbac::can('short_links_view')): ?>
|
||||||
|
<li>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=short_links"
|
||||||
|
class="nav-link <?= ($view ?? '') === 'short_links' ? 'active' : '' ?>"
|
||||||
|
aria-label="Short links"
|
||||||
|
title="Short links">
|
||||||
|
<span class="nav-icon nav-icon--link" aria-hidden="true"></span>
|
||||||
|
<span class="nav-text">
|
||||||
|
<span class="nav-label" data-i18n="home.card_short_links">Short links</span>
|
||||||
|
<span class="nav-desc" data-i18n="home.card_short_links_desc">Mint bearer tokens and shorten URLs for s.f0xx.org.</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (Rbac::canManageRbac()): ?>
|
||||||
|
<li>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/?view=rbac"
|
||||||
|
class="nav-link <?= ($view ?? '') === 'rbac' ? 'active' : '' ?>"
|
||||||
|
aria-label="Access control"
|
||||||
|
title="Access control">
|
||||||
|
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
|
||||||
|
<span class="nav-text">
|
||||||
|
<span class="nav-label" data-i18n="nav.access">Access</span>
|
||||||
|
<span class="nav-desc" data-i18n="nav.access_desc">Roles, privilege sets, and operators.</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
<li>
|
||||||
|
<a href="/app/androidcast_project/build/"
|
||||||
|
class="nav-link"
|
||||||
|
aria-label="Builder"
|
||||||
|
title="Builder">
|
||||||
|
<span class="nav-icon nav-icon--builder" aria-hidden="true"></span>
|
||||||
|
<span class="nav-text">
|
||||||
|
<span class="nav-label" data-i18n="nav.builder">Builder</span>
|
||||||
|
<span class="nav-desc" data-i18n="nav.builder_desc">Docker CI for APK baking and OTA artifacts.</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="nav-locale">
|
||||||
|
<label class="toolbar-select locale-toolbar-select" data-i18n-title="lang.label" title="Language">
|
||||||
|
<span class="locale-flag" id="locale-flag" aria-hidden="true">🇬🇧</span>
|
||||||
|
<select class="lang-select" id="lang-select" data-i18n-aria="lang.label" aria-label="Language">
|
||||||
|
<option value="en">EN</option>
|
||||||
|
<option value="ru">RU</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="nav-user">
|
||||||
|
<span class="nav-user-name" title="<?= h(Auth::user()['username'] ?? '') ?>">
|
||||||
|
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
|
||||||
|
<span class="nav-label"><?= h(Auth::user()['username'] ?? '') ?></span>
|
||||||
|
</span>
|
||||||
|
<a href="<?= h(Auth::basePath()) ?>/account-security"
|
||||||
|
class="nav-link"
|
||||||
|
data-i18n-aria="nav.security" data-i18n-title="nav.security"
|
||||||
|
aria-label="Security"
|
||||||
|
title="Security">
|
||||||
|
<span class="nav-icon nav-icon--user" aria-hidden="true"></span>
|
||||||
|
<span class="nav-label" data-i18n="nav.security">Security</span>
|
||||||
|
</a>
|
||||||
|
<a href="<?= h(Auth::authUrl('/logout')) ?>"
|
||||||
|
class="nav-link nav-link--logout"
|
||||||
|
data-i18n-aria="nav.logout" data-i18n-title="nav.logout"
|
||||||
|
aria-label="Logout"
|
||||||
|
title="Logout">
|
||||||
|
<span class="nav-icon nav-icon--logout" aria-hidden="true"></span>
|
||||||
|
<span class="nav-label" data-i18n="nav.logout">Logout</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="main-pane">
|
||||||
|
<?php if (($view ?? 'home') === 'home'): ?>
|
||||||
|
<?php require __DIR__ . '/partials/console_home_landing.php'; ?>
|
||||||
|
<?php elseif (($view ?? '') === 'report' && !empty($report)): ?>
|
||||||
|
<?php require __DIR__ . '/report_detail.php'; ?>
|
||||||
|
<?php elseif (($view ?? '') === 'ticket' && !empty($ticket)): ?>
|
||||||
|
<?php require __DIR__ . '/ticket_detail.php'; ?>
|
||||||
|
<?php elseif (($view ?? '') === 'tickets'): ?>
|
||||||
|
<div id="tickets-app" class="reports-app tickets-app">
|
||||||
|
<div class="toolbar reports-toolbar">
|
||||||
|
<h1 data-i18n="tickets.title">Tickets</h1>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<button type="button" class="btn btn-primary js-new-ticket-btn" data-i18n="tickets.new">New ticket</button>
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span data-i18n="theme.label">Theme</span>
|
||||||
|
<select id="theme-select" aria-label="UI theme">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span data-i18n="reports.per_page">Per page</span>
|
||||||
|
<select id="tickets-per-page" aria-label="Tickets per page">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50" selected>50</option>
|
||||||
|
<option value="75">75</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span data-i18n="tickets.filter_tag">Status tag</span>
|
||||||
|
<select id="tickets-tag-filter" aria-label="Filter by tag">
|
||||||
|
<option value="" data-i18n="tickets.filter_all">All</option>
|
||||||
|
<option value="open">open</option>
|
||||||
|
<option value="in-progress">in progress</option>
|
||||||
|
<option value="confirmed">confirmed</option>
|
||||||
|
<option value="closed">closed</option>
|
||||||
|
<option value="urgent">urgent</option>
|
||||||
|
<option value="20260604">20260604 (roadmap)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p id="tickets-status" class="reports-status muted" aria-live="polite" data-i18n="reports.loading">Loading…</p>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-tree reports-table--cols" id="tickets-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="report-tree-head" aria-label="Expand"></th>
|
||||||
|
<th class="sortable" data-sort="title" scope="col" data-i18n="tickets.col_issue">Issue</th>
|
||||||
|
<th class="sortable sortable--active" data-sort="opened_at_ms" scope="col" data-i18n="tickets.col_opened">Opened</th>
|
||||||
|
<th scope="col" data-i18n="tickets.col_env">App / OS</th>
|
||||||
|
<th class="sortable" data-sort="rating" scope="col" data-i18n="col.rating">Rating</th>
|
||||||
|
<th class="col-tags" scope="col" data-i18n="col.tags">Tags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tickets-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<nav class="reports-pagination" id="tickets-pagination" aria-label="Tickets pages"></nav>
|
||||||
|
</div>
|
||||||
|
<?php elseif (($view ?? '') === 'reports'): ?>
|
||||||
|
<div id="reports-app" class="reports-app" data-grouped="0">
|
||||||
|
<div class="toolbar reports-toolbar">
|
||||||
|
<h1 data-i18n="reports.title">Issues</h1>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<button type="button" class="btn btn-primary js-new-issue-btn" data-i18n="issues.new">New issue</button>
|
||||||
|
<button type="button" class="btn reports-mode-btn active" data-grouped="0" data-i18n="reports.by_time">By time</button>
|
||||||
|
<button type="button" class="btn reports-mode-btn" data-grouped="1" data-i18n="reports.grouped">Grouped</button>
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span data-i18n="theme.label">Theme</span>
|
||||||
|
<select id="theme-select" aria-label="UI theme" data-i18n-options="theme">
|
||||||
|
<option value="dark" data-i18n="theme.dark">Dark</option>
|
||||||
|
<option value="light" data-i18n="theme.light">Light</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="toolbar-select tag-mode-select-wrap" id="tag-mode-wrap" hidden>
|
||||||
|
<span data-i18n="tag.filter_mode">Match</span>
|
||||||
|
<select id="tag-mode-select" aria-label="Tag filter mode">
|
||||||
|
<option value="and" data-i18n="tag.filter_and">All tags (AND)</option>
|
||||||
|
<option value="or" data-i18n="tag.filter_or">Any tag (OR)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span data-i18n="reports.per_page">Per page</span>
|
||||||
|
<select id="per-page-select" aria-label="Reports per page">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50" selected>50</option>
|
||||||
|
<option value="75">75</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
<option value="200">200</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tag-filter-bar" id="tag-filter-bar">
|
||||||
|
<span class="muted tag-filter-label" data-i18n="tag.filter_label">Filter by tag</span>
|
||||||
|
<div class="tag-filter-chips" id="tag-filter-chips" role="group" aria-label="Tag filters"></div>
|
||||||
|
</div>
|
||||||
|
<p id="reports-filter-banner" class="reports-filter-banner muted" hidden></p>
|
||||||
|
<div class="reports-search-row">
|
||||||
|
<label class="reports-search-label" for="reports-search-input" data-i18n="reports.search">Search</label>
|
||||||
|
<div class="reports-search-field">
|
||||||
|
<input type="search" id="reports-search-input" class="reports-search-input"
|
||||||
|
data-i18n-placeholder="reports.search_placeholder"
|
||||||
|
placeholder="Keywords (exceptions, devices, stack traces…)" autocomplete="off" spellcheck="false">
|
||||||
|
<ul id="reports-search-suggest" class="reports-search-suggest" hidden role="listbox"></ul>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn" id="reports-search-clear" hidden data-i18n="reports.clear">Clear</button>
|
||||||
|
</div>
|
||||||
|
<p id="reports-status" class="reports-status muted" aria-live="polite" data-i18n="reports.loading">Loading…</p>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-tree reports-table--cols" id="reports-tree">
|
||||||
|
<colgroup id="reports-colgroup"></colgroup>
|
||||||
|
<thead id="reports-thead"></thead>
|
||||||
|
<tbody id="reports-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav>
|
||||||
|
</div>
|
||||||
|
<?php elseif (($view ?? '') === 'graphs'): ?>
|
||||||
|
<div id="graphs-app" class="reports-app graphs-app">
|
||||||
|
<div class="toolbar reports-toolbar">
|
||||||
|
<h1>AndroidCast analytics</h1>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span>Theme</span>
|
||||||
|
<select id="theme-select" aria-label="UI theme">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span>Window</span>
|
||||||
|
<select id="graphs-days" aria-label="Graphs window">
|
||||||
|
<option value="1">1d</option>
|
||||||
|
<option value="7">7d</option>
|
||||||
|
<option value="14" selected>14d</option>
|
||||||
|
<option value="30">30d</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span>Columns</span>
|
||||||
|
<select id="graphs-columns" aria-label="Graph grid columns">
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2" selected>2</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
|
||||||
|
<p id="graphs-session-notice" class="graphs-session-notice" role="status" hidden data-i18n="graphs.session_deficit">
|
||||||
|
Session charts are empty. On the device: Developer settings → enable “Grab session stats”, cast a session, then wait for upload to graph_upload.php.
|
||||||
|
</p>
|
||||||
|
<p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||||
|
|
||||||
|
<section class="graphs-scope" data-graph-scope="user">
|
||||||
|
<h2 class="graphs-scope-title">Your activity</h2>
|
||||||
|
<div class="cards cards--lift graphs-grid">
|
||||||
|
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-user-sessions" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-user-devices" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Avg duration (s) / day</h3><canvas id="graph-user-duration" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Send sessions / day</h3><canvas id="graph-user-send" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Receive sessions / day</h3><canvas id="graph-user-recv" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Success ratio</h3><p id="graph-user-success" class="graph-kpi">—</p></article>
|
||||||
|
<article class="card card--lift"><h3>Outbound kbps / day</h3><canvas id="graph-user-bitrate" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Inbound kbps / day</h3><canvas id="graph-user-recv-kbps" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Issues / day</h3><canvas id="graph-user-crashes" width="520" height="160"></canvas></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope" data-graph-scope="slug_admin" hidden>
|
||||||
|
<h2 class="graphs-scope-title">Company (slug admin)</h2>
|
||||||
|
<div class="cards cards--lift graphs-grid">
|
||||||
|
<article class="card card--lift"><h3>Sessions / day</h3><canvas id="graph-slug-sessions" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Unique devices / day</h3><canvas id="graph-slug-devices" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-slug-install-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>NTP sources</h3><canvas id="graph-slug-ntp-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Transport mix</h3><div id="graph-slug-transport" class="graph-breakdown"></div></article>
|
||||||
|
<article class="card card--lift"><h3>App versions</h3><div id="graph-slug-versions" class="graph-breakdown"></div></article>
|
||||||
|
<article class="card card--lift"><h3>Issues / day</h3><canvas id="graph-slug-crashes" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-slug-tickets" width="520" height="160"></canvas></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope" data-graph-scope="platform_admin" hidden>
|
||||||
|
<h2 class="graphs-scope-title">Platform reliability</h2>
|
||||||
|
<div class="cards cards--lift graphs-grid">
|
||||||
|
<article class="card card--lift"><h3>Sessions / day (all slugs)</h3><canvas id="graph-platform-sessions" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Issues / day</h3><canvas id="graph-platform-crashes" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Tickets / day</h3><canvas id="graph-platform-tickets" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Install sources</h3><canvas id="graph-platform-install-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>NTP correction</h3><p id="graph-platform-ntp-avg" class="graph-kpi muted">—</p></article>
|
||||||
|
<article class="card card--lift"><h3>Top issue fingerprints</h3><div id="graph-platform-fingerprints" class="graph-breakdown"></div></article>
|
||||||
|
<article class="card card--lift"><h3>Issues by app version</h3><canvas id="graph-platform-crash-versions" width="520" height="180"></canvas></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope" id="graphs-live-cast-section">
|
||||||
|
<h2 class="graphs-scope-title">Live cast</h2>
|
||||||
|
<div class="cards cards--lift graphs-grid">
|
||||||
|
<article class="card card--lift"><h3>Live casts / day</h3><canvas id="graph-live-casts" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Avg cast length (s) / day</h3><canvas id="graph-live-avg-length" width="520" height="160"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Cast platforms</h3><canvas id="graph-live-platform-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Join platforms</h3><canvas id="graph-live-join-platform-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Video codecs</h3><canvas id="graph-live-video-codec-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Audio codecs</h3><canvas id="graph-live-audio-codec-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Bandwidth modes</h3><canvas id="graph-live-bw-pie" width="520" height="180"></canvas></article>
|
||||||
|
<article class="card card--lift"><h3>Top casters</h3><div id="graph-live-top-users" class="graph-breakdown"></div></article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="graph-detail-overlay" class="graph-detail-overlay" hidden>
|
||||||
|
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="graph-detail-title">
|
||||||
|
<header class="graph-detail-header">
|
||||||
|
<div class="graph-detail-header-text">
|
||||||
|
<h2 id="graph-detail-title">Graph</h2>
|
||||||
|
<p id="graph-detail-stats" class="graph-detail-stats muted" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn graph-detail-close" id="graph-detail-close" aria-label="Close full view">Close</button>
|
||||||
|
<button type="button" class="btn" id="graph-detail-copy-link">Copy link</button>
|
||||||
|
<a class="btn" id="graph-detail-share" href="#" hidden aria-hidden="true"></a>
|
||||||
|
</header>
|
||||||
|
<div class="graph-detail-canvas-wrap">
|
||||||
|
<canvas id="graph-detail-canvas" hidden></canvas>
|
||||||
|
<div id="graph-detail-body" class="graph-detail-body"></div>
|
||||||
|
<p id="graph-detail-tooltip" class="graph-detail-tooltip" hidden></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php elseif (($view ?? '') === 'live_sessions'): ?>
|
||||||
|
<div id="live-sessions-app" class="reports-app">
|
||||||
|
<div class="toolbar reports-toolbar">
|
||||||
|
<h1>Live cast sessions</h1>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span>Window</span>
|
||||||
|
<select id="live-sessions-days" aria-label="Sessions window">
|
||||||
|
<option value="1">1d</option>
|
||||||
|
<option value="7">7d</option>
|
||||||
|
<option value="14" selected>14d</option>
|
||||||
|
<option value="30">30d</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<a class="btn" href="<?= h(Auth::basePath()) ?>/live/education">Education demo</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
|
||||||
|
<p id="live-sessions-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||||
|
<p class="muted graphs-footnote">Read-only session tree for all signed-in users. Sorted by last heartbeat (most recent first).</p>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="live-sessions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Platform</th>
|
||||||
|
<th>Video</th>
|
||||||
|
<th>Duration</th>
|
||||||
|
<th>Last heartbeat</th>
|
||||||
|
<th>Join opens</th>
|
||||||
|
<th>Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="live-sessions-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php elseif (($view ?? '') === 'remote_access'): ?>
|
||||||
|
<div id="remote-access-app" class="reports-app">
|
||||||
|
<div class="toolbar reports-toolbar">
|
||||||
|
<h1>Remote access</h1>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span>Theme</span>
|
||||||
|
<select id="theme-select" aria-label="UI theme">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
|
||||||
|
<p id="ra-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Devices</h2>
|
||||||
|
<p class="muted graphs-footnote ra-whitelist-hint">
|
||||||
|
Devices appear after they poll remote access (dev settings → WireGuard or RSSH). Sort is by <strong>last seen</strong> — top rows are actively polling.
|
||||||
|
Whitelist rows marked <span class="tag-pill tag-pill--warn">Needs whitelist</span> (opted in, not yet allowed).
|
||||||
|
Stale rows have not polled in 7+ days.
|
||||||
|
<strong>Poll source IP</strong> is the address BE sees on the HTTP request (often a router or proxy on <code>10.7.x.x</code>) — not the device WAN or LAN.
|
||||||
|
</p>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-tree reports-table--cols" id="ra-devices-table">
|
||||||
|
<colgroup id="ra-devices-colgroup"></colgroup>
|
||||||
|
<thead id="ra-devices-thead"></thead>
|
||||||
|
<tbody id="ra-devices-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<form id="ra-whitelist-form" class="tag-editor-add" style="margin-top:1rem">
|
||||||
|
<label class="tag-field"><span>Device ID</span><input type="text" name="device_id" required maxlength="128"></label>
|
||||||
|
<label class="tag-field"><span>Notes</span><input type="text" name="notes" maxlength="255"></label>
|
||||||
|
<button type="submit" class="btn">Add / whitelist</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Active / pending sessions</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="ra-active-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Session</th>
|
||||||
|
<th scope="col">Device</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Tunnel</th>
|
||||||
|
<th scope="col">Endpoint</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ra-active-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Inactive / closed sessions</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="ra-inactive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Session</th>
|
||||||
|
<th scope="col">Device</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Closed</th>
|
||||||
|
<th scope="col">Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ra-inactive-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Audit log</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="ra-events-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Device</th>
|
||||||
|
<th scope="col">Action</th>
|
||||||
|
<th scope="col">Reason</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ra-events-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<?php elseif (($view ?? '') === 'short_links'): ?>
|
||||||
|
<div id="short-links-app" class="reports-app">
|
||||||
|
<div class="toolbar reports-toolbar">
|
||||||
|
<h1>Short links</h1>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span>Theme</span>
|
||||||
|
<select id="theme-select" aria-label="UI theme">
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php require __DIR__ . '/partials/console_quick_links.php'; ?>
|
||||||
|
<p id="sl-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||||
|
<p class="muted graphs-footnote">
|
||||||
|
Public API: <code>https://s.f0xx.org/api/v1/shorten</code> · machine clients use bearer tokens minted here.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Bearer tokens</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="sl-bearers-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">ID</th>
|
||||||
|
<th scope="col">Label</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Caps</th>
|
||||||
|
<th scope="col">Links</th>
|
||||||
|
<th scope="col">Rate/h</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sl-bearers-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<form id="sl-mint-form" class="tag-editor-add" style="margin-top:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
|
||||||
|
<label class="tag-field"><span>Label</span><input type="text" name="label" required maxlength="128" placeholder="prod-hub"></label>
|
||||||
|
<label class="tag-field"><span>Rate limit / hour</span><input type="number" name="rate_limit" value="1000" min="1" max="100000"></label>
|
||||||
|
<label class="tag-field tag-field--check"><span><input type="checkbox" name="can_temporary" value="1" checked> Temporary links</span></label>
|
||||||
|
<label class="tag-field tag-field--check"><span><input type="checkbox" name="can_permanent" value="1" id="sl-can-permanent"> Permanent links</span></label>
|
||||||
|
<label class="tag-field tag-field--check" id="sl-mint-signer-wrap" hidden><span><input type="checkbox" name="mint_signer" value="1" checked> Mint signer key</span></label>
|
||||||
|
<button type="submit" class="btn btn-primary">Mint bearer</button>
|
||||||
|
<?php if (Rbac::isGlobalAdmin()): ?>
|
||||||
|
<button type="button" class="btn" id="sl-purge-btn">Purge expired</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Signer keys (permanent links)</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="sl-signers-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">ID</th>
|
||||||
|
<th scope="col">Label</th>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sl-signers-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<form id="sl-mint-signer-form" class="tag-editor-add" style="margin-top:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
|
||||||
|
<label class="tag-field"><span>Label</span><input type="text" name="label" required maxlength="128" placeholder="prod-perm"></label>
|
||||||
|
<button type="submit" class="btn">Mint signer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope" id="sl-links-section" hidden>
|
||||||
|
<h2 class="graphs-scope-title">Links for bearer <code id="sl-links-bearer-label">—</code></h2>
|
||||||
|
<form id="sl-shorten-form" class="tag-editor-add" style="margin-bottom:1rem"<?= Rbac::can('short_links_operate') ? '' : ' hidden' ?>>
|
||||||
|
<input type="hidden" name="bearer_id" id="sl-shorten-bearer-id" value="">
|
||||||
|
<label class="tag-field tag-field--wide"><span>Long URL</span><input type="url" name="url" required maxlength="2048" placeholder="https://example.com/path"></label>
|
||||||
|
<label class="tag-field tag-field--check"><span><input type="checkbox" name="permanent" value="1" id="sl-permanent-cb"> Permanent (ttl=0)</span></label>
|
||||||
|
<label class="tag-field" id="sl-ttl-wrap"><span>TTL (seconds)</span><input type="number" name="ttl" id="sl-ttl-input" value="86400" min="60" max="31536000"></label>
|
||||||
|
<label class="tag-field tag-field--wide" id="sl-signer-wrap" hidden><span>Signer key</span><input type="password" name="signer" id="sl-signer-input" autocomplete="off" placeholder="required for permanent"></label>
|
||||||
|
<button type="submit" class="btn btn-primary">Shorten</button>
|
||||||
|
</form>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="sl-links-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Short URL</th>
|
||||||
|
<th scope="col">Origin</th>
|
||||||
|
<th scope="col">Expires</th>
|
||||||
|
<th scope="col">Hits</th>
|
||||||
|
<th scope="col">QR</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sl-links-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Audit log</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="sl-audit-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Time</th>
|
||||||
|
<th scope="col">Event</th>
|
||||||
|
<th scope="col">Bearer</th>
|
||||||
|
<th scope="col">Slug</th>
|
||||||
|
<th scope="col">Detail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sl-audit-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="sl-token-modal" class="graph-detail-overlay" hidden>
|
||||||
|
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="sl-token-title">
|
||||||
|
<header class="graph-detail-header">
|
||||||
|
<h2 id="sl-token-title">Bearer token — copy now</h2>
|
||||||
|
<button type="button" class="btn graph-detail-close" id="sl-token-close">Close</button>
|
||||||
|
</header>
|
||||||
|
<p class="muted">Shown once. Store in a secrets manager or <code>BEARER=…</code> for curl scripts.</p>
|
||||||
|
<p><code id="sl-token-value" style="word-break:break-all"></code></p>
|
||||||
|
<button type="button" class="btn btn-primary" id="sl-token-copy">Copy token</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="sl-signer-modal" class="graph-detail-overlay" hidden>
|
||||||
|
<div class="graph-detail-panel" role="dialog" aria-modal="true" aria-labelledby="sl-signer-title">
|
||||||
|
<header class="graph-detail-header">
|
||||||
|
<h2 id="sl-signer-title">Signer key — copy now</h2>
|
||||||
|
<button type="button" class="btn graph-detail-close" id="sl-signer-close">Close</button>
|
||||||
|
</header>
|
||||||
|
<p class="muted">Shown once. Required for <code>ttl=0</code> permanent links with a bearer that has permanent capability.</p>
|
||||||
|
<p><code id="sl-signer-value" style="word-break:break-all"></code></p>
|
||||||
|
<button type="button" class="btn btn-primary" id="sl-signer-copy">Copy signer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php elseif (($view ?? '') === 'rbac'): ?>
|
||||||
|
<div id="rbac-app" class="reports-app">
|
||||||
|
<div class="toolbar reports-toolbar">
|
||||||
|
<h1>Access control</h1>
|
||||||
|
</div>
|
||||||
|
<p id="rbac-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||||
|
<p id="rbac-scope-hint" class="muted graphs-footnote"></p>
|
||||||
|
<section class="graphs-scope">
|
||||||
|
<h2 class="graphs-scope-title">Users & company roles</h2>
|
||||||
|
<div class="reports-table-wrap">
|
||||||
|
<table class="data-table reports-table--cols" id="rbac-users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">User</th>
|
||||||
|
<th scope="col">Global role</th>
|
||||||
|
<th scope="col">Company</th>
|
||||||
|
<th scope="col">Company role</th>
|
||||||
|
<th scope="col">Privilege set</th>
|
||||||
|
<th scope="col">Auth lockouts</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rbac-users-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<p class="muted graphs-footnote">
|
||||||
|
API: <code><?= h(Auth::basePath()) ?>/api/rbac.php</code> ·
|
||||||
|
Root edits global roles; company owner/admin edits memberships and remote-access privilege sets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<?php if (Auth::canEditTags()): ?>
|
||||||
|
<div id="tag-modal" class="tag-modal" hidden aria-hidden="true">
|
||||||
|
<div class="tag-modal-backdrop" data-tag-modal-close></div>
|
||||||
|
<div class="tag-modal-panel" role="dialog" aria-labelledby="tag-modal-title">
|
||||||
|
<header class="tag-modal-header">
|
||||||
|
<h2 id="tag-modal-title" data-i18n="tag.edit_title">Edit tags</h2>
|
||||||
|
<button type="button" class="tag-modal-close" data-tag-modal-close data-i18n-aria="tag.close" aria-label="Close">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="tag-editor tag-editor--modal" id="tag-editor-modal" data-report-id="0">
|
||||||
|
<div class="tag-editor-preview" id="tag-modal-preview"></div>
|
||||||
|
<ul class="tag-editor-list" id="tag-modal-list"></ul>
|
||||||
|
<form class="tag-editor-add" id="tag-modal-add">
|
||||||
|
<label class="tag-field"><span data-i18n="tag.label">Label</span><input type="text" name="label" maxlength="40" required></label>
|
||||||
|
<label class="tag-field"><span data-i18n="tag.color">Color</span><input type="color" name="bg" value="#5c6b82"></label>
|
||||||
|
<button type="submit" class="btn" data-i18n="tag.add">Add</button>
|
||||||
|
</form>
|
||||||
|
<div class="tag-preset-bar" id="tag-modal-presets" hidden>
|
||||||
|
<span class="muted" data-i18n="tag.suggestions">Suggestions:</span>
|
||||||
|
<div class="tag-preset-chips" id="tag-modal-preset-chips"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tag-editor-actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="tag-modal-save">Save</button>
|
||||||
|
<span class="tag-editor-status muted" id="tag-modal-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<dialog id="ticket-create-dialog" class="ticket-create-dialog">
|
||||||
|
<form method="dialog" id="ticket-create-form" class="ticket-create-form">
|
||||||
|
<header class="ticket-create-header">
|
||||||
|
<h2 id="ticket-create-heading" data-i18n="tickets.new">New ticket</h2>
|
||||||
|
<button type="button" class="ticket-create-close" id="ticket-create-close" data-i18n-aria="tag.close" aria-label="Close">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="ticket-create-body">
|
||||||
|
<label><span data-i18n="ticket.title">Title</span>
|
||||||
|
<input name="title" id="ticket-create-title" required maxlength="256" autofocus></label>
|
||||||
|
<label><span data-i18n="ticket.brief">Brief</span>
|
||||||
|
<input name="brief" id="ticket-create-brief" maxlength="512"></label>
|
||||||
|
<label><span data-i18n="ticket.body">Description</span>
|
||||||
|
<textarea name="body" id="ticket-create-body" rows="6"></textarea></label>
|
||||||
|
</div>
|
||||||
|
<footer class="ticket-create-footer">
|
||||||
|
<p id="ticket-create-status" class="muted" aria-live="polite"></p>
|
||||||
|
<div class="ticket-create-actions">
|
||||||
|
<button type="button" class="btn" id="ticket-create-cancel" data-i18n="ticket.cancel">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary" data-i18n="ticket.create">Create</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
|
||||||
|
<?php platform_render_footer(); ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
views/live_education.php
Normal file
50
views/live_education.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
$bp = Auth::basePath();
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Education demo — <?= h(cfg('app_name')) ?></title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('crash_console_theme');
|
||||||
|
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
|
||||||
|
<script src="<?= h($bp) ?>/assets/js/cookie_consent.js" defer></script>
|
||||||
|
<?php AnalyticsHead::render('live_education'); ?>
|
||||||
|
<script src="<?= h($bp) ?>/assets/js/live_cast_webrtc.js" defer></script>
|
||||||
|
<script src="<?= h($bp) ?>/assets/js/live_education.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="live-page" data-base-path="<?= h($bp) ?>" data-view="live_education">
|
||||||
|
<main id="live-education-app" class="live-shell">
|
||||||
|
<header class="live-header">
|
||||||
|
<a href="<?= h(Auth::projectBasePath()) ?>/" class="live-brand">← Back to the console</a>
|
||||||
|
<span class="tag-pill">Education demo</span>
|
||||||
|
</header>
|
||||||
|
<section class="card card--lift live-card">
|
||||||
|
<h1>Try screen sharing in your browser</h1>
|
||||||
|
<p class="muted">Free demo up to 5 minutes. No install required — great for first-time visitors.</p>
|
||||||
|
<p id="edu-status" class="reports-status muted" aria-live="polite">Ready when you are.</p>
|
||||||
|
<p class="live-timer-label">Time left: <strong id="edu-timer">5:00</strong></p>
|
||||||
|
<video id="edu-preview" class="live-preview" playsinline muted autoplay hidden></video>
|
||||||
|
<div id="edu-share" class="live-share card card--lift" hidden></div>
|
||||||
|
<pre id="edu-stats" class="live-stats-nerds" hidden aria-live="polite"></pre>
|
||||||
|
<div class="live-actions">
|
||||||
|
<button type="button" class="btn btn--primary" id="edu-start">Start screen share</button>
|
||||||
|
<button type="button" class="btn" id="edu-stop">Stop</button>
|
||||||
|
<button type="button" class="btn btn--ghost" id="edu-notify">Enable notifications</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted graphs-footnote">
|
||||||
|
WebRTC P2P (no SFU): share the viewer link or QR. Same REST signaling API supports future mobile cast to browser on LAN.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
|
||||||
|
<?php platform_render_footer(); ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
views/live_join.php
Normal file
47
views/live_join.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
$bp = Auth::basePath();
|
||||||
|
$sessionId = trim((string) ($_GET['session_id'] ?? ''));
|
||||||
|
$direct = isset($_GET['direct']) && (string) $_GET['direct'] === '1';
|
||||||
|
$role = trim((string) ($_GET['role'] ?? ''));
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Join live cast — <?= h(cfg('app_name')) ?></title>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('crash_console_theme');
|
||||||
|
if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="<?= h($bp) ?>/assets/css/app.css">
|
||||||
|
<script src="<?= h($bp) ?>/assets/js/cookie_consent.js" defer></script>
|
||||||
|
<?php AnalyticsHead::render('live_join'); ?>
|
||||||
|
<script src="<?= h($bp) ?>/assets/js/live_cast_webrtc.js" defer></script>
|
||||||
|
<script src="<?= h($bp) ?>/assets/js/live_join.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="live-page" data-base-path="<?= h($bp) ?>"
|
||||||
|
data-view="live_join">
|
||||||
|
<main id="live-join-app" class="live-shell"
|
||||||
|
data-session-id="<?= h($sessionId) ?>"
|
||||||
|
data-direct="<?= $direct ? '1' : '0' ?>"
|
||||||
|
data-role="<?= h($role) ?>">
|
||||||
|
<header class="live-header">
|
||||||
|
<a href="<?= h(Auth::projectBasePath()) ?>/" class="live-brand">← Back to the console</a>
|
||||||
|
<span class="tag-pill tag-pill--ok">Live viewer</span>
|
||||||
|
</header>
|
||||||
|
<section class="card card--lift live-card">
|
||||||
|
<h1>Join live cast</h1>
|
||||||
|
<p id="live-join-status" class="reports-status muted" aria-live="polite">Loading session…</p>
|
||||||
|
<ul id="live-join-meta" class="live-meta-list"></ul>
|
||||||
|
<div id="live-join-player" class="live-player-wrap"></div>
|
||||||
|
<pre id="live-join-stats-panel" class="live-stats-nerds" hidden aria-live="polite"></pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<?php require __DIR__ . '/partials/cookie_consent.php'; ?>
|
||||||
|
<?php platform_render_footer(); ?>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
views/partials/console_home_landing.php
Normal file
58
views/partials/console_home_landing.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
$bp = Auth::basePath();
|
||||||
|
$proj = '/app/androidcast_project';
|
||||||
|
?>
|
||||||
|
<div class="console-home">
|
||||||
|
<div class="toolbar reports-toolbar console-home-toolbar">
|
||||||
|
<h1 data-i18n="home.title">Console</h1>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<label class="toolbar-select">
|
||||||
|
<span data-i18n="theme.label">Theme</span>
|
||||||
|
<select id="theme-select" aria-label="UI theme" data-i18n-options="theme">
|
||||||
|
<option value="dark" data-i18n="theme.dark">Dark</option>
|
||||||
|
<option value="light" data-i18n="theme.light">Light</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="console-home-intro" data-i18n="home.intro">Issue triage, tickets, and session metrics — pick a workspace below.</p>
|
||||||
|
|
||||||
|
<div class="cards cards--lift console-home-cards">
|
||||||
|
<a href="<?= h($bp) ?>/?view=reports" class="card card--lift console-home-card">
|
||||||
|
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
|
||||||
|
<h2 data-i18n="home.card_issues">Issues</h2>
|
||||||
|
<p class="muted" data-i18n="home.card_issues_desc">Browse and triage issues, tags, and fingerprints.</p>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="card card--lift console-home-card console-home-card--btn js-new-issue-btn">
|
||||||
|
<span class="nav-icon nav-icon--reports" aria-hidden="true"></span>
|
||||||
|
<h2 data-i18n="issues.new">New issue</h2>
|
||||||
|
<p class="muted" data-i18n="home.card_new_issue_desc">Log a new issue or task in one step.</p>
|
||||||
|
</button>
|
||||||
|
<a href="<?= h($bp) ?>/?view=tickets" class="card card--lift console-home-card">
|
||||||
|
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
|
||||||
|
<h2 data-i18n="nav.tickets">Tickets</h2>
|
||||||
|
<p class="muted" data-i18n="home.card_tickets_desc">Roadmap tasks, QA items, and workflow tags.</p>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="card card--lift console-home-card console-home-card--btn js-new-ticket-btn">
|
||||||
|
<span class="nav-icon nav-icon--tickets" aria-hidden="true"></span>
|
||||||
|
<h2 data-i18n="tickets.new">New ticket</h2>
|
||||||
|
<p class="muted" data-i18n="home.card_new_ticket_desc">Create a roadmap or QA ticket in one step.</p>
|
||||||
|
</button>
|
||||||
|
<a href="<?= h($proj) ?>/graphs/" class="card card--lift console-home-card">
|
||||||
|
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
|
||||||
|
<h2 data-i18n="home.card_graphs">Graphs</h2>
|
||||||
|
<p class="muted" data-i18n="home.card_graphs_desc">Sessions, issues, and device activity over time.</p>
|
||||||
|
</a>
|
||||||
|
<?php if (Rbac::can('short_links_view')): ?>
|
||||||
|
<a href="<?= h($bp) ?>/?view=short_links" class="card card--lift console-home-card">
|
||||||
|
<span class="nav-icon nav-icon--link" aria-hidden="true"></span>
|
||||||
|
<h2 data-i18n="home.card_short_links">Short links</h2>
|
||||||
|
<p class="muted" data-i18n="home.card_short_links_desc">Mint bearer tokens and shorten URLs for s.f0xx.org.</p>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="console-home-more" data-i18n="home.more">All services</h2>
|
||||||
|
<?php $skip_home_link = true; require __DIR__ . '/console_quick_links.php'; ?>
|
||||||
|
</div>
|
||||||
37
views/partials/console_quick_links.php
Normal file
37
views/partials/console_quick_links.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
/**
|
||||||
|
* Horizontal service links with the same icons as the left nav (8px gap).
|
||||||
|
* @var string|null $extra_class optional class on <nav>
|
||||||
|
* @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'];
|
||||||
|
?>
|
||||||
|
<nav class="<?= h($navClass) ?>" aria-label="Related services">
|
||||||
|
<?php foreach ($items as [$label, $href, $icon]): ?>
|
||||||
|
<a href="<?= h($href) ?>" class="console-quick-link">
|
||||||
|
<span class="nav-icon <?= h($icon) ?>" aria-hidden="true"></span>
|
||||||
|
<span><?= h($label) ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</nav>
|
||||||
15
views/partials/cookie_consent.php
Normal file
15
views/partials/cookie_consent.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php declare(strict_types=1); ?>
|
||||||
|
<div id="cookie-consent-banner" class="cookie-consent" hidden role="dialog" aria-labelledby="cookie-consent-title" aria-modal="false">
|
||||||
|
<div class="cookie-consent-inner">
|
||||||
|
<p id="cookie-consent-title" class="cookie-consent-title">Cookies on AndroidCast</p>
|
||||||
|
<p class="cookie-consent-text muted">
|
||||||
|
We use necessary session cookies to sign you in. With your consent we also load analytics to improve the service.
|
||||||
|
You can change this later in your browser by clearing site data.
|
||||||
|
</p>
|
||||||
|
<div class="cookie-consent-actions">
|
||||||
|
<button type="button" class="btn btn--primary" id="cookie-consent-all">Accept all</button>
|
||||||
|
<button type="button" class="btn" id="cookie-consent-necessary">Necessary only</button>
|
||||||
|
<button type="button" class="btn btn--ghost" id="cookie-consent-reject">Reject optional</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
185
views/report_detail.php
Normal file
185
views/report_detail.php
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
$p = is_array($report['payload'] ?? null) ? $report['payload'] : [];
|
||||||
|
$device = is_array($p['device'] ?? null) ? $p['device'] : [];
|
||||||
|
$app = is_array($p['app'] ?? null) ? $p['app'] : [];
|
||||||
|
$pageTitle = 'Report ' . ($report['report_id'] ?? '');
|
||||||
|
$view = 'reports';
|
||||||
|
$jsonFlags = JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
||||||
|
$rawJson = safe_json_encode($p, $jsonFlags);
|
||||||
|
$sessionJson = !empty($p['session_context'])
|
||||||
|
? safe_json_encode($p['session_context'], $jsonFlags)
|
||||||
|
: '';
|
||||||
|
$bp = Auth::basePath();
|
||||||
|
$reportDbId = (int) ($report['id'] ?? 0);
|
||||||
|
$deviceName = device_display_name($device);
|
||||||
|
$deviceFamily = device_model_family(
|
||||||
|
(string) ($device['manufacturer'] ?? ''),
|
||||||
|
(string) ($device['model'] ?? '')
|
||||||
|
);
|
||||||
|
$filterDeviceQ = $deviceFamily !== '' ? $deviceFamily : $deviceName;
|
||||||
|
$similarUrl = $bp . '/?view=reports&similar_to=' . $reportDbId;
|
||||||
|
$filterDeviceUrl = $bp . '/?view=reports&filter_device=' . rawurlencode($filterDeviceQ);
|
||||||
|
$sdk = (int) ($device['sdk_int'] ?? 0);
|
||||||
|
$release = (string) ($device['release'] ?? '');
|
||||||
|
$filterOsUrl = $sdk > 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="detail-header">
|
||||||
|
<a href="<?= h($bp) ?>/?view=reports" class="back-link" data-i18n-aria="detail.back" data-i18n-title="detail.back" aria-label="Back to reports list" title="Back to list">
|
||||||
|
<span class="back-icon" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<h1 data-i18n="detail.title">Issue report</h1>
|
||||||
|
<p class="muted" data-i18n="detail.report_meta" data-i18n-id="<?= h($report['report_id'] ?? '') ?>" data-i18n-type="<?= h($p['crash_type'] ?? '') ?>">Report <?= h($report['report_id'] ?? '') ?> · <?= h($p['crash_type'] ?? '') ?></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="tag-editor" id="tag-editor" data-report-id="<?= $reportDbId ?>">
|
||||||
|
<div class="tag-editor-head">
|
||||||
|
<h2 data-i18n="detail.tags">Tags</h2>
|
||||||
|
<p class="muted tag-editor-hint" data-i18n="tag.hint_auto">Auto tags (NDK / Java / Android / new) appear in the list. Add custom labels below.</p>
|
||||||
|
</div>
|
||||||
|
<div class="tag-editor-preview" id="tag-editor-preview" aria-live="polite"></div>
|
||||||
|
<?php if (Auth::canEditTags()): ?>
|
||||||
|
<ul class="tag-editor-list" id="tag-editor-list"></ul>
|
||||||
|
<form class="tag-editor-add" id="tag-editor-add">
|
||||||
|
<label class="tag-field">
|
||||||
|
<span data-i18n="tag.label">Label</span>
|
||||||
|
<input type="text" name="label" maxlength="40" required data-i18n-placeholder="tag.placeholder" placeholder="e.g. regression">
|
||||||
|
</label>
|
||||||
|
<label class="tag-field">
|
||||||
|
<span data-i18n="tag.color">Color</span>
|
||||||
|
<input type="color" name="bg" value="#5c6b82">
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn" data-i18n="tag.add_tag">Add tag</button>
|
||||||
|
</form>
|
||||||
|
<div class="tag-preset-bar" id="tag-preset-bar" hidden>
|
||||||
|
<span class="muted" data-i18n="tag.suggestions">Suggestions:</span>
|
||||||
|
<div class="tag-preset-chips" id="tag-preset-chips"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tag-editor-actions">
|
||||||
|
<button type="button" class="btn btn-primary" id="tag-editor-save">Save tags</button>
|
||||||
|
<span class="tag-editor-status muted" id="tag-editor-status"></span>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="muted" data-i18n="tag.hint_admin">Sign in as admin to edit custom tags.</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
<script type="application/json" id="report-custom-tags-data"><?=
|
||||||
|
safe_json_encode($report['tags'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||||
|
?></script>
|
||||||
|
<div class="cards cards--lift">
|
||||||
|
<div class="card card--lift"><h3>Device</h3>
|
||||||
|
<?php if ($deviceName !== ''): ?>
|
||||||
|
<p><a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterDeviceUrl) ?>" title="Show reports for similar devices"><?= h($deviceName) ?></a></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="muted">—</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($filterOsUrl !== ''): ?>
|
||||||
|
<p><a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterOsUrl) ?>" title="Show reports on this OS">Android <?= h($release) ?> (SDK <?= $sdk ?>)</a></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p>Android <?= h($release) ?> (SDK <?= $sdk ?>)</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($abiList !== []): ?>
|
||||||
|
<p class="muted detail-abi-row">ABIs:
|
||||||
|
<?php foreach ($abiList as $i => $abi): ?>
|
||||||
|
<?php if ($i > 0): ?>, <?php endif; ?>
|
||||||
|
<a class="detail-filter-link detail-filter-link--tag" href="<?= h($bp . '/?view=reports&filter_abi=' . rawurlencode($abi)) ?>"><?= h($abi) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="card card--lift"><h3 data-i18n="detail.app">App</h3>
|
||||||
|
<p><?= h($app['package'] ?? '') ?></p>
|
||||||
|
<?php
|
||||||
|
$versionName = (string) ($app['version_name'] ?? '');
|
||||||
|
$filterAppUrl = $versionName !== ''
|
||||||
|
? $bp . '/?view=reports&filter_app_version=' . rawurlencode($versionName)
|
||||||
|
: '';
|
||||||
|
?>
|
||||||
|
<?php if ($filterAppUrl !== ''): ?>
|
||||||
|
<p><a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterAppUrl) ?>" data-i18n-title="detail.filter_version" title="Show reports for this app version">v<?= h($versionName) ?></a> <span class="muted">(<?= h((string)($app['version_code'] ?? '')) ?>)</span></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p>v<?= h($versionName) ?> (<?= h((string)($app['version_code'] ?? '')) ?>)</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($p['build'])):
|
||||||
|
$b = $p['build'];
|
||||||
|
$buildId = trim((string) ($b['git_commit'] ?? $b['git'] ?? ''));
|
||||||
|
$filterBuildUrl = $buildId !== ''
|
||||||
|
? $bp . '/?view=reports&filter_build=' . rawurlencode($buildId)
|
||||||
|
: '';
|
||||||
|
?>
|
||||||
|
<?php if ($filterBuildUrl !== ''): ?>
|
||||||
|
<p><span class="muted" data-i18n="detail.build">Build:</span> <a class="detail-filter-link detail-filter-link--tag" href="<?= h($filterBuildUrl) ?>" data-i18n-title="detail.filter_build" title="Show reports from this build"><?= h($buildId) ?></a></p>
|
||||||
|
<?php else: ?>
|
||||||
|
<p class="muted"><span data-i18n="detail.build">Build:</span> <?= h($buildId) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="card card--lift"><h3>Timing</h3>
|
||||||
|
<p>Generated: <?= h(date('c', (int)(($p['generated_at_epoch_ms'] ?? 0)/1000))) ?></p>
|
||||||
|
<p>Received: <?= h(date('c', (int)(($report['received_at_ms'] ?? 0)/1000))) ?></p>
|
||||||
|
<p class="muted">Fingerprint: <code><?= h($p['fingerprint'] ?? $report['fingerprint'] ?? '') ?></code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($p['java'])): $j = $p['java']; ?>
|
||||||
|
<section class="stack-block">
|
||||||
|
<div class="stack-block-head">
|
||||||
|
<h2>Java exception</h2>
|
||||||
|
<a class="btn btn-similar" href="<?= h($similarUrl) ?>">Find similar reports</a>
|
||||||
|
</div>
|
||||||
|
<p class="exc"><?= h($j['exception'] ?? '') ?><?= !empty($j['message']) ? ': ' . h($j['message']) : '' ?></p>
|
||||||
|
<p class="muted"><span data-i18n="detail.thread">Thread:</span> <?= h($j['thread'] ?? '') ?></p>
|
||||||
|
<pre class="stack"><?php foreach ($j['stack_frames'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($p['native'])): $n = $p['native']; ?>
|
||||||
|
<section class="stack-block">
|
||||||
|
<div class="stack-block-head">
|
||||||
|
<h2 data-i18n="detail.native_crash">Native issue</h2>
|
||||||
|
<a class="btn btn-similar" href="<?= h($similarUrl) ?>">Find similar reports</a>
|
||||||
|
</div>
|
||||||
|
<p class="exc">Signal: <?= h($n['signal'] ?? '') ?></p>
|
||||||
|
<pre class="stack"><?php foreach ($n['backtrace'] ?? [] as $frame) { echo h((string)$frame) . "\n"; } ?></pre>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="detail-tree" id="detail-tree">
|
||||||
|
<?php if ($sessionJson !== ''): ?>
|
||||||
|
<div class="detail-tree-item">
|
||||||
|
<div class="detail-tree-row report-row" role="button" tabindex="0" data-tree-toggle data-controls="detail-session">
|
||||||
|
<span class="report-tree-cell">
|
||||||
|
<button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="detail-session" tabindex="-1">
|
||||||
|
<span class="report-tree-arrow" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span class="detail-tree-caption" data-i18n="detail.session_context">Session context (issue)</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-tree-body" id="detail-session" hidden>
|
||||||
|
<pre class="stack"><?= h($sessionJson) ?></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="detail-tree-item">
|
||||||
|
<div class="detail-tree-row report-row" role="button" tabindex="0" data-tree-toggle data-controls="detail-raw-json">
|
||||||
|
<span class="report-tree-cell">
|
||||||
|
<button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="detail-raw-json" tabindex="-1">
|
||||||
|
<span class="report-tree-arrow" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<span class="detail-tree-caption" data-i18n="detail.raw_json">Raw JSON</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-tree-body" id="detail-raw-json" hidden>
|
||||||
|
<pre class="stack raw-json"><?= h($rawJson) ?></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user