Files
2026-09-26 18:44:15 +02:00

506 lines
15 KiB
HTML
Raw Permalink 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="en">
<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">connecting…</span>
<span class="spacer"></span>
<label><input type="checkbox" id="wrapToggle" checked> Word wrap</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="Show only certain levels">
<option value="all">All</option>
<option value="ew">Errors + warnings</option>
<option value="e">Errors only</option>
</select>
<button class="follow on" title="Automatically scroll to the end">Follow</button>
<button class="clear" title="Clear view (file stays unchanged)">Clear</button>
<button class="open" title="Open log folder in Explorer">Folder</button>
</div>
</div>
<div class="log-wrap">
<div class="log"></div>
<button class="jump" hidden>↓ New lines</button>
</div>
<div class="crash" hidden>
<div class="crash-head">
<span>Crash Report</span>
<button class="crash-close" title="Close crash report">✕</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 = [];
// Lines like "21:19:25.435 INIT (E): ..." or "(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;
}
// --- Column widths ---------------------------------------------------------
// Widths are stored as flex-grow ratios so they scale proportionally
// when the window is resized.
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 = 'Drag to resize, double-click = equal widths';
panesEl.appendChild(sp);
sp.addEventListener('pointerdown', (e) => {
e.preventDefault();
const left = sp.previousElementSibling;
const right = sp.nextElementSibling;
// "Freeze" all columns at their current pixel width. Measure all first,
// then set – otherwise the intermediate layout skews the measurement.
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; // continuation line
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 closes the crash panel.
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
? 'Folder not found: ' + info.logsDir
: info.folder ? 'Waiting for ' + info.file + '…' : 'No log folder yet.';
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 = 'connected';
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 = 'Disconnected – retrying…';
connEl.classList.add('down');
};
</script>
</body>
</html>