Output Console (Premium)

This commit is contained in:
Deubelskerl
2026-09-26 18:37:30 +02:00
parent 495948721a
commit 97726e95ae
4 changed files with 767 additions and 0 deletions
+505
View File
@@ -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>