Feature: ORS-Stats im Admin-Panel — Tagesverbrauch/2000, 30-Tage-Sparkline, Top-Nutzer — SW by-v485, APP_VER 462
This commit is contained in:
parent
69140a261e
commit
392359df45
6 changed files with 170 additions and 3 deletions
|
|
@ -1422,3 +1422,14 @@ def _migrate(conn_factory):
|
|||
);
|
||||
""")
|
||||
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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
@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")
|
||||
async def generate_media_previews(user=Depends(require_admin)):
|
||||
"""Generiert fehlende _preview.jpg für alle Bilder in /data/media."""
|
||||
|
|
|
|||
|
|
@ -283,6 +283,14 @@ async def suggest_route(data: SuggestRequest, user=Depends(get_current_user)):
|
|||
""", (user["id"], week_start))
|
||||
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)
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
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 = (() => {
|
||||
|
||||
|
|
|
|||
|
|
@ -903,6 +903,7 @@ window.Page_admin = (() => {
|
|||
${UI.icon('arrows-clockwise')} Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div id="adm-ors-card"></div>
|
||||
<div id="adm-sys-cards">Lade…</div>
|
||||
<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);
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
const d = Math.floor(secs / 86400);
|
||||
const h = Math.floor((secs % 86400) / 3600);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v484';
|
||||
const CACHE_VERSION = 'by-v485';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue