From 0067e0b76152d25f881fe59b50e028cec38fa5f4 Mon Sep 17 00:00:00 2001 From: Richard Date: Sun, 13 Sep 2026 14:16:12 +0200 Subject: [PATCH] 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 --- .../Configuration/LogDirectory.cs | 8 + src/PolyTrader.Web/Endpoints/LogsEndpoints.cs | 105 ++++++++++++ src/PolyTrader.Web/wwwroot/index.html | 42 +++++ src/PolyTrader.Web/wwwroot/js/app.js | 152 ++++++++++++++++++ .../WebUi/LogViewerEndpointTests.cs | 126 +++++++++++++++ 5 files changed, 433 insertions(+) create mode 100644 src/PolyTrader.Web/Configuration/LogDirectory.cs create mode 100644 tests/PolyTrader.Tests/WebUi/LogViewerEndpointTests.cs diff --git a/src/PolyTrader.Web/Configuration/LogDirectory.cs b/src/PolyTrader.Web/Configuration/LogDirectory.cs new file mode 100644 index 0000000..afef42b --- /dev/null +++ b/src/PolyTrader.Web/Configuration/LogDirectory.cs @@ -0,0 +1,8 @@ +namespace PolyTrader.Web.Configuration +{ + /// + /// Ordner der Log-Tagesdateien für den Log-Viewer (#564). Im Betrieb nicht registriert: Dann gilt derselbe + /// Ordner, in den der TerminalLogger schreibt. Tests legen ihn in ein temporäres Verzeichnis. + /// + public sealed record LogDirectory(string Path); +} diff --git a/src/PolyTrader.Web/Endpoints/LogsEndpoints.cs b/src/PolyTrader.Web/Endpoints/LogsEndpoints.cs index 1562e49..4f12780 100644 --- a/src/PolyTrader.Web/Endpoints/LogsEndpoints.cs +++ b/src/PolyTrader.Web/Endpoints/LogsEndpoints.cs @@ -1,3 +1,8 @@ +using PolyTrader.Web.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.IO; +using System.Globalization; +using System.Collections.Generic; using System; using System.Linq; using System.Text.Json; @@ -13,6 +18,17 @@ namespace PolyTrader.Web.Endpoints { public static class LogsEndpoints { + /// Wie im Desktop-Viewer: Schutz bei sehr grossen Tagen. + internal const int MaxMatches = 20000; + + /// Etwas mehr als ein Jahr - aeltere Tage lassen sich weiter direkt ueber das Datum abrufen. + internal const int MaxListedDays = 400; + + /// Ordner der Tagesdateien. Ohne Registrierung derselbe wie im TerminalLogger. + private static string ResolveLogDirectory(IServiceProvider sp) => + sp.GetService()?.Path + ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); + public static IEndpointRouteBuilder MapLogsEndpoints(this IEndpointRouteBuilder routes) { var group = routes.MapGroup("/api/logs"); @@ -84,6 +100,95 @@ namespace PolyTrader.Web.Endpoints } }); + // ---- Log-Viewer (#564) --------------------------------------------------------------- + // Gegenstueck zum Tab "Log Viewer" im Desktop (TerminalWindow.LoadViewer): Tagesdateien + // Logs/yyyy-MM-dd.jsonl, gefiltert nach Level, Korrelations-ID und Text, hoechstens + // MaxMatches Treffer. Der Live-Stream zeigt nur, was seit dem Prozessstart aufgelaufen ist. + + group.MapGet("/files", (IServiceProvider sp) => + { + string dir = ResolveLogDirectory(sp); + if (!Directory.Exists(dir)) + return Results.Ok(new List()); + + var files = Directory.EnumerateFiles(dir, "*.jsonl") + .Select(p => (Full: p, Name: Path.GetFileNameWithoutExtension(p))) + .Where(f => DateOnly.TryParseExact(f.Name, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) + .OrderByDescending(f => f.Name, StringComparer.Ordinal) + .Take(MaxListedDays) + .Select(f => (object)new { date = f.Name, sizeBytes = new FileInfo(f.Full).Length }) + .ToList(); + + return Results.Ok(files); + }); + + group.MapGet("/file", (IServiceProvider sp, string? date, string? level, string? cid, string? q, int? page, int? pageSize) => + { + if (string.IsNullOrWhiteSpace(date) + || !DateOnly.TryParseExact(date.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var day)) + return Results.BadRequest(new { error = "Datum fehlt oder ist ungültig – erwartet yyyy-MM-dd." }); + + // Der Dateiname entsteht aus dem GEPRUEFTEN Datum, nie aus der Eingabe. Ein "../" kann so + // gar nicht erst in den Pfad gelangen. + string path = Path.Combine(ResolveLogDirectory(sp), $"{day:yyyy-MM-dd}.jsonl"); + if (!File.Exists(path)) + return Results.NotFound(new { error = $"Für den {day:dd.MM.yyyy} gibt es keine Logdatei." }); + + string levelFilter = string.IsNullOrWhiteSpace(level) || level.Trim().Equals("Alle", StringComparison.OrdinalIgnoreCase) + ? string.Empty : level.Trim(); + string cidFilter = cid?.Trim() ?? string.Empty; + string textFilter = q?.Trim() ?? string.Empty; + + var matches = new List(); + bool truncated = false; + int unreadable = 0; + + // FileShare.ReadWrite: Der Logger haengt an die Tagesdatei laufend Zeilen an. + using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + using (var reader = new StreamReader(fs)) + { + string? line; + while ((line = reader.ReadLine()) != null) + { + var parsed = LogJson.ParseLine(line); + if (parsed == null) + { + if (!string.IsNullOrWhiteSpace(line)) unreadable++; + continue; + } + if (levelFilter.Length > 0 && !parsed.Level.Equals(levelFilter, StringComparison.OrdinalIgnoreCase)) continue; + if (cidFilter.Length > 0 && !parsed.Cid.Contains(cidFilter, StringComparison.OrdinalIgnoreCase)) continue; + if (textFilter.Length > 0 && !parsed.Message.Contains(textFilter, StringComparison.OrdinalIgnoreCase)) continue; + + if (matches.Count >= MaxMatches) + { + truncated = true; + break; + } + matches.Add(parsed); + } + } + + int size = Math.Clamp(pageSize ?? 500, 50, 2000); + int totalPages = Math.Max(1, (int)Math.Ceiling(matches.Count / (double)size)); + int current = Math.Clamp(page ?? 1, 1, totalPages); + + return Results.Ok(new + { + date = day.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + total = matches.Count, + truncated, + maxMatches = MaxMatches, + unreadableLines = unreadable, + page = current, + pageSize = size, + totalPages, + items = matches.Skip((current - 1) * size).Take(size) + .Select(p => new { time = p.Time, level = p.Level, cid = p.Cid, msg = p.Message }) + .ToList() + }); + }); + return routes; } } diff --git a/src/PolyTrader.Web/wwwroot/index.html b/src/PolyTrader.Web/wwwroot/index.html index a2d8be7..ded0a5e 100644 --- a/src/PolyTrader.Web/wwwroot/index.html +++ b/src/PolyTrader.Web/wwwroot/index.html @@ -1119,6 +1119,13 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
+ +
+ + +
+ +
@@ -1139,11 +1146,46 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d +
+
+ +
diff --git a/src/PolyTrader.Web/wwwroot/js/app.js b/src/PolyTrader.Web/wwwroot/js/app.js index 2de9c96..2200582 100644 --- a/src/PolyTrader.Web/wwwroot/js/app.js +++ b/src/PolyTrader.Web/wwwroot/js/app.js @@ -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 => ``).join('') + : ''; + 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 = '
Keine Einträge für diese Auswahl.
'; + 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(); diff --git a/tests/PolyTrader.Tests/WebUi/LogViewerEndpointTests.cs b/tests/PolyTrader.Tests/WebUi/LogViewerEndpointTests.cs new file mode 100644 index 0000000..4ecf7e1 --- /dev/null +++ b/tests/PolyTrader.Tests/WebUi/LogViewerEndpointTests.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using PolyTrader.Web.Configuration; +using PolyTraderSharp.Services; +using Xunit; + +namespace PolyTrader.Tests.WebUi +{ + /// + /// Log-Viewer für vergangene Tage über das WebUI (#564). + /// + public sealed class LogViewerEndpointTests : IDisposable + { + private readonly string _dir = Path.Combine(Path.GetTempPath(), "polytrader-logs-" + Guid.NewGuid().ToString("N")); + + public LogViewerEndpointTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch { } + } + + private void WriteDay(string date, params (string Msg, LogLevel Level, string Cid)[] lines) => + WriteRaw(date, lines.Select(l => LogJson.Format(new LogMessageEventArgs(l.Msg, l.Level, l.Cid))).ToArray()); + + private void WriteRaw(string date, params string[] lines) => + File.WriteAllLines(Path.Combine(_dir, date + ".jsonl"), lines, new UTF8Encoding(false)); + + private Task StartAsync() => + WebUiTestHost.StartAsync(s => s.AddSingleton(new LogDirectory(_dir))); + + [Fact] + public async Task Tagesliste_zeigt_nur_Tagesdateien_neueste_zuerst() + { + WriteDay("2026-09-12", ("a", LogLevel.Info, "")); + WriteDay("2026-09-13", ("b", LogLevel.Info, "")); + File.WriteAllText(Path.Combine(_dir, "2026-09-13-Info.log"), "[12:00:00] b"); + File.WriteAllText(Path.Combine(_dir, "notizen.jsonl"), "{}"); + await using var web = await StartAsync(); + + var files = await web.GetJsonAsync("/api/logs/files"); + + Assert.Equal(new[] { "2026-09-13", "2026-09-12" }, + files.EnumerateArray().Select(f => f.GetProperty("date").GetString()).ToArray()); + } + + [Fact] + public async Task Filter_nach_Level_Korrelations_ID_und_Text() + { + WriteDay("2026-09-12", + ("Start", LogLevel.Info, ""), + ("Signal abgelehnt", LogLevel.Warning, "sig-42"), + ("VPN weg", LogLevel.Error, "")); + await using var web = await StartAsync(); + + var all = await web.GetJsonAsync("/api/logs/file?date=2026-09-12"); + var warnings = await web.GetJsonAsync("/api/logs/file?date=2026-09-12&level=warning"); + var byCid = await web.GetJsonAsync("/api/logs/file?date=2026-09-12&cid=SIG-42"); + var byText = await web.GetJsonAsync("/api/logs/file?date=2026-09-12&q=vpn"); + + Assert.Equal(3, all.GetProperty("total").GetInt32()); + Assert.Equal("Signal abgelehnt", Assert.Single(warnings.GetProperty("items").EnumerateArray()).GetProperty("msg").GetString()); + Assert.Equal("sig-42", Assert.Single(byCid.GetProperty("items").EnumerateArray()).GetProperty("cid").GetString()); + Assert.Equal("Error", Assert.Single(byText.GetProperty("items").EnumerateArray()).GetProperty("level").GetString()); + } + + [Fact] + public async Task Kaputte_Zeilen_werden_uebersprungen_und_gezaehlt() + { + WriteRaw("2026-09-12", + LogJson.Format(new LogMessageEventArgs("gut", LogLevel.Info)), + "{kaputt", + "", + LogJson.Format(new LogMessageEventArgs("auch gut", LogLevel.Info))); + await using var web = await StartAsync(); + + var result = await web.GetJsonAsync("/api/logs/file?date=2026-09-12"); + + Assert.Equal(2, result.GetProperty("total").GetInt32()); + Assert.Equal(1, result.GetProperty("unreadableLines").GetInt32()); // Leerzeilen zaehlen nicht + } + + [Fact] + public async Task Seitenaufteilung() + { + WriteDay("2026-09-12", Enumerable.Range(1, 120).Select(i => ($"Zeile {i}", LogLevel.Info, "")).ToArray()); + await using var web = await StartAsync(); + + var last = await web.GetJsonAsync("/api/logs/file?date=2026-09-12&pageSize=50&page=9"); + + Assert.Equal(3, last.GetProperty("totalPages").GetInt32()); + Assert.Equal(3, last.GetProperty("page").GetInt32()); // auf die letzte Seite begrenzt + Assert.Equal(20, last.GetProperty("items").GetArrayLength()); + Assert.Equal("Zeile 101", last.GetProperty("items")[0].GetProperty("msg").GetString()); + } + + [Theory] + [InlineData("../../etc/passwd")] + [InlineData("2026-09-12/../../geheim")] + [InlineData("2026-02-30")] + [InlineData("")] + public async Task Ungueltiges_Datum_400(string date) + { + WriteDay("2026-09-12", ("a", LogLevel.Info, "")); + await using var web = await StartAsync(); + + using var res = await web.Client.GetAsync("/api/logs/file?date=" + Uri.EscapeDataString(date)); + + Assert.Equal(400, (int)res.StatusCode); + } + + [Fact] + public async Task Tag_ohne_Datei_404() + { + await using var web = await StartAsync(); + + using var res = await web.Client.GetAsync("/api/logs/file?date=2020-01-01"); + + Assert.Equal(404, (int)res.StatusCode); + } + } +}