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>
196 lines
8.6 KiB
C#
196 lines
8.6 KiB
C#
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
|
||
{
|
||
/// <summary>Wie im Desktop-Viewer: Schutz bei sehr grossen Tagen.</summary>
|
||
internal const int MaxMatches = 20000;
|
||
|
||
/// <summary>Etwas mehr als ein Jahr - aeltere Tage lassen sich weiter direkt ueber das Datum abrufen.</summary>
|
||
internal const int MaxListedDays = 400;
|
||
|
||
/// <summary>Ordner der Tagesdateien. Ohne Registrierung derselbe wie im TerminalLogger.</summary>
|
||
private static string ResolveLogDirectory(IServiceProvider sp) =>
|
||
sp.GetService<LogDirectory>()?.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<LogMessageEventArgs>(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<object>());
|
||
|
||
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<LogJson.ParsedLogLine>();
|
||
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;
|
||
}
|
||
}
|
||
}
|