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); + } + } +}