feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery
- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung - keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload) - confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix) - TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login - Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator - fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen - VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034) - Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032) - unbound-control: edgeguard in unbound-Gruppe via postinst Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Alert, Button, Card, Descriptions, Input, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import { Alert, Button, Card, Descriptions, Input, List, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined, SwapOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -87,6 +87,28 @@ interface CertStatus {
|
||||
peer?: CertInfo
|
||||
}
|
||||
|
||||
interface VIPInfo {
|
||||
id: number
|
||||
address: string
|
||||
prefix: number
|
||||
device: string
|
||||
}
|
||||
|
||||
interface VIPStatusEntry {
|
||||
vip: VIPInfo
|
||||
active_on: string[]
|
||||
}
|
||||
|
||||
interface VIPTestStep {
|
||||
step: string
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface VIPTestResult {
|
||||
steps: VIPTestStep[]
|
||||
}
|
||||
|
||||
function statusTag(s: HANode['status'], t: (k: string) => string) {
|
||||
switch (s) {
|
||||
case 'online': return <Tag color="green">{t('cluster.status.online')}</Tag>
|
||||
@@ -272,6 +294,42 @@ export default function ClusterPage() {
|
||||
onError: () => message.error(t('cluster.joinTokenFailed')),
|
||||
})
|
||||
|
||||
const isClusterMode = data?.mode === 'cluster'
|
||||
|
||||
const vipStatusQuery = useQuery({
|
||||
queryKey: ['cluster', 'vip-status'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/cluster/vip-status')
|
||||
const payload = isEnvelope(r.data) ? (r.data.data as { vips?: VIPStatusEntry[] }) : null
|
||||
return payload?.vips ?? []
|
||||
},
|
||||
enabled: isClusterMode,
|
||||
refetchInterval: 30_000,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
const [vipTestResult, setVipTestResult] = useState<{ id: number; steps: VIPTestStep[] } | null>(null)
|
||||
|
||||
const vipSwing = useMutation({
|
||||
mutationFn: async ({ id, action }: { id: number; action: 'to_secondary' | 'restore' }) => {
|
||||
const r = await apiClient.post('/cluster/vip-test', { ip_address_id: id, action })
|
||||
return isEnvelope(r.data) ? (r.data.data as VIPTestResult) : null
|
||||
},
|
||||
onSuccess: (result, { id, action }) => {
|
||||
if (result) setVipTestResult({ id, steps: result.steps })
|
||||
const allOk = result?.steps.every(s => s.ok) ?? false
|
||||
if (allOk) {
|
||||
const key = action === 'to_secondary' ? 'cluster.vipTest.swingOk' : 'cluster.vipTest.restoreOk'
|
||||
void message.success(t(key))
|
||||
} else {
|
||||
const key = action === 'to_secondary' ? 'cluster.vipTest.swingFailed' : 'cluster.vipTest.restoreFailed'
|
||||
void message.error(t(key))
|
||||
}
|
||||
void vipStatusQuery.refetch()
|
||||
},
|
||||
onError: (e: Error) => void message.error(e.message),
|
||||
})
|
||||
|
||||
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
|
||||
|
||||
const peerColumns: ColumnsType<HANode> = [
|
||||
@@ -641,6 +699,134 @@ export default function ClusterPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── VIP-Schwenk Test ─────────────────────────────────── */}
|
||||
{isClusterMode && (
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><SwapOutlined />{t('cluster.vipTest.cardTitle')}</Space>}
|
||||
className="mb-16"
|
||||
extra={
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => void vipStatusQuery.refetch()}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('cluster.vipTest.cardDesc')}
|
||||
className="mb-12"
|
||||
/>
|
||||
{vipStatusQuery.isLoading ? (
|
||||
<Spin />
|
||||
) : (vipStatusQuery.data?.length ?? 0) === 0 ? (
|
||||
<Text type="secondary">{t('cluster.vipTest.noVips')}</Text>
|
||||
) : (
|
||||
<Table<VIPStatusEntry>
|
||||
size="small"
|
||||
rowKey={r => String(r.vip.id)}
|
||||
dataSource={vipStatusQuery.data ?? []}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowRender: r => {
|
||||
const res = vipTestResult?.id === r.vip.id ? vipTestResult : null
|
||||
if (!res) return null
|
||||
return (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={res.steps}
|
||||
renderItem={s => (
|
||||
<List.Item>
|
||||
<Space>
|
||||
<Tag color={s.ok ? 'green' : 'red'}>{s.ok ? t('cluster.vipTest.stepOk') : t('cluster.vipTest.stepFail')}</Tag>
|
||||
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.step}</Text>
|
||||
{s.message && <Text type="danger" style={{ fontSize: 12 }}>{s.message}</Text>}
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
rowExpandable: r => vipTestResult?.id === r.vip.id,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: t('cluster.vipTest.colAddress'),
|
||||
key: 'address',
|
||||
render: (_, r) => (
|
||||
<Text style={{ fontFamily: 'monospace' }}>{r.vip.address}/{r.vip.prefix}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('cluster.vipTest.colInterface'),
|
||||
key: 'device',
|
||||
width: 120,
|
||||
render: (_, r) => <Tag>{r.vip.device}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('cluster.vipTest.colActiveOn'),
|
||||
key: 'activeOn',
|
||||
render: (_, r) => {
|
||||
if (!r.active_on || r.active_on.length === 0) {
|
||||
return <Tag color="red">{t('cluster.vipTest.unknown')}</Tag>
|
||||
}
|
||||
return (
|
||||
<Space size={4}>
|
||||
{r.active_on.map(fqdn => <Tag key={fqdn} color="green">{fqdn}</Tag>)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.actions'),
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_, r) => {
|
||||
const localFqdn = data?.local_node?.fqdn
|
||||
const onLocal = r.active_on?.includes(localFqdn ?? '') ?? false
|
||||
const onPeer = r.active_on?.some(f => f !== localFqdn) ?? false
|
||||
const loading = vipSwing.isPending && (vipSwing.variables as { id: number })?.id === r.vip.id
|
||||
return (
|
||||
<Space size={4}>
|
||||
{!isViewer && !onPeer && (
|
||||
<Popconfirm
|
||||
title={t('cluster.vipTest.confirmSwing', { addr: r.vip.address })}
|
||||
okText={t('common.yes')}
|
||||
cancelText={t('common.no')}
|
||||
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'to_secondary' })}
|
||||
>
|
||||
<Button size="small" loading={loading && onLocal}>
|
||||
{t('cluster.vipTest.swingBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{!isViewer && onPeer && (
|
||||
<Popconfirm
|
||||
title={t('cluster.vipTest.confirmRestore', { addr: r.vip.address })}
|
||||
okText={t('common.yes')}
|
||||
cancelText={t('common.no')}
|
||||
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'restore' })}
|
||||
>
|
||||
<Button size="small" type="primary" loading={loading}>
|
||||
{t('cluster.vipTest.restoreBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{isViewer && (
|
||||
<Tooltip title={t('auth.viewerBadge')}>
|
||||
<Button size="small" disabled>{t('cluster.vipTest.swingBtn')}</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Per-Node Resources ────────────────────────────────── */}
|
||||
<Card
|
||||
size="small"
|
||||
|
||||
Reference in New Issue
Block a user