import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Popconfirm, Select, Space, Spin, Switch, Tooltip, Typography, message } from 'antd' import { ApartmentOutlined, BranchesOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import apiClient, { isEnvelope } from '../../api/client' import PageHeader from '../../components/PageHeader' import { useAuthStore } from '../../stores/auth' interface SetupStatus { completed: boolean admin_email: string acme_email: string fqdn: string } interface ContactEmailValues { admin_email: string acme_email: string } interface SystemHealth { status: string version: string hostname?: string kernel?: string os?: string } interface ChangePasswordValues { current_password: string new_password: string confirm_password: string } interface VIPSettingsValues { vip_address?: string vip_interface?: string vip_auth_pass?: string vrrp_router_id?: number hb_interface?: string hb_src_ip?: string hb_peer_ip?: string hb_router_id?: number gw_check_ip?: string } interface OIDCSettingsView { enabled: boolean issuer_url: string client_id: string scopes: string email_claim: string button_label: string secret_configured: boolean redirect_uri: string } interface OIDCFormValues { enabled: boolean issuer_url: string client_id: string client_secret?: string scopes: string email_claim: string button_label: string } export default function SettingsPage() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' const [msg, msgCtx] = message.useMessage() const [pwForm] = Form.useForm() const [vipForm] = Form.useForm() const { data: setupStatus, isLoading: loadingSetup } = useQuery({ queryKey: ['setup', 'status'], queryFn: async () => { const r = await apiClient.get('/setup/status') if (isEnvelope(r.data)) return r.data.data as SetupStatus return null }, }) const { data: health, isLoading: loadingHealth } = useQuery({ queryKey: ['system', 'health'], queryFn: async () => { const r = await apiClient.get('/system/health') if (isEnvelope(r.data)) return r.data.data as SystemHealth return null }, }) const { data: vipSettings } = useQuery({ queryKey: ['cluster', 'vip-settings'], queryFn: async () => { const r = await apiClient.get('/cluster/vip-settings') return isEnvelope(r.data) ? r.data.data as VIPSettingsValues : null }, }) useEffect(() => { if (vipSettings) vipForm.setFieldsValue(vipSettings) }, [vipSettings, vipForm]) const updateVIP = useMutation({ mutationFn: async (v: VIPSettingsValues) => apiClient.put('/cluster/vip-settings', v), onSuccess: () => { msg.success(t('cluster.vipCard.saved')) void qc.invalidateQueries({ queryKey: ['cluster', 'vip-settings'] }) }, onError: (e: Error) => msg.error(t('cluster.vipCard.saveFailed') + ': ' + e.message), }) const [oidcForm] = Form.useForm() const { data: oidc } = useQuery({ queryKey: ['oidc', 'settings'], queryFn: async () => { const r = await apiClient.get('/oidc/settings') return isEnvelope(r.data) ? r.data.data as OIDCSettingsView : null }, }) useEffect(() => { if (oidc) { oidcForm.setFieldsValue({ enabled: oidc.enabled, issuer_url: oidc.issuer_url, client_id: oidc.client_id, scopes: oidc.scopes, email_claim: oidc.email_claim, button_label: oidc.button_label, client_secret: '', }) } }, [oidc, oidcForm]) const updateOIDC = useMutation({ mutationFn: async (v: OIDCFormValues) => { const body: Record = { ...v } // leeres Secret = unverändert → Feld weglassen (Backend: nil) if (!v.client_secret) delete body.client_secret return apiClient.put('/oidc/settings', body) }, onSuccess: () => { msg.success(t('settings.oidc.saved')) void qc.invalidateQueries({ queryKey: ['oidc', 'settings'] }) }, onError: (e: Error) => msg.error(t('settings.oidc.saveFailed') + ': ' + e.message), }) const [emailForm] = Form.useForm() const updateEmails = useMutation({ mutationFn: async (v: ContactEmailValues) => { const r = await apiClient.post('/setup/contact-emails', v) return r.data }, onSuccess: () => { msg.success(t('settings.emailsSaved')) void qc.invalidateQueries({ queryKey: ['setup', 'status'] }) }, onError: (e: Error) => { msg.error(t('settings.emailsFailed') + ': ' + e.message) }, }) const { data: maintenance, refetch: refetchMaintenance } = useQuery({ queryKey: ['system', 'maintenance'], queryFn: async () => { const r = await apiClient.get('/system/maintenance') return isEnvelope(r.data) ? (r.data.data as { enabled: boolean; message: string }) : { enabled: false, message: '' } }, }) const [maintMessage, setMaintMessage] = useState('') // Bei Daten-Aktualisierung: lokales Textarea mit DB-Wert syncen, // wenn der Operator gerade nicht tippt. Trigger via key-Prop unten. const toggleMaintenance = useMutation({ mutationFn: async (vals: { enabled: boolean; message: string }) => { const r = await apiClient.post('/system/maintenance', vals) return r.data }, onSuccess: () => { msg.success(t('settings.maintenanceSaved')) void refetchMaintenance() }, onError: (e: Error) => { msg.error(t('settings.maintenanceFailed') + ': ' + e.message) }, }) const { data: backupRetention } = useQuery({ queryKey: ['system', 'backup-retention'], queryFn: async () => { const r = await apiClient.get('/system/backup-retention') return isEnvelope(r.data) ? (r.data.data as { keep: number; default: number }) : { keep: 0, default: 14 } }, }) const setBackupRetention = useMutation({ mutationFn: async (keep: number) => { const r = await apiClient.post('/system/backup-retention', { keep }) return r.data }, onSuccess: () => { msg.success(t('settings.backupRetentionSaved')) void qc.invalidateQueries({ queryKey: ['system', 'backup-retention'] }) }, onError: (e: Error) => msg.error(t('settings.backupRetentionFailed') + ': ' + e.message), }) const haproxyReload = useMutation({ mutationFn: async () => apiClient.post('/system/haproxy-reload'), onSuccess: () => msg.success(t('settings.haproxyReloadOk')), onError: (e: Error) => msg.error(t('settings.haproxyReloadFailed') + ': ' + e.message), }) const renderConfigs = useMutation({ mutationFn: async () => apiClient.post('/system/render-configs'), onSuccess: (r) => { const d = r.data?.data as { ok: boolean; rendered: string[]; errors: Record } | undefined if (d && !d.ok && d.errors && Object.keys(d.errors).length > 0) { const failed = Object.entries(d.errors).map(([k, v]) => `${k}: ${v}`).join('; ') msg.warning(t('settings.renderConfigsPartial', { failed })) } else { msg.success(t('settings.renderConfigsOk')) } }, onError: (e: Error) => msg.error(t('settings.renderConfigsFailed') + ': ' + e.message), }) const triggerBackup = useMutation({ mutationFn: async () => apiClient.post('/backups'), onSuccess: () => msg.success(t('settings.backupNowOk')), onError: (e: Error) => msg.error(t('settings.backupNowFailed') + ': ' + e.message), }) const [restartingService, setRestartingService] = useState(null) const serviceRestart = useMutation({ mutationFn: async (service: string) => { setRestartingService(service) await apiClient.post('/system/service-restart', { service }) }, onSuccess: (_, service) => { msg.success(t('settings.serviceRestartOk', { service })) setRestartingService(null) void qc.invalidateQueries({ queryKey: ['system', 'services'] }) }, onError: (e: Error, service) => { msg.error(t('settings.serviceRestartFailed', { service }) + ': ' + e.message) setRestartingService(null) }, }) interface ServiceStatus { label: string; unit: string; active: boolean; state: string } const { data: services, refetch: refetchServices } = useQuery({ queryKey: ['system', 'services'], queryFn: async () => { const r = await apiClient.get('/system/services') return isEnvelope(r.data) ? (r.data.data as { services: ServiceStatus[] }).services : [] }, refetchInterval: 15_000, }) // Restartable services — subset der Allowlist; API lehnt andere ab. const RESTARTABLE = ['haproxy', 'squid', 'unbound', 'chrony', 'edgeguard-scheduler'] const { data: upgradeStatus, refetch: refetchUpgrade } = useQuery({ queryKey: ['system', 'upgrade-status'], queryFn: async () => { const r = await apiClient.get('/system/upgrade-status') return isEnvelope(r.data) ? (r.data.data as { state: string result: string exec_main_pid: number exit_code: number started_at: string finished_at: string log: string[] }) : null }, refetchInterval: 30_000, }) const { data: dbSize } = useQuery({ queryKey: ['system', 'db-size'], queryFn: async () => { const r = await apiClient.get('/system/db-size') return isEnvelope(r.data) ? (r.data.data as { total_bytes: number human_total: string top_tables: { name: string; bytes: number; human_size: string }[] }) : null }, // DB-Größe ändert sich langsam → 5 min Refresh, eher konservativ. refetchInterval: 5 * 60_000, }) const { data: auditRetention } = useQuery({ queryKey: ['system', 'audit-retention'], queryFn: async () => { const r = await apiClient.get('/system/audit-retention') return isEnvelope(r.data) ? (r.data.data as { days: number; default: number }) : { days: 0, default: 90 } }, }) const setAuditRetention = useMutation({ mutationFn: async (days: number) => { const r = await apiClient.post('/system/audit-retention', { days }) return r.data }, onSuccess: () => { msg.success(t('settings.auditRetentionSaved')) void qc.invalidateQueries({ queryKey: ['system', 'audit-retention'] }) }, onError: (e: Error) => msg.error(t('settings.auditRetentionFailed') + ': ' + e.message), }) const { data: autoUpdate } = useQuery({ queryKey: ['system', 'auto-update'], queryFn: async () => { const r = await apiClient.get('/system/auto-update') return isEnvelope(r.data) ? (r.data.data as { enabled: boolean }) : { enabled: false } }, }) const toggleAutoUpdate = useMutation({ mutationFn: async (enabled: boolean) => { const r = await apiClient.post('/system/auto-update', { enabled }) return r.data }, onSuccess: () => { msg.success(t('settings.autoUpdateToggled')) void qc.invalidateQueries({ queryKey: ['system', 'auto-update'] }) }, onError: (e: Error) => { msg.error(t('settings.autoUpdateFailed') + ': ' + e.message) }, }) const [channelDraft, setChannelDraft] = useState(null) const { data: updateChannel } = useQuery({ queryKey: ['cluster', 'update-channel'], queryFn: async () => { const r = await apiClient.get('/cluster/update-channel') return isEnvelope(r.data) ? (r.data.data as { channel: string; peer_channel?: string; peer_reached: boolean; peer_drifted: boolean }) : { channel: 'stable', peer_reached: false, peer_drifted: false } }, }) const setUpdateChannel = useMutation({ mutationFn: async (channel: string) => { const r = await apiClient.post('/cluster/update-channel', { channel }) return r.data }, onSuccess: () => { msg.success(t('settings.updateChannelSaved')) void qc.invalidateQueries({ queryKey: ['cluster', 'update-channel'] }) void qc.invalidateQueries({ queryKey: ['system', 'package-versions'] }) }, onError: (e: Error) => msg.error(t('settings.updateChannelFailed') + ': ' + e.message), onSettled: () => setChannelDraft(null), }) const { data: ipv6 } = useQuery({ queryKey: ['system', 'ipv6'], queryFn: async () => { const r = await apiClient.get('/system/ipv6') return isEnvelope(r.data) ? (r.data.data as { enabled: boolean }) : { enabled: false } }, }) const toggleIPv6 = useMutation({ mutationFn: async (enabled: boolean) => { const r = await apiClient.post('/system/ipv6', { enabled }) return r.data }, onSuccess: () => { msg.success(t('settings.ipv6Toggled')) void qc.invalidateQueries({ queryKey: ['system', 'ipv6'] }) }, onError: (e: Error) => { msg.error(t('settings.ipv6Failed') + ': ' + e.message) }, }) const [previewGen, setPreviewGen] = useState('haproxy') const { data: previewData, isFetching: previewLoading, refetch: loadPreview, } = useQuery({ queryKey: ['system', 'config-preview', previewGen], queryFn: async () => { const r = await apiClient.get('/system/config-preview', { params: { generator: previewGen } }) return isEnvelope(r.data) ? (r.data.data as { generator: string; content: string }) : null }, enabled: false, }) const changePassword = useMutation({ mutationFn: async (v: { current_password: string; new_password: string }) => { const r = await apiClient.post('/auth/change-password', v) return r.data }, onSuccess: () => { msg.success(t('settings.passwordChanged')) pwForm.resetFields() }, onError: (e: Error) => { // API liefert 401 mit error="invalid_current_password" oder // 400 mit error-Message; wir zeigen beides als Toast. msg.error(t('settings.passwordChangeFailed') + ': ' + e.message) }, }) // Form-Pre-Fill nach Status-Reload: setup-status liefert die zwei // Email-Felder; wir resetten das Form drauf damit nach Save der frische // Wert sichtbar wird. useEffect(() => { if (setupStatus) { emailForm.setFieldsValue({ admin_email: setupStatus.admin_email, acme_email: setupStatus.acme_email, }) } }, [setupStatus, emailForm]) if (loadingSetup || loadingHealth) { return } return (
{msgCtx} } title={t('settings.title')} subtitle={t('settings.intro')} /> {health?.version ?? '—'} {health?.status ?? '—'} {health?.hostname && {health.hostname}} {health?.os && {health.os}} {health?.kernel && {health.kernel}} {dbSize && ( {dbSize.human_total} {dbSize.top_tables.length > 0 && ( {t('settings.dbSizeTop')}:{' '} {dbSize.top_tables.slice(0, 3).map(tbl => `${tbl.name} (${tbl.human_size})` ).join(', ')} )} )} {t('settings.actionsCardTitle')}} className="mb-12" size="small" > {t('settings.actionsHint')} {t('settings.serviceRestartCardTitle')}} className="mb-12" size="small" extra={} > {RESTARTABLE.map((svc) => { const status = services?.find(s => s.unit === svc + '.service' || s.unit === svc) return ( {svc} {status && ( {status.state} )} serviceRestart.mutate(svc)} > ) })} {t('settings.serviceRestartHint')} {upgradeStatus && upgradeStatus.started_at && ( {t('settings.upgradeStatusCardTitle')}} className="mb-12" size="small" extra={ } > {upgradeStatus.started_at ? new Date(upgradeStatus.started_at).toLocaleString() : '—'} {upgradeStatus.finished_at ? new Date(upgradeStatus.finished_at).toLocaleString() : '—'} {upgradeStatus.result === 'success' ? ( {t('settings.upgradeStatusOk')} ) : ( {upgradeStatus.result || upgradeStatus.state} {upgradeStatus.exit_code !== 0 && ` (exit ${upgradeStatus.exit_code})`} )} {upgradeStatus.state} {upgradeStatus.log.length > 0 && (
{t('settings.upgradeStatusShowLog', { n: upgradeStatus.log.length })}
{upgradeStatus.log.join('\n')}
)}
)} {setupStatus?.fqdn ?? '—'} {setupStatus?.completed ? t('common.yes') : t('common.no')} {t('settings.emailsCardTitle')}} className="mb-12" size="small" > form={emailForm} layout="vertical" onFinish={(v) => updateEmails.mutate(v)} > {t('settings.maintenanceCardTitle')}} className="mb-12" size="small" > {maintenance?.enabled && ( } message={t('settings.maintenanceActiveTitle')} description={t('settings.maintenanceActiveDesc')} className="mb-12" /> )} toggleMaintenance.mutate({ enabled: checked, message: maintMessage || maintenance?.message || '', })} /> {maintenance?.enabled ? t('settings.maintenanceOn') : t('settings.maintenanceOff')} setMaintMessage(e.target.value)} placeholder={t('settings.maintenanceMessagePlaceholder')} rows={2} maxLength={500} /> {t('settings.maintenanceHint')} {t('settings.backupRetentionCardTitle')}} className="mb-12" size="small" > setBackupRetention.mutate((v as number) ?? 0)} disabled={setBackupRetention.isPending || isViewer} addonAfter={t('settings.backupRetentionUnit')} style={{ width: 200 }} /> {(backupRetention?.keep ?? 0) === 0 ? t('settings.backupRetentionDefault', { n: backupRetention?.default ?? 14 }) : t('settings.backupRetentionCustom', { n: backupRetention?.keep })} {t('settings.backupRetentionHint')} {t('settings.auditRetentionCardTitle')}} className="mb-12" size="small" > setAuditRetention.mutate((v as number) ?? 0)} disabled={setAuditRetention.isPending || isViewer} addonAfter={t('settings.auditRetentionUnit')} style={{ width: 200 }} /> {(auditRetention?.days ?? 0) === 0 ? t('settings.auditRetentionDefault', { n: auditRetention?.default ?? 90 }) : t('settings.auditRetentionCustom', { n: auditRetention?.days })} {t('settings.auditRetentionHint')} {t('settings.autoUpdateCardTitle')}} className="mb-12" size="small" > toggleAutoUpdate.mutate(checked)} /> {autoUpdate?.enabled ? t('settings.autoUpdateOn') : t('settings.autoUpdateOff')} {t('settings.autoUpdateHint')} {t('settings.updateChannelCardTitle')}} className="mb-12" size="small" > setPreviewGen(v)} style={{ width: 140 }} options={[ { value: 'haproxy', label: 'haproxy' }, { value: 'nftables', label: 'nftables' }, { value: 'squid', label: 'squid' }, { value: 'unbound', label: 'unbound' }, { value: 'chrony', label: 'chrony' }, { value: 'wireguard', label: 'wireguard' }, ]} /> {previewData?.content && ( <> )} {previewData?.content && (
              {previewData.content}
            
)} {t('settings.configPreviewHint')}
{t('cluster.vipCard.title')}} size="small" className="mb-12" >
  • {t('cluster.vipCard.hintPrimary')}
  • {t('cluster.vipCard.hintStandby')}
  • {t('cluster.vipCard.hintKeepalived')}
  • {t('cluster.vipCard.hintFailover')}
  • } /> form={vipForm} layout="vertical" onFinish={(v) => updateVIP.mutate(v)} initialValues={{ vrrp_router_id: 51 }} > {t('cluster.vipCard.splitBrainSection')} {!isViewer && ( )}
    {t('settings.oidc.title')}} className="mb-12" size="small"> {t('settings.oidc.intro')} form={oidcForm} layout="vertical" onFinish={(v) => updateOIDC.mutate(v)}> {oidc?.redirect_uri || ''} {t('settings.passwordCardTitle')}} size="small"> form={pwForm} layout="vertical" onFinish={(v) => { if (v.new_password !== v.confirm_password) { msg.error(t('settings.passwordMismatch')) return } changePassword.mutate({ current_password: v.current_password, new_password: v.new_password, }) }} // Wir lassen den Submit-Button explizit click-bar — autoComplete // off damit der Browser nicht "Current password" mit dem im // Manager gespeicherten autofill'd. autoComplete="off" > ({ validator(_, value) { if (!value || getFieldValue('new_password') === value) { return Promise.resolve() } return Promise.reject(new Error(t('settings.passwordMismatch'))) }, }), ]} >
    ) }