Files
edgeguard-native/management-ui/src/components/UpdateBanner.tsx
Debian 35b7308ce2 feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)
Backend:
- Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date)
- NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle)
- System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler)
- Domain-Response-Headers + Rate-Limit (Migration 0024)
- Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out
- apt-Service für Update-Banner (apt-get update + Versionsprüfung)
- Backup-Retry mit exponential backoff (retry_apt 3×)
- publish.sh fail-fast + cleanup-old.sh (max 10 Versionen)

Frontend:
- Audit-Log-Page (/audit) mit Filter + Pagination
- ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+)
- Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix)
- EmptyState-Komponente überall ausgerollt
- SSL: Aggregate-Karte (total/expiring/expired/errors)
- Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now
- NTP: Sync-Status-Karte (chronyc tracking live)
- Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats
- Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN)
- Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler)
- Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention
- Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint
- System-Regeln im Firewall als eigener Tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:18:41 +02:00

280 lines
10 KiB
TypeScript

import { Alert, Button, Popconfirm, Space, Tooltip, message } from 'antd'
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../api/client'
interface UpdateBannerProps {
// compact: rendert nur einen kleinen „Jetzt prüfen"-Button (z. B. im
// Header), unabhängig davon ob aktuell ein Update verfügbar ist. So
// hat der Operator immer einen Weg den Server-seitigen Throttle zu
// umgehen — sonst Catch-22 wenn der Banner mangels Update nicht
// rendert. Pattern 1:1 aus mail-gateway/Dashboard/v2/UpdateBanner.
compact?: boolean
}
interface PackageVersions { [key: string]: string }
interface SystemHealth { status: string; version: string }
interface PendingUpdate { pkg: string; installed: string; available: string }
// allUpdates parsed das flache map-Format ({pkg_installed,pkg_available})
// das /system/package-versions zurückliefert. Eines davon ist meist
// das meta-Paket "edgeguard" → die "Ziel-Version".
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) {
out.push({ pkg, installed, available })
}
}
return out
}
export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}) {
const { t } = useTranslation()
const [msg, msgCtx] = message.useMessage()
const pkgVersions = useQuery({
queryKey: ['system', 'package-versions'],
queryFn: async () => {
const r = await apiClient.get('/system/package-versions')
return isEnvelope(r.data) ? (r.data.data as PackageVersions) : ({} as PackageVersions)
},
refetchInterval: 30_000,
refetchOnMount: 'always',
staleTime: 0,
gcTime: 0,
})
const [upgrading, setUpgrading] = useState(false)
const [upgradeElapsed, setUpgradeElapsed] = useState(0)
const [forceChecking, setForceChecking] = useState(false)
const upgradePollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const upgradeTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
const installedRef = useRef<string>('')
const targetRef = useRef<string>('')
useEffect(() => () => {
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
}, [])
const data = pkgVersions.data ?? {}
const updates = allUpdates(data)
const updateAvailable = updates.length > 0
const meta = updates.find(u => u.pkg === 'edgeguard') ?? updates[0]
const installedVersion = meta?.installed ?? ''
const targetVersion = meta?.available ?? ''
const forceCheck = async () => {
setForceChecking(true)
try {
// ?force=1: bypassed den Server-seitigen 5-min-Throttle für
// apt-get update. Ohne den Force-Hint würde der Endpoint
// einfach den letzten Cache zurückliefern (max. 5 min alt) und
// der Button fühlt sich kaputt an. Pattern aus mail-gateway.
const r = await apiClient.get('/system/package-versions?force=1')
const fresh = (isEnvelope(r.data) ? (r.data.data as PackageVersions) : {})
// useQuery-Cache mit dem frischen Wert füttern damit der Banner
// sofort umschaltet, ohne auf die nächste 30s-Welle zu warten.
void pkgVersions.refetch()
const found = allUpdates(fresh).length > 0
msg[found ? 'success' : 'info'](
found ? t('update.checkDone') : t('update.noUpdate'),
)
} catch {
msg.error(t('update.checkFailed'))
} finally {
setForceChecking(false)
}
}
const startUpgrade = () => {
installedRef.current = installedVersion
targetRef.current = targetVersion
setUpgrading(true)
setUpgradeElapsed(0)
upgradeTickRef.current = setInterval(() => setUpgradeElapsed((e) => e + 1), 1000)
apiClient.post('/system/upgrade')
.then(() => {
// Poll /healthz (kein Auth, robust auch wenn die API gerade
// restartet und Cookie ihre Session nicht erkennt).
let sawDown = false
upgradePollRef.current = setInterval(async () => {
try {
const res = await apiClient.get('/system/health')
const newV = (isEnvelope(res.data) ? (res.data.data as SystemHealth).version : '')
const flipped = newV && installedRef.current && newV !== installedRef.current
if (flipped || sawDown) {
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
setUpgrading(false)
msg.success(t('update.success', { version: targetRef.current }))
setTimeout(() => window.location.reload(), 1500)
}
} catch {
// Connection refused / 502 → API restartet. Beim nächsten
// erfolgreichen Poll erkennen wir den Version-Flip.
sawDown = true
}
}, 3000)
// Sicherheits-Timeout: nach 2 Min einfach reload — falls der
// Restart länger braucht als erwartet, kommt die UI in jedem
// Fall wieder hoch.
setTimeout(() => {
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
setUpgrading(false)
window.location.reload()
}, 120_000)
})
.catch((e: Error) => {
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
setUpgrading(false)
msg.error(t('update.failed') + ': ' + e.message)
})
}
if (compact) {
// Compact-Variante: Force-Check-Button für "ich will jetzt prüfen",
// wenn aktuell NICHTS ausstehendes da ist. Sobald ein Update
// verfügbar ist, übernimmt der gelbe Full-Mode-Banner (in
// AppLayout) die Sichtbarkeit — wir blenden den Compact-Button
// dann komplett aus, sonst doppelt-doppelt Info (Befund 2026-05-15:
// "die roten Banner können weg, der gelbe Banner reicht").
if (updateAvailable) {
return <>{msgCtx}</>
}
return (
<>
{msgCtx}
<Tooltip title={t('update.checkNowHint')}>
<Button
type="default"
icon={<ReloadOutlined />}
loading={forceChecking}
onClick={forceCheck}
>
{t('update.checkNow')}
</Button>
</Tooltip>
</>
)
}
if (!updateAvailable && !upgrading) {
return <>{msgCtx}</>
}
return (
<>
{msgCtx}
{updateAvailable && !upgrading && (
<Alert
type="warning"
banner
showIcon
icon={<CloudDownloadOutlined />}
message={t('update.available', { version: targetVersion })}
description={updates.length > 1
? t('update.multiPackageHint', { count: updates.length })
: undefined}
action={
<Space>
<Button
size="small"
icon={<ReloadOutlined />}
loading={forceChecking}
onClick={forceCheck}
>
{t('update.checkNow')}
</Button>
<Popconfirm
title={t('update.confirmTitle')}
description={t('update.confirmDesc', { version: targetVersion })}
okText={t('update.applyNow')}
cancelText={t('common.cancel')}
onConfirm={startUpgrade}
>
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
{t('update.applyNow')}
</Button>
</Popconfirm>
</Space>
}
/>
)}
{upgrading && (
<div className="update-modal-overlay">
<div className="update-modal">
<div className="update-modal__orbit">
<div className="update-modal__ring" />
<div className="update-modal__ring update-modal__ring--2" />
<div className="update-modal__dot" />
<div className="update-modal__dot update-modal__dot--2" />
<div className="update-modal__center">
<RocketOutlined className="update-modal__icon" />
</div>
</div>
<div className="update-modal__title">{t('update.running')}</div>
<div className="update-modal__version">
v{installedRef.current || '…'} v{targetRef.current || '…'}
</div>
<div className="update-modal__steps">
<Step
done={upgradeElapsed >= 5}
active={upgradeElapsed < 5}
label={t('update.stepDownload')}
/>
<Step
done={upgradeElapsed >= 15}
active={upgradeElapsed >= 5 && upgradeElapsed < 15}
label={t('update.stepInstall')}
/>
<Step
done={upgradeElapsed >= 25}
active={upgradeElapsed >= 15 && upgradeElapsed < 25}
label={t('update.stepRestart')}
/>
<Step
done={false}
active={upgradeElapsed >= 25}
label={t('update.stepVerify')}
/>
</div>
<div className="update-modal__timer">{upgradeElapsed}s</div>
<div className="update-modal__hint">{t('update.waitHint')}</div>
</div>
</div>
)}
</>
)
}
function Step({ done, active, label }: { done: boolean; active: boolean; label: string }) {
const cls =
'update-modal__step' +
(active ? ' update-modal__step--active' : '') +
(done ? ' update-modal__step--done' : '')
return (
<div className={cls}>
<span className="update-modal__step-dot" />
<span>{label}</span>
</div>
)
}