Sicherungscommit vor dem Aufraeumen des Repos, damit nachvollziehbar bleibt, welcher Stand vor der Bereinigung galt. Build gruen, 476 Tests gruen. Zwei Straenge, die sich ueber .csproj, Program.cs und appsettings.json ueberschneiden und darum gemeinsam abgelegt werden: Deploymentcenter-Integration (P3c, Plan D-0 bis D-5 code-seitig fertig): - Deploymentcenter.Client 2.5.0 als lokales Paket, Source-Mapping erweitert - DeploymentcenterOptions, DeploymentcenterErrorReporter, LicenseGate/LicenseCli - WatchdogHeartbeatService auf die Deploymentcenter-API umgestellt (version, os, checks, metrics, status stopped) - Security: MasterKeyResolver, SecretRedactor, FilePermissions - Directory.Build.props mit zentraler Version 0.1.0 (Packager-Versionsdisziplin) - deploy/: Packager-Vorlage und systemd-Unit; echte Zugangsdaten bleiben ueber .gitignore aussen vor - setup.json fuer die Erstinstallation - UMSETZUNGSPLAN-Deploymentcenter-Integration.md; ANALYSE-Linux-Portierung.md verweist auf den neuen Plan Einfenster-Shell (UI-Redesign): - ShellWindow + ShellNavModel als Seitenleisten-Shell - WindowMenuBar und WindowMenuModel entfallen - Modul- und Kernfenster auf die Shell-Einbettung angepasst Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
226 lines
9.6 KiB
C#
226 lines
9.6 KiB
C#
using System;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.Collections.ObjectModel;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using Avalonia.Controls;
|
||
using Avalonia.Interactivity;
|
||
using Avalonia.Markup.Xaml;
|
||
using Avalonia.Media;
|
||
using Avalonia.Threading;
|
||
using PolyTrader.App.Avalonia.ViewModels;
|
||
using PolyTrader.Core.Modularity;
|
||
using PolyTraderSharp.Services;
|
||
|
||
namespace PolyTrader.App.Avalonia.Views
|
||
{
|
||
/// <summary>
|
||
/// Terminal-/Log-Ansicht. Live-Ausgabe des <see cref="TerminalLogger"/> plus Viewer für die
|
||
/// JSONL-Tagesdateien. Layout vollständig in TerminalWindow.axaml.
|
||
/// </summary>
|
||
public partial class TerminalWindow : UserControl
|
||
{
|
||
/// <summary>
|
||
/// Obergrenze der angezeigten Zeilen. Die WinForms-Fassung leerte bei Erreichen einer
|
||
/// Zeichengrenze das GESAMTE Terminal; hier werden stattdessen nur die ältesten Zeilen
|
||
/// verdrängt, der jüngste Verlauf bleibt also immer sichtbar.
|
||
/// </summary>
|
||
private const int MaxLines = 5000;
|
||
|
||
/// <summary>Höchstzahl je UI-Takt verarbeiteter Meldungen – hält die Oberfläche flüssig.</summary>
|
||
private const int MaxPerTick = 500;
|
||
|
||
private static readonly IBrush BrushDefault = Brushes.WhiteSmoke;
|
||
private static readonly IBrush BrushError = Brushes.Tomato;
|
||
private static readonly IBrush BrushWarning = Brushes.Gold;
|
||
private static readonly IBrush BrushTrade = Brushes.LightGreen;
|
||
private static readonly IBrush BrushReasoning = Brushes.Orange;
|
||
|
||
private readonly ConcurrentQueue<LogMessageEventArgs> _queue = new();
|
||
private readonly ObservableCollection<LogLine> _lines = new();
|
||
private readonly ObservableCollection<LogViewerRow> _viewerRows = new();
|
||
private readonly DispatcherTimer _timer = new() { Interval = TimeSpan.FromMilliseconds(250) };
|
||
|
||
private TerminalLogger? _logger;
|
||
private EventHandler<LogMessageEventArgs>? _logHandler;
|
||
private bool _autoScroll = true;
|
||
|
||
public TerminalWindow() => AvaloniaXamlLoader.Load(this);
|
||
|
||
public TerminalWindow(IModuleUiHost host, TerminalLogger logger) : this()
|
||
{
|
||
_logger = logger;
|
||
this.FindControl<ItemsControl>("logLines")!.ItemsSource = _lines;
|
||
this.FindControl<DataGrid>("gridLogs")!.ItemsSource = _viewerRows;
|
||
|
||
var levels = new[] { "Alle" }.Concat(Enum.GetNames<LogLevel>()).ToArray();
|
||
var cbLevel = this.FindControl<ComboBox>("cbLogLevel")!;
|
||
var cbViewerLevel = this.FindControl<ComboBox>("cbViewerLevel")!;
|
||
cbLevel.ItemsSource = levels;
|
||
cbViewerLevel.ItemsSource = levels;
|
||
cbLevel.SelectedIndex = 0;
|
||
cbViewerLevel.SelectedIndex = 0;
|
||
|
||
this.FindControl<CalendarDatePicker>("dtViewerDate")!.SelectedDate = DateTime.Now;
|
||
|
||
this.FindControl<Button>("btnAutoscroll")!.Click += (_, _) => ToggleAutoscroll();
|
||
this.FindControl<Button>("btnClear")!.Click += (_, _) => { _lines.Clear(); UpdateLiveStatus(); };
|
||
this.FindControl<Button>("btnCopyAll")!.Click += async (_, _) => await CopyAllAsync();
|
||
this.FindControl<Button>("btnViewerLoad")!.Click += (_, _) => LoadViewer();
|
||
|
||
// Doppelklick auf eine Zeile: nach deren CID filtern (komplette Signal-Kette ansehen).
|
||
this.FindControl<DataGrid>("gridLogs")!.DoubleTapped += (_, _) =>
|
||
{
|
||
if (this.FindControl<DataGrid>("gridLogs")!.SelectedItem is LogViewerRow row
|
||
&& !string.IsNullOrEmpty(row.Cid))
|
||
{
|
||
this.FindControl<TextBox>("tbViewerCid")!.Text = row.Cid;
|
||
this.FindControl<TextBox>("tbViewerText")!.Text = string.Empty;
|
||
LoadViewer();
|
||
}
|
||
};
|
||
|
||
// Jüngste Historie vorladen, damit das Fenster beim Öffnen nicht leer ist.
|
||
foreach (var e in _logger.GetHistory(TimeSpan.FromMinutes(10)))
|
||
_queue.Enqueue(e);
|
||
|
||
_logHandler = (_, e) => _queue.Enqueue(e);
|
||
_logger.OnLogMessage += _logHandler;
|
||
|
||
_timer.Tick += (_, _) => ProcessQueue();
|
||
_timer.Start();
|
||
|
||
// Kein Abmelden mehr: Die Seite wird in der Einfenster-Shell einmal erzeugt und läuft
|
||
// bis zum Programmende weiter – genau wie früher ein offen gelassenes Fenster. Der
|
||
// Takt bleibt deshalb absichtlich an, damit der Verlauf beim Zurückwechseln vollständig
|
||
// ist und keine Meldungen fehlen.
|
||
|
||
ProcessQueue();
|
||
}
|
||
|
||
// ===== Live-Ausgabe =====
|
||
|
||
private void ToggleAutoscroll()
|
||
{
|
||
_autoScroll = !_autoScroll;
|
||
var btn = this.FindControl<Button>("btnAutoscroll")!;
|
||
btn.Content = _autoScroll ? "Stop Autoscroll" : "Start Autoscroll";
|
||
btn.Background = new SolidColorBrush(_autoScroll ? Colors.LightGreen : Colors.IndianRed);
|
||
}
|
||
|
||
private void ProcessQueue()
|
||
{
|
||
if (_queue.IsEmpty) return;
|
||
|
||
string filter = this.FindControl<ComboBox>("cbLogLevel")!.SelectedItem as string ?? "Alle";
|
||
int processed = 0;
|
||
bool appended = false;
|
||
|
||
while (processed < MaxPerTick && _queue.TryDequeue(out var e))
|
||
{
|
||
processed++;
|
||
if (filter != "Alle" && e.Level.ToString() != filter) continue;
|
||
|
||
// Anzeige-Zeitzone aus den Server-Settings (siehe AppTimeZone) – nicht mehr fest
|
||
// gegen eine Windows-Zeitzonen-ID gerechnet.
|
||
DateTime t = AppTimeZone.ToDisplay(e.Timestamp);
|
||
|
||
_lines.Add(new LogLine
|
||
{
|
||
Text = $"[{t:HH:mm:ss}] [{e.Level}] {e.Message}",
|
||
Brush = e.Level switch
|
||
{
|
||
LogLevel.Error => BrushError,
|
||
LogLevel.Warning => BrushWarning,
|
||
LogLevel.Trade => BrushTrade,
|
||
LogLevel.TradeReasoning => BrushReasoning,
|
||
_ => BrushDefault
|
||
}
|
||
});
|
||
appended = true;
|
||
}
|
||
|
||
// Ringpuffer: nur die aeltesten Zeilen verdraengen statt alles zu verwerfen.
|
||
while (_lines.Count > MaxLines) _lines.RemoveAt(0);
|
||
|
||
if (appended)
|
||
{
|
||
UpdateLiveStatus();
|
||
if (_autoScroll) this.FindControl<ScrollViewer>("logScroll")!.ScrollToEnd();
|
||
}
|
||
}
|
||
|
||
private void UpdateLiveStatus() =>
|
||
this.FindControl<TextBlock>("lblLiveStatus")!.Text =
|
||
$"{_lines.Count} Zeilen (max. {MaxLines}) · vollständige Logs im Ordner /Logs";
|
||
|
||
private async System.Threading.Tasks.Task CopyAllAsync()
|
||
{
|
||
try
|
||
{
|
||
var sb = new StringBuilder();
|
||
foreach (var l in _lines) sb.AppendLine(l.Text);
|
||
|
||
// Seit der Umstellung auf Seiten ist „this" kein TopLevel mehr – die Zwischenablage
|
||
// hängt am Shell-Fenster und wird über den Baum aufgelöst.
|
||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||
if (clipboard != null) await clipboard.SetTextAsync(sb.ToString());
|
||
}
|
||
catch
|
||
{
|
||
// Zwischenablage kann kurzzeitig belegt sein – bewusst ignorieren, wie bisher.
|
||
}
|
||
}
|
||
|
||
public async void OnContextMenuCopyClick(object? sender, RoutedEventArgs e) =>
|
||
await CopyAllAsync();
|
||
|
||
public async void OnContextMenuSelectAllClick(object? sender, RoutedEventArgs e) =>
|
||
await CopyAllAsync();
|
||
|
||
// ===== Log Viewer (JSONL-Tagesdateien) =====
|
||
|
||
private void LoadViewer()
|
||
{
|
||
var status = this.FindControl<TextBlock>("lblViewerStatus")!;
|
||
try
|
||
{
|
||
DateTime day = this.FindControl<CalendarDatePicker>("dtViewerDate")!.SelectedDate ?? DateTime.Now;
|
||
string path = Path.Combine(AppContext.BaseDirectory, "Logs", $"{day:yyyy-MM-dd}.jsonl");
|
||
|
||
_viewerRows.Clear();
|
||
if (!File.Exists(path))
|
||
{
|
||
status.Text = "Keine JSONL-Datei für dieses Datum.";
|
||
return;
|
||
}
|
||
|
||
string levelFilter = this.FindControl<ComboBox>("cbViewerLevel")!.SelectedItem as string ?? "Alle";
|
||
string cidFilter = this.FindControl<TextBox>("tbViewerCid")!.Text?.Trim() ?? string.Empty;
|
||
string textFilter = this.FindControl<TextBox>("tbViewerText")!.Text?.Trim() ?? string.Empty;
|
||
|
||
int count = 0;
|
||
foreach (string line in File.ReadLines(path))
|
||
{
|
||
var p = LogJson.ParseLine(line);
|
||
if (p == null) continue;
|
||
if (levelFilter != "Alle" && p.Level != levelFilter) continue;
|
||
if (cidFilter.Length > 0 && !p.Cid.Contains(cidFilter, StringComparison.OrdinalIgnoreCase)) continue;
|
||
if (textFilter.Length > 0 && !p.Message.Contains(textFilter, StringComparison.OrdinalIgnoreCase)) continue;
|
||
|
||
_viewerRows.Add(new LogViewerRow { Time = p.Time, Level = p.Level, Cid = p.Cid, Message = p.Message });
|
||
if (++count >= 20000) break; // Schutz bei sehr grossen Tagen
|
||
}
|
||
|
||
status.Text = $"{_viewerRows.Count} Einträge.";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
status.Text = $"Fehler: {ex.Message}";
|
||
}
|
||
}
|
||
}
|
||
}
|