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.")
|
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
|
# 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."""
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 = (() => {
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue