Files
PolyTraderSharp/src/PolyTrader.App.Avalonia/Views/TerminalWindow.axaml.cs
T
RichardandClaude Opus 5 929fa20ee0 Terminal nach Avalonia + konfigurierbare Zeitzone (P2-Kern)
Alle vier Core-Fenster sind damit portiert.

TERMINAL:
- Live-Ausgabe als virtualisiertes ItemsControl ueber eine begrenzte Zeilenliste statt
  RichTextBox. Damit entfaellt das Auto-Clear der WinForms-Fassung, das bei Erreichen der
  Zeichengrenze den GESAMTEN Verlauf verwarf - jetzt werden nur die aeltesten Zeilen
  verdraengt (Ringpuffer, 5000 Zeilen), der juengste Verlauf bleibt immer sichtbar.
  Die Zeilenzahl steht in der Statuszeile.
- Log-Viewer unveraendert im Funktionsumfang: JSONL-Tagesdateien, Filter nach Datum, Level,
  CID und Volltext, Doppelklick uebernimmt die CID (Signal-Kette verfolgen).

ZEITZONE (Befund aus der Linux-Analyse, hier faellig geworden):
Die Terminal-Ansicht rechnete hart gegen die WINDOWS-ID 'W. Europe Standard Time'. Auf Linux
traegt die nur ueber die ICU-Zuordnung und faellt ganz aus, wenn ICU fehlt oder
InvariantGlobalization gesetzt ist - das Fenster haette beim Oeffnen geworfen.

Neu: PolyTraderSharp.Services.AppTimeZone + ServerSettings.ApplicationTimeZoneId
(Default 'Europe/Berlin', IANA-Schreibweise). Aufloesung versucht die ID direkt, dann die
jeweils andere Schreibweise (IANA<->Windows), zuletzt die Systemzeitzone - ein unbekannter
Wert ist damit nie fatal, sondern erzeugt nur eine Warnung. Beide Programm-Einstiege setzen
sie einmalig beim Start; laut Vorgabe wird sie bei der Installation festgelegt und nicht im
laufenden Betrieb gewechselt (Aenderung verschiebt Logdatei-Tagesgrenzen).

8 neue Tests (AppTimeZoneTests) halten fest: IANA- UND Windows-ID liefern denselben
UTC-Versatz (Winter +1, Sommer +2), unbekannte IDs fallen mit Warnung auf die Systemzeitzone
zurueck, leere Angabe = Systemzeitzone, UTC wird korrekt umgerechnet.

Verifiziert: Solution baut, 450 Tests gruen, --smoke-ui gruen (6 Fenster + Editor-Pruefung,
Zeitzone loest als Europe/Berlin auf), App laeuft real, Linux-Publish laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:06:24 +02:00

220 lines
9.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.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 : Window
{
/// <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()
{
this.FindControl<Controls.WindowMenuBar>("menuBar")!.Attach(host, "core.terminal", 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();
Closed += (_, _) =>
{
_timer.Stop();
if (_logger != null && _logHandler != null) _logger.OnLogMessage -= _logHandler;
};
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);
var clipboard = GetTopLevel(this)?.Clipboard;
if (clipboard != null) await clipboard.SetTextAsync(sb.ToString());
}
catch
{
// Zwischenablage kann kurzzeitig belegt sein bewusst ignorieren, wie bisher.
}
}
// ===== 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}";
}
}
}
}