feat(ui): Update-Modal mit Multi-Package-Liste + Live-Progress

Vorher: Update-Banner war eine Inline-Alert mit "Apply now"-Button —
ein Click → Hintergrund-Apt-Run, kein Feedback ob's durch ist.

Jetzt:
* Banner zeigt nur noch Hint + "Details anzeigen"-Button.
* Modal listet alle Pakete mit Upgrade (installed → available),
  prominenter Warnhinweis dass edgeguard-api+scheduler restartet
  werden (~2-5s Unterbrechung) — HAProxy/nft/WG/Squid/Unbound/Chrony
  bleiben durch.
* "Jetzt anwenden" startet den apt-Run, Modal schaltet auf Apply-
  Mode (kein Cancel mehr, Progress-Alert mit aktueller→Ziel-Version).
* Polling von /system/health auf 2s erhöht während Apply. Sobald
  health.version == target → success-toast + auto-reload der UI
  (1.5s delay damit das neue UI-Bundle gecached werden kann).

Pattern entlehnt von enconf/mail-gateway. Plus i18n-Erweiterung
für update.modalTitle / modalIntro / modalWarn / applyingHint /
applyingDetail / success.

Version 1.0.45.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-11 07:56:57 +02:00
parent 9464322450
commit f4ccfc3c0c
9 changed files with 158 additions and 40 deletions

View File

@@ -1 +1 @@
1.0.44 1.0.45

View File

