feat: zentrales Pushover-Logging + neuer Admin-Tab Logs

This commit is contained in:
2026-07-16 00:18:56 +02:00
parent 7e6ea9b010
commit 2b2a2dce2d
11 changed files with 213 additions and 26 deletions

View File

@@ -636,4 +636,18 @@ db.exec(`
) )
`); `);
// Log aller verschickten Pushover-Nachrichten (für Admin-Übersicht "Logs")
db.exec(`
CREATE TABLE IF NOT EXISTS push_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
title TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
priority INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL DEFAULT '',
success INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
module.exports = db; module.exports = db;

View File

@@ -3,6 +3,7 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
require('./db'); require('./db');
const { logPush } = require('./pushLog');
const app = express(); const app = express();
// Hinter Nginx Proxy Manager (SSL-Terminierung) — sonst meldet req.protocol // Hinter Nginx Proxy Manager (SSL-Terminierung) — sonst meldet req.protocol
@@ -32,22 +33,25 @@ app.use((req, res, next) => {
if (wasInactive && user?.role !== 'admin') { if (wasInactive && user?.role !== 'admin') {
const username = db.prepare('SELECT username FROM users WHERE id=?').get(p.id)?.username || 'Jemand'; const username = db.prepare('SELECT username FROM users WHERE id=?').get(p.id)?.username || 'Jemand';
const admins = db.prepare(` const admins = db.prepare(`
SELECT p.user_key, p.app_token FROM pushover_settings p SELECT p.user_id, p.user_key, p.app_token FROM pushover_settings p
JOIN users u ON u.id = p.user_id JOIN users u ON u.id = p.user_id
WHERE u.role = 'admin' AND p.user_key IS NOT NULL AND p.app_token IS NOT NULL WHERE u.role = 'admin' AND p.user_key IS NOT NULL AND p.app_token IS NOT NULL
`).all(); `).all();
for (const admin of admins) { for (const admin of admins) {
const title = '👤 DickenDock';
const message = `${username} ist gerade online gegangen.`;
fetch('https://api.pushover.net/1/messages.json', { fetch('https://api.pushover.net/1/messages.json', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
token: admin.app_token, token: admin.app_token,
user: admin.user_key, user: admin.user_key,
title: '👤 DickenDock', title,
message: `${username} ist gerade online gegangen.`, message,
priority: -1, priority: -1,
}), }),
}).catch(() => {}); }).catch(() => {});
logPush({ userId: admin.user_id, title, message, priority: -1, source: 'presence' });
} }
} }
} }
@@ -280,11 +284,14 @@ mqttClient.setMediaAckAllHandler(() => {
mqttClient.publishMediaAnfragen(); mqttClient.publishMediaAnfragen();
}); });
async function sendPushoverMsg(userKey, appToken, message, opts = {}) { async function sendPushoverMsg(userKey, appToken, message, opts = {}, userId = null) {
const title = 'DickenDock Erinnerung';
let priority = 0;
try { try {
const params = { token: appToken, user: userKey, title: 'DickenDock Erinnerung', message }; const params = { token: appToken, user: userKey, title, message };
if (opts.retry && opts.expire) { if (opts.retry && opts.expire) {
params.priority = 2; params.priority = 2;
priority = 2;
params.retry = opts.retry; params.retry = opts.retry;
params.expire = opts.expire; params.expire = opts.expire;
} }
@@ -293,7 +300,11 @@ async function sendPushoverMsg(userKey, appToken, message, opts = {}) {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params), body: new URLSearchParams(params),
}); });
} catch(e) { console.error('Pushover Fehler:', e.message); } logPush({ userId, title, message, priority, source: 'dashboard-erinnerung' });
} catch(e) {
console.error('Pushover Fehler:', e.message);
logPush({ userId, title, message, priority, source: 'dashboard-erinnerung', success: false });
}
} }
// Beim Start auf die nächste volle Minute synchronisieren // Beim Start auf die nächste volle Minute synchronisieren
@@ -319,7 +330,7 @@ async function runScheduler() {
`).all(now); `).all(now);
for (const row of due) { for (const row of due) {
await sendPushoverMsg(row.user_key, row.app_token, row.message, await sendPushoverMsg(row.user_key, row.app_token, row.message,
{ retry: row.retry, expire: row.expire }); { retry: row.retry, expire: row.expire }, row.user_id);
db.prepare('UPDATE push_schedules SET sent=1 WHERE id=?').run(row.id); db.prepare('UPDATE push_schedules SET sent=1 WHERE id=?').run(row.id);
console.log(`✓ Push gesendet an user ${row.user_id}: "${row.message}" (fällig: ${row.scheduled_at})`); console.log(`✓ Push gesendet an user ${row.user_id}: "${row.message}" (fällig: ${row.scheduled_at})`);
} }

