Import des bestehenden Projektstands in Git. - .NET 10 WinForms Anwendung (Multi-Agent / Tool-System) - .gitignore fuer Build-Artefakte, Secrets und Runtime-Daten ergaenzt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
256 lines
7.4 KiB
C#
256 lines
7.4 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace ClawdDotNet.Services;
|
|
|
|
/// <summary>
|
|
/// Ü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}
|
|
/// </summary>
|
|
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<string, long> _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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gibt alle erkannten Modul-Namen zurück (basierend auf den vorhandenen .log-Dateien).
|
|
/// </summary>
|
|
public List<string> GetAvailableModules()
|
|
{
|
|
var modules = new HashSet<string> { "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();
|
|
}
|
|
}
|