@@ -45,7 +45,7 @@ import (
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
) )
var version = "1.0.44" var version = "1.0.45"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -9,7 +9,7 @@ import (
"os" "os"
) )
var version = "1.0.44" var version = "1.0.45"
const usage = `edgeguard-ctl — EdgeGuard CLI const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -21,7 +21,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
) )
var version = "1.0.44" var version = "1.0.45"
const ( const (
// renewTickInterval — how often we re-evaluate expiring certs. // renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -1,7 +1,7 @@
{ {
"name": "edgeguard-management-ui", "name": "edgeguard-management-ui",
"private": true, "private": true,
"version": "1.0.44", "version": "1.0.45",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -75,7 +75,7 @@ const NAV: NavSection[] = [
}, },
] ]
const VERSION = '1.0.44' const VERSION = '1.0.45'
export default function Sidebar({ isOpen, onClose }: SidebarProps) { export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const { t } = useTranslation() const { t } = useTranslation()

View File

@@ -1,35 +1,49 @@
import { Alert, Button, message } from 'antd' import { Alert, Button, List, Modal, Tag, Typography, message } from 'antd'
import { CloudDownloadOutlined } from '@ant-design/icons'
import { useQuery, useMutation } from '@tanstack/react-query' import { useQuery, useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useState } from 'react' import { useState, useEffect } from 'react'
import apiClient, { isEnvelope } from '../api/client' import apiClient, { isEnvelope } from '../api/client'
const { Text } = Typography
interface PackageVersions { interface PackageVersions {
[key: string]: string [key: string]: string
} }
// hasUpdate compares an *_installed value against its *_available interface PendingUpdate {
// counterpart. Returns the package base name + both versions when an pkg: string
// update is pending; null otherwise. We pick the first package that installed: string
// has a real upgrade — matching enconf's "show one upgrade hint at available: string
// a time" UX. }
function pickUpdate(v: PackageVersions): { pkg: string; installed: string; available: string } | null {
// allUpdates lists every package with installed != available.
// Match enconf/mail-gateway pattern of showing the multi-package
// list in a confirm-modal rather than just one banner-line.
function allUpdates(v: PackageVersions): PendingUpdate[] {
const out: PendingUpdate[] = []
for (const key of Object.keys(v)) { for (const key of Object.keys(v)) {
if (!key.endsWith('_installed')) continue if (!key.endsWith('_installed')) continue
const pkg = key.replace('_installed', '') const pkg = key.replace('_installed', '')
const installed = v[key] const installed = v[key]
const available = v[`${pkg}_available`] const available = v[`${pkg}_available`]
if (installed && available && installed !== available) { if (installed && available && installed !== available) {
return { pkg, installed, available } out.push({ pkg, installed, available })
} }
} }
return null return out
} }
interface SystemHealth { status: string; version: string }
export default function UpdateBanner() { export default function UpdateBanner() {
const { t } = useTranslation() const { t } = useTranslation()
const [applying, setApplying] = useState(false) const [open, setOpen] = useState(false)
// applyingFor holds the version we expect AFTER the upgrade so the
// modal can poll system/health and detect the version-flip. Switches
// back to null + closes the modal once the new version is live.
const [applyingFor, setApplyingFor] = useState<string | null>(null)
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['system', 'package-versions'], queryKey: ['system', 'package-versions'],
@@ -38,34 +52,124 @@ export default function UpdateBanner() {
if (isEnvelope(r.data)) return r.data.data as PackageVersions if (isEnvelope(r.data)) return r.data.data as PackageVersions
return {} as PackageVersions return {} as PackageVersions
}, },
refetchInterval: 5 * 60 * 1000, // 5 min poll matches enconf cadence refetchInterval: 5 * 60 * 1000,
})
const { data: health } = useQuery({
queryKey: ['system', 'health'],
queryFn: async () => {
const r = await apiClient.get('/system/health')
return isEnvelope(r.data) ? (r.data.data as SystemHealth) : null
},
// poll faster while we're applying so the version-flip is caught
refetchInterval: applyingFor ? 2_000 : 30_000,
}) })
const upgrade = useMutation({ const upgrade = useMutation({
mutationFn: async () => { mutationFn: async () => { await apiClient.post('/system/upgrade') },
await apiClient.post('/system/upgrade') onError: (e: Error) => message.error(e.message),
},
onSuccess: () => {
setApplying(true)
message.success(t('update.started'))
},
}) })
// Detect version flip: api came back with the new version after
// restart → upgrade succeeded, close modal + tell user.
useEffect(() => {
if (!applyingFor || !health) return
if (health.version === applyingFor) {
message.success(t('update.success', { version: applyingFor }))
setApplyingFor(null)
setOpen(false)
// page reload picks up the rebuilt UI bundle
setTimeout(() => window.location.reload(), 1500)
}
}, [applyingFor, health, t])
if (!data) return null if (!data) return null
const u = pickUpdate(data) const updates = allUpdates(data)
if (!u) return null if (updates.length === 0) return null
// Anchor: prefer the meta package's available version as the
// "target" (since edgeguard depends on edgeguard-api/ui at the
// same version). Falls back to the first listed package.
const meta = updates.find(u => u.pkg === 'edgeguard') ?? updates[0]
return ( return (
<Alert <>
type="warning" <Alert
banner type="warning"
showIcon banner
message={t('update.available', u)} showIcon
action={ icon={<CloudDownloadOutlined />}
<Button size="small" type="primary" loading={applying} onClick={() => upgrade.mutate()}> message={t('update.available', meta)}
{applying ? t('update.applying') : t('update.applyNow')} action={
</Button> <Button size="small" type="primary" onClick={() => setOpen(true)}>
} {t('update.viewDetails')}
/> </Button>
}
/>
<Modal
title={t('update.modalTitle')}
open={open}
onCancel={() => { if (!applyingFor) setOpen(false) }}
maskClosable={!applyingFor}
closable={!applyingFor}
width={620}
footer={
applyingFor ? null : [
<Button key="cancel" onClick={() => setOpen(false)}>{t('common.cancel')}</Button>,
<Button
key="apply"
type="primary"
icon={<CloudDownloadOutlined />}
loading={upgrade.isPending}
onClick={() => { setApplyingFor(meta.available); upgrade.mutate() }}
>
{t('update.applyNow')}
</Button>,
]
}
>
{applyingFor ? (
<div>
<Alert
type="info"
showIcon
message={t('update.applying')}
description={t('update.applyingHint', { version: applyingFor })}
/>
<div style={{ marginTop: 12, fontSize: 12, color: '#64748B' }}>
{t('update.applyingDetail', { current: health?.version ?? '—', target: applyingFor })}
</div>
</div>
) : (
<>
<Text>{t('update.modalIntro')}</Text>
<List
size="small"
style={{ marginTop: 12 }}
dataSource={updates}
renderItem={u => (
<List.Item>
<List.Item.Meta
title={<code>{u.pkg}</code>}
description={
<span>
<Tag>{u.installed}</Tag> <Tag color="blue">{u.available}</Tag>
</span>
}
/>
</List.Item>
)}
/>
<Alert
type="warning"
showIcon
style={{ marginTop: 12 }}
message={t('update.modalWarn')}
/>
</>
)}
</Modal>
</>
) )
} }

View File

@@ -284,9 +284,16 @@
}, },
"update": { "update": {
"available": "Update verfügbar: {{pkg}} {{installed}} → {{available}}", "available": "Update verfügbar: {{pkg}} {{installed}} → {{available}}",
"viewDetails": "Details anzeigen",
"applyNow": "Jetzt aktualisieren", "applyNow": "Jetzt aktualisieren",
"applying": "Update läuft …", "applying": "Update läuft …",
"started": "Update wurde gestartet — der Server wird in Kürze neu starten." "applyingHint": "Pakete werden installiert + Services restartet. Verbindung kann kurz brechen — die UI lädt automatisch neu sobald Version {{version}} live ist.",
"applyingDetail": "aktuell {{current}} · Ziel {{target}}",
"started": "Update wurde gestartet — der Server wird in Kürze neu starten.",
"success": "Update auf {{version}} abgeschlossen.",
"modalTitle": "EdgeGuard-Update",
"modalIntro": "Folgende Pakete werden aktualisiert:",
"modalWarn": "Während des Updates werden edgeguard-api + edgeguard-scheduler neu gestartet (~2-5 Sekunden Service-Unterbrechung). HAProxy + nft + WireGuard + Squid + Unbound + Chrony bleiben durchgehend aktiv."
}, },
"wg": { "wg": {
"title": "WireGuard", "title": "WireGuard",

View File

@@ -284,9 +284,16 @@
}, },
"update": { "update": {
"available": "Update available: {{pkg}} {{installed}} → {{available}}", "available": "Update available: {{pkg}} {{installed}} → {{available}}",
"viewDetails": "View details",
"applyNow": "Apply now", "applyNow": "Apply now",
"applying": "Update in progress …", "applying": "Update in progress …",
"started": "Update has started — the server will restart shortly." "applyingHint": "Packages are installed and services restarted. Connection may briefly break — the UI reloads automatically once version {{version}} is live.",
"applyingDetail": "current {{current}} · target {{target}}",
"started": "Update has started — the server will restart shortly.",
"success": "Updated to {{version}}.",
"modalTitle": "EdgeGuard update",
"modalIntro": "The following packages will be updated:",
"modalWarn": "During the update edgeguard-api + edgeguard-scheduler restart (~2-5 s service interruption). HAProxy + nft + WireGuard + Squid + Unbound + Chrony stay up the whole time."
}, },
"wg": { "wg": {
"title": "WireGuard", "title": "WireGuard",