17
backend/src/pushLog.js Normal file
View File

@@ -0,0 +1,17 @@
const db = require('./db');
// Wird von jeder Stelle im Code aufgerufen, die eine Pushover-Nachricht
// verschickt — unabhängig vom Versand selbst, damit ein Fehler im Logging
// nie den eigentlichen Push verhindert.
function logPush({ userId = null, title = '', message = '', priority = 0, source = '', success = true }) {
try {
db.prepare(`
INSERT INTO push_log (user_id, title, message, priority, source, success, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now','localtime'))
`).run(userId, title, message, priority ?? 0, source, success ? 1 : 0);
} catch (e) {
console.error('Push-Log Fehler:', e.message);
}
}
module.exports = { logPush };

View File

@@ -3,6 +3,7 @@ const fs = require('fs');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const multer = require('multer'); const multer = require('multer');
const db = require('../db'); const db = require('../db');
const { logPush } = require('../pushLog');
const { authenticate, requireAdmin } = require('../middleware/auth'); const { authenticate, requireAdmin } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
@@ -191,16 +192,25 @@ router.post('/users/:id/test-push', authenticate, requireAdmin, async (req, res)
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(user.id); const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(user.id);
if (!poCfg?.app_token || !poCfg?.user_key) return res.status(400).json({ error: 'Kein Pushover eingerichtet' }); if (!poCfg?.app_token || !poCfg?.user_key) return res.status(400).json({ error: 'Kein Pushover eingerichtet' });
const { message = 'Wat is mit meinen Fische?' } = req.body; const { message = 'Wat is mit meinen Fische?' } = req.body;
const title = '📣 DockStation';
try { try {
const r = await fetch('https://api.pushover.net/1/messages.json', { const r = await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: poCfg.app_token, user: poCfg.user_key, title: '📣 DockStation', message, priority: 0 }), body: JSON.stringify({ token: poCfg.app_token, user: poCfg.user_key, title, message, priority: 0 }),
}); });
const d = await r.json(); const d = await r.json();
if (d.status === 1) res.json({ ok: true }); if (d.status === 1) {
else res.status(500).json({ error: d.errors?.join(', ') || 'Pushover-Fehler' }); logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test' });
} catch(e) { res.status(500).json({ error: e.message }); } res.json({ ok: true });
} else {
logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test', success: false });
res.status(500).json({ error: d.errors?.join(', ') || 'Pushover-Fehler' });
}
} catch(e) {
logPush({ userId: user.id, title, message, priority: 0, source: 'admin-test', success: false });
res.status(500).json({ error: e.message });
}
}); });
// ── WebDAV / Synology NAS Einstellungen ─────────────────────────────────── // ── WebDAV / Synology NAS Einstellungen ───────────────────────────────────
@@ -500,4 +510,18 @@ router.get('/disk-analysis', authenticate, requireAdmin, (req, res) => {
res.json({ locations: result, tmpFiles, nodeModulesMb: fmt(nodeSize), bigFiles: bigFiles.slice(0,20) }); res.json({ locations: result, tmpFiles, nodeModulesMb: fmt(nodeSize), bigFiles: bigFiles.slice(0,20) });
}); });
// GET /push-logs Übersicht aller verschickten Pushover-Nachrichten (Admin)
router.get('/push-logs', authenticate, requireAdmin, (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 200, 1000);
const logs = db.prepare(`
SELECT pl.id, pl.user_id, u.username, pl.title, pl.message, pl.priority,
pl.source, pl.success, pl.created_at
FROM push_log pl
LEFT JOIN users u ON u.id = pl.user_id
ORDER BY pl.id DESC
LIMIT ?
`).all(limit);
res.json(logs);
});
module.exports = router; module.exports = router;

View File

