Feature: Analytics 30-Tage Dual-Chart, Referrers, Umami-Credentials — SW by-v489, APP_VER 466
This commit is contained in:
parent
a4da7144d6
commit
e22dcc3c3d
4 changed files with 154 additions and 90 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
"""BAN YARO — Admin / Moderator Backend"""
|
"""BAN YARO — Admin / Moderator Backend"""
|
||||||
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
@ -707,35 +708,44 @@ async def get_analytics(user=Depends(require_mod)):
|
||||||
now = datetime.now(_TZ)
|
now = datetime.now(_TZ)
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
week_start = today_start - timedelta(days=6)
|
week_start = today_start - timedelta(days=6)
|
||||||
|
month_start = today_start - timedelta(days=29)
|
||||||
now_ms = int(now.timestamp() * 1000)
|
now_ms = int(now.timestamp() * 1000)
|
||||||
today_ms = int(today_start.timestamp() * 1000)
|
today_ms = int(today_start.timestamp() * 1000)
|
||||||
week_ms = int(week_start.timestamp() * 1000)
|
week_ms = int(week_start.timestamp() * 1000)
|
||||||
|
month_ms = int(month_start.timestamp() * 1000)
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=10) as c:
|
async with httpx.AsyncClient(timeout=15) as c:
|
||||||
r_today = await c.get(f"{url}/api/websites/{site_id}/stats",
|
r_today, r_week, r_month, r_pv_month, r_pages, r_refs = await asyncio.gather(
|
||||||
params={"startAt": today_ms, "endAt": now_ms}, headers=headers)
|
c.get(f"{url}/api/websites/{site_id}/stats",
|
||||||
r_week = await c.get(f"{url}/api/websites/{site_id}/stats",
|
params={"startAt": today_ms, "endAt": now_ms}, headers=headers),
|
||||||
params={"startAt": week_ms, "endAt": now_ms}, headers=headers)
|
c.get(f"{url}/api/websites/{site_id}/stats",
|
||||||
r_pv = await c.get(f"{url}/api/websites/{site_id}/pageviews",
|
params={"startAt": week_ms, "endAt": now_ms}, headers=headers),
|
||||||
params={"startAt": week_ms, "endAt": now_ms,
|
c.get(f"{url}/api/websites/{site_id}/stats",
|
||||||
"unit": "day", "timezone": "Europe/Berlin"}, headers=headers)
|
params={"startAt": month_ms, "endAt": now_ms}, headers=headers),
|
||||||
r_pages = await c.get(f"{url}/api/websites/{site_id}/metrics",
|
c.get(f"{url}/api/websites/{site_id}/pageviews",
|
||||||
params={"startAt": week_ms, "endAt": now_ms,
|
params={"startAt": month_ms, "endAt": now_ms,
|
||||||
"type": "url", "limit": 8}, headers=headers)
|
"unit": "day", "timezone": "Europe/Berlin"}, headers=headers),
|
||||||
|
c.get(f"{url}/api/websites/{site_id}/metrics",
|
||||||
|
params={"startAt": month_ms, "endAt": now_ms,
|
||||||
|
"type": "url", "limit": 10}, headers=headers),
|
||||||
|
c.get(f"{url}/api/websites/{site_id}/metrics",
|
||||||
|
params={"startAt": month_ms, "endAt": now_ms,
|
||||||
|
"type": "referrer", "limit": 8}, headers=headers),
|
||||||
|
)
|
||||||
|
|
||||||
def _to_list(r):
|
def _to_list(r):
|
||||||
j = r.json()
|
j = r.json()
|
||||||
if isinstance(j, list):
|
if isinstance(j, list): return j
|
||||||
return j
|
if isinstance(j, dict): return j.get("data", j.get("metrics", []))
|
||||||
if isinstance(j, dict):
|
|
||||||
return j.get("data", j.get("metrics", []))
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"today": r_today.json(),
|
"today": r_today.json(),
|
||||||
"week": r_week.json(),
|
"week": r_week.json(),
|
||||||
"pageviews": r_pv.json(),
|
"month": r_month.json(),
|
||||||
|
"pageviews": r_pv_month.json(),
|
||||||
"top_pages": _to_list(r_pages),
|
"top_pages": _to_list(r_pages),
|
||||||
|
"referrers": _to_list(r_refs),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '465'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '466'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
|
|
||||||
const App = (() => {
|
const App = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,95 +218,149 @@ window.Page_admin = (() => {
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// TAB: ANALYTICS
|
// TAB: ANALYTICS
|
||||||
async function _renderAnalytics(el) {
|
async function _renderAnalytics(el) {
|
||||||
const d = await API.get('/admin/analytics');
|
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-text-muted);font-size:var(--text-sm)">Lade Analytics…</div>`;
|
||||||
|
let d;
|
||||||
|
try { d = await API.get('/admin/analytics'); }
|
||||||
|
catch (err) {
|
||||||
|
el.innerHTML = `<div style="padding:var(--space-4);color:var(--c-danger);font-size:var(--text-sm)">
|
||||||
|
${UI.icon('warning')} Fehler: ${_esc(err.message || String(err))}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tv = v => (v != null && typeof v === 'object') ? (v.value ?? 0) : (v ?? 0);
|
||||||
|
const fmt = v => Number(v).toLocaleString('de');
|
||||||
|
|
||||||
const pv = d.pageviews?.pageviews ?? [];
|
const pv = d.pageviews?.pageviews ?? [];
|
||||||
const ses = d.pageviews?.sessions ?? [];
|
const ses = d.pageviews?.sessions ?? [];
|
||||||
|
|
||||||
// Sparkline SVG (Seitenaufrufe 7 Tage)
|
// Bounce + Verweildauer
|
||||||
function _sparkline(data, color) {
|
const _bounces = tv(d.today?.bounces), _vis = tv(d.today?.visits);
|
||||||
if (!data.length) return '<span style="color:var(--c-text-muted);font-size:var(--text-xs)">Keine Daten</span>';
|
const bounceToday = _vis > 0 ? ((_bounces / _vis) * 100).toFixed(0) + ' %' : '—';
|
||||||
const vals = data.map(p => p.y ?? 0);
|
const _tt = tv(d.week?.totaltime), _vw = tv(d.week?.visits);
|
||||||
const max = Math.max(...vals, 1);
|
const timeWeek = _tt > 0 && _vw > 0 ? Math.round(_tt / _vw) + ' s' : '—';
|
||||||
const W = 200, H = 48, pad = 4;
|
|
||||||
const pts = vals.map((v, i) => {
|
// Dual-Series Area+Line Chart (SVG)
|
||||||
const x = pad + i * ((W - 2*pad) / Math.max(vals.length - 1, 1));
|
function _dualChart(pvData, sesData) {
|
||||||
const y = H - pad - (v / max) * (H - 2*pad);
|
if (!pvData.length) return `<p style="color:var(--c-text-muted);font-size:var(--text-xs);margin:0">Keine Daten</p>`;
|
||||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
const W = 800, H = 120, padX = 0, padY = 8;
|
||||||
}).join(' ');
|
const pvVals = pvData.map(p => p.y ?? 0);
|
||||||
return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:48px">
|
const sesVals = sesData.map(p => p.y ?? 0);
|
||||||
<polyline points="${pts}" fill="none" stroke="${color}" stroke-width="2"
|
const maxVal = Math.max(...pvVals, ...sesVals, 1);
|
||||||
stroke-linejoin="round" stroke-linecap="round"/>
|
const n = pvData.length;
|
||||||
|
|
||||||
|
function toXY(vals) {
|
||||||
|
return vals.map((v, i) => {
|
||||||
|
const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1));
|
||||||
|
const y = H - padY - (v / maxVal) * (H - 2*padY);
|
||||||
|
return [x.toFixed(1), y.toFixed(1)];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pvPts = toXY(pvVals);
|
||||||
|
const sesPts = toXY(sesVals);
|
||||||
|
const pvLine = pvPts.map(p => p.join(',')).join(' ');
|
||||||
|
const sesLine = sesPts.map(p => p.join(',')).join(' ');
|
||||||
|
|
||||||
|
// Filled area unter pageviews
|
||||||
|
const areaPath = `M ${pvPts[0][0]},${H} ` +
|
||||||
|
pvPts.map(p => `L ${p[0]},${p[1]}`).join(' ') +
|
||||||
|
` L ${pvPts[pvPts.length-1][0]},${H} Z`;
|
||||||
|
|
||||||
|
// X-Achsen-Labels: Anfang, Mitte, Ende
|
||||||
|
const labelIdx = [0, Math.floor(n/2), n-1].filter((v,i,a) => a.indexOf(v)===i);
|
||||||
|
const labels = labelIdx.map(i => {
|
||||||
|
const x = n === 1 ? W/2 : padX + i * ((W - 2*padX) / (n - 1));
|
||||||
|
const date = pvData[i]?.x ? new Date(pvData[i].x).toLocaleDateString('de',{day:'2-digit',month:'2-digit'}) : '';
|
||||||
|
return `<text x="${x.toFixed(0)}" y="${H+14}" text-anchor="middle"
|
||||||
|
font-size="10" fill="currentColor" style="color:var(--c-text-muted)">${date}</text>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<svg viewBox="0 0 ${W} ${H+18}" style="width:100%;height:100px;display:block;overflow:visible"
|
||||||
|
preserveAspectRatio="none">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="pvGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="var(--c-primary)" stop-opacity="0.25"/>
|
||||||
|
<stop offset="100%" stop-color="var(--c-primary)" stop-opacity="0.02"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="${areaPath}" fill="url(#pvGrad)"/>
|
||||||
|
<polyline points="${pvLine}" fill="none" stroke="var(--c-primary)"
|
||||||
|
stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
|
||||||
|
<polyline points="${sesLine}" fill="none" stroke="var(--c-success)"
|
||||||
|
stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" stroke-dasharray="4,2"/>
|
||||||
|
${labels}
|
||||||
</svg>`;
|
</svg>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Umami v2 liefert plain numbers, v1 liefert {value: X} — beide abfangen
|
// Balken-Chart für Top-Pages / Referrers
|
||||||
const tv = v => (v != null && typeof v === 'object') ? (v.value ?? 0) : (v ?? 0);
|
function _barChart(items, labelKey = 'x', valKey = 'y') {
|
||||||
const fmt = v => Number(v).toLocaleString('de');
|
if (!items?.length) return `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Keine Daten</p>`;
|
||||||
|
const maxV = Math.max(...items.map(p => p[valKey] ?? 0), 1);
|
||||||
// Bounce Rate & Verweildauer
|
return `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
||||||
const _bounces = tv(d.today?.bounces);
|
${items.map(p => {
|
||||||
const _visits = tv(d.today?.visits);
|
const pct = ((p[valKey] ?? 0) / maxV * 100).toFixed(0);
|
||||||
const bounceToday = _visits > 0
|
return `<div>
|
||||||
? ((_bounces / _visits) * 100).toFixed(0) + ' %'
|
<div style="display:flex;justify-content:space-between;font-size:var(--text-xs);margin-bottom:3px">
|
||||||
: '—';
|
<span style="color:var(--c-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:78%">${_esc(p[labelKey] || '—')}</span>
|
||||||
const _totaltime = tv(d.week?.totaltime);
|
<span style="color:var(--c-text-secondary);flex-shrink:0;margin-left:var(--space-2)">${fmt(p[valKey] ?? 0)}</span>
|
||||||
const _visitsW = tv(d.week?.visits);
|
</div>
|
||||||
const timeWeek = _totaltime > 0 && _visitsW > 0
|
<div style="height:4px;border-radius:2px;background:var(--c-surface-2)">
|
||||||
? Math.round(_totaltime / _visitsW) + ' s'
|
<div style="height:100%;width:${pct}%;border-radius:2px;background:var(--c-primary);transition:width .4s"></div>
|
||||||
: '—';
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
|
<div style="padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4)">
|
||||||
|
|
||||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:var(--space-3)">
|
<!-- Stat-Karten -->
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:var(--space-3)">
|
||||||
${_statCard('user', 'Besucher heute', fmt(tv(d.today?.visitors)), 'var(--c-primary)')}
|
${_statCard('user', 'Besucher heute', fmt(tv(d.today?.visitors)), 'var(--c-primary)')}
|
||||||
${_statCard('eye', 'Aufrufe heute', fmt(tv(d.today?.pageviews)), 'var(--c-primary)')}
|
${_statCard('eye', 'Aufrufe heute', fmt(tv(d.today?.pageviews)), 'var(--c-primary)')}
|
||||||
${_statCard('users','Besucher 7 Tage', fmt(tv(d.week?.visitors)), 'var(--c-success)')}
|
${_statCard('users', 'Besucher 7 Tage', fmt(tv(d.week?.visitors)), 'var(--c-success)')}
|
||||||
${_statCard('eye', 'Aufrufe 7 Tage', fmt(tv(d.week?.pageviews)), 'var(--c-success)')}
|
${_statCard('eye', 'Aufrufe 7 Tage', fmt(tv(d.week?.pageviews)), 'var(--c-success)')}
|
||||||
${_statCard('arrow-u-up-left','Bounce heute', bounceToday, 'var(--c-text-secondary)')}
|
${_statCard('users', 'Besucher 30 Tage', fmt(tv(d.month?.visitors)), 'var(--c-text-secondary)')}
|
||||||
${_statCard('timer','Ø Verweildauer 7 Tage', timeWeek, 'var(--c-text-secondary)')}
|
${_statCard('eye', 'Aufrufe 30 Tage', fmt(tv(d.month?.pageviews)), 'var(--c-text-secondary)')}
|
||||||
|
${_statCard('arrow-u-up-left', 'Bounce heute', bounceToday, 'var(--c-text-secondary)')}
|
||||||
|
${_statCard('timer', 'Ø Verweildauer 7d', timeWeek, 'var(--c-text-secondary)')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Verlaufschart 30 Tage -->
|
||||||
<div class="card" style="padding:var(--space-4)">
|
<div class="card" style="padding:var(--space-4)">
|
||||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-3)">
|
||||||
color:var(--c-text);margin-bottom:var(--space-3)">Seitenaufrufe — letzte 7 Tage</div>
|
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold)">Verlauf — letzte 30 Tage</span>
|
||||||
${_sparkline(pv, 'var(--c-primary)')}
|
<div style="display:flex;gap:var(--space-3);font-size:10px;color:var(--c-text-muted)">
|
||||||
<div style="display:flex;justify-content:space-between;
|
<span style="display:flex;align-items:center;gap:4px">
|
||||||
font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-1)">
|
<span style="width:12px;height:2px;background:var(--c-primary);display:inline-block;border-radius:1px"></span> Aufrufe
|
||||||
${pv.map(p => `<span>${new Date(p.x).toLocaleDateString('de',{weekday:'short'})}</span>`).join('')}
|
</span>
|
||||||
|
<span style="display:flex;align-items:center;gap:4px">
|
||||||
|
<span style="width:12px;height:2px;background:var(--c-success);display:inline-block;border-radius:1px;
|
||||||
|
border-bottom:2px dashed var(--c-success);background:none"></span> Besucher
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
${_dualChart(pv, ses)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Seiten + Referrers nebeneinander -->
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-4)">
|
||||||
<div class="card" style="padding:var(--space-4)">
|
<div class="card" style="padding:var(--space-4)">
|
||||||
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||||
color:var(--c-text);margin-bottom:var(--space-3)">Top Seiten — letzte 7 Tage</div>
|
margin-bottom:var(--space-3)">Top Seiten — 30 Tage</div>
|
||||||
${(d.top_pages ?? []).length === 0
|
${_barChart(d.top_pages)}
|
||||||
? `<p style="color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Daten</p>`
|
|
||||||
: `<div style="display:flex;flex-direction:column;gap:var(--space-2)">
|
|
||||||
${d.top_pages.map(p => {
|
|
||||||
const maxY = d.top_pages[0].y;
|
|
||||||
const pct = maxY > 0 ? (p.y / maxY * 100).toFixed(0) : 0;
|
|
||||||
return `
|
|
||||||
<div>
|
|
||||||
<div style="display:flex;justify-content:space-between;
|
|
||||||
font-size:var(--text-xs);margin-bottom:3px">
|
|
||||||
<span style="color:var(--c-text);overflow:hidden;text-overflow:ellipsis;
|
|
||||||
white-space:nowrap;max-width:75%">${UI.escape(p.x)}</span>
|
|
||||||
<span style="color:var(--c-text-secondary);flex-shrink:0">${fmt(p.y)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div style="height:4px;border-radius:2px;background:var(--c-surface-3)">
|
<div class="card" style="padding:var(--space-4)">
|
||||||
<div style="height:100%;width:${pct}%;border-radius:2px;
|
<div style="font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||||
background:var(--c-primary);transition:width .3s"></div>
|
margin-bottom:var(--space-3)">Referrers — 30 Tage</div>
|
||||||
|
${_barChart(d.referrers)}
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
|
||||||
}).join('')}
|
|
||||||
</div>`}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// TAB: ÜBERSICHT
|
// TAB: ÜBERSICHT
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v488';
|
const CACHE_VERSION = 'by-v489';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue