From e22dcc3c3d76a6469eca1a26d5147a1a57556411 Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 29 Apr 2026 11:06:18 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Analytics=2030-Tage=20Dual-Chart,=20?= =?UTF-8?q?Referrers,=20Umami-Credentials=20=E2=80=94=20SW=20by-v489,=20AP?= =?UTF-8?q?P=5FVER=20466?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/admin.py | 48 +++++--- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 192 ++++++++++++++++++++----------- backend/static/sw.js | 2 +- 4 files changed, 154 insertions(+), 90 deletions(-) diff --git a/backend/routes/admin.py b/backend/routes/admin.py index b3bee12..4868a61 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1,4 +1,5 @@ """BAN YARO — Admin / Moderator Backend""" +import asyncio import os import sys import time @@ -707,35 +708,44 @@ async def get_analytics(user=Depends(require_mod)): now = datetime.now(_TZ) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) week_start = today_start - timedelta(days=6) + month_start = today_start - timedelta(days=29) now_ms = int(now.timestamp() * 1000) today_ms = int(today_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: - r_today = await c.get(f"{url}/api/websites/{site_id}/stats", - params={"startAt": today_ms, "endAt": now_ms}, headers=headers) - r_week = await c.get(f"{url}/api/websites/{site_id}/stats", - params={"startAt": week_ms, "endAt": now_ms}, headers=headers) - r_pv = await c.get(f"{url}/api/websites/{site_id}/pageviews", - params={"startAt": week_ms, "endAt": now_ms, - "unit": "day", "timezone": "Europe/Berlin"}, headers=headers) - r_pages = await c.get(f"{url}/api/websites/{site_id}/metrics", - params={"startAt": week_ms, "endAt": now_ms, - "type": "url", "limit": 8}, headers=headers) + async with httpx.AsyncClient(timeout=15) as c: + r_today, r_week, r_month, r_pv_month, r_pages, r_refs = await asyncio.gather( + c.get(f"{url}/api/websites/{site_id}/stats", + params={"startAt": today_ms, "endAt": now_ms}, headers=headers), + c.get(f"{url}/api/websites/{site_id}/stats", + params={"startAt": week_ms, "endAt": now_ms}, headers=headers), + c.get(f"{url}/api/websites/{site_id}/stats", + params={"startAt": month_ms, "endAt": now_ms}, headers=headers), + c.get(f"{url}/api/websites/{site_id}/pageviews", + params={"startAt": month_ms, "endAt": now_ms, + "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): j = r.json() - if isinstance(j, list): - return j - if isinstance(j, dict): - return j.get("data", j.get("metrics", [])) + if isinstance(j, list): return j + if isinstance(j, dict): return j.get("data", j.get("metrics", [])) return [] return { - "today": r_today.json(), - "week": r_week.json(), - "pageviews": r_pv.json(), - "top_pages": _to_list(r_pages), + "today": r_today.json(), + "week": r_week.json(), + "month": r_month.json(), + "pageviews": r_pv_month.json(), + "top_pages": _to_list(r_pages), + "referrers": _to_list(r_refs), } diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 48715ab..44bbf47 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ 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 = (() => { diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 24d651a..b1a0c3b 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -218,95 +218,149 @@ window.Page_admin = (() => { // ------------------------------------------------------------------ // TAB: ANALYTICS async function _renderAnalytics(el) { - const d = await API.get('/admin/analytics'); - - const pv = d.pageviews?.pageviews ?? []; - const ses = d.pageviews?.sessions ?? []; - - // Sparkline SVG (Seitenaufrufe 7 Tage) - function _sparkline(data, color) { - if (!data.length) return 'Keine Daten'; - const vals = data.map(p => p.y ?? 0); - const max = Math.max(...vals, 1); - const W = 200, H = 48, pad = 4; - const pts = vals.map((v, i) => { - const x = pad + i * ((W - 2*pad) / Math.max(vals.length - 1, 1)); - const y = H - pad - (v / max) * (H - 2*pad); - return `${x.toFixed(1)},${y.toFixed(1)}`; - }).join(' '); - return ` - - `; + el.innerHTML = `
Lade Analytics…
`; + let d; + try { d = await API.get('/admin/analytics'); } + catch (err) { + el.innerHTML = `
+ ${UI.icon('warning')} Fehler: ${_esc(err.message || String(err))}
`; + return; } - // Umami v2 liefert plain numbers, v1 liefert {value: X} — beide abfangen const tv = v => (v != null && typeof v === 'object') ? (v.value ?? 0) : (v ?? 0); const fmt = v => Number(v).toLocaleString('de'); - // Bounce Rate & Verweildauer - const _bounces = tv(d.today?.bounces); - const _visits = tv(d.today?.visits); - const bounceToday = _visits > 0 - ? ((_bounces / _visits) * 100).toFixed(0) + ' %' - : '—'; - const _totaltime = tv(d.week?.totaltime); - const _visitsW = tv(d.week?.visits); - const timeWeek = _totaltime > 0 && _visitsW > 0 - ? Math.round(_totaltime / _visitsW) + ' s' - : '—'; + const pv = d.pageviews?.pageviews ?? []; + const ses = d.pageviews?.sessions ?? []; + + // Bounce + Verweildauer + const _bounces = tv(d.today?.bounces), _vis = tv(d.today?.visits); + const bounceToday = _vis > 0 ? ((_bounces / _vis) * 100).toFixed(0) + ' %' : '—'; + const _tt = tv(d.week?.totaltime), _vw = tv(d.week?.visits); + const timeWeek = _tt > 0 && _vw > 0 ? Math.round(_tt / _vw) + ' s' : '—'; + + // Dual-Series Area+Line Chart (SVG) + function _dualChart(pvData, sesData) { + if (!pvData.length) return `

Keine Daten

`; + const W = 800, H = 120, padX = 0, padY = 8; + const pvVals = pvData.map(p => p.y ?? 0); + const sesVals = sesData.map(p => p.y ?? 0); + const maxVal = Math.max(...pvVals, ...sesVals, 1); + 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 `${date}`; + }).join(''); + + return ` + + + + + + + + + + ${labels} + `; + } + + // Balken-Chart für Top-Pages / Referrers + function _barChart(items, labelKey = 'x', valKey = 'y') { + if (!items?.length) return `

Keine Daten

`; + const maxV = Math.max(...items.map(p => p[valKey] ?? 0), 1); + return `
+ ${items.map(p => { + const pct = ((p[valKey] ?? 0) / maxV * 100).toFixed(0); + return `
+
+ ${_esc(p[labelKey] || '—')} + ${fmt(p[valKey] ?? 0)} +
+
+
+
+
`; + }).join('')} +
`; + } el.innerHTML = `
-
- ${_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('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('arrow-u-up-left','Bounce heute', bounceToday, 'var(--c-text-secondary)')} - ${_statCard('timer','Ø Verweildauer 7 Tage', timeWeek, 'var(--c-text-secondary)')} + +
+ ${_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('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('users', 'Besucher 30 Tage', fmt(tv(d.month?.visitors)), '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)')}
+
-
Seitenaufrufe — letzte 7 Tage
- ${_sparkline(pv, 'var(--c-primary)')} -
- ${pv.map(p => `${new Date(p.x).toLocaleDateString('de',{weekday:'short'})}`).join('')} +
+ Verlauf — letzte 30 Tage +
+ + Aufrufe + + + Besucher + +
+ ${_dualChart(pv, ses)}
-
-
Top Seiten — letzte 7 Tage
- ${(d.top_pages ?? []).length === 0 - ? `

Noch keine Daten

` - : `
- ${d.top_pages.map(p => { - const maxY = d.top_pages[0].y; - const pct = maxY > 0 ? (p.y / maxY * 100).toFixed(0) : 0; - return ` -
-
- ${UI.escape(p.x)} - ${fmt(p.y)} -
-
-
-
-
`; - }).join('')} -
`} + +
+
+
Top Seiten — 30 Tage
+ ${_barChart(d.top_pages)} +
+
+
Referrers — 30 Tage
+ ${_barChart(d.referrers)} +
`; } + // ------------------------------------------------------------------ // TAB: ÜBERSICHT // ------------------------------------------------------------------ diff --git a/backend/static/sw.js b/backend/static/sw.js index 046ab85..b3950ea 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v488'; +const CACHE_VERSION = 'by-v489'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten