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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 & 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. -->
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
Reference in New Issue
Block a user