Feature: ORS-Stats im Admin-Panel — Tagesverbrauch/2000, 30-Tage-Sparkline, Top-Nutzer — SW by-v485, APP_VER 462

This commit is contained in:
rene 2026-04-29 10:10:59 +02:00
parent 69140a261e
commit 392359df45
6 changed files with 170 additions and 3 deletions

View file

@ -1422,3 +1422,14 @@ def _migrate(conn_factory):
); );
""") """)
logger.info("Migration: route_suggest_usage Tabelle bereit.") logger.info("Migration: route_suggest_usage Tabelle bereit.")
# ORS tägliche Gesamtaufrufe (für Admin-Dashboard)
existing_ors = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='ors_daily_total'").fetchone()
if not existing_ors:
conn.executescript("""
CREATE TABLE ors_daily_total (
date TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0
);
""")
logger.info("Migration: ors_daily_total erstellt.")

View file

@ -853,6 +853,54 @@ async def admin_delete_zuchter(zuchter_id: int, user=Depends(require_mod)):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# POST /api/admin/media/generate-previews — Previews für Bestandsmedien # POST /api/admin/media/generate-previews — Previews für Bestandsmedien
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.get("/ors/stats")
async def ors_stats(user=Depends(require_mod)):
"""ORS-Routenvorschlag Statistiken für Admin-Panel."""
with db() as conn:
# Heute
today = __import__('datetime').date.today().isoformat()
today_row = conn.execute(
"SELECT COALESCE(count,0) as count FROM ors_daily_total WHERE date=?", (today,)
).fetchone()
today_count = today_row["count"] if today_row else 0
# Letzte 30 Tage (Verlauf)
daily_history = conn.execute("""
SELECT date, count FROM ors_daily_total
WHERE date >= DATE('now', '-29 days')
ORDER BY date ASC
""").fetchall()
# Letzte 8 Wochen (Wochensummen aus route_suggest_usage)
weekly_totals = conn.execute("""
SELECT week, SUM(count) as count
FROM route_suggest_usage
WHERE week >= DATE('now', '-56 days')
GROUP BY week ORDER BY week ASC
""").fetchall()
# Top-Nutzer (alle Zeiten)
top_users = conn.execute("""
SELECT u.name, u.email,
SUM(r.count) as total,
MAX(r.week) as last_week
FROM route_suggest_usage r
JOIN users u ON u.id = r.user_id
GROUP BY r.user_id
ORDER BY total DESC
LIMIT 15
""").fetchall()
return {
"today_count": today_count,
"today_limit": 2000,
"daily_history": [{"date": r["date"], "count": r["count"]} for r in daily_history],
"weekly_totals": [{"week": r["week"], "count": r["count"]} for r in weekly_totals],
"top_users": [{"name": r["name"], "email": r["email"],
"total": r["total"], "last_week": r["last_week"]} for r in top_users],
}
@router.post("/media/generate-previews") @router.post("/media/generate-previews")
async def generate_media_previews(user=Depends(require_admin)): async def generate_media_previews(user=Depends(require_admin)):
"""Generiert fehlende _preview.jpg für alle Bilder in /data/media.""" """Generiert fehlende _preview.jpg für alle Bilder in /data/media."""

View file

