Output Console (Premium)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
title Reforger Output Console
|
||||
cd /d "%~dp0"
|
||||
node server.js
|
||||
pause
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Reforger Output Console</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #15171a;
|
||||
--pane: #1b1e22;
|
||||
--header: #22262b;
|
||||
--border: #2f343a;
|
||||
--text: #c9d1d9;
|
||||
--muted: #7d8590;
|
||||
--accent: #4ea1ff;
|
||||
--warn: #e3b341;
|
||||
--error: #f47067;
|
||||
--warn-bg: rgba(227, 179, 65, 0.07);
|
||||
--error-bg: rgba(244, 112, 103, 0.10);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 13px/1.4 "Segoe UI", system-ui, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--header);
|
||||
}
|
||||
#topbar h1 { font-size: 14px; margin: 0; font-weight: 600; }
|
||||
#conn { color: var(--muted); font-size: 12px; }
|
||||
#conn.down { color: var(--error); }
|
||||
#topbar label { display: flex; align-items: center; gap: 4px; color: var(--muted); cursor: pointer; }
|
||||
#topbar .spacer { flex: 1; }
|
||||
#panes { flex: 1; display: flex; min-height: 0; }
|
||||
.pane {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--pane);
|
||||
position: relative;
|
||||
}
|
||||
.splitter {
|
||||
flex: 0 0 5px;
|
||||
cursor: col-resize;
|
||||
background: var(--border);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.splitter:hover, .splitter.dragging { background: var(--accent); }
|
||||
body.resizing { cursor: col-resize; user-select: none; }
|
||||
.pane-head {
|
||||
padding: 6px 8px;
|
||||
background: var(--header);
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
transition: background 0.6s;
|
||||
}
|
||||
.pane-head.flash { background: #1f3b5c; transition: none; }
|
||||
.row { display: flex; align-items: center; gap: 6px; }
|
||||
.name { font-weight: 600; white-space: nowrap; }
|
||||
.folder { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.counts { font-size: 12px; white-space: nowrap; }
|
||||
.counts .e { color: var(--error); }
|
||||
.counts .w { color: var(--warn); }
|
||||
button {
|
||||
background: #2d333b;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #373e47; }
|
||||
button.on { border-color: var(--accent); color: var(--accent); }
|
||||
input[type=search] {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
select {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
.log {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
font: 12px/1.35 Consolas, "Cascadia Mono", monospace;
|
||||
padding: 4px 0 4em;
|
||||
}
|
||||
.log div { padding: 0 8px; white-space: pre; }
|
||||
body.wrap .log div { white-space: pre-wrap; word-break: break-all; }
|
||||
.log div.e { color: var(--error); background: var(--error-bg); }
|
||||
.log div.w { color: var(--warn); background: var(--warn-bg); }
|
||||
.log div.partial { opacity: 0.8; }
|
||||
.log div.hidden { display: none; }
|
||||
.log mark { background: #6e5a12; color: inherit; }
|
||||
.empty { color: var(--muted); padding: 12px; font-style: italic; }
|
||||
.log-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
|
||||
.crash {
|
||||
flex: 0 0 40%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-top: 2px solid var(--error);
|
||||
}
|
||||
.crash[hidden] { display: none; }
|
||||
.crash-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
background: #3a1f1f;
|
||||
color: var(--error);
|
||||
font-weight: 600;
|
||||
}
|
||||
.crash-head span { flex: 1; }
|
||||
.crash-head button { padding: 0 7px; font-size: 14px; line-height: 1.3; }
|
||||
.crash-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 6px 8px 3em;
|
||||
font: 12px/1.35 Consolas, "Cascadia Mono", monospace;
|
||||
color: var(--text);
|
||||
background: #1e1a1a;
|
||||
white-space: pre;
|
||||
}
|
||||
body.wrap .crash-body { white-space: pre-wrap; word-break: break-all; }
|
||||
.jump {
|
||||
position: absolute;
|
||||
right: 18px;
|
||||
bottom: 14px;
|
||||
background: var(--accent);
|
||||
color: #0d1117;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.jump:hover { background: #79b8ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="wrap">
|
||||
<div id="topbar">
|
||||
<h1>Reforger Output Console</h1>
|
||||
<span id="conn">verbinde…</span>
|
||||
<span class="spacer"></span>
|
||||
<label><input type="checkbox" id="wrapToggle" checked> Zeilenumbruch</label></div>
|
||||
<div id="panes"></div>
|
||||
|
||||
<template id="paneTpl">
|
||||
<section class="pane">
|
||||
<div class="pane-head">
|
||||
<div class="row">
|
||||
<span class="name"></span>
|
||||
<span class="folder"></span>
|
||||
<span class="counts"><span class="e"></span> <span class="w"></span></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input type="search" class="filter" placeholder="Filter…">
|
||||
<select class="level" title="Nur bestimmte Stufen anzeigen">
|
||||
<option value="all">Alle</option>
|
||||
<option value="ew">Fehler + Warnungen</option>
|
||||
<option value="e">Nur Fehler</option>
|
||||
</select>
|
||||
<button class="follow on" title="Automatisch ans Ende scrollen">Folgen</button>
|
||||
<button class="clear" title="Anzeige leeren (Datei bleibt unverändert)">Leeren</button>
|
||||
<button class="open" title="Log-Ordner im Explorer öffnen">Ordner</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-wrap">
|
||||
<div class="log"></div>
|
||||
<button class="jump" hidden>↓ Neue Zeilen</button>
|
||||
</div>
|
||||
<div class="crash" hidden>
|
||||
<div class="crash-head">
|
||||
<span>Crash Report</span>
|
||||
<button class="crash-close" title="Crash Report schließen">✕</button>
|
||||
</div>
|
||||
<pre class="crash-body"></pre>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const panesEl = document.getElementById('panes');
|
||||
const connEl = document.getElementById('conn');
|
||||
let maxLines = 20000;
|
||||
const panes = [];
|
||||
|
||||
// Zeilen wie "21:19:25.435 INIT (E): ..." bzw. "(W)"
|
||||
const LEVEL_RE = /^\s*\d{1,2}:\d{2}:\d{2}\.\d+\s+\S+\s*\((E|W)\)/;
|
||||
const TIME_RE = /^\s*\d{1,2}:\d{2}:\d{2}\.\d+\s/;
|
||||
|
||||
document.getElementById('wrapToggle').addEventListener('change', (e) => {
|
||||
document.body.classList.toggle('wrap', e.target.checked);
|
||||
});
|
||||
|
||||
function createPane(info) {
|
||||
const el = document.getElementById('paneTpl').content.firstElementChild.cloneNode(true);
|
||||
const p = {
|
||||
id: info.id,
|
||||
el,
|
||||
head: el.querySelector('.pane-head'),
|
||||
log: el.querySelector('.log'),
|
||||
folderEl: el.querySelector('.folder'),
|
||||
eEl: el.querySelector('.counts .e'),
|
||||
wEl: el.querySelector('.counts .w'),
|
||||
jump: el.querySelector('.jump'),
|
||||
followBtn: el.querySelector('.follow'),
|
||||
crashEl: el.querySelector('.crash'),
|
||||
crashBody: el.querySelector('.crash-body'),
|
||||
filter: '',
|
||||
level: 'all',
|
||||
follow: true,
|
||||
partial: '',
|
||||
partialEl: null,
|
||||
lastLevel: '',
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
};
|
||||
el.querySelector('.name').textContent = info.name;
|
||||
|
||||
el.querySelector('.filter').addEventListener('input', (e) => {
|
||||
p.filter = e.target.value.toLowerCase();
|
||||
refilter(p);
|
||||
});
|
||||
el.querySelector('.level').addEventListener('change', (e) => {
|
||||
p.level = e.target.value;
|
||||
refilter(p);
|
||||
});
|
||||
p.followBtn.addEventListener('click', () => setFollow(p, !p.follow));
|
||||
el.querySelector('.clear').addEventListener('click', () => clearPane(p));
|
||||
el.querySelector('.open').addEventListener('click', () => fetch('/open?id=' + p.id, { method: 'POST' }));
|
||||
p.jump.addEventListener('click', () => setFollow(p, true));
|
||||
el.querySelector('.crash-close').addEventListener('click', () => showCrash(p, null));
|
||||
p.log.addEventListener('scroll', () => {
|
||||
const atBottom = p.log.scrollHeight - p.log.scrollTop - p.log.clientHeight < 20;
|
||||
if (atBottom && !p.follow) setFollow(p, true);
|
||||
else if (!atBottom && p.follow && !p.autoScrolling) setFollow(p, false);
|
||||
p.autoScrolling = false;
|
||||
});
|
||||
|
||||
panesEl.appendChild(el);
|
||||
return p;
|
||||
}
|
||||
|
||||
// --- Spaltenbreiten ---------------------------------------------------------
|
||||
// Breiten werden als flex-grow-Verhältnis gespeichert, damit sie beim
|
||||
// Ändern der Fenstergröße proportional mitwachsen.
|
||||
const MIN_PANE_PX = 150;
|
||||
const WIDTHS_KEY = 'paneWidths';
|
||||
|
||||
function paneEls() {
|
||||
return [...panesEl.querySelectorAll(':scope > .pane')];
|
||||
}
|
||||
|
||||
function saveWidths() {
|
||||
try {
|
||||
localStorage.setItem(WIDTHS_KEY, JSON.stringify(paneEls().map((el) => Number(el.style.flexGrow) || 1)));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function restoreWidths() {
|
||||
let widths;
|
||||
try {
|
||||
widths = JSON.parse(localStorage.getItem(WIDTHS_KEY));
|
||||
} catch {}
|
||||
const els = paneEls();
|
||||
if (!Array.isArray(widths) || widths.length !== els.length) return;
|
||||
els.forEach((el, i) => (el.style.flexGrow = widths[i]));
|
||||
}
|
||||
|
||||
function addSplitter() {
|
||||
const sp = document.createElement('div');
|
||||
sp.className = 'splitter';
|
||||
sp.title = 'Ziehen zum Anpassen, Doppelklick = gleich breit';
|
||||
panesEl.appendChild(sp);
|
||||
|
||||
sp.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
const left = sp.previousElementSibling;
|
||||
const right = sp.nextElementSibling;
|
||||
// Alle Spalten auf ihre aktuelle Pixelbreite "einfrieren". Erst alle
|
||||
// messen, dann setzen – sonst verfälscht das Zwischen-Layout die Messung.
|
||||
const els = paneEls();
|
||||
const widths = els.map((el) => el.getBoundingClientRect().width);
|
||||
els.forEach((el, i) => (el.style.flexGrow = widths[i]));
|
||||
const startX = e.clientX;
|
||||
const startLeft = widths[els.indexOf(left)];
|
||||
const total = startLeft + widths[els.indexOf(right)];
|
||||
|
||||
sp.setPointerCapture(e.pointerId);
|
||||
sp.classList.add('dragging');
|
||||
document.body.classList.add('resizing');
|
||||
|
||||
const onMove = (ev) => {
|
||||
const w = Math.min(Math.max(startLeft + ev.clientX - startX, MIN_PANE_PX), total - MIN_PANE_PX);
|
||||
left.style.flexGrow = w;
|
||||
right.style.flexGrow = total - w;
|
||||
};
|
||||
const onUp = () => {
|
||||
sp.removeEventListener('pointermove', onMove);
|
||||
sp.classList.remove('dragging');
|
||||
document.body.classList.remove('resizing');
|
||||
saveWidths();
|
||||
};
|
||||
sp.addEventListener('pointermove', onMove);
|
||||
sp.addEventListener('pointerup', onUp, { once: true });
|
||||
sp.addEventListener('pointercancel', onUp, { once: true });
|
||||
});
|
||||
|
||||
sp.addEventListener('dblclick', () => {
|
||||
for (const el of paneEls()) el.style.flexGrow = 1;
|
||||
saveWidths();
|
||||
});
|
||||
}
|
||||
|
||||
function setFollow(p, on) {
|
||||
p.follow = on;
|
||||
p.followBtn.classList.toggle('on', on);
|
||||
if (on) {
|
||||
p.jump.hidden = true;
|
||||
scrollToEnd(p);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToEnd(p) {
|
||||
p.autoScrolling = true;
|
||||
p.log.scrollTop = p.log.scrollHeight;
|
||||
}
|
||||
|
||||
function matches(p, div) {
|
||||
if (p.level === 'e' && div.dataset.lvl !== 'e') return false;
|
||||
if (p.level === 'ew' && !div.dataset.lvl) return false;
|
||||
if (p.filter && !div.textContent.toLowerCase().includes(p.filter)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function refilter(p) {
|
||||
for (const div of p.log.children) {
|
||||
if (div.classList.contains('empty')) continue;
|
||||
div.classList.toggle('hidden', !matches(p, div));
|
||||
}
|
||||
if (p.follow) scrollToEnd(p);
|
||||
}
|
||||
|
||||
function updateCounts(p) {
|
||||
p.eEl.textContent = p.errors ? p.errors + ' E' : '';
|
||||
p.wEl.textContent = p.warnings ? p.warnings + ' W' : '';
|
||||
}
|
||||
|
||||
function clearPane(p) {
|
||||
p.log.textContent = '';
|
||||
p.partial = '';
|
||||
p.partialEl = null;
|
||||
p.lastLevel = '';
|
||||
p.errors = 0;
|
||||
p.warnings = 0;
|
||||
updateCounts(p);
|
||||
}
|
||||
|
||||
function makeLine(p, text) {
|
||||
const div = document.createElement('div');
|
||||
const m = LEVEL_RE.exec(text);
|
||||
let lvl = '';
|
||||
if (m) lvl = m[1] === 'E' ? 'e' : 'w';
|
||||
else if (!TIME_RE.test(text) && text.trim() !== '') lvl = p.lastLevel; // Fortsetzungszeile
|
||||
if (TIME_RE.test(text) || text.trim() === '') p.lastLevel = lvl;
|
||||
if (lvl) {
|
||||
div.className = lvl;
|
||||
div.dataset.lvl = lvl;
|
||||
}
|
||||
div.textContent = text;
|
||||
return { div, lvl, isNew: !!m };
|
||||
}
|
||||
|
||||
function appendText(p, chunk) {
|
||||
const empty = p.log.querySelector('.empty');
|
||||
if (empty) empty.remove();
|
||||
|
||||
const parts = (p.partial + chunk.replace(/\r/g, '')).split('\n');
|
||||
p.partial = parts.pop();
|
||||
if (p.partialEl) {
|
||||
p.partialEl.remove();
|
||||
p.partialEl = null;
|
||||
}
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const line of parts) {
|
||||
const { div, lvl, isNew } = makeLine(p, line);
|
||||
if (isNew && lvl === 'e') p.errors++;
|
||||
if (isNew && lvl === 'w') p.warnings++;
|
||||
if (!matches(p, div)) div.classList.add('hidden');
|
||||
frag.appendChild(div);
|
||||
}
|
||||
if (p.partial) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'partial';
|
||||
div.textContent = p.partial;
|
||||
p.partialEl = div;
|
||||
frag.appendChild(div);
|
||||
}
|
||||
p.log.appendChild(frag);
|
||||
|
||||
let excess = p.log.childElementCount - maxLines;
|
||||
while (excess-- > 0) p.log.firstElementChild.remove();
|
||||
|
||||
updateCounts(p);
|
||||
if (p.follow) scrollToEnd(p);
|
||||
else if (parts.length) p.jump.hidden = false;
|
||||
}
|
||||
|
||||
// text === null schließt den Crash-Bereich.
|
||||
function showCrash(p, text) {
|
||||
p.crashBody.textContent = text || '';
|
||||
p.crashEl.hidden = text === null;
|
||||
if (text !== null) p.crashBody.scrollTop = 0;
|
||||
if (p.follow) scrollToEnd(p);
|
||||
}
|
||||
|
||||
function resetPane(p, info) {
|
||||
clearPane(p);
|
||||
showCrash(p, info.crash);
|
||||
p.folderEl.textContent = info.folder ? info.folder + ' / ' + info.file : '';
|
||||
p.folderEl.title = info.logsDir + (info.folder ? '\\' + info.folder + '\\' + info.file : '');
|
||||
if (info.text) {
|
||||
appendText(p, info.text);
|
||||
} else {
|
||||
const msg = document.createElement('div');
|
||||
msg.className = 'empty';
|
||||
msg.textContent = info.dirMissing
|
||||
? 'Ordner nicht gefunden: ' + info.logsDir
|
||||
: info.folder ? 'Warte auf ' + info.file + '…' : 'Noch kein Log-Ordner vorhanden.';
|
||||
p.log.appendChild(msg);
|
||||
}
|
||||
setFollow(p, true);
|
||||
}
|
||||
|
||||
function flash(p) {
|
||||
p.head.classList.add('flash');
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => p.head.classList.remove('flash')));
|
||||
}
|
||||
|
||||
const es = new EventSource('/events');
|
||||
es.addEventListener('init', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
maxLines = data.maxLines;
|
||||
panesEl.textContent = '';
|
||||
panes.length = 0;
|
||||
for (const info of data.panes) {
|
||||
if (panes.length) addSplitter();
|
||||
const p = createPane(info);
|
||||
panes[info.id] = p;
|
||||
resetPane(p, info);
|
||||
}
|
||||
restoreWidths();
|
||||
connEl.textContent = 'verbunden';
|
||||
connEl.classList.remove('down');
|
||||
});
|
||||
es.addEventListener('reset', (e) => {
|
||||
const info = JSON.parse(e.data);
|
||||
const p = panes[info.id];
|
||||
if (!p) return;
|
||||
resetPane(p, info);
|
||||
flash(p);
|
||||
});
|
||||
es.addEventListener('append', (e) => {
|
||||
const { id, chunk } = JSON.parse(e.data);
|
||||
if (panes[id]) appendText(panes[id], chunk);
|
||||
});
|
||||
es.addEventListener('crash', (e) => {
|
||||
const { id, text } = JSON.parse(e.data);
|
||||
const p = panes[id];
|
||||
if (!p) return;
|
||||
showCrash(p, text);
|
||||
flash(p);
|
||||
});
|
||||
es.onerror = () => {
|
||||
connEl.textContent = 'Verbindung getrennt – versuche erneut…';
|
||||
connEl.classList.add('down');
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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