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