@ -283,6 +283,14 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)):
""", (user["id"], week_start)) """, (user["id"], week_start))
current_count += 1 current_count += 1
# Täglichen Gesamtzähler hochzählen (für Admin-Stats)
today_str = _dt.date.today().isoformat()
with db() as conn:
conn.execute("""
INSERT INTO ors_daily_total (date, count) VALUES (?, 1)
ON CONFLICT(date) DO UPDATE SET count = count + 1
""", (today_str,))
weekly_remaining = None if is_privileged else max(0, WEEKLY_LIMIT - current_count) weekly_remaining = None if is_privileged else max(0, WEEKLY_LIMIT - current_count)
return { return {

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '461'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '462'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const App = (() => { const App = (() => {

View file

@ -903,6 +903,7 @@ window.Page_admin = (() => {
${UI.icon('arrows-clockwise')} Aktualisieren ${UI.icon('arrows-clockwise')} Aktualisieren
</button> </button>
</div> </div>
<div id="adm-ors-card"></div>
<div id="adm-sys-cards">Lade</div> <div id="adm-sys-cards">Lade</div>
<div class="card" style="margin-top:var(--space-4);padding:var(--space-4)"> <div class="card" style="margin-top:var(--space-4);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);
@ -1071,7 +1072,11 @@ window.Page_admin = (() => {
} }
}); });
await _loadSystemCards(el.querySelector('#adm-sys-cards')); const [, orsStats] = await Promise.all([
_loadSystemCards(el.querySelector('#adm-sys-cards')),
API.get('/admin/ors/stats').catch(() => null),
]);
_renderOrsCard(el.querySelector('#adm-ors-card'), orsStats);
await loadLogs(); await loadLogs();
} }
@ -1109,6 +1114,101 @@ window.Page_admin = (() => {
`; `;
} }
function _renderOrsCard(el, d) {
if (!el) return;
if (!d || d.today_count == null) { el.innerHTML = ''; return; }
const limit = d.today_limit ?? 2000;
const count = d.today_count ?? 0;
const pct = Math.min(Math.round(count / limit * 100), 100);
const barColor = pct < 50 ? '#4ade80' : pct <= 80 ? '#facc15' : '#f87171';
// Sparkline aus daily_history (letzte 30 Tage)
const hist = Array.isArray(d.daily_history) ? d.daily_history : [];
const W = 400, H = 60, padY = 5;
let sparkline = '';
if (hist.length >= 2) {
const maxC = Math.max(...hist.map(h => h.count), 1);
const pts = hist.map((h, i) => {
const x = (i / (hist.length - 1)) * W;
const y = H - padY - (h.count / maxC) * (H - 2 * padY);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
sparkline = `<polyline points="${pts}" fill="none" stroke="var(--c-primary)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>`;
} else {
sparkline = `<polyline points="0,55 ${W},55" fill="none" stroke="var(--c-primary)" stroke-width="1.5"/>`;
}
const lastDate = hist.length ? hist[hist.length - 1].date : '';
// Top-Nutzer-Tabelle
const topUsers = Array.isArray(d.top_users) ? d.top_users.slice(0, 10) : [];
const userRows = topUsers.map(u => {
const emailDisplay = (u.email || '').length > 20
? '@' + (u.email || '').split('@')[1]
: _esc(u.email || '');
return `<tr>
<td style="padding:5px 8px;font-weight:500">${_esc(u.name || '')}</td>
<td style="padding:5px 8px;color:var(--c-text-muted);font-size:var(--text-xs)">${emailDisplay}</td>
<td style="padding:5px 8px;text-align:right;font-weight:600">${u.total ?? 0}</td>
<td style="padding:5px 8px;text-align:right;color:var(--c-text-muted);font-size:var(--text-xs)">${u.last_week || ''}</td>
</tr>`;
}).join('');
el.innerHTML = `
<div class="card" style="margin-bottom:var(--space-4);padding:var(--space-4)">
<!-- Header -->
<div style="display:flex;align-items:center;gap:var(--space-3);margin-bottom:var(--space-3)">
<svg class="ph-icon" style="width:20px;height:20px;color:var(--c-primary);flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#path"></use>
</svg>
<span style="font-size:var(--text-sm);font-weight:var(--weight-semibold);color:var(--c-text);flex:1">
OpenRouteService
</span>
<span style="font-size:var(--text-xs);font-weight:700;padding:3px 10px;border-radius:999px;
background:${barColor}22;color:${barColor};border:1px solid ${barColor}44">
${count.toLocaleString('de')} / ${limit.toLocaleString('de')} heute
</span>
</div>
<!-- Fortschrittsbalken -->
<div style="height:6px;border-radius:3px;background:var(--c-border);overflow:hidden;margin-bottom:var(--space-4)">
<div style="height:100%;width:${pct}%;background:${barColor};border-radius:3px;transition:width 0.6s ease"></div>
</div>
<!-- Sparkline -->
<div style="margin-bottom:var(--space-1)">
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:60px;display:block">
${sparkline}
</svg>
</div>
<div style="display:flex;justify-content:space-between;font-size:var(--text-xs);
color:var(--c-text-muted);margin-bottom:var(--space-4)">
<span>30 Tage</span>
<span>${_esc(lastDate)}</span>
</div>
${topUsers.length ? `
<!-- Top-Nutzer -->
<div style="font-size:var(--text-xs);font-weight:700;text-transform:uppercase;letter-spacing:.05em;
color:var(--c-text-secondary);margin-bottom:var(--space-2)">Aktivste Nutzer</div>
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:var(--text-xs)">
<thead>
<tr style="border-bottom:1px solid var(--c-border)">
<th style="text-align:left;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Name</th>
<th style="text-align:left;padding:4px 8px;color:var(--c-text-muted);font-weight:600">E-Mail</th>
<th style="text-align:right;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Gesamt</th>
<th style="text-align:right;padding:4px 8px;color:var(--c-text-muted);font-weight:600">Letzte Woche</th>
</tr>
</thead>
<tbody>${userRows}</tbody>
</table>
</div>` : ''}
</div>`;
}
function _formatUptime(secs) { function _formatUptime(secs) {
const d = Math.floor(secs / 86400); const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600); const h = Math.floor((secs % 86400) / 3600);

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v484'; const CACHE_VERSION = 'by-v485';
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