diff --git a/src/PolyTrader.Web/Endpoints/UpdatesEndpoints.cs b/src/PolyTrader.Web/Endpoints/UpdatesEndpoints.cs new file mode 100644 index 0000000..608eb2c --- /dev/null +++ b/src/PolyTrader.Web/Endpoints/UpdatesEndpoints.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using PolyTrader.Core; +using PolyTrader.Web.Updates; + +namespace PolyTrader.Web.Endpoints +{ + /// + /// Update-Status im WebUI (#565): laufende und verfügbare Fassung, Hinweis auf ein Sicherheitsupdate. + /// + /// Kein Installieren über das WebUI. Die Installation startet den Update-Agent und beendet + /// danach die Anwendung – und mit ihr den Webserver. Richards Vorgabe beschränkt das Beenden über das + /// WebUI auf das Trading (#563). Installiert wird im Desktop. + /// + public static class UpdatesEndpoints + { + public static IEndpointRouteBuilder MapUpdatesEndpoints(this IEndpointRouteBuilder routes) + { + routes.MapGet("/api/updates", async (IServiceProvider sp, bool? refresh, CancellationToken ct) => + { + var service = sp.GetService(); + if (service == null) + return Results.Problem("Die Update-Prüfung ist nicht verfügbar.", statusCode: StatusCodes.Status503ServiceUnavailable); + + var (snapshot, fromCache, retryAfter) = await service.GetAsync(refresh == true, ct); + + return Results.Ok(new + { + currentVersion = BuildInfo.Version, + currentBuild = BuildInfo.Summary, + enabled = snapshot.Enabled, + updateAvailable = snapshot.UpdateAvailable, + latestVersion = snapshot.LatestVersion, + changelog = snapshot.Changelog, + isCritical = snapshot.IsCritical, + unauthorized = snapshot.Unauthorized, + error = snapshot.Error, + checkedAt = snapshot.CheckedAtUtc.ToString("o"), + fromCache, + retryAfterSeconds = retryAfter.HasValue ? (int?)Math.Ceiling(retryAfter.Value.TotalSeconds) : null, + installVia = "desktop" + }); + }); + + return routes; + } + } +} diff --git a/src/PolyTrader.Web/Hosting/WebUiHostingExtensions.cs b/src/PolyTrader.Web/Hosting/WebUiHostingExtensions.cs index f8ed8c8..63fb6b3 100644 --- a/src/PolyTrader.Web/Hosting/WebUiHostingExtensions.cs +++ b/src/PolyTrader.Web/Hosting/WebUiHostingExtensions.cs @@ -1,3 +1,4 @@ +using PolyTrader.Web.Updates; using System; using System.IO; using System.Net; @@ -23,6 +24,10 @@ namespace PolyTrader.Web.Hosting hostBuilder.ConfigureServices((_, services) => { services.AddSingleton(Options.Create(options)); + + // #565: Update-Status mit Zwischenspeicher - nicht jeder Seitenaufruf fragt das Deploymentcenter. + services.AddSingleton(); + services.AddSingleton(sp => new UpdateStatusService(sp.GetRequiredService())); }); hostBuilder.ConfigureWebHostDefaults(webBuilder => @@ -81,6 +86,7 @@ namespace PolyTrader.Web.Hosting endpoints.MapResolutionFarmingEndpoints(); endpoints.MapSupervisorEndpoints(); endpoints.MapAccountingEndpoints(); + endpoints.MapUpdatesEndpoints(); endpoints.MapGet("/login", async httpContext => { diff --git a/src/PolyTrader.Web/Updates/UpdateStatusService.cs b/src/PolyTrader.Web/Updates/UpdateStatusService.cs new file mode 100644 index 0000000..81578f3 --- /dev/null +++ b/src/PolyTrader.Web/Updates/UpdateStatusService.cs @@ -0,0 +1,138 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Deploymentcenter.Client; +using Microsoft.Extensions.DependencyInjection; +using PolyTrader.Core; +using PolyTrader.Core.Configuration; +using PolyTrader.Core.Security; +using PolyTraderSharp.Models; + +namespace PolyTrader.Web.Updates +{ + /// Ergebnis einer Update-Prüfung, wie das WebUI es anzeigt (#565). + public sealed record UpdateCheckSnapshot( + DateTime CheckedAtUtc, + bool Enabled, + bool UpdateAvailable, + bool Unauthorized, + string? LatestVersion, + string? Changelog, + bool IsCritical, + string? Error); + + /// Fragt, ob es eine neuere Fassung gibt. Hinter einer Schnittstelle, damit sich die Zwischenspeicherung ohne Netz prüfen lässt. + public interface IUpdateChecker + { + Task CheckAsync(CancellationToken ct); + } + + /// + /// Hält das letzte Prüfergebnis vor (#565). + /// + /// Ohne diesen Zwischenspeicher fragte jeder Seitenaufruf des WebUI das Deploymentcenter – bei + /// mehreren offenen Browserfenstern ein Mehrfaches dessen, was der Desktop mit einer Prüfung beim + /// Start braucht. "Jetzt prüfen" umgeht den Zwischenspeicher, aber frühestens nach + /// ; ein Doppelklick oder ein Skript in einer Schleife soll keine + /// Anfrageflut auslösen. + /// + public sealed class UpdateStatusService + { + public static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30); + public static readonly TimeSpan MinManualInterval = TimeSpan.FromSeconds(60); + + private readonly IUpdateChecker _checker; + private readonly Func _utcNow; + private readonly SemaphoreSlim _gate = new(1, 1); + private UpdateCheckSnapshot? _last; + + public UpdateStatusService(IUpdateChecker checker, Func? utcNow = null) + { + _checker = checker; + _utcNow = utcNow ?? (() => DateTime.UtcNow); + } + + /// + /// Liefert das Ergebnis, bei Bedarf frisch geprüft. RetryAfter ist gesetzt, wenn eine erzwungene + /// Prüfung zu früh kam und stattdessen der vorige Stand geliefert wird. + /// + public async Task<(UpdateCheckSnapshot Snapshot, bool FromCache, TimeSpan? RetryAfter)> GetAsync(bool force, CancellationToken ct) + { + // Eine Prüfung zur Zeit: Gleichzeitige Aufrufe warten und bekommen danach das frische Ergebnis. + await _gate.WaitAsync(ct); + try + { + var now = _utcNow(); + if (_last != null) + { + var age = now - _last.CheckedAtUtc; + if (!force && age < CacheDuration) + return (_last, true, null); + if (force && age < MinManualInterval) + return (_last, true, MinManualInterval - age); + } + + _last = await _checker.CheckAsync(ct); + return (_last, false, null); + } + finally + { + _gate.Release(); + } + } + } + + /// + /// Prüft gegen das Deploymentcenter – derselbe Aufruf wie beim Start des Desktops (LauncherWindow). + /// Der Lizenzschlüssel für den Paketabruf wird hier gelesen und verlässt den Server nicht. + /// + internal sealed class DeploymentcenterUpdateChecker : IUpdateChecker + { + private readonly IServiceProvider _services; + + public DeploymentcenterUpdateChecker(IServiceProvider services) => _services = services; + + public async Task CheckAsync(CancellationToken ct) + { + var now = DateTime.UtcNow; + var dc = _services.GetService(); + if (dc == null || !dc.UpdateCheckEnabled || string.IsNullOrWhiteSpace(dc.BaseUrl)) + return new UpdateCheckSnapshot(now, false, false, false, null, null, false, + "Die Update-Prüfung ist nicht eingerichtet (Deploymentcenter: UpdateCheckEnabled, BaseUrl)."); + + string? licenseKey = null; + try + { + var settings = _services.GetService()?.Read() ?? _services.GetService(); + licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty); + } + catch + { + // Nicht lesbarer Schlüssel: Die Prüfung läuft ohne Zugangsdaten weiter, wie im Desktop. + } + + try + { + var result = await new UpdateClient().CheckForUpdateAsync( + baseUrl: dc.BaseUrl, + projectId: dc.ProjectSlug, + currentVersion: BuildInfo.Version, + channel: dc.Channel, + credentials: ReleaseCredentials.FromLicenseKey(licenseKey)); + + if (result.Unauthorized) + return new UpdateCheckSnapshot(now, true, false, true, null, null, false, + "Die Lizenz trägt nicht für den Paketabruf."); + + bool available = result.UpdateAvailable && result.LatestRelease != null; + return new UpdateCheckSnapshot(now, true, available, false, + result.LatestRelease?.Version, result.LatestRelease?.Changelog, available && result.IsCritical, null); + } + catch (Exception ex) + { + return new UpdateCheckSnapshot(now, true, false, false, null, null, false, + $"Deploymentcenter nicht erreichbar: {ex.Message}"); + } + } + } +} diff --git a/src/PolyTrader.Web/wwwroot/index.html b/src/PolyTrader.Web/wwwroot/index.html index ded0a5e..5048152 100644 --- a/src/PolyTrader.Web/wwwroot/index.html +++ b/src/PolyTrader.Web/wwwroot/index.html @@ -83,6 +83,8 @@
+ +
Gesamt PnL
@@ -1204,6 +1206,20 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d

Einstellungen

+ +
+
+ Version & Updates + +
+
+
Stand
+
Lade …
+
+ +
+ 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); + } + } +}