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>
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 = 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}";
|
||
}
|
||
}
|
||
}
|
||
}
|