@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const db = require('../../db'); const db = require('../../db');
const { logPush } = require('../../pushLog');
const { authenticate } = require('../../middleware/auth'); const { authenticate } = require('../../middleware/auth');
const router = express.Router(); const router = express.Router();
@@ -207,6 +208,7 @@ function sendPush(userId, title, message) {
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }), body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }),
}).catch(() => {}); }).catch(() => {});
logPush({ userId, title, message, priority: 0, source: 'gebietseroberung' });
} }
// Spiel laden + Fog anwenden + echte Scores beifügen // Spiel laden + Fog anwenden + echte Scores beifügen

View File

@@ -2,6 +2,7 @@ const express = require('express');
const https = require('https'); const https = require('https');
const db = require('../../db'); const db = require('../../db');
const mqttClient = require('../../mqtt'); const mqttClient = require('../../mqtt');
const { logPush } = require('../../pushLog');
const { authenticate } = require('../../middleware/auth'); const { authenticate } = require('../../middleware/auth');
const router = express.Router(); const router = express.Router();
@@ -430,14 +431,15 @@ function formatOfferMessage(offers) {
async function sendKoepiPushover(offers, onlyUserId = null) { async function sendKoepiPushover(offers, onlyUserId = null) {
const recipients = onlyUserId const recipients = onlyUserId
? db.prepare(` ? db.prepare(`
SELECT user_key, app_token FROM pushover_settings SELECT user_id, user_key, app_token FROM pushover_settings
WHERE user_id = ? AND user_key IS NOT NULL AND app_token IS NOT NULL WHERE user_id = ? AND user_key IS NOT NULL AND app_token IS NOT NULL
`).all(onlyUserId) `).all(onlyUserId)
: db.prepare(` : db.prepare(`
SELECT user_key, app_token FROM pushover_settings SELECT user_id, user_key, app_token FROM pushover_settings
WHERE user_key IS NOT NULL AND app_token IS NOT NULL WHERE user_key IS NOT NULL AND app_token IS NOT NULL
`).all(); `).all();
const message = formatOfferMessage(offers); const message = formatOfferMessage(offers);
const title = '🍺 KöPi Angebote';
for (const r of recipients) { for (const r of recipients) {
try { try {
await fetch('https://api.pushover.net/1/messages.json', { await fetch('https://api.pushover.net/1/messages.json', {
@@ -446,12 +448,16 @@ async function sendKoepiPushover(offers, onlyUserId = null) {
body: new URLSearchParams({ body: new URLSearchParams({
token: r.app_token, token: r.app_token,
user: r.user_key, user: r.user_key,
title: '🍺 KöPi Angebote', title,
message, message,
priority: 0, priority: 0,
}), }),
}); });
} catch (e) { console.error('KöPi Pushover Fehler:', e.message); } logPush({ userId: r.user_id, title, message, priority: 0, source: 'koepi' });
} catch (e) {
console.error('KöPi Pushover Fehler:', e.message);
logPush({ userId: r.user_id, title, message, priority: 0, source: 'koepi', success: false });
}
} }
} }

View File

@@ -1,6 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const db = require('../../db'); const db = require('../../db');
const { logPush } = require('../../pushLog');
const { authenticate, requireAdmin } = require('../../middleware/auth'); const { authenticate, requireAdmin } = require('../../middleware/auth');
const TMDB_BASE = 'https://api.themoviedb.org/3'; const TMDB_BASE = 'https://api.themoviedb.org/3';
@@ -898,17 +899,20 @@ function ackFavorite(id) {
try { try {
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(fav.user_id); const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(fav.user_id);
if (poCfg?.app_token && poCfg?.user_key) { if (poCfg?.app_token && poCfg?.user_key) {
const title = '✅ Favorit bestätigt';
const message = `"${fav.title}" wurde von einem Admin bestätigt.`;
fetch('https://api.pushover.net/1/messages.json', { fetch('https://api.pushover.net/1/messages.json', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
token: poCfg.app_token, token: poCfg.app_token,
user: poCfg.user_key, user: poCfg.user_key,
title: '✅ Favorit bestätigt', title,
message: `"${fav.title}" wurde von einem Admin bestätigt.`, message,
priority: 0, priority: 0,
}), }),
}).catch(() => {}); }).catch(() => {});
logPush({ userId: fav.user_id, title, message, priority: 0, source: 'media-favorit' });
} }
} catch {} } catch {}
return fav; return fav;
@@ -923,6 +927,7 @@ function ackAllFavorites() {
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
if (!poCfg?.app_token || !poCfg?.user_key) continue; if (!poCfg?.app_token || !poCfg?.user_key) continue;
const userFavs = toAck.filter(f => f.user_id === userId); const userFavs = toAck.filter(f => f.user_id === userId);
const title = '✅ Favoriten bestätigt';
const message = userFavs.length === 1 const message = userFavs.length === 1
? `"${userFavs[0].title}" wurde von einem Admin bestätigt.` ? `"${userFavs[0].title}" wurde von einem Admin bestätigt.`
: `${userFavs.length} deiner Favoriten wurden von einem Admin bestätigt.`; : `${userFavs.length} deiner Favoriten wurden von einem Admin bestätigt.`;
@@ -932,11 +937,12 @@ function ackAllFavorites() {
body: JSON.stringify({ body: JSON.stringify({
token: poCfg.app_token, token: poCfg.app_token,
user: poCfg.user_key, user: poCfg.user_key,
title: '✅ Favoriten bestätigt', title,
message, message,
priority: 0, priority: 0,
}), }),
}).catch(() => {}); }).catch(() => {});
logPush({ userId, title, message, priority: 0, source: 'media-favorit' });
} }
} catch {} } catch {}
return toAck.length; return toAck.length;

