Sprint 18: Notification Center, Routen entdecken, Onboarding, Admin-Erweiterungen
- Notifications: History-Tabelle, /api/notifications Endpoints, push.py schreibt in DB - Notifications: Page (notifications.js) mit Badge, Typen-Icons, gelesen-Markierung - Routen: Entdecken-Modus mit Ersteller-Anzeige, Nearby-Filter, Mine/Discover Toggle - Onboarding: Willkommens-Modal nach Registrierung, Push-Angebot nach Login - Admin: Scheduler-Tab (Jobs anzeigen + manuell triggern), System-Health (DB/Disk/Uptime) - Admin: Audit-Log (wer hat was wann gemacht), erweiterte Stats (Push-Abos, aktive User, Routen) - SW: by-v152, APP_VER 125
This commit is contained in:
parent
5927d384bf
commit
92620c2c52
14 changed files with 1035 additions and 46 deletions
|
|
@ -10,9 +10,12 @@ window.Page_admin = (() => {
|
|||
let _tab = 'uebersicht';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'uebersicht', label: 'Übersicht', icon: 'chart-bar' },
|
||||
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
|
||||
{ id: 'uebersicht', label: 'Übersicht', icon: 'chart-bar' },
|
||||
{ id: 'nutzer', label: 'Nutzer', icon: 'users' },
|
||||
{ id: 'forum', label: 'Forum & Meldungen',icon: 'chat-circle-dots' },
|
||||
{ id: 'system', label: 'System', icon: 'cpu' },
|
||||
{ id: 'jobs', label: 'Jobs', icon: 'timer' },
|
||||
{ id: 'audit', label: 'Audit-Log', icon: 'list-bullets' },
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
|
@ -76,6 +79,9 @@ window.Page_admin = (() => {
|
|||
case 'uebersicht': await _renderStats(el); break;
|
||||
case 'nutzer': await _renderUsers(el); break;
|
||||
case 'forum': await _renderForum(el); break;
|
||||
case 'system': await _renderSystem(el); break;
|
||||
case 'jobs': await _renderJobs(el); break;
|
||||
case 'audit': await _renderAudit(el); break;
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerHTML = _emptyState('warning', 'Fehler', e.message || 'Unbekannter Fehler.');
|
||||
|
|
@ -90,13 +96,18 @@ window.Page_admin = (() => {
|
|||
el.innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:var(--space-3);
|
||||
margin-bottom:var(--space-5)">
|
||||
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
|
||||
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')}
|
||||
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')}
|
||||
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')}
|
||||
${_statCard('warning', 'Offene Meldungen',s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')}
|
||||
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')}
|
||||
${_statCard('warning-octagon','Giftk. aktiv', s.poison_active,'var(--c-danger)')}
|
||||
${_statCard('users', 'Nutzer gesamt', s.users_total, 'var(--c-primary)')}
|
||||
${_statCard('user-plus', 'Neu heute', s.users_today, 'var(--c-success)')}
|
||||
${_statCard('activity', 'Aktiv (7 Tage)', s.active_users_7d, 'var(--c-primary)')}
|
||||
${_statCard('paw-print', 'Hunde', s.dogs_total, 'var(--c-primary)')}
|
||||
${_statCard('chat-circle-dots','Threads', s.threads, 'var(--c-text-secondary)')}
|
||||
${_statCard('warning', 'Offene Meldungen', s.open_reports, s.open_reports > 0 ? 'var(--c-danger)' : 'var(--c-text-muted)')}
|
||||
${_statCard('skull', 'Gesperrte User', s.banned, s.banned > 0 ? '#f59e0b' : 'var(--c-text-muted)')}
|
||||
${_statCard('warning-octagon', 'Giftk. aktiv', s.poison_active, 'var(--c-danger)')}
|
||||
${_statCard('bell', 'Push-Abos', s.push_subscriptions, 'var(--c-text-secondary)')}
|
||||
${_statCard('image', 'Media-Einträge', s.media_count, 'var(--c-text-secondary)')}
|
||||
${_statCard('map-pin', 'Routen', s.routes_total, 'var(--c-text-secondary)')}
|
||||
${_statCard('calendar', 'Events', s.events_total, 'var(--c-text-secondary)')}
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding:var(--space-4)">
|
||||
|
|
@ -532,6 +543,204 @@ window.Page_admin = (() => {
|
|||
} catch (e) { UI.toast.error(e.message); }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: SYSTEM
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderSystem(el) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
<button class="btn btn-ghost btn-sm" id="adm-sys-refresh">
|
||||
${UI.icon('arrows-clockwise')} Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div id="adm-sys-cards">Lade…</div>
|
||||
`;
|
||||
el.querySelector('#adm-sys-refresh').addEventListener('click', () => _loadSystemCards(el.querySelector('#adm-sys-cards')));
|
||||
await _loadSystemCards(el.querySelector('#adm-sys-cards'));
|
||||
}
|
||||
|
||||
async function _loadSystemCards(el) {
|
||||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||||
const s = await API.get('/admin/system');
|
||||
const diskUsedGb = s.disk_total_gb - s.disk_free_gb;
|
||||
const diskPct = s.disk_total_gb > 0 ? Math.round(diskUsedGb / s.disk_total_gb * 100) : 0;
|
||||
const uptime = _formatUptime(s.uptime_seconds);
|
||||
el.innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:var(--space-3)">
|
||||
${_statCard('database', 'Datenbank', s.db_size_mb.toFixed(1) + ' MB', 'var(--c-primary)')}
|
||||
${_statCard('image', 'Media-Ordner', s.media_size_mb.toFixed(1) + ' MB','var(--c-text-secondary)')}
|
||||
${_statCard('timer', 'Uptime', uptime, 'var(--c-success)')}
|
||||
${_statCard('hard-drive','Disk frei', s.disk_free_gb.toFixed(1) + ' GB','diskPct > 85 ? "var(--c-danger)" : "var(--c-text-secondary)"')}
|
||||
</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);
|
||||
color:var(--c-text);margin-bottom:var(--space-3)">Disk-Auslastung</div>
|
||||
<div style="display:flex;align-items:center;gap:var(--space-3)">
|
||||
<div style="flex:1;height:8px;background:var(--c-surface-2);border-radius:4px;overflow:hidden">
|
||||
<div style="height:100%;width:${diskPct}%;background:${diskPct > 85 ? 'var(--c-danger)' : diskPct > 65 ? '#f59e0b' : 'var(--c-success)'};border-radius:4px;transition:width .3s"></div>
|
||||
</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary);white-space:nowrap">
|
||||
${diskPct}% · ${diskUsedGb.toFixed(1)} / ${s.disk_total_gb.toFixed(1)} GB
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:var(--space-3);font-size:var(--text-xs);color:var(--c-text-muted)">
|
||||
Python ${_esc(s.python_version)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function _formatUptime(secs) {
|
||||
const d = Math.floor(secs / 86400);
|
||||
const h = Math.floor((secs % 86400) / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}min`;
|
||||
return `${m}min`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: JOBS
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderJobs(el) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
<button class="btn btn-ghost btn-sm" id="adm-jobs-refresh">
|
||||
${UI.icon('arrows-clockwise')} Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div id="adm-jobs-list">Lade…</div>
|
||||
`;
|
||||
el.querySelector('#adm-jobs-refresh').addEventListener('click', () => _loadJobs(el.querySelector('#adm-jobs-list')));
|
||||
await _loadJobs(el.querySelector('#adm-jobs-list'));
|
||||
}
|
||||
|
||||
async function _loadJobs(el) {
|
||||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||||
const jobs = await API.get('/admin/scheduler/jobs');
|
||||
if (!jobs.length) {
|
||||
el.innerHTML = _emptyState('timer', 'Keine Jobs', 'Der Scheduler hat keine registrierten Jobs.');
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="card" style="overflow:hidden">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
|
||||
<thead>
|
||||
<tr style="background:var(--c-surface-2);text-align:left">
|
||||
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Job</th>
|
||||
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Nächster Lauf</th>
|
||||
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Trigger</th>
|
||||
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${jobs.map((j, i) => `
|
||||
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
|
||||
<td style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text)">
|
||||
${_esc(j.name)}
|
||||
<div style="font-size:var(--text-xs);color:var(--c-text-muted);font-weight:normal">${_esc(j.id)}</div>
|
||||
</td>
|
||||
<td style="padding:var(--space-3) var(--space-4);color:var(--c-text-secondary)">
|
||||
${j.next_run_time ? _formatDateTime(j.next_run_time) : '<span style="color:var(--c-text-muted)">—</span>'}
|
||||
</td>
|
||||
<td style="padding:var(--space-3) var(--space-4);color:var(--c-text-muted);font-size:var(--text-xs);max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
${_esc(j.trigger)}
|
||||
</td>
|
||||
<td style="padding:var(--space-3) var(--space-4);text-align:right">
|
||||
<button class="btn btn-sm btn-ghost adm-job-trigger" data-id="${_esc(j.id)}" data-name="${_esc(j.name)}"
|
||||
title="Jetzt ausführen" style="color:var(--c-primary)">
|
||||
${UI.icon('play')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
el.querySelectorAll('.adm-job-trigger').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await API.post(`/admin/scheduler/trigger/${encodeURIComponent(btn.dataset.id)}`, {});
|
||||
UI.toast.success(`Job "${btn.dataset.name}" wird ausgeführt.`);
|
||||
} catch (e) {
|
||||
UI.toast.error(e.message || 'Fehler beim Auslösen des Jobs.');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _formatDateTime(iso) {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit' });
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB: AUDIT-LOG
|
||||
// ------------------------------------------------------------------
|
||||
async function _renderAudit(el) {
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
|
||||
<button class="btn btn-ghost btn-sm" id="adm-audit-refresh">
|
||||
${UI.icon('arrows-clockwise')} Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div id="adm-audit-list">Lade…</div>
|
||||
`;
|
||||
el.querySelector('#adm-audit-refresh').addEventListener('click', () => _loadAudit(el.querySelector('#adm-audit-list')));
|
||||
await _loadAudit(el.querySelector('#adm-audit-list'));
|
||||
}
|
||||
|
||||
async function _loadAudit(el) {
|
||||
el.innerHTML = `<div style="padding:var(--space-4);text-align:center;color:var(--c-text-muted)">Lade…</div>`;
|
||||
const rows = await API.get('/admin/audit?limit=50');
|
||||
if (!rows.length) {
|
||||
el.innerHTML = _emptyState('list-bullets', 'Keine Einträge', 'Noch keine Admin-Aktionen protokolliert.');
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div class="card" style="overflow:hidden">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:var(--text-sm)">
|
||||
<thead>
|
||||
<tr style="background:var(--c-surface-2);text-align:left">
|
||||
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Wann</th>
|
||||
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Admin</th>
|
||||
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Aktion</th>
|
||||
<th style="padding:var(--space-3) var(--space-4);font-weight:var(--weight-semibold);color:var(--c-text-secondary)">Ziel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map((r, i) => `
|
||||
<tr style="${i % 2 === 1 ? 'background:var(--c-surface-2)' : ''}">
|
||||
<td style="padding:var(--space-2) var(--space-4);color:var(--c-text-muted);white-space:nowrap;font-size:var(--text-xs)">
|
||||
${_formatDateTime(r.created_at)}
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-4);color:var(--c-text)">
|
||||
${_esc(r.admin_name || '—')}
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-4)">
|
||||
<span style="font-size:var(--text-xs);padding:2px 7px;border-radius:3px;
|
||||
background:var(--c-surface-2);color:var(--c-text-secondary);font-family:monospace">
|
||||
${_esc(r.action)}
|
||||
</span>
|
||||
${r.detail ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">${_esc(r.detail)}</div>` : ''}
|
||||
</td>
|
||||
<td style="padding:var(--space-2) var(--space-4);color:var(--c-text-secondary);font-size:var(--text-xs)">
|
||||
${_esc(r.target || '—')}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HELPERS
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue