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:
@@ -77,6 +77,11 @@ internal static class Program
|
|||||||
// Account-Credentials als auch für den gespeicherten Lizenzschlüssel gebraucht.
|
// Account-Credentials als auch für den gespeicherten Lizenzschlüssel gebraucht.
|
||||||
ConfigureSecretProtection(bootLog);
|
ConfigureSecretProtection(bootLog);
|
||||||
|
|
||||||
|
// 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 => bootLog.Warning(m));
|
||||||
|
bootLog.Info($"Anzeige-Zeitzone: {AppTimeZone.CurrentId}");
|
||||||
|
|
||||||
// Alle bekannten Module; „modules" enthält nur die aktiven (nicht in DisabledModules).
|
// Alle bekannten Module; „modules" enthält nur die aktiven (nicht in DisabledModules).
|
||||||
var allModules = new System.Collections.Generic.List<IPolyTraderModule>
|
var allModules = new System.Collections.Generic.List<IPolyTraderModule>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ namespace PolyTrader.App.Avalonia
|
|||||||
|
|
||||||
ConfigureSecretProtection(logger);
|
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>
|
var allModules = new List<IPolyTraderModule>
|
||||||
{
|
{
|
||||||
new CopyTradingModule(),
|
new CopyTradingModule(),
|
||||||
|
|||||||
@@ -59,8 +59,14 @@ namespace PolyTrader.App.Avalonia.Shell
|
|||||||
CreateView = () => new Views.JobsWindow(host, services.GetRequiredService<JobManager>())
|
CreateView = () => new Views.JobsWindow(host, services.GetRequiredService<JobManager>())
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO Portierung: core.terminal (Order 40) folgt.
|
host.RegisterView(new ModuleView
|
||||||
// Aufbau siehe docs/UI-SPEZIFIKATION-WinForms.md, Originalcode im Git-Tag winforms-final.
|
{
|
||||||
|
Id = "core.terminal",
|
||||||
|
Title = "Terminal / Logs",
|
||||||
|
Group = "Core",
|
||||||
|
Order = 40,
|
||||||
|
CreateView = () => new Views.TerminalWindow(host, services.GetRequiredService<TerminalLogger>())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,14 @@ namespace PolyTraderSharp.Models
|
|||||||
[XmlArrayItem("Module")]
|
[XmlArrayItem("Module")]
|
||||||
public List<string> DisabledModules { get; set; } = new();
|
public List<string> DisabledModules { get; set; } = new();
|
||||||
|
|
||||||
|
[Category("Anzeige")]
|
||||||
|
[DisplayName("Zeitzone")]
|
||||||
|
[Description("Zeitzone für Anzeige und Logdatei-Tagesgrenzen, IANA-Schreibweise (z.B. \"Europe/Berlin\", " +
|
||||||
|
"\"America/New_York\"). Leer = Zeitzone des Systems. Wird üblicherweise EINMALIG bei der " +
|
||||||
|
"Installation gesetzt: eine spätere Änderung verschiebt Tagesgrenzen in Logs und Auswertungen. " +
|
||||||
|
"Greift erst nach einem Neustart.")]
|
||||||
|
public string ApplicationTimeZoneId { get; set; } = PolyTraderSharp.Services.AppTimeZone.DefaultId;
|
||||||
|
|
||||||
[Category("Mullvad VPN")]
|
[Category("Mullvad VPN")]
|
||||||
[DisplayName("VPN Enabled")]
|
[DisplayName("VPN Enabled")]
|
||||||
[Description("Enable or disable automatic VPN rotation.")]
|
[Description("Enable or disable automatic VPN rotation.")]
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace PolyTraderSharp.Services
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Anzeige-Zeitzone der Anwendung. Wird beim Start EINMAL aus den Server-Settings gesetzt
|
||||||
|
/// (<c>ApplicationTimeZoneId</c>) und danach überall zum Umrechnen von UTC-Zeitstempeln in die
|
||||||
|
/// dargestellte Ortszeit verwendet.
|
||||||
|
///
|
||||||
|
/// <para><b>Warum das nötig war:</b> Die Terminal-Ansicht rechnete bisher hart gegen
|
||||||
|
/// <c>"W. Europe Standard Time"</c> – eine <i>Windows</i>-Zeitzonen-ID. Auf Linux funktioniert die
|
||||||
|
/// nur über die ICU-Zuordnung und fällt komplett aus, wenn ICU fehlt oder
|
||||||
|
/// <c>InvariantGlobalization</c> gesetzt ist. Dann hätte das Fenster beim Öffnen geworfen.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Auflösung:</b> Zuerst die konfigurierte ID direkt, dann über die Umrechnung zwischen
|
||||||
|
/// IANA- und Windows-Schreibweise (damit dieselbe Konfiguration auf beiden Plattformen trägt),
|
||||||
|
/// zuletzt die Zeitzone des Systems. Ein Fehlschlag ist damit nie fatal – er wird nur gemeldet.</para>
|
||||||
|
///
|
||||||
|
/// <para>Die Zeitzone wird bewusst einmalig bei der Installation festgelegt und nicht im
|
||||||
|
/// laufenden Betrieb gewechselt: eine Änderung verschiebt Logdatei-Tagesgrenzen und
|
||||||
|
/// Auswertungszeiträume. Änderungen greifen deshalb erst nach einem Neustart.</para>
|
||||||
|
/// </summary>
|
||||||
|
public static class AppTimeZone
|
||||||
|
{
|
||||||
|
/// <summary>Empfehlung für neue Installationen – IANA-Schreibweise, funktioniert auf beiden Plattformen.</summary>
|
||||||
|
public const string DefaultId = "Europe/Berlin";
|
||||||
|
|
||||||
|
/// <summary>Aktuell gültige Anzeige-Zeitzone. Vor <see cref="Configure"/> die des Systems.</summary>
|
||||||
|
public static TimeZoneInfo Current { get; private set; } = TimeZoneInfo.Local;
|
||||||
|
|
||||||
|
/// <summary>Die tatsächlich verwendete ID (kann von der gewünschten abweichen, wenn ausgewichen wurde).</summary>
|
||||||
|
public static string CurrentId => Current.Id;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setzt die Anzeige-Zeitzone. Leere Angabe = Systemzeitzone. Meldet über
|
||||||
|
/// <paramref name="warn"/>, wenn auf etwas anderes als das Gewünschte ausgewichen wurde.
|
||||||
|
/// </summary>
|
||||||
|
public static void Configure(string? timeZoneId, Action<string>? warn = null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(timeZoneId))
|
||||||
|
{
|
||||||
|
Current = TimeZoneInfo.Local;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string id = timeZoneId.Trim();
|
||||||
|
if (TryFind(id, out var tz)) { Current = tz!; return; }
|
||||||
|
|
||||||
|
// Andere Schreibweise versuchen: eine unter Windows gepflegte Konfiguration soll auch
|
||||||
|
// auf Linux tragen und umgekehrt.
|
||||||
|
if (TimeZoneInfo.TryConvertIanaIdToWindowsId(id, out string? windowsId)
|
||||||
|
&& TryFind(windowsId!, out tz))
|
||||||
|
{
|
||||||
|
Current = tz!;
|
||||||
|
warn?.Invoke($"Zeitzone „{id}\" wurde als „{tz!.Id}\" aufgelöst.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TimeZoneInfo.TryConvertWindowsIdToIanaId(id, out string? ianaId)
|
||||||
|
&& TryFind(ianaId!, out tz))
|
||||||
|
{
|
||||||
|
Current = tz!;
|
||||||
|
warn?.Invoke($"Zeitzone „{id}\" wurde als „{tz!.Id}\" aufgelöst.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Current = TimeZoneInfo.Local;
|
||||||
|
warn?.Invoke($"⚠️ Zeitzone „{id}\" ist auf diesem System unbekannt – es wird die Systemzeitzone " +
|
||||||
|
$"„{TimeZoneInfo.Local.Id}\" verwendet. Empfohlen ist die IANA-Schreibweise, z.B. „{DefaultId}\".");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Rechnet einen Zeitstempel in die Anzeige-Zeitzone um (UTC-Kennzeichnung wird beachtet).</summary>
|
||||||
|
public static DateTime ToDisplay(DateTime value) =>
|
||||||
|
value.Kind == DateTimeKind.Utc
|
||||||
|
? TimeZoneInfo.ConvertTimeFromUtc(value, Current)
|
||||||
|
: TimeZoneInfo.ConvertTime(value, Current);
|
||||||
|
|
||||||
|
private static bool TryFind(string id, out TimeZoneInfo? tz)
|
||||||
|
{
|
||||||
|
try { tz = TimeZoneInfo.FindSystemTimeZoneById(id); return true; }
|
||||||
|
catch (TimeZoneNotFoundException) { tz = null; return false; }
|
||||||
|
catch (InvalidTimeZoneException) { tz = null; return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
using System;
|
||||||
|
using PolyTraderSharp.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PolyTrader.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Sicherheitsnetz für die Anzeige-Zeitzone. Die frühere Fassung rechnete hart gegen die
|
||||||
|
/// WINDOWS-ID "W. Europe Standard Time" – auf Linux ohne vollständiges ICU wirft das.
|
||||||
|
/// Diese Tests halten fest, dass beide Schreibweisen tragen und ein unbekannter Wert
|
||||||
|
/// niemals fatal ist, sondern auf die Systemzeitzone zurückfällt.
|
||||||
|
/// </summary>
|
||||||
|
public class AppTimeZoneTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("Europe/Berlin")] // IANA – Empfehlung, funktioniert auf beiden Plattformen
|
||||||
|
[InlineData("W. Europe Standard Time")] // Windows – Altbestand muss weiter tragen
|
||||||
|
public void Known_ids_resolve_to_the_same_zone(string id)
|
||||||
|
{
|
||||||
|
AppTimeZone.Configure(id);
|
||||||
|
|
||||||
|
// Der Bezeichner unterscheidet sich je Plattform; entscheidend ist der Versatz.
|
||||||
|
var berlinWinter = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
Assert.Equal(TimeSpan.FromHours(1), AppTimeZone.Current.GetUtcOffset(berlinWinter));
|
||||||
|
|
||||||
|
var berlinSommer = new DateTime(2026, 7, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
Assert.Equal(TimeSpan.FromHours(2), AppTimeZone.Current.GetUtcOffset(berlinSommer));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unknown_id_falls_back_to_system_zone_and_warns()
|
||||||
|
{
|
||||||
|
string? warning = null;
|
||||||
|
AppTimeZone.Configure("Gibt/EsNicht", w => warning = w);
|
||||||
|
|
||||||
|
Assert.Equal(TimeZoneInfo.Local.Id, AppTimeZone.Current.Id);
|
||||||
|
Assert.NotNull(warning);
|
||||||
|
Assert.Contains("Gibt/EsNicht", warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void Empty_id_means_system_zone(string? id)
|
||||||
|
{
|
||||||
|
AppTimeZone.Configure(id);
|
||||||
|
Assert.Equal(TimeZoneInfo.Local.Id, AppTimeZone.Current.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToDisplay_converts_utc_into_the_configured_zone()
|
||||||
|
{
|
||||||
|
AppTimeZone.Configure("Europe/Berlin");
|
||||||
|
|
||||||
|
// 12:00 UTC im Januar = 13:00 Berliner Zeit (MEZ).
|
||||||
|
var utc = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc);
|
||||||
|
Assert.Equal(13, AppTimeZone.ToDisplay(utc).Hour);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToDisplay_leaves_already_local_timestamps_alone()
|
||||||
|
{
|
||||||
|
AppTimeZone.Configure(null); // Systemzeitzone
|
||||||
|
|
||||||
|
var local = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Local);
|
||||||
|
Assert.Equal(12, AppTimeZone.ToDisplay(local).Hour);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user