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:
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
banner
|
banner
|
||||||
showIcon
|
showIcon
|
||||||
message={t('update.available', u)}
|
icon={<CloudDownloadOutlined />}
|
||||||
|
message={t('update.available', meta)}
|
||||||
action={
|
action={
|
||||||
<Button size="small" type="primary" loading={applying} onClick={() => upgrade.mutate()}>
|
<Button size="small" type="primary" onClick={() => setOpen(true)}>
|
||||||
{applying ? t('update.applying') : t('update.applyNow')}
|
{t('update.viewDetails')}
|
||||||
</Button>
|
</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": {
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user