View File

@@ -1,14 +1,17 @@
const express = require('express'); const express = require('express');
const db = require('../../db'); const db = require('../../db');
const { logPush } = require('../../pushLog');
const { authenticate } = require('../../middleware/auth'); const { authenticate } = require('../../middleware/auth');
const router = express.Router(); const router = express.Router();
// ── Pushover Helper ─────────────────────────────────────────────────────────── // ── Pushover Helper ───────────────────────────────────────────────────────────
async function sendPushover(userKey, appToken, title, message, opts = {}) { async function sendPushover(userKey, appToken, title, message, opts = {}, userId = null) {
let priority = 0;
try { try {
const params = { token: appToken, user: userKey, title, message }; const params = { token: appToken, user: userKey, title, message };
if (opts.retry && opts.expire) { if (opts.retry && opts.expire) {
params.priority = 2; params.priority = 2;
priority = 2;
params.retry = opts.retry; params.retry = opts.retry;
params.expire = opts.expire; params.expire = opts.expire;
} }
@@ -17,7 +20,10 @@ async function sendPushover(userKey, appToken, title, message, opts = {}) {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(params), body: new URLSearchParams(params),
}); });
} catch {} logPush({ userId, title, message, priority, source: 'nachrichten' });
} catch {
logPush({ userId, title, message, priority, source: 'nachrichten', success: false });
}
} }
// ── Public Keys ─────────────────────────────────────────────────────────────── // ── Public Keys ───────────────────────────────────────────────────────────────
@@ -137,7 +143,7 @@ router.post('/messages/:userId', authenticate, async (req, res) => {
`).get(recipId); `).get(recipId);
if (!presence) { if (!presence) {
await sendPushover(pushover.user_key, pushover.app_token, 'DickenDock', `Neue Nachricht von ${senderName}`, await sendPushover(pushover.user_key, pushover.app_token, 'DickenDock', `Neue Nachricht von ${senderName}`,
{ retry: pushover.retry, expire: pushover.expire }); { retry: pushover.retry, expire: pushover.expire }, recipId);
} }
} }
@@ -203,7 +209,7 @@ router.post('/pushover/test', authenticate, async (req, res) => {
? `🚨 Emergency-Test! Wiederholt alle ${row.retry}s für max. ${row.expire}s. Bitte in Pushover quittieren.` ? `🚨 Emergency-Test! Wiederholt alle ${row.retry}s für max. ${row.expire}s. Bitte in Pushover quittieren.`
: 'Pushover-Verbindung erfolgreich! 🎉'; : 'Pushover-Verbindung erfolgreich! 🎉';
await sendPushover(row.user_key, row.app_token, 'DickenDock', msg, await sendPushover(row.user_key, row.app_token, 'DickenDock', msg,
useEmergency ? { retry: row.retry, expire: row.expire } : {}); useEmergency ? { retry: row.retry, expire: row.expire } : {}, req.user.id);
res.json({ ok: true }); res.json({ ok: true });
}); });

View File

