// Reforger Output Console // Überwacht mehrere Log-Ordner, nimmt jeweils den neuesten Unterordner und // streamt die darin liegende Log-Datei live an eine Browser-Oberfläche. const http = require('http'); const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); const { StringDecoder } = require('string_decoder'); const CONFIG_PATH = path.join(__dirname, 'config.json'); const INDEX_PATH = path.join(__dirname, 'index.html'); const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); const PORT = config.port || 8420; const POLL_MS = config.pollIntervalMs || 500; const MAX_LINES = config.maxLinesPerPane || 20000; // Server-seitiger Puffer, damit neu verbundene Browser den bisherigen Inhalt bekommen. const MAX_CHARS = MAX_LINES * 300; const CRASH_FILE = config.crashFile || 'crash.log'; const panes = config.panes.map((p, i) => ({ id: i, name: p.name || path.basename(path.dirname(p.logsDir)), logsDir: path.resolve(__dirname, p.logsDir), file: p.file || 'console.log', folder: undefined, offset: 0, decoder: new StringDecoder('utf8'), text: '', dirMissing: false, crash: null, crashStamp: '', })); const clients = new Set(); function broadcast(event, data) { const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; for (const res of clients) res.write(msg); } function snapshot(p) { return { id: p.id, name: p.name, file: p.file, folder: p.folder || null, logsDir: p.logsDir, dirMissing: p.dirMissing, text: p.text, crash: p.crash, }; } // Neuester Unterordner nach Erstellungszeit (bei Gleichstand nach Name). function newestFolder(dir) { let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return undefined; } let best = null; let bestTime = -Infinity; for (const e of entries) { if (!e.isDirectory()) continue; let t; try { t = fs.statSync(path.join(dir, e.name)).birthtimeMs; } catch { continue; } if (t > bestTime || (t === bestTime && e.name > best)) { best = e.name; bestTime = t; } } return best; } function resetPane(p) { p.offset = 0; p.decoder = new StringDecoder('utf8'); p.text = ''; p.crash = null; p.crashStamp = ''; broadcast('reset', snapshot(p)); } // Crash-Report ist klein und wird bei jeder Änderung komplett neu gesendet. function pollCrash(p) { const file = path.join(p.logsDir, p.folder, CRASH_FILE); let st; try { st = fs.statSync(file); } catch { return; } const stamp = `${st.size}:${st.mtimeMs}`; if (stamp === p.crashStamp) return; let text; try { text = fs.readFileSync(file, 'utf8'); } catch { return; } if (p.crash === null) console.log(`[${p.name}] Crash-Report gefunden: ${p.folder}\\${CRASH_FILE}`); p.crashStamp = stamp; p.crash = text; broadcast('crash', { id: p.id, text }); } function pollPane(p) { const folder = newestFolder(p.logsDir); const dirMissing = folder === undefined; if (folder !== p.folder || dirMissing !== p.dirMissing) { if (p.folder !== undefined && folder) { console.log(`[${p.name}] Neuer Log-Ordner: ${folder}`); } p.folder = folder; p.dirMissing = dirMissing; resetPane(p); } if (!folder) return; pollCrash(p); const file = path.join(p.logsDir, folder, p.file); let size; try { size = fs.statSync(file).size; } catch { return; // Datei existiert (noch) nicht } if (size < p.offset) resetPane(p); // Datei wurde gekürzt/neu geschrieben if (size === p.offset) return; let start = p.offset; if (size - start > MAX_CHARS) start = size - MAX_CHARS; const len = size - start; const buf = Buffer.alloc(len); let fd; try { fd = fs.openSync(file, 'r'); fs.readSync(fd, buf, 0, len, start); } catch { return; } finally { if (fd !== undefined) fs.closeSync(fd); } p.offset = size; const chunk = p.decoder.write(buf); if (!chunk) return; p.text += chunk; if (p.text.length > MAX_CHARS) { const cut = p.text.indexOf('\n', p.text.length - MAX_CHARS); p.text = p.text.slice(cut + 1); } broadcast('append', { id: p.id, chunk }); } function pollAll() { for (const p of panes) { try { pollPane(p); } catch (err) { console.error(`[${p.name}]`, err.message); } } } const server = http.createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host}`); if (url.pathname === '/') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); fs.createReadStream(INDEX_PATH).pipe(res); return; } if (url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }); res.write(`event: init\ndata: ${JSON.stringify({ maxLines: MAX_LINES, panes: panes.map(snapshot) })}\n\n`); clients.add(res); req.on('close', () => clients.delete(res)); return; } // Öffnet den aktuellen Log-Ordner eines Panes im Explorer. if (url.pathname === '/open' && req.method === 'POST') { const p = panes[Number(url.searchParams.get('id'))]; if (p) { const dir = p.folder ? path.join(p.logsDir, p.folder) : p.logsDir; spawn('explorer', [dir], { detached: true, stdio: 'ignore' }).unref(); } res.writeHead(204).end(); return; } res.writeHead(404).end(); }); // Keep-alive, damit Verbindungen nicht wegen Inaktivität geschlossen werden. setInterval(() => { for (const res of clients) res.write(': ping\n\n'); }, 15000); server.listen(PORT, '127.0.0.1', () => { const url = `http://localhost:${PORT}`; console.log(`Reforger Output Console läuft auf ${url}`); for (const p of panes) console.log(` - ${p.name}: ${p.logsDir}\\\\${p.file}`); console.log('Beenden mit Strg+C.'); pollAll(); setInterval(pollAll, POLL_MS); if (config.openBrowser !== false) { spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }).unref(); } }); server.on('error', (err) => { if (err.code === 'EADDRINUSE') { console.error(`Port ${PORT} ist schon belegt – läuft die Konsole bereits? Sonst "port" in config.json ändern.`); } else { console.error(err); } process.exit(1); });