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>
This commit is contained in:
Richard
2026-08-06 21:06:24 +02:00
co-authored by Claude Opus 5
parent 4ba8149e64
commit 929fa20ee0
9 changed files with 514 additions and 2 deletions
+5
View File
@@ -93,6 +93,11 @@ namespace PolyTrader.App.Avalonia
ConfigureSecretProtection(logger);
// Anzeige-Zeitzone einmalig setzen (Logs, Auswertungen). Ein unbekannter Wert ist nicht
// fatal - es wird auf die Systemzeitzone ausgewichen und gewarnt.
AppTimeZone.Configure(serverSettings.ApplicationTimeZoneId, m => logger.Warning(m));
logger.Info($"Anzeige-Zeitzone: {AppTimeZone.CurrentId}");
var allModules = new List<IPolyTraderModule>
{
new CopyTradingModule(),
@@ -59,8 +59,14 @@ namespace PolyTrader.App.Avalonia.Shell
CreateView = () => new Views.JobsWindow(host, services.GetRequiredService<JobManager>())
});
// TODO Portierung: core.terminal (Order 40) folgt.
// Aufbau siehe docs/UI-SPEZIFIKATION-WinForms.md, Originalcode im Git-Tag winforms-final.
host.RegisterView(new ModuleView
{
Id = "core.terminal",
Title = "Terminal / Logs",
Group = "Core",
Order = 40,
CreateView = () => new Views.TerminalWindow(host, services.GetRequiredService<TerminalLogger>())
});
}
/// <summary>
@@ -0,0 +1,23 @@
using Avalonia.Media;
namespace PolyTrader.App.Avalonia.ViewModels
{
/// <summary>
/// Eine Zeile der Live-Ausgabe. Farbe wird beim Erzeugen festgelegt die Zeile ist danach
/// unveränderlich, was die Virtualisierung billig macht (kein Change-Tracking je Zeile).
/// </summary>
public sealed class LogLine
{
public string Text { get; init; } = string.Empty;
public IBrush Brush { get; init; } = Brushes.White;
}
/// <summary>Anzeige-Zeile des Log Viewers (JSONL-Tagesdatei).</summary>
public sealed class LogViewerRow
{
public string Time { get; init; } = string.Empty;
public string Level { get; init; } = string.Empty;
public string Cid { get; init; } = string.Empty;
public string Message { get; init; } = string.Empty;
}
}
@@ -0,0 +1,91 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PolyTrader.App.Avalonia.Controls"
xmlns:vm="using:PolyTrader.App.Avalonia.ViewModels"
x:Class="PolyTrader.App.Avalonia.Views.TerminalWindow"
Title="Terminal / Logs"
Width="1429" Height="1083">
<!--
Terminal- und Log-Ansicht.
Live-Tab: farbige Log-Zeilen, abschaltbarer Autoscroll, Level-Filter.
Log-Viewer-Tab: die JSONL-Tagesdateien, filterbar nach Datum, Level, CID und Volltext.
Die Live-Ausgabe ist ein virtualisiertes ItemsControl über eine begrenzte Zeilenliste
statt einer RichTextBox. Damit entfällt das Auto-Clear der WinForms-Fassung, das bei
Erreichen der RAM-Grenze den gesamten Verlauf verwarf: es werden nur noch die ältesten
Zeilen verdrängt, der sichtbare Verlauf bleibt erhalten.
-->
<DockPanel>
<controls:WindowMenuBar Name="menuBar" DockPanel.Dock="Top" />
<TabControl>
<TabItem Header="Live">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Name="btnAutoscroll" Content="Stop Autoscroll" />
<Button Name="btnClear" Content="Terminal leeren" />
<Button Name="btnCopyAll" Content="Alles kopieren" />
<TextBlock Text="Level:" VerticalAlignment="Center" />
<ComboBox Name="cbLogLevel" MinWidth="150" />
<TextBlock Name="lblLiveStatus" VerticalAlignment="Center" Foreground="#777777" />
</StackPanel>
</Border>
<Border Background="#1E1E1E">
<ScrollViewer Name="logScroll">
<ItemsControl Name="logLines" Margin="8">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LogLine">
<SelectableTextBlock Text="{Binding Text}"
Foreground="{Binding Brush}"
FontFamily="Consolas,Menlo,DejaVu Sans Mono,monospace"
FontSize="12"
TextWrapping="Wrap" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</DockPanel>
</TabItem>
<TabItem Header="Log Viewer">
<DockPanel>
<Border Classes="toolbar" DockPanel.Dock="Top">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Datum:" VerticalAlignment="Center" />
<CalendarDatePicker Name="dtViewerDate" MinWidth="150" />
<TextBlock Text="Level:" VerticalAlignment="Center" />
<ComboBox Name="cbViewerLevel" MinWidth="130" />
<TextBlock Text="CID:" VerticalAlignment="Center" />
<TextBox Name="tbViewerCid" MinWidth="150" />
<TextBlock Text="Text:" VerticalAlignment="Center" />
<TextBox Name="tbViewerText" MinWidth="220" />
<Button Name="btnViewerLoad" Content="Laden" />
<TextBlock Name="lblViewerStatus" VerticalAlignment="Center" Foreground="#777777" />
</StackPanel>
</Border>
<DataGrid Name="gridLogs" x:DataType="vm:LogViewerRow">
<DataGrid.Columns>
<DataGridTextColumn Header="Zeit" Width="90" Binding="{Binding Time}" />
<DataGridTextColumn Header="Level" Width="110" Binding="{Binding Level}" />
<DataGridTextColumn Header="CID" Width="160" Binding="{Binding Cid}" />
<DataGridTextColumn Header="Nachricht" Width="*" Binding="{Binding Message}" />
</DataGrid.Columns>
</DataGrid>
</DockPanel>
</TabItem>
</TabControl>
</DockPanel>
</Window>
@@ -0,0 +1,219 @@
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}";
}
}
}
}