@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const db = require('../../db'); const db = require('../../db');
const { logPush } = require('../../pushLog');
const { authenticate } = require('../../middleware/auth'); const { authenticate } = require('../../middleware/auth');
const router = express.Router(); const router = express.Router();
@@ -103,6 +104,7 @@ function sendPush(userId, title, message) {
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }), body: JSON.stringify({ token:cfg.app_token, user:cfg.user_key, title, message, priority:0 }),
}).catch(() => {}); }).catch(() => {});
logPush({ userId, title, message, priority: 0, source: 'schocken' });
} }
const uid = req => { const uid = req => {

View File

@@ -1,5 +1,6 @@
const express = require('express'); const express = require('express');
const db = require('../../db'); const db = require('../../db');
const { logPush } = require('../../pushLog');
const { authenticate, requireAdmin } = require('../../middleware/auth'); const { authenticate, requireAdmin } = require('../../middleware/auth');
const router = express.Router(); const router = express.Router();
@@ -174,17 +175,19 @@ router.post('/:id/save', authenticate, async (req, res) => {
for (const userId of notify) { for (const userId of notify) {
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId); const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(userId);
if (!poCfg?.app_token || !poCfg?.user_key) continue; if (!poCfg?.app_token || !poCfg?.user_key) continue;
const title = '🖊 Whiteboard aktualisiert';
fetch('https://api.pushover.net/1/messages.json', { fetch('https://api.pushover.net/1/messages.json', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
token: poCfg.app_token, token: poCfg.app_token,
user: poCfg.user_key, user: poCfg.user_key,
title: '🖊 Whiteboard aktualisiert', title,
message, message,
priority: 0, priority: 0,
}), }),
}).catch(() => {}); }).catch(() => {});
logPush({ userId, title, message, priority: 0, source: 'whiteboard-save' });
} }
} catch {} } catch {}
}); });
@@ -227,17 +230,20 @@ router.put('/:id/permissions', authenticate, async (req, res) => {
const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(p.user_id); const poCfg = db.prepare('SELECT user_key, app_token FROM pushover_settings WHERE user_id=?').get(p.user_id);
if (!poCfg?.app_token || !poCfg?.user_key) continue; if (!poCfg?.app_token || !poCfg?.user_key) continue;
const roleLabel = p.role === 'edit' ? 'bearbeiten' : 'ansehen'; const roleLabel = p.role === 'edit' ? 'bearbeiten' : 'ansehen';
const title = '🖊 Whiteboard geteilt';
const message = `${owner.username} hat das Whiteboard "${wb.title}" mit dir geteilt (${roleLabel}).`;
fetch('https://api.pushover.net/1/messages.json', { fetch('https://api.pushover.net/1/messages.json', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
token: poCfg.app_token, token: poCfg.app_token,
user: poCfg.user_key, user: poCfg.user_key,
title: '🖊 Whiteboard geteilt', title,
message: `${owner.username} hat das Whiteboard "${wb.title}" mit dir geteilt (${roleLabel}).`, message,
priority: 0, priority: 0,
}), }),
}).catch(() => {}); }).catch(() => {});
logPush({ userId: p.user_id, title, message, priority: 0, source: 'whiteboard-share' });
} catch {} } catch {}
} }

View File

