WebUI: Update-Status anzeigen, installiert wird im Desktop

Umsetzung von #565.

GET /api/updates liefert die laufende Fassung (BuildInfo), die verfuegbare,
den Hinweis auf ein Sicherheitsupdate und den Changelog. Geprueft wird mit
demselben Aufruf wie beim Start des Desktops (UpdateClient gegen das
Deploymentcenter). Den Lizenzschluessel fuer den Paketabruf liest der Server,
er verlaesst ihn nicht.

Zwischenspeicher: UpdateStatusService haelt das Ergebnis 30 Minuten vor.
"Jetzt pruefen" umgeht das, aber fruehestens nach 60 Sekunden; bis dahin kommt
der vorige Stand mit der Wartezeit zurueck. Gleichzeitige Aufrufe loesen eine
einzige Pruefung aus. Ohne das fragte jeder Seitenaufruf, jedes offene
Browserfenster, das Deploymentcenter. Die Pruefung liegt hinter IUpdateChecker
und ist damit ohne Netz testbar.

Kein Installieren ueber das WebUI, und ein Test haelt fest, dass es keine
solche Route gibt: Die Installation startet den Update-Agent und beendet
danach die Anwendung und mit ihr den Webserver. Richards Vorgabe beschraenkt
das Beenden ueber das WebUI auf das Trading (#563).

Oberflaeche:
- Karte "Version & Updates" in den Einstellungen, mit "Jetzt pruefen".
- Im Dashboard ein Banner wie im Desktop-Launcher, sobald ein Update vorliegt.
  Er verweist zur Installation auf den Desktop.

Nicht durch Unit-Tests abgedeckt: DeploymentcenterUpdateChecker selbst, weil
er ueber das Netz geht. Er wird auf der VM gegen das echte Deploymentcenter
geprueft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-13 14:20:07 +02:00
co-authored by Claude Opus 5
parent 0067e0b761
commit bc96807d81
6 changed files with 417 additions and 0 deletions
@@ -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
{
/// <summary>
/// Update-Status im WebUI (#565): laufende und verfügbare Fassung, Hinweis auf ein Sicherheitsupdate.
///
/// <para><b>Kein Installieren über das WebUI.</b> 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.</para>
/// </summary>
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<UpdateStatusService>();
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;
}
}
}
@@ -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<IUpdateChecker, DeploymentcenterUpdateChecker>();
services.AddSingleton(sp => new UpdateStatusService(sp.GetRequiredService<IUpdateChecker>()));
});
hostBuilder.ConfigureWebHostDefaults(webBuilder =>
@@ -81,6 +86,7 @@ namespace PolyTrader.Web.Hosting
endpoints.MapResolutionFarmingEndpoints();
endpoints.MapSupervisorEndpoints();
endpoints.MapAccountingEndpoints();
endpoints.MapUpdatesEndpoints();
endpoints.MapGet("/login", async httpContext =>
{
@@ -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
{
/// <summary>Ergebnis einer Update-Prüfung, wie das WebUI es anzeigt (#565).</summary>
public sealed record UpdateCheckSnapshot(
DateTime CheckedAtUtc,
bool Enabled,
bool UpdateAvailable,
bool Unauthorized,
string? LatestVersion,
string? Changelog,
bool IsCritical,
string? Error);
/// <summary>Fragt, ob es eine neuere Fassung gibt. Hinter einer Schnittstelle, damit sich die Zwischenspeicherung ohne Netz prüfen lässt.</summary>
public interface IUpdateChecker
{
Task<UpdateCheckSnapshot> CheckAsync(CancellationToken ct);
}
/// <summary>
/// Hält das letzte Prüfergebnis vor (#565).
///
/// <para>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
/// <see cref="MinManualInterval"/>; ein Doppelklick oder ein Skript in einer Schleife soll keine
/// Anfrageflut auslösen.</para>
/// </summary>
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<DateTime> _utcNow;
private readonly SemaphoreSlim _gate = new(1, 1);
private UpdateCheckSnapshot? _last;
public UpdateStatusService(IUpdateChecker checker, Func<DateTime>? utcNow = null)
{
_checker = checker;
_utcNow = utcNow ?? (() => DateTime.UtcNow);
}
/// <summary>
/// Liefert das Ergebnis, bei Bedarf frisch geprüft. <c>RetryAfter</c> ist gesetzt, wenn eine erzwungene
/// Prüfung zu früh kam und stattdessen der vorige Stand geliefert wird.
/// </summary>
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();
}
}
}
/// <summary>
/// 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.
/// </summary>
internal sealed class DeploymentcenterUpdateChecker : IUpdateChecker
{
private readonly IServiceProvider _services;
public DeploymentcenterUpdateChecker(IServiceProvider services) => _services = services;
public async Task<UpdateCheckSnapshot> CheckAsync(CancellationToken ct)
{
var now = DateTime.UtcNow;
var dc = _services.GetService<DeploymentcenterOptions>();
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<ServerSettingsFile>()?.Read() ?? _services.GetService<ServerSettings>();
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}");
}
}
}
}
+16
View File
@@ -83,6 +83,8 @@
<main class="app-main">
<!-- VIEW: DASHBOARD -->
<section id="view-dashboard" class="view-panel active">
<!-- #565: wie das Update-Banner im Desktop-Launcher. Installation nur im Desktop. -->
<div id="dashUpdateBanner" class="rate-warning" style="margin-bottom: 16px;" hidden></div>
<div class="grid-4">
<div class="card">
<div class="card-title">Gesamt PnL</div>
@@ -1204,6 +1206,20 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
<section id="view-settings" class="view-panel">
<h2 style="margin-bottom: 16px; font-size: 18px;">Einstellungen</h2>
<!-- Version und Updates (#565). Installiert wird im Desktop: Die Installation beendet die
Anwendung und damit auch das WebUI. -->
<div class="card settings-form" style="margin-bottom: 16px;">
<div class="card-title">
<span>Version &amp; Updates</span>
<button class="btn btn-secondary btn-sm" onclick="loadUpdateStatus(true)">Jetzt prüfen</button>
</div>
<dl class="settings-readonly" id="updateStatusBody">
<dt>Stand</dt>
<dd>Lade …</dd>
</dl>
<div id="updateStatusMsg" class="form-desc" style="margin-top: 10px;" hidden></div>
</div>
<!-- Zugang zum WebUI (#562): nur lesend. Port, Netzfreigabe und Token werden ausschliesslich
im Desktop gepflegt. Wer das Token hat, soll sich damit weder den Zugang ins Netz oeffnen
noch ihn verlegen oder sich selbst aussperren koennen. -->
+57
View File
@@ -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 = {};
@@ -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
{
/// <summary>
/// Update-Status im WebUI (#565): Zwischenspeicher, Mindestabstand für "Jetzt prüfen", Anzeige.
/// </summary>
public class UpdateStatusTests
{
private sealed class FakeChecker : IUpdateChecker
{
private readonly Func<DateTime> _now;
public int Calls;
public UpdateCheckSnapshot Result { get; set; }
public FakeChecker(Func<DateTime> now)
{
_now = now;
Result = new UpdateCheckSnapshot(DateTime.MinValue, true, true, false, "0.2.0", "Neu: Log-Viewer", true, null);
}
public async Task<UpdateCheckSnapshot> 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<Microsoft.AspNetCore.Routing.EndpointDataSource>().Endpoints
.OfType<Microsoft.AspNetCore.Routing.RouteEndpoint>()
.Select(e => e.RoutePattern.RawText ?? string.Empty)
.Where(r => r.Contains("update", StringComparison.OrdinalIgnoreCase))
.ToList();
Assert.Equal(new[] { "/api/updates" }, routes);
}
}
}