Files
1960-utils/ReforgerOutputConsole/index.html
T
2026-09-26 18:37:30 +02:00

506 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>