feat: Server-Konsolen-Mitschnitt (24h, wie docker logs) als Download im Logs-Bereich
This commit is contained in:
57
backend/src/consoleLog.js
Normal file
57
backend/src/consoleLog.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Persistenter Pfad (selbes Volume wie die DB), übersteht Container-Neustarts
|
||||||
|
const LOG_FILE = process.env.CONSOLE_LOG_FILE || '/data/app-console.log';
|
||||||
|
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const origLog = console.log.bind(console);
|
||||||
|
const origError = console.error.bind(console);
|
||||||
|
const origWarn = console.warn.bind(console);
|
||||||
|
|
||||||
|
function stringifyArg(a) {
|
||||||
|
if (typeof a === 'string') return a;
|
||||||
|
if (a instanceof Error) return a.stack || a.message;
|
||||||
|
try { return JSON.stringify(a); } catch { return String(a); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLine(level, args) {
|
||||||
|
try {
|
||||||
|
const msg = args.map(stringifyArg).join(' ');
|
||||||
|
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [${level}] ${msg}\n`);
|
||||||
|
} catch {
|
||||||
|
// Darf niemals die eigentliche Konsolen-Ausgabe verhindern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entfernt Zeilen, die älter als MAX_AGE_MS sind. Zeilen ohne erkennbaren
|
||||||
|
// Zeitstempel (z.B. Fortsetzungszeilen eines mehrzeiligen Stacktraces) werden
|
||||||
|
// bewusst behalten, um nichts mittendrin abzuschneiden.
|
||||||
|
function pruneOldLines() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(LOG_FILE)) return;
|
||||||
|
const content = fs.readFileSync(LOG_FILE, 'utf8');
|
||||||
|
const cutoff = Date.now() - MAX_AGE_MS;
|
||||||
|
const lines = content.split('\n').filter(line => {
|
||||||
|
const m = line.match(/^\[([^\]]+)\]/);
|
||||||
|
if (!m) return true;
|
||||||
|
const t = new Date(m[1]).getTime();
|
||||||
|
return Number.isNaN(t) || t >= cutoff;
|
||||||
|
});
|
||||||
|
fs.writeFileSync(LOG_FILE, lines.join('\n'));
|
||||||
|
} catch (e) {
|
||||||
|
origError('Konsolen-Log-Aufräumen fehlgeschlagen:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installConsoleCapture() {
|
||||||
|
console.log = (...args) => { origLog(...args); appendLine('LOG', args); };
|
||||||
|
console.error = (...args) => { origError(...args); appendLine('ERROR', args); };
|
||||||
|
console.warn = (...args) => { origWarn(...args); appendLine('WARN', args); };
|
||||||
|
|
||||||
|
pruneOldLines(); // einmal direkt beim Start
|
||||||
|
setInterval(pruneOldLines, 60 * 60 * 1000); // danach stündlich
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogFilePath() { return LOG_FILE; }
|
||||||
|
|
||||||
|
module.exports = { installConsoleCapture, getLogFilePath, pruneOldLines };
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
require('./consoleLog').installConsoleCapture();
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|||||||
@@ -590,4 +590,14 @@ router.delete('/logs/:id', authenticate, requireAdmin, (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /console-log/download – Live-Konsolen-Mitschnitt (letzte 24h) als
|
||||||
|
// Datei herunterladen (Admin)
|
||||||
|
router.get('/console-log/download', authenticate, requireAdmin, (req, res) => {
|
||||||
|
const { getLogFilePath } = require('../consoleLog');
|
||||||
|
const filePath = getLogFilePath();
|
||||||
|
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'Noch keine Logs vorhanden' });
|
||||||
|
const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
|
||||||
|
res.download(filePath, `dickendock-console-${stamp}.log`);
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -3238,6 +3238,21 @@ function PushLogs({ toast }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const downloadConsoleLog = async () => {
|
||||||
|
try {
|
||||||
|
const tokenVal = localStorage.getItem('sk_token');
|
||||||
|
const res = await fetch('/api/admin/console-log/download', { headers: { Authorization: `Bearer ${tokenVal}` } });
|
||||||
|
if (!res.ok) { const d = await res.json().catch(()=>({})); throw new Error(d.error || 'Fehler'); }
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `dickendock-console-${new Date().toISOString().slice(0,10)}.log`;
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch(e) { toast?.(e.message||'Fehler','error'); }
|
||||||
|
};
|
||||||
|
|
||||||
const fmtDate = iso => {
|
const fmtDate = iso => {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
const d = new Date(iso.replace(' ', 'T'));
|
const d = new Date(iso.replace(' ', 'T'));
|
||||||
@@ -3263,11 +3278,18 @@ function PushLogs({ toast }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Sec title={`LOGS (${filtered.length})`}>
|
<Sec title={`LOGS (${filtered.length})`}>
|
||||||
|
<div style={{ ...S.sub, marginBottom:10 }}>
|
||||||
|
Live-Mitschnitt der Server-Konsole (wie <code>docker logs -f dickendock</code>), letzte 24 Stunden — danach
|
||||||
|
werden ältere Zeilen automatisch überschrieben.
|
||||||
|
</div>
|
||||||
<div style={{ display:'flex', gap:8, marginBottom:8, flexWrap:'wrap' }}>
|
<div style={{ display:'flex', gap:8, marginBottom:8, flexWrap:'wrap' }}>
|
||||||
<select value={typeFilter} onChange={e=>setTypeFilter(e.target.value)}
|
<select value={typeFilter} onChange={e=>setTypeFilter(e.target.value)}
|
||||||
style={{ ...S.inp, flex:'1 1 200px' }}>
|
style={{ ...S.inp, flex:'1 1 200px' }}>
|
||||||
{LOG_TYPE_OPTIONS.map(([id,label]) => <option key={id} value={id}>{label}</option>)}
|
{LOG_TYPE_OPTIONS.map(([id,label]) => <option key={id} value={id}>{label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
<button onClick={downloadConsoleLog} style={{ ...S.btn('#4ecdc4'), flexShrink:0, whiteSpace:'nowrap' }}>
|
||||||
|
⬇ Server-Konsole (24h)
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display:'flex', gap:8, marginBottom:12 }}>
|
<div style={{ display:'flex', gap:8, marginBottom:12 }}>
|
||||||
<input value={filter} onChange={e=>setFilter(e.target.value)}
|
<input value={filter} onChange={e=>setFilter(e.target.value)}
|
||||||
|
|||||||
Reference in New Issue
Block a user