Files
web-petting/templates/admin/analytics.html
T
ab 866118497f
Build and Publish / Build and Publish Docker Image (push) Successful in 1m43s
added log filter
2026-09-20 20:11:18 +03:00

581 lines
24 KiB
HTML

{% extends "admin/layout.html" %}
{% let active_page = "analytics" %}
{% block title %}{{ t.analytics_title }}{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>{{ t.analytics_title }}</h1>
<p>{{ t.analytics_intro }}</p>
</div>
</div>
<div class="analytics-controls"
data-labels='{
"landing_views": "{{ t.analytics_metric_landing_views }}",
"landing_unique": "{{ t.analytics_metric_landing_unique }}",
"portal_opens": "{{ t.analytics_metric_portal_opens }}",
"media_views": "{{ t.analytics_metric_media_views }}",
"no_data": "{{ t.analytics_no_data }}",
"loading": "{{ t.analytics_loading }}",
"error": "{{ t.analytics_load_error }}",
"log_empty": "{{ t.analytics_log_empty }}",
"log_end": "{{ t.analytics_log_end }}",
"visitor": "{{ t.analytics_log_visitor }}",
"landing_view": "{{ t.analytics_ev_landing_view }}",
"portal_open": "{{ t.analytics_ev_portal_open }}",
"media_view": "{{ t.analytics_ev_media_view }}"
}'>
<div class="analytics-presets">
<button type="button" class="button is-small preset-btn" data-days="7">{{ t.analytics_preset_7 }}</button>
<button type="button" class="button is-small preset-btn" data-days="30">{{ t.analytics_preset_30 }}</button>
<button type="button" class="button is-small preset-btn" data-days="90">{{ t.analytics_preset_90 }}</button>
<button type="button" class="button is-small preset-btn" data-days="365">{{ t.analytics_preset_365 }}</button>
</div>
<div class="analytics-range">
<label>{{ t.analytics_range_from }}
<input type="date" id="anFrom" class="input is-small">
</label>
<label>{{ t.analytics_range_to }}
<input type="date" id="anTo" class="input is-small">
</label>
<label>{{ t.analytics_granularity }}
<select id="anGran" class="input is-small">
<option value="day">{{ t.analytics_gran_day }}</option>
<option value="week">{{ t.analytics_gran_week }}</option>
<option value="month">{{ t.analytics_gran_month }}</option>
</select>
</label>
</div>
</div>
<div class="analytics-kpis" id="anKpis"></div>
<div class="analytics-metric-toggles">
<span class="toggles-label">{{ t.analytics_metrics_label }}:</span>
<label><input type="checkbox" class="metric-toggle" data-key="landing_views" checked> {{ t.analytics_metric_landing_views }}</label>
<label><input type="checkbox" class="metric-toggle" data-key="landing_unique" checked> {{ t.analytics_metric_landing_unique }}</label>
<label><input type="checkbox" class="metric-toggle" data-key="portal_opens" checked> {{ t.analytics_metric_portal_opens }}</label>
<label><input type="checkbox" class="metric-toggle" data-key="media_views" checked> {{ t.analytics_metric_media_views }}</label>
</div>
<div class="analytics-chart-wrap">
<div class="analytics-chart-box">
<canvas id="anChart"></canvas>
</div>
</div>
<div class="analytics-tops">
<div class="analytics-top-card">
<h2>{{ t.analytics_top_clients }}</h2>
<table class="analytics-top-table" id="anTopClients">
<thead><tr><th>{{ t.analytics_top_clients }}</th><th>{{ t.analytics_count }}</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="analytics-top-card">
<h2>{{ t.analytics_top_media }}</h2>
<table class="analytics-top-table" id="anTopMedia">
<thead><tr><th>{{ t.analytics_top_media }}</th><th>{{ t.analytics_count }}</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="analytics-log-card">
<div class="analytics-log-head">
<h2>{{ t.analytics_log_title }}</h2>
<div class="analytics-metric-toggles analytics-log-filters">
<span class="toggles-label">{{ t.analytics_log_show }}:</span>
<label><input type="checkbox" class="log-type-toggle" data-type="landing_view"> {{ t.analytics_ev_landing_view }}</label>
<label><input type="checkbox" class="log-type-toggle" data-type="portal_open" checked> {{ t.analytics_ev_portal_open }}</label>
<label><input type="checkbox" class="log-type-toggle" data-type="media_view" checked> {{ t.analytics_ev_media_view }}</label>
</div>
</div>
<div class="analytics-log" id="anLog"></div>
</div>
<style>
.analytics-controls { display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end; margin-bottom:18px; }
.analytics-presets { display:flex; gap:6px; flex-wrap:wrap; }
.analytics-range { display:flex; gap:12px; flex-wrap:wrap; }
.analytics-range label { display:flex; flex-direction:column; font-size:.8rem; color:#666; gap:3px; }
.analytics-kpis { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:18px; }
.analytics-kpi { background:#fff; border-radius:12px; padding:14px 16px; box-shadow:0 1px 4px rgba(0,0,0,.06); border-left:4px solid var(--kpi-color,#7c6ed4); }
.analytics-kpi .kpi-value { font-size:1.7rem; font-weight:700; line-height:1.1; }
.analytics-kpi .kpi-label { font-size:.8rem; color:#777; margin-top:2px; }
.analytics-metric-toggles { display:flex; flex-wrap:wrap; gap:14px; align-items:center; margin-bottom:12px; font-size:.85rem; }
.analytics-metric-toggles .toggles-label { color:#777; font-weight:600; }
.analytics-metric-toggles label { display:flex; align-items:center; gap:5px; cursor:pointer; }
.analytics-chart-wrap { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-bottom:20px; }
.analytics-chart-box { position:relative; height:340px; width:100%; }
@media (max-width:600px) { .analytics-chart-box { height:260px; } }
.analytics-tops { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:16px; }
.analytics-top-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); }
.analytics-top-card h2 { font-size:1rem; margin:0 0 10px; }
.analytics-top-table { width:100%; border-collapse:collapse; font-size:.9rem; }
.analytics-top-table th { text-align:left; color:#888; font-weight:600; font-size:.78rem; border-bottom:1px solid #eee; padding:6px 4px; }
.analytics-top-table td { padding:6px 4px; border-bottom:1px solid #f4f4f4; }
.analytics-top-table td:last-child, .analytics-top-table th:last-child { text-align:right; width:70px; }
.analytics-top-empty { color:#aaa; padding:10px 4px; }
.an-media-link { display:flex; align-items:center; gap:8px; color:inherit; text-decoration:none; }
.an-media-link:hover { text-decoration:underline; }
.an-media-thumb { width:40px; height:40px; border-radius:6px; object-fit:cover; background:#eceaf5; flex:0 0 auto; }
.an-media-missing { width:40px; height:40px; border-radius:6px; background:#f0f0f3; display:inline-flex; align-items:center; justify-content:center; color:#bbb; flex:0 0 auto; }
.analytics-log-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-top:20px; }
.analytics-log-card h2 { font-size:1rem; margin:0; }
.analytics-log-head { display:flex; flex-wrap:wrap; gap:10px 18px; align-items:center; justify-content:space-between; margin-bottom:10px; }
.analytics-log-filters { margin-bottom:0; }
.analytics-log { max-height:460px; overflow-y:auto; border:1px solid #f0f0f0; border-radius:8px; }
.an-log-row { display:flex; gap:12px; align-items:flex-start; padding:10px 12px; border-bottom:1px solid #f5f5f5; font-size:.85rem; }
.an-log-row:last-child { border-bottom:none; }
.an-log-icon { font-size:1.1rem; line-height:1.4; flex:0 0 auto; }
.an-log-main { flex:1 1 auto; min-width:0; }
.an-log-head { display:flex; flex-wrap:wrap; gap:8px; align-items:baseline; }
.an-log-event { font-weight:600; }
.an-log-client { color:#7c6ed4; }
.an-log-time { color:#999; font-size:.78rem; margin-left:auto; white-space:nowrap; }
.an-log-meta { color:#888; font-size:.78rem; margin-top:3px; word-break:break-word; }
.an-log-meta span { margin-right:12px; }
.an-log-meta code { background:rgba(0,0,0,.04); padding:1px 4px; border-radius:3px; }
.an-log-thumb { width:44px; height:44px; border-radius:6px; object-fit:cover; background:#eceaf5; flex:0 0 auto; }
.an-log-status { text-align:center; color:#aaa; padding:14px; font-size:.85rem; }
</style>
<script src="/static/chart.min.js?v={{ t.app_version() }}"></script>
<script>
(function () {
var controls = document.querySelector('.analytics-controls');
var L = JSON.parse(controls.getAttribute('data-labels'));
var META = [
{ key: 'landing_views', color: '#7c6ed4' },
{ key: 'landing_unique', color: '#00b496' },
{ key: 'portal_opens', color: '#ff8c26' },
{ key: 'media_views', color: '#ff5287' }
];
var fromInput = document.getElementById('anFrom');
var toInput = document.getElementById('anTo');
var granSelect = document.getElementById('anGran');
var kpisEl = document.getElementById('anKpis');
var chart = null;
// Draws a centered status message ("loading", "no data", error) directly on
// the chart canvas, so state changes never shift the page layout.
var messageOverlay = {
id: 'messageOverlay',
afterDraw: function (c) {
var msg = c.$message;
if (!msg) { return; }
var ctx = c.ctx;
var a = c.chartArea;
ctx.save();
ctx.fillStyle = 'rgba(255,255,255,0.72)';
ctx.fillRect(a.left, a.top, a.right - a.left, a.bottom - a.top);
ctx.fillStyle = '#8a8a8a';
ctx.font = '600 14px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(msg, (a.left + a.right) / 2, (a.top + a.bottom) / 2);
ctx.restore();
}
};
function setMessage(msg) {
if (!chart) { return; }
chart.$message = msg;
chart.update('none');
}
function fmt(d) {
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
function setRange(days) {
var to = new Date();
var from = new Date();
from.setDate(from.getDate() - (days - 1));
fromInput.value = fmt(from);
toInput.value = fmt(to);
}
function metricLabel(key) { return L[key] || key; }
function hexToRgba(hex, a) {
var n = parseInt(hex.slice(1), 16);
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
}
function renderKpis(report) {
kpisEl.replaceChildren();
META.forEach(function (m) {
var metric = report.metrics.find(function (x) { return x.key === m.key; });
var total = metric ? metric.total : 0;
var card = document.createElement('div');
card.className = 'analytics-kpi';
card.style.setProperty('--kpi-color', m.color);
var v = document.createElement('div');
v.className = 'kpi-value';
v.textContent = total.toLocaleString();
var lbl = document.createElement('div');
lbl.className = 'kpi-label';
lbl.textContent = metricLabel(m.key);
card.appendChild(v);
card.appendChild(lbl);
kpisEl.appendChild(card);
});
}
function initChart() {
var ctx = document.getElementById('anChart').getContext('2d');
var datasets = META.map(function (m) {
return {
label: metricLabel(m.key),
data: [],
borderColor: m.color,
backgroundColor: hexToRgba(m.color, 0.12),
borderWidth: 2,
pointRadius: 2,
tension: 0.25,
fill: true,
hidden: false,
_key: m.key
};
});
chart = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
},
plugins: [messageOverlay]
});
}
function applyData(report) {
chart.data.labels = report.labels;
chart.data.datasets.forEach(function (ds) {
var metric = report.metrics.find(function (x) { return x.key === ds._key; });
ds.data = metric ? metric.values : [];
});
chart.update('none');
}
function renderTop(tableId, rows) {
var tbody = document.querySelector('#' + tableId + ' tbody');
tbody.replaceChildren();
if (!rows || rows.length === 0) {
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 2;
td.className = 'analytics-top-empty';
td.textContent = L.no_data;
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
rows.forEach(function (row) {
var tr = document.createElement('tr');
var name = document.createElement('td');
name.textContent = row.label;
var cnt = document.createElement('td');
cnt.textContent = row.count.toLocaleString();
tr.appendChild(name);
tr.appendChild(cnt);
tbody.appendChild(tr);
});
}
function mediaLabel(m) {
if (!m.exists) { return '#' + m.media_id; }
var base = m.client_name || '';
if (m.caption) { return base ? base + ': ' + m.caption : m.caption; }
return base ? base + ' #' + m.media_id : '#' + m.media_id;
}
// Builds a clickable preview (opens the shared lightbox) for a media ref,
// or a placeholder if the file was deleted.
function mediaLink(m, thumbClass) {
if (m && m.exists && m.url) {
var a = document.createElement('a');
a.href = m.url;
a.setAttribute('data-lightbox', m.file_type === 'video' ? 'video' : 'photo');
a.className = 'an-media-link';
var img = document.createElement('img');
img.className = thumbClass;
img.src = m.thumbnail_url;
img.alt = '';
img.loading = 'lazy';
a.appendChild(img);
var span = document.createElement('span');
span.textContent = mediaLabel(m);
a.appendChild(span);
return a;
}
var wrap = document.createElement('span');
wrap.className = 'an-media-link';
var ph = document.createElement('span');
ph.className = 'an-media-missing';
ph.textContent = '🗑';
wrap.appendChild(ph);
var lbl = document.createElement('span');
lbl.textContent = mediaLabel(m);
wrap.appendChild(lbl);
return wrap;
}
function renderTopMedia(rows) {
var tbody = document.querySelector('#anTopMedia tbody');
tbody.replaceChildren();
if (!rows || rows.length === 0) {
var tr = document.createElement('tr');
var td = document.createElement('td');
td.colSpan = 2;
td.className = 'analytics-top-empty';
td.textContent = L.no_data;
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
rows.forEach(function (m) {
var tr = document.createElement('tr');
var td1 = document.createElement('td');
td1.appendChild(mediaLink(m, 'an-media-thumb'));
var td2 = document.createElement('td');
td2.textContent = (m.count || 0).toLocaleString();
tr.appendChild(td1);
tr.appendChild(td2);
tbody.appendChild(tr);
});
}
function hasData(report) {
return report.metrics.some(function (m) { return m.total > 0; });
}
function load() {
var params = new URLSearchParams({
from: fromInput.value,
to: toInput.value,
granularity: granSelect.value
});
setMessage(L.loading);
fetch('/admin/analytics/data?' + params.toString(), { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (report) {
renderKpis(report);
applyData(report);
renderTop('anTopClients', report.top_clients);
renderTopMedia(report.top_media);
setMessage(hasData(report) ? '' : L.no_data);
})
.catch(function () { setMessage(L.error); });
}
document.querySelectorAll('.preset-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
setRange(parseInt(btn.getAttribute('data-days'), 10));
load();
});
});
fromInput.addEventListener('change', load);
toInput.addEventListener('change', load);
granSelect.addEventListener('change', load);
document.querySelectorAll('.metric-toggle').forEach(function (cb) {
cb.addEventListener('change', function () {
if (!chart) { return; }
chart.data.datasets.forEach(function (ds) {
if (ds._key === cb.getAttribute('data-key')) { ds.hidden = !cb.checked; }
});
chart.update();
});
});
// --- Event log (newest first, infinite scroll of older events) ---------
var logEl = document.getElementById('anLog');
var logCursor = null;
var logLoading = false;
var logDone = false;
function logStatus(text) {
var s = document.createElement('div');
s.className = 'an-log-status';
s.textContent = text;
return s;
}
function eventIcon(type) {
if (type === 'landing_view') { return '🌐'; }
if (type === 'portal_open') { return '👤'; }
if (type === 'media_view') { return '🖼️'; }
return '•';
}
function shortUa(ua) {
if (!ua) { return ''; }
var browser = '';
if (/Edg\//.test(ua)) { browser = 'Edge'; }
else if (/OPR\/|Opera/.test(ua)) { browser = 'Opera'; }
else if (/Chrome\//.test(ua)) { browser = 'Chrome'; }
else if (/Firefox\//.test(ua)) { browser = 'Firefox'; }
else if (/Safari\//.test(ua)) { browser = 'Safari'; }
var os = '';
if (/Windows/.test(ua)) { os = 'Windows'; }
else if (/iPhone|iPad|iPod/.test(ua)) { os = 'iOS'; }
else if (/Android/.test(ua)) { os = 'Android'; }
else if (/Mac OS X|Macintosh/.test(ua)) { os = 'macOS'; }
else if (/Linux/.test(ua)) { os = 'Linux'; }
return [browser, os].filter(Boolean).join(' · ');
}
function metaSpan(text) {
var s = document.createElement('span');
s.textContent = text;
return s;
}
function renderLogRow(e) {
var row = document.createElement('div');
row.className = 'an-log-row';
var icon = document.createElement('div');
icon.className = 'an-log-icon';
icon.textContent = eventIcon(e.event_type);
row.appendChild(icon);
if (e.media && e.media.exists && e.media.url) {
var a = document.createElement('a');
a.href = e.media.url;
a.setAttribute('data-lightbox', e.media.file_type === 'video' ? 'video' : 'photo');
var img = document.createElement('img');
img.className = 'an-log-thumb';
img.src = e.media.thumbnail_url;
img.alt = '';
img.loading = 'lazy';
a.appendChild(img);
row.appendChild(a);
}
var main = document.createElement('div');
main.className = 'an-log-main';
var head = document.createElement('div');
head.className = 'an-log-head';
var ev = document.createElement('span');
ev.className = 'an-log-event';
ev.textContent = L[e.event_type] || e.event_type;
head.appendChild(ev);
if (e.client_name) {
var cl = document.createElement('span');
cl.className = 'an-log-client';
cl.textContent = e.client_name;
head.appendChild(cl);
}
var tm = document.createElement('span');
tm.className = 'an-log-time';
tm.textContent = e.created_at;
head.appendChild(tm);
main.appendChild(head);
var meta = document.createElement('div');
meta.className = 'an-log-meta';
if (e.visitor) { meta.appendChild(metaSpan(L.visitor + ': ' + e.visitor)); }
var ua = shortUa(e.user_agent);
if (ua) { meta.appendChild(metaSpan(ua)); }
if (e.path && e.path !== '/') {
var p = document.createElement('span');
var code = document.createElement('code');
code.textContent = e.path;
p.appendChild(code);
meta.appendChild(p);
}
if (e.referer) { meta.appendChild(metaSpan('← ' + e.referer)); }
if (meta.childNodes.length) { main.appendChild(meta); }
row.appendChild(main);
return row;
}
function selectedLogTypes() {
var types = [];
document.querySelectorAll('.log-type-toggle').forEach(function (cb) {
if (cb.checked) { types.push(cb.getAttribute('data-type')); }
});
return types;
}
function resetLog() {
logEl.replaceChildren();
logCursor = null;
logLoading = false;
logDone = false;
loadLog();
}
function loadLog() {
if (logLoading || logDone) { return; }
var types = selectedLogTypes();
if (types.length === 0) {
logEl.replaceChildren(logStatus(L.log_empty));
logDone = true;
return;
}
logLoading = true;
var busy = logStatus(L.loading);
logEl.appendChild(busy);
var params = new URLSearchParams({ limit: '40' });
if (logCursor) { params.set('before', logCursor); }
types.forEach(function (t) { params.append('types', t); });
fetch('/admin/analytics/events?' + params.toString(), { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
busy.remove();
(data.events || []).forEach(function (e) { logEl.appendChild(renderLogRow(e)); });
logCursor = data.next_cursor;
logLoading = false;
if (!logEl.querySelector('.an-log-row')) {
logEl.appendChild(logStatus(L.log_empty));
logDone = true;
return;
}
if (!logCursor) {
logDone = true;
logEl.appendChild(logStatus(L.log_end));
return;
}
// Keep loading until the scroll container is actually scrollable.
if (logEl.scrollHeight <= logEl.clientHeight + 20) { loadLog(); }
})
.catch(function () {
busy.remove();
logLoading = false;
logEl.appendChild(logStatus(L.error));
});
}
logEl.addEventListener('scroll', function () {
if (logEl.scrollTop + logEl.clientHeight >= logEl.scrollHeight - 40) { loadLog(); }
});
document.querySelectorAll('.log-type-toggle').forEach(function (cb) {
cb.addEventListener('change', resetLog);
});
initChart();
setRange(30);
load();
loadLog();
})();
</script>
{% endblock %}