Gesamt PnL
@@ -1204,6 +1206,20 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
Einstellungen
+
+
+
diff --git a/src/PolyTrader.Web/wwwroot/js/app.js b/src/PolyTrader.Web/wwwroot/js/app.js
index 2200582..43e6b1f 100644
--- a/src/PolyTrader.Web/wwwroot/js/app.js
+++ b/src/PolyTrader.Web/wwwroot/js/app.js
@@ -6,6 +6,7 @@ let allLogs = [];
// Init on DOM ready
document.addEventListener('DOMContentLoaded', () => {
+ loadUpdateStatus(false); // #565: Banner im Dashboard; der Server haelt das Ergebnis vor
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('modalStopTrading')?.style.display === 'flex') closeStopTradingModal();
});
@@ -813,6 +814,7 @@ async function copyLogViewer() {
// Settings View
async function loadSettings() {
loadServerSettings();
+ loadUpdateStatus(false);
try {
const res = await fetch('/api/settings/webui');
if (!res.ok) return;
@@ -829,6 +831,61 @@ async function loadSettings() {
} catch (e) {}
}
+// ==================== Updates (#565) ====================
+
+async function loadUpdateStatus(refresh) {
+ const box = document.getElementById('updateStatusBody');
+ try {
+ const res = await fetch('/api/updates' + (refresh ? '?refresh=true' : ''));
+ if (!res.ok) throw new Error(await errorText(res));
+ renderUpdateStatus(await res.json(), refresh);
+ } catch (e) {
+ if (box) {
+ box.innerHTML = '';
+ appendDefinition(box, 'Stand', `Update-Status nicht abrufbar: ${e.message}`);
+ }
+ }
+}
+
+function appendDefinition(list, term, value) {
+ const dt = document.createElement('dt');
+ dt.textContent = term;
+ const dd = document.createElement('dd');
+ dd.textContent = value;
+ list.append(dt, dd);
+}
+
+function renderUpdateStatus(u, refreshRequested) {
+ let state;
+ if (!u.enabled) state = u.error || 'Die Update-Prüfung ist nicht eingerichtet.';
+ else if (u.error) state = u.error;
+ else if (u.updateAvailable) state = `Update verfügbar: v${u.latestVersion}${u.isCritical ? ' – empfohlenes Sicherheitsupdate' : ''}`;
+ else state = 'Diese Fassung ist aktuell.';
+
+ const box = document.getElementById('updateStatusBody');
+ if (box) {
+ box.innerHTML = '';
+ appendDefinition(box, 'Laufende Fassung', u.currentBuild);
+ appendDefinition(box, 'Stand', state);
+ appendDefinition(box, 'Geprüft', new Date(u.checkedAt).toLocaleString('de-DE') + (u.fromCache ? ' (zwischengespeichert)' : ''));
+ if (u.updateAvailable && u.changelog) appendDefinition(box, 'Änderungen', u.changelog);
+ appendDefinition(box, 'Installation', 'Nur im Desktop – sie beendet die Anwendung und damit auch das WebUI.');
+ }
+
+ const msg = document.getElementById('updateStatusMsg');
+ if (msg) {
+ msg.hidden = !(refreshRequested && u.retryAfterSeconds);
+ if (!msg.hidden) msg.textContent = `Gerade erst geprüft – erneut möglich in ${u.retryAfterSeconds} s.`;
+ }
+
+ const banner = document.getElementById('dashUpdateBanner');
+ if (banner) {
+ banner.hidden = !u.updateAvailable;
+ if (u.updateAvailable)
+ banner.textContent = `Update verfügbar: v${u.latestVersion}${u.isCritical ? ' (empfohlenes Sicherheitsupdate)' : ''} – Installation über den Desktop.`;
+ }
+}
+
// ==================== Server-Einstellungen (#561) ====================
let serverSettingsLoaded = {};
diff --git a/tests/PolyTrader.Tests/WebUi/UpdateStatusTests.cs b/tests/PolyTrader.Tests/WebUi/UpdateStatusTests.cs
new file mode 100644
index 0000000..b8ed097
--- /dev/null
+++ b/tests/PolyTrader.Tests/WebUi/UpdateStatusTests.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.DependencyInjection;
+using PolyTrader.Web.Updates;
+using Xunit;
+
+namespace PolyTrader.Tests.WebUi
+{
+ ///
+ /// Update-Status im WebUI (#565): Zwischenspeicher, Mindestabstand für "Jetzt prüfen", Anzeige.
+ ///
+ public class UpdateStatusTests
+ {
+ private sealed class FakeChecker : IUpdateChecker
+ {
+ private readonly Func _now;
+ public int Calls;
+ public UpdateCheckSnapshot Result { get; set; }
+
+ public FakeChecker(Func now)
+ {
+ _now = now;
+ Result = new UpdateCheckSnapshot(DateTime.MinValue, true, true, false, "0.2.0", "Neu: Log-Viewer", true, null);
+ }
+
+ public async Task CheckAsync(CancellationToken ct)
+ {
+ Interlocked.Increment(ref Calls);
+ await Task.Delay(20, ct);
+ return Result with { CheckedAtUtc = _now() };
+ }
+ }
+
+ private sealed class Clock
+ {
+ public DateTime Now = new(2026, 9, 13, 12, 0, 0, DateTimeKind.Utc);
+ }
+
+ [Fact]
+ public async Task Innerhalb_der_Haltezeit_wird_nicht_erneut_gefragt()
+ {
+ var clock = new Clock();
+ var checker = new FakeChecker(() => clock.Now);
+ var service = new UpdateStatusService(checker, () => clock.Now);
+
+ var first = await service.GetAsync(force: false, CancellationToken.None);
+ clock.Now = clock.Now.AddMinutes(29);
+ var second = await service.GetAsync(force: false, CancellationToken.None);
+
+ Assert.False(first.FromCache);
+ Assert.True(second.FromCache);
+ Assert.Equal(1, checker.Calls);
+ }
+
+ [Fact]
+ public async Task Nach_Ablauf_der_Haltezeit_wird_frisch_gefragt()
+ {
+ var clock = new Clock();
+ var checker = new FakeChecker(() => clock.Now);
+ var service = new UpdateStatusService(checker, () => clock.Now);
+
+ await service.GetAsync(false, CancellationToken.None);
+ clock.Now = clock.Now + UpdateStatusService.CacheDuration;
+ var again = await service.GetAsync(false, CancellationToken.None);
+
+ Assert.False(again.FromCache);
+ Assert.Equal(2, checker.Calls);
+ }
+
+ [Fact]
+ public async Task Jetzt_pruefen_zu_frueh_liefert_den_vorigen_Stand_mit_Wartezeit()
+ {
+ var clock = new Clock();
+ var checker = new FakeChecker(() => clock.Now);
+ var service = new UpdateStatusService(checker, () => clock.Now);
+
+ await service.GetAsync(false, CancellationToken.None);
+ clock.Now = clock.Now.AddSeconds(20);
+ var tooEarly = await service.GetAsync(force: true, CancellationToken.None);
+
+ Assert.True(tooEarly.FromCache);
+ Assert.Equal(TimeSpan.FromSeconds(40), tooEarly.RetryAfter);
+ Assert.Equal(1, checker.Calls);
+
+ clock.Now = clock.Now.AddSeconds(40);
+ var allowed = await service.GetAsync(force: true, CancellationToken.None);
+
+ Assert.False(allowed.FromCache);
+ Assert.Null(allowed.RetryAfter);
+ Assert.Equal(2, checker.Calls);
+ }
+
+ [Fact]
+ public async Task Gleichzeitige_Aufrufe_fragen_nur_einmal()
+ {
+ var clock = new Clock();
+ var checker = new FakeChecker(() => clock.Now);
+ var service = new UpdateStatusService(checker, () => clock.Now);
+
+ await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => service.GetAsync(false, CancellationToken.None)));
+
+ Assert.Equal(1, checker.Calls);
+ }
+
+ [Fact]
+ public async Task Route_liefert_Fassung_Stand_und_verweist_zur_Installation_auf_den_Desktop()
+ {
+ var clock = new Clock();
+ var checker = new FakeChecker(() => clock.Now);
+ await using var web = await WebUiTestHost.StartAsync(s =>
+ s.AddSingleton(new UpdateStatusService(checker, () => clock.Now)));
+
+ var status = await web.GetJsonAsync("/api/updates");
+
+ Assert.Equal(PolyTrader.Core.BuildInfo.Version, status.GetProperty("currentVersion").GetString());
+ Assert.True(status.GetProperty("updateAvailable").GetBoolean());
+ Assert.Equal("0.2.0", status.GetProperty("latestVersion").GetString());
+ Assert.True(status.GetProperty("isCritical").GetBoolean());
+ Assert.Equal("desktop", status.GetProperty("installVia").GetString());
+ }
+
+ [Fact]
+ public async Task Ohne_Dienst_503()
+ {
+ await using var web = await WebUiTestHost.StartAsync();
+
+ using var res = await web.Client.GetAsync("/api/updates");
+
+ Assert.Equal(503, (int)res.StatusCode);
+ }
+
+ [Fact]
+ public async Task Das_WebUI_kennt_keinen_Weg_zum_Installieren()
+ {
+ await using var web = await WebUiTestHost.StartAsync();
+
+ var routes = web.Get().Endpoints
+ .OfType()
+ .Select(e => e.RoutePattern.RawText ?? string.Empty)
+ .Where(r => r.Contains("update", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ Assert.Equal(new[] { "/api/updates" }, routes);
+ }
+ }
+}