Output Console (Premium)
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
// 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}\\<neuester Ordner>\\${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);
|
||||
});
|
||||
Reference in New Issue
Block a user