diff --git a/ReforgerOutputConsole/Start.bat b/ReforgerOutputConsole/Start.bat
new file mode 100644
index 0000000..d4b9fa8
--- /dev/null
+++ b/ReforgerOutputConsole/Start.bat
@@ -0,0 +1,5 @@
+@echo off
+title Reforger Output Console
+cd /d "%~dp0"
+node server.js
+pause
diff --git a/ReforgerOutputConsole/config.json b/ReforgerOutputConsole/config.json
new file mode 100644
index 0000000..f2cbdac
--- /dev/null
+++ b/ReforgerOutputConsole/config.json
@@ -0,0 +1,23 @@
+{
+ "port": 8420,
+ "openBrowser": true,
+ "pollIntervalMs": 500,
+ "maxLinesPerPane": 20000,
+ "panes": [
+ {
+ "name": "Dedicated Server",
+ "logsDir": "C:/Users/nicho/Documents/My Games/DedicatedServer/logs",
+ "file": "console.log"
+ },
+ {
+ "name": "PeerPlugin11",
+ "logsDir": "C:/Users/nicho/Documents/My Games/PeerPlugin11/logs",
+ "file": "console.log"
+ },
+ {
+ "name": "PeerPlugin22",
+ "logsDir": "C:/Users/nicho/Documents/My Games/PeerPlugin22/logs",
+ "file": "console.log"
+ }
+ ]
+}
diff --git a/ReforgerOutputConsole/index.html b/ReforgerOutputConsole/index.html
new file mode 100644
index 0000000..bc57f1c
--- /dev/null
+++ b/ReforgerOutputConsole/index.html
@@ -0,0 +1,505 @@
+
+
+
+
+Reforger Output Console
+
+
+
+
+
Reforger Output Console
+ verbinde…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Crash Report
+
+
+
+
+
+
+
+
+
+
diff --git a/ReforgerOutputConsole/server.js b/ReforgerOutputConsole/server.js
new file mode 100644
index 0000000..25d77fd
--- /dev/null
+++ b/ReforgerOutputConsole/server.js
@@ -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}\\\\${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);
+});