WebUI: Log-Viewer fuer vergangene Tage und Kopieren

Umsetzung von #564.

Das WebUI zeigte bisher nur den Live-Stream seit dem Prozessstart. GET
/api/logs lieferte den Verlauf im Speicher, die Oberflaeche nutzte ihn nicht.
Der Desktop hat einen Log-Viewer ueber die Tagesdateien. Den gibt es jetzt auch
im WebUI.

- GET /api/logs/files listet die vorhandenen Tage (Logs/yyyy-MM-dd.jsonl),
  neueste zuerst. Die .log-Textdateien und fremde Dateien bleiben aussen vor.
- GET /api/logs/file filtert nach Level, Korrelations-ID und Text, alle ohne
  Gross-/Kleinschreibung. Hoechstens 20000 Treffer wie im Desktop, dazu
  Seitenaufteilung. Unlesbare Zeilen werden uebersprungen und gezaehlt.
- Der Dateiname entsteht aus dem geprueften Datum, nie aus der Eingabe.
  "../../etc/passwd" kommt deshalb gar nicht erst in den Pfad; die Tests halten
  das fest.
- Gelesen wird mit FileShare.ReadWrite, weil der Logger laufend an die
  Tagesdatei anhaengt.

Oberflaeche: "Live" und "Log-Viewer (vergangene Tage)" als Unter-Tabs.
"Alles kopieren" im Live-Tab, "Seite kopieren" im Viewer. navigator.clipboard
gibt es nur in sicheren Kontexten, ueber LAN/VPN laeuft das WebUI aber per
http. Dafuer gibt es einen Rueckweg ueber ein unsichtbares Textfeld, sonst
schluege das Kopieren dort still fehl. Logzeilen werden per textContent
eingesetzt, nicht per innerHTML: Logtexte enthalten, was andere eingegeben
haben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-13 14:16:12 +02:00
co-authored by Claude Opus 5
parent c1e13f5441
commit 0067e0b761
5 changed files with 433 additions and 0 deletions
+152
View File
@@ -658,6 +658,158 @@ function clearLogs() {
document.getElementById('logContainer').innerHTML = '';
}
// ==================== Log-Viewer und Kopieren (#564) ====================
let logViewerState = { page: 1, totalPages: 1, items: [] };
let logViewerDaysLoaded = false;
function switchLogTab(tab) {
['live', 'viewer'].forEach(t => {
document.getElementById(`logTabBtn-${t}`)?.classList.toggle('active', t === tab);
const panel = document.getElementById(`logTab-${t}`);
if (panel) panel.hidden = t !== tab;
});
if (tab === 'viewer' && !logViewerDaysLoaded) loadLogViewerDays();
}
async function loadLogViewerDays() {
const select = document.getElementById('logViewerDate');
const status = document.getElementById('logViewerStatus');
try {
const res = await fetch('/api/logs/files');
if (!res.ok) throw new Error(await errorText(res));
const days = await res.json();
select.innerHTML = days.length
? days.map(d => `<option value="${escapeHtml(d.date)}">${escapeHtml(formatLogDay(d.date))} · ${escapeHtml(formatBytes(d.sizeBytes))}</option>`).join('')
: '<option value="">keine Logdateien</option>';
logViewerDaysLoaded = true;
} catch (e) {
status.textContent = `Tage nicht ladbar: ${e.message}`;
}
}
function formatLogDay(iso) {
const [y, m, d] = String(iso).split('-');
return `${d}.${m}.${y}`;
}
function formatBytes(n) {
return n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`;
}
/** Eine Logzeile als DOM-Knoten. textContent statt innerHTML: Logtexte enthalten, was andere eingegeben haben. */
function buildLogRow(time, level, msg, cid) {
const div = document.createElement('div');
div.className = 'log-entry';
const t = document.createElement('span');
t.className = 'log-time';
t.textContent = `[${time}]`;
const l = document.createElement('span');
l.className = 'log-level';
l.classList.add(String(level).replace(/[^A-Za-z]/g, ''));
l.textContent = level;
const m = document.createElement('span');
m.className = 'log-msg';
m.textContent = cid ? `${msg} ⟨${cid}⟩` : msg;
div.append(t, l, m);
return div;
}
async function loadLogViewer(page) {
const date = document.getElementById('logViewerDate').value;
const status = document.getElementById('logViewerStatus');
const container = document.getElementById('logViewerContainer');
if (!date) {
status.textContent = 'Kein Tag gewählt.';
return;
}
const params = new URLSearchParams({ date, page: String(page || 1), pageSize: '500' });
const level = document.getElementById('logViewerLevel').value;
const cid = document.getElementById('logViewerCid').value.trim();
const q = document.getElementById('logViewerText').value.trim();
if (level) params.set('level', level);
if (cid) params.set('cid', cid);
if (q) params.set('q', q);
status.textContent = 'Lade …';
try {
const res = await fetch(`/api/logs/file?${params}`);
if (!res.ok) throw new Error(await errorText(res));
const data = await res.json();
logViewerState = { page: data.page, totalPages: data.totalPages, items: data.items };
container.innerHTML = '';
// ts ist ISO mit Offset ("2026-09-12T14:03:22.123+02:00"); angezeigt wird die Uhrzeit.
data.items.forEach(item => container.appendChild(
buildLogRow(item.time.length >= 19 ? item.time.substring(11, 19) : item.time, item.level, item.msg, item.cid)));
if (!data.items.length)
container.innerHTML = '<div class="form-desc" style="padding: 12px;">Keine Einträge für diese Auswahl.</div>';
container.scrollTop = 0;
let text = `${data.total.toLocaleString('de-DE')} Einträge · Seite ${data.page}/${data.totalPages}`;
if (data.truncated) text += ` · nach ${data.maxMatches.toLocaleString('de-DE')} Treffern abgeschnitten – Filter enger fassen`;
if (data.unreadableLines) text += ` · ${data.unreadableLines} unlesbare Zeilen übersprungen`;
status.textContent = text;
} catch (e) {
container.innerHTML = '';
status.textContent = e.message;
}
}
function changeLogViewerPage(delta) {
const next = logViewerState.page + delta;
if (next < 1 || next > logViewerState.totalPages) return;
loadLogViewer(next);
}
/**
* navigator.clipboard gibt es nur in sicheren Kontexten (https oder localhost). Über LAN/VPN läuft das
* WebUI per http – ohne diesen Rückweg über ein unsichtbares Textfeld schlüge das Kopieren dort still fehl.
*/
async function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.position = 'fixed';
area.style.opacity = '0';
document.body.appendChild(area);
area.select();
const ok = document.execCommand('copy');
area.remove();
if (!ok) throw new Error('Die Zwischenablage ist in diesem Browser nicht erreichbar.');
}
async function copyWithFeedback(text, okMessage) {
if (!text) {
alert('Nichts zu kopieren.');
return;
}
try {
await copyText(text);
alert(okMessage);
} catch (e) {
alert(`Kopieren fehlgeschlagen: ${e.message}`);
}
}
async function copyLiveLogs() {
const filter = document.getElementById('logLevelFilter').value;
const visible = allLogs.filter(e => filter === 'ALL' || e.level.toUpperCase() === filter);
await copyWithFeedback(visible.map(e => `[${e.time}] ${e.level} ${e.msg}`).join('\n'), `${visible.length} Zeilen kopiert.`);
}
async function copyLogViewer() {
const items = logViewerState.items;
await copyWithFeedback(
items.map(e => `[${e.time}] ${e.level}${e.cid ? ` ⟨${e.cid}⟩` : ''} ${e.msg}`).join('\n'),
`${items.length} Zeilen kopiert.`);
}
// Settings View
async function loadSettings() {
loadServerSettings();