using System.Collections.Concurrent; using System.Text.RegularExpressions; namespace ClawdDotNet.Services; /// /// Überwacht das Logs-Verzeichnis und liefert neue Log-Einträge gefiltert /// an eine RichTextBox. Bereinigt die RichTextBox automatisch wenn sie /// zu voll wird, damit das UI reaktionsfähig bleibt. /// /// Struktur: Logs/{Datum}/{Modul}.log /// Log-Format: [{Timestamp}] [{LEVEL}] {Message} /// public sealed class LiveLogViewerService : IDisposable { private readonly string _logDirectory; private readonly RichTextBox _target; private readonly System.Windows.Forms.Timer _refreshTimer; private readonly int _maxLines; // Tracking: pro Datei die letzte gelesene Position private readonly ConcurrentDictionary _filePositions = new(); // Filter private string _moduleFilter = ""; // leer = alle private string _levelFilter = ""; // leer = alle private static readonly Regex LevelRegex = new( @"\[(INF|WRN|ERR|DBG)\]", RegexOptions.Compiled); public LiveLogViewerService(string logDirectory, RichTextBox target, int refreshIntervalMs = 500, int maxLines = 2000) { _logDirectory = logDirectory; _target = target; _maxLines = maxLines; _refreshTimer = new System.Windows.Forms.Timer { Interval = refreshIntervalMs }; _refreshTimer.Tick += OnTimerTick; } public void Start() => _refreshTimer.Start(); public void Stop() => _refreshTimer.Stop(); public void SetModuleFilter(string module) { _moduleFilter = module; ClearAndResetPositions(); } public void SetLevelFilter(string level) { _levelFilter = level; ClearAndResetPositions(); } /// /// Gibt alle erkannten Modul-Namen zurück (basierend auf den vorhandenen .log-Dateien). /// public List GetAvailableModules() { var modules = new HashSet { "Alle" }; if (!Directory.Exists(_logDirectory)) return modules.ToList(); // Alle Unterordner (Datum-Ordner) durchsuchen foreach (var dateDir in Directory.GetDirectories(_logDirectory)) { foreach (var logFile in Directory.GetFiles(dateDir, "*.log")) { var name = Path.GetFileNameWithoutExtension(logFile); modules.Add(name); } } return modules.OrderBy(m => m == "Alle" ? "" : m).ToList(); } private void OnTimerTick(object? sender, EventArgs e) { try { ReadNewEntries(); } catch { // Logging-Viewer darf niemals das UI crashen } } private void ReadNewEntries() { if (!Directory.Exists(_logDirectory)) return; // Heutiges Datum-Verzeichnis (und ggf. gestriges für Logs um Mitternacht) var today = DateTime.Now.ToString("yyyy-MM-dd"); var todayDir = Path.Combine(_logDirectory, today); if (!Directory.Exists(todayDir)) return; var logFiles = Directory.GetFiles(todayDir, "*.log"); var newLines = new List<(DateTime time, string line, string module)>(); foreach (var filePath in logFiles) { var moduleName = Path.GetFileNameWithoutExtension(filePath); // Modul-Filter if (!string.IsNullOrEmpty(_moduleFilter) && _moduleFilter != "Alle" && !string.Equals(moduleName, _moduleFilter, StringComparison.OrdinalIgnoreCase)) continue; var lastPos = _filePositions.GetOrAdd(filePath, 0L); try { using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); if (fs.Length < lastPos) { // Datei wurde rotiert/gekürzt lastPos = 0; } if (fs.Length == lastPos) continue; fs.Seek(lastPos, SeekOrigin.Begin); using var reader = new StreamReader(fs); while (reader.ReadLine() is { } line) { if (string.IsNullOrWhiteSpace(line)) continue; // Level-Filter if (!string.IsNullOrEmpty(_levelFilter) && _levelFilter != "Alle") { if (!PassesLevelFilter(line)) continue; } newLines.Add((DateTime.Now, $"[{moduleName}] {line}", moduleName)); } _filePositions[filePath] = fs.Position; } catch (IOException) { // Datei wird gerade geschrieben - nächstes Mal versuchen } } if (newLines.Count == 0) return; // In UI schreiben AppendToRichTextBox(newLines); } private bool PassesLevelFilter(string line) { var match = LevelRegex.Match(line); if (!match.Success) return true; // Unbekanntes Format durchlassen var level = match.Groups[1].Value; return _levelFilter switch { "Info" => level is "INF" or "WRN" or "ERR", "Warn" => level is "WRN" or "ERR", "Error" => level is "ERR", _ => true }; } private void AppendToRichTextBox(List<(DateTime time, string line, string module)> lines) { if (_target.IsDisposed || !_target.IsHandleCreated) return; _target.BeginInvoke(() => { _target.SuspendLayout(); foreach (var (_, line, module) in lines) { var color = GetColorForLine(line); _target.SelectionStart = _target.TextLength; _target.SelectionLength = 0; _target.SelectionColor = color; _target.AppendText(line + Environment.NewLine); } // Bereinigung: wenn zu viele Zeilen, die ältesten entfernen TrimIfNeeded(); // Auto-Scroll zum Ende _target.SelectionStart = _target.TextLength; _target.ScrollToCaret(); _target.ResumeLayout(); }); } private static Color GetColorForLine(string line) { if (line.Contains("[ERR]")) return Color.Red; if (line.Contains("[WRN]")) return Color.Orange; if (line.Contains("[DBG]")) return Color.Gray; return Color.LightGreen; // INF } private void TrimIfNeeded() { if (_target.Lines.Length <= _maxLines) return; // Die älteste Hälfte entfernen var removeCount = _maxLines / 2; var removeEndIndex = _target.GetFirstCharIndexFromLine(removeCount); if (removeEndIndex <= 0) return; _target.SelectionStart = 0; _target.SelectionLength = removeEndIndex; _target.SelectedText = $"--- {removeCount} ältere Zeilen entfernt ---{Environment.NewLine}"; } private void ClearAndResetPositions() { _filePositions.Clear(); if (_target.IsHandleCreated && !_target.IsDisposed) { _target.BeginInvoke(() => { _target.Clear(); }); } } public void Dispose() { _refreshTimer.Stop(); _refreshTimer.Dispose(); } }