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; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using PolyTraderSharp.Services; 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"); group.MapGet("/", (TerminalLogger logger, int? minutes) => { var span = TimeSpan.FromMinutes(minutes.GetValueOrDefault(60)); var history = logger.GetHistory(span) .Select(e => new { time = e.Timestamp.ToString("HH:mm:ss"), timestamp = e.Timestamp, level = e.Level.ToString(), cid = e.CorrelationId, msg = e.Message }) .ToList(); return Results.Ok(history); }); group.MapGet("/stream", async (HttpContext context, TerminalLogger logger, CancellationToken ct) => { context.Response.Headers.ContentType = "text/event-stream"; context.Response.Headers.CacheControl = "no-cache"; context.Response.Headers.Connection = "keep-alive"; var channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleWriter = false, SingleReader = true }); void Handler(object? _, LogMessageEventArgs e) { channel.Writer.TryWrite(e); } logger.OnLogMessage += Handler; try { await context.Response.WriteAsync(": connected\n\n", ct); await context.Response.Body.FlushAsync(ct); while (!ct.IsCancellationRequested) { var log = await channel.Reader.ReadAsync(ct); var payload = JsonSerializer.Serialize(new { time = log.Timestamp.ToString("HH:mm:ss"), level = log.Level.ToString(), cid = log.CorrelationId, msg = log.Message }); await context.Response.WriteAsync($"data: {payload}\n\n", ct); await context.Response.Body.FlushAsync(ct); } } catch (OperationCanceledException) { // Client hat Verbindung getrennt } finally { logger.OnLogMessage -= Handler; channel.Writer.TryComplete(); } }); // ---- 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; } } }