@@ -3185,6 +3185,95 @@ function WebDavSettings({ toast }) {
); );
} }
// Admin: Log aller verschickten Pushover-Nachrichten
const PRIORITY_LABELS = { '-2':'Silent', '-1':'Leise', '0':'Normal', '1':'Hoch', '2':'Emergency' };
const PRIORITY_COLORS = { '-2':'#888888', '-1':'#4ecdc4', '0':'rgba(255,255,255,0.6)', '1':'#ffe66d', '2':'#ff6b9d' };
function PushLogs({ toast }) {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const load = () => {
setLoading(true);
api('/admin/push-logs?limit=300').then(setLogs).catch(e=>toast(e.message,'error')).finally(()=>setLoading(false));
};
useEffect(() => { load(); }, []);
const fmtDate = iso => {
if (!iso) return '—';
const d = new Date(iso.replace(' ', 'T'));
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit' });
};
const filtered = filter.trim()
? logs.filter(l => (l.username||'').toLowerCase().includes(filter.toLowerCase())
|| (l.title||'').toLowerCase().includes(filter.toLowerCase())
|| (l.message||'').toLowerCase().includes(filter.toLowerCase())
|| (l.source||'').toLowerCase().includes(filter.toLowerCase()))
: logs;
return (
<div>
<Sec title={`PUSHOVER-VERLAUF (${filtered.length})`}>
<div style={{ display:'flex', gap:8, marginBottom:12 }}>
<input value={filter} onChange={e=>setFilter(e.target.value)}
placeholder="Filtern nach Benutzer, Text, Quelle…"
style={{ ...S.inp, flex:1 }} />
<button onClick={load} style={{ ...S.btn('#4ecdc4'), flexShrink:0 }}></button>
</div>
{loading ? (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12 }}>Lädt</div>
) : filtered.length === 0 ? (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:12 }}>Keine Einträge.</div>
) : (
<div style={{ display:'flex', flexDirection:'column', gap:6, maxHeight:'65vh', overflowY:'auto' }}>
{filtered.map(l => (
<div key={l.id} style={{
border:'1px solid rgba(255,255,255,0.08)', borderRadius:8, padding:'8px 10px',
background: l.success ? 'transparent' : 'rgba(248,113,113,0.06)',
}}>
<div style={{ display:'flex', justifyContent:'space-between', alignItems:'baseline', gap:8, marginBottom:3, flexWrap:'wrap' }}>
<span style={{ color:'#4ecdc4', fontFamily:"'Space Mono',monospace", fontSize:11, fontWeight:700 }}>
{l.username || `User #${l.user_id ?? '?'}`}
</span>
<span style={{ color:'rgba(255,255,255,0.35)', fontFamily:'monospace', fontSize:10 }}>
{fmtDate(l.created_at)}
</span>
</div>
<div style={{ color:'rgba(255,255,255,0.85)', fontFamily:'monospace', fontSize:12, marginBottom:2 }}>
{l.title}
</div>
<div style={{ color:'rgba(255,255,255,0.5)', fontFamily:'monospace', fontSize:11, marginBottom:6, whiteSpace:'pre-wrap' }}>
{l.message}
</div>
<div style={{ display:'flex', gap:8, flexWrap:'wrap', alignItems:'center' }}>
<span style={{
color: PRIORITY_COLORS[String(l.priority)] || 'rgba(255,255,255,0.5)',
fontFamily:'monospace', fontSize:9, letterSpacing:1,
border:`1px solid ${PRIORITY_COLORS[String(l.priority)] || 'rgba(255,255,255,0.2)'}`,
borderRadius:4, padding:'1px 6px',
}}>
{PRIORITY_LABELS[String(l.priority)] || l.priority}
</span>
<span style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:9 }}>
{l.source || '—'}
</span>
{!l.success && (
<span style={{ color:'#f87171', fontFamily:'monospace', fontSize:9 }}> fehlgeschlagen</span>
)}
</div>
</div>
))}
</div>
)}
</Sec>
</div>
);
}
function AdminPanel({ toast, mobile, user, nav }) { function AdminPanel({ toast, mobile, user, nav }) {
const [section, setSection] = useState('profil'); const [section, setSection] = useState('profil');
useEffect(() => { if (nav?.section) setSection(nav.section); }, [nav?.ts]); useEffect(() => { if (nav?.section) setSection(nav.section); }, [nav?.ts]);
@@ -3246,7 +3335,7 @@ function AdminPanel({ toast, mobile, user, nav }) {
</div> </div>
<div style={{ display:'flex', gap:6, marginBottom:18, flexWrap:'wrap' }}> <div style={{ display:'flex', gap:6, marginBottom:18, flexWrap:'wrap' }}>
{[['profil','Profil'],['sicherheit','Sicherheit'],['dashboard','Dashboard'], ...(user?.role==='admin'?[['benutzer','Benutzer'],['backup','Backup']]:[])] {[['profil','Profil'],['sicherheit','Sicherheit'],['dashboard','Dashboard'], ...(user?.role==='admin'?[['benutzer','Benutzer'],['backup','Backup'],['logs','Logs']]:[])]
.map(([k,l]) => ( .map(([k,l]) => (
<button key={k} onClick={()=>setSection(k)} style={{ <button key={k} onClick={()=>setSection(k)} style={{
padding:'6px 16px', borderRadius:20, fontFamily:'monospace', fontSize:12, cursor:'pointer', padding:'6px 16px', borderRadius:20, fontFamily:'monospace', fontSize:12, cursor:'pointer',
@@ -3382,6 +3471,10 @@ function AdminPanel({ toast, mobile, user, nav }) {
</Sec> </Sec>
</div> </div>
)} )}
{section === 'logs' && user?.role==='admin' && (
<PushLogs toast={toast}/>
)}
</div> </div>
); );
} }