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