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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "edgeguard-management-ui",
|
||||
"private": true,
|
||||
"version": "1.0.44",
|
||||
"version": "1.0.45",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -75,7 +75,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.44'
|
||||
const VERSION = '1.0.45'
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -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 { useTranslation } from 'react-i18next'
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
import apiClient, { isEnvelope } from '../api/client'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface PackageVersions {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
// hasUpdate compares an *_installed value against its *_available
|
||||
// counterpart. Returns the package base name + both versions when an
|
||||
// update is pending; null otherwise. We pick the first package that
|
||||
// has a real upgrade — matching enconf's "show one upgrade hint at
|
||||
// a time" UX.
|
||||
function pickUpdate(v: PackageVersions): { pkg: string; installed: string; available: string } | null {
|
||||
interface PendingUpdate {
|
||||
pkg: string
|
||||
installed: string
|
||||
available: string
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
if (!key.endsWith('_installed')) continue
|
||||
const pkg = key.replace('_installed', '')
|
||||
const installed = v[key]
|
||||
const available = v[`${pkg}_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() {
|
||||
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({
|
||||
queryKey: ['system', 'package-versions'],
|
||||
@@ -38,34 +52,124 @@ export default function UpdateBanner() {
|
||||
if (isEnvelope(r.data)) return r.data.data 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({
|
||||
mutationFn: async () => {
|
||||
await apiClient.post('/system/upgrade')
|
||||
},
|
||||
onSuccess: () => {
|
||||
setApplying(true)
|
||||
message.success(t('update.started'))
|
||||
},
|
||||
mutationFn: async () => { await apiClient.post('/system/upgrade') },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
// 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
|
||||
const u = pickUpdate(data)
|
||||
if (!u) return null
|
||||
const updates = allUpdates(data)
|
||||
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 (
|
||||
<Alert
|
||||
type="warning"
|
||||
banner
|
||||
showIcon
|
||||
message={t('update.available', u)}
|
||||
action={
|
||||
<Button size="small" type="primary" loading={applying} onClick={() => upgrade.mutate()}>
|
||||
{applying ? t('update.applying') : t('update.applyNow')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<Alert
|
||||
type="warning"
|
||||
banner
|
||||
showIcon
|
||||
icon={<CloudDownloadOutlined />}
|
||||
message={t('update.available', meta)}
|
||||
action={
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -284,9 +284,16 @@
|
||||
},
|
||||
"update": {
|
||||
"available": "Update verfügbar: {{pkg}} {{installed}} → {{available}}",
|
||||
"viewDetails": "Details anzeigen",
|
||||
"applyNow": "Jetzt aktualisieren",
|
||||
"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": {
|
||||
"title": "WireGuard",
|
||||
|
||||
@@ -284,9 +284,16 @@
|
||||
},
|
||||
"update": {
|
||||
"available": "Update available: {{pkg}} {{installed}} → {{available}}",
|
||||
"viewDetails": "View details",
|
||||
"applyNow": "Apply now",
|
||||
"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": {
|
||||
"title": "WireGuard",
|
||||
|
||||
Reference in New Issue
Block a user