Files
PolyTraderSharp/src/PolyTrader.App.Avalonia/Views/TerminalWindow.axaml.cs
T
RichardandClaude Opus 5 9b2294cb4c
CI / Build & Tests (Linux) (push) Canceled after 0s
CI / Plattformneutralität & Hygiene (push) Canceled after 0s
Testanleitung: eigenes X-Display je Projekt fuer Parallelbetrieb
Damit mehrere Agenten gleichzeitig Oberflaechen testen koennen, bekommt jedes
Projekt eine feste Displaynummer (Vorgabe Richard):

  :99 PolyTrader, :98 Predictalytics, :97 ClawdDotNet, :96 IBKRTrader,
  :95 und absteigend fuer kuenftige Projekte.

Zwei Prozesse auf demselben Display sehen die Fenster des jeweils anderen -
Klicks landen im falschen Programm, Aufnahmen zeigen fremde Anwendungen.

Beim Durcharbeiten fielen zwei weitere Kollisionspunkte auf, die zum
Parallelbetrieb dazugehoeren und mit aufgenommen wurden:

- Screenshot- und Logdateien in /tmp brauchen ein Projektpraefix. /tmp/v_1.png
  ueberschreiben sich parallel laufende Projekte gegenseitig.
- "xvfb-run -a" darf NICHT mehr verwendet werden: Das -a sucht sich selbst ein
  freies Display und beginnt dabei bei :99 - ausgerechnet PolyTraders Nummer.
  Stattdessen "xvfb-run -n <nummer>".

Die Warnung ist nicht theoretisch: Auf der VM lag ein verwaister X98-Socket,
entstanden durch genau so einen "xvfb-run -a"-Aufruf beim smoke-ui-Test, als
:99 belegt war. Er blockierte Predictalytics' reservierte Nummer, ohne dass ein
Prozess lief. Entfernt, und die Anleitung erklaert jetzt, wie man verwaiste
Sockets erkennt (ls /tmp/.X11-unix/ gegen pgrep -a Xvfb lesen) und aufraeumt.

Weiter ergaenzt:
- pkill nie ohne Displaynummer - "pkill Xvfb" erschlaegt die Sitzungen aller
  anderen Projekte.
- DISPLAY in jedem Skript setzen, sonst landet man auf :0, dem echten Desktop.
- Die Skriptvorlage hat jetzt einen Kopf mit PROJEKT/DISP/FENSTER; das ist
  alles, was sich je Projekt unterscheidet. Praktisch gegengetestet.
- Aufraeumabschnitt und Belegungsliste um Displays und Verzeichnisse erweitert.

Nebenbei aufgefallen: Das PolyTrader-Publish lag unter ~/pt-test statt
~/polytrader-test und verletzte damit die eigene Verzeichniskonvention.
Umbenannt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 20:49:28 +02:00

226 lines
9.6 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.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 = AppTimeZone.Today;
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;
// Der Zeitstempel steht seit dem 27.08.2026 bereits in der Anzeige-Zeitzone
// (siehe AppTimeZone.Now) - hier wird nicht noch einmal umgerechnet.
DateTimeOffset t = 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 ?? AppTimeZone.Today;
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}";
}
}
}
}