feat(ui): Quick-Toggles, Config-Preview, Dashboard-Alerts, Domain-Detail-Health
Quick-Toggle-Switches (kein Modal nötig) für: Backends, Backend-Server, DNS-Zonen, DNS-Records, Domains (active), Firewall-Rules, NAT-Rules, Forward-Proxy ACLs, Routing-Rules. Dashboard: Alert-Banner für komplett ausgefallene Backends (HAProxy-Stats) und Domains im Maintenance-Mode. Domain-Detail: HAProxy-Live-Health-Badge (15s Polling), TLS-Cert ausstellen/erneuern direkt aus dem Detail, Routing-Rules-Panel inline. Config-Preview (Settings): alle 4 Generatoren (haproxy, nftables, squid, unbound) rendern via RenderToString ohne Disk-Write — GET /system/config-preview. ActionButtons: Viewer-Rolle blendet Delete aus (RBAC-Ergänzung). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { Button, Popconfirm, Space, Tooltip } from 'antd'
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
// ActionButtons is the standard "Edit / Delete" pair used at the
|
||||
// end of every CRUD table row. Centralising it means we only style
|
||||
@@ -8,6 +9,9 @@ import { useTranslation } from 'react-i18next'
|
||||
//
|
||||
// Either prop may be omitted to suppress that button — useful for
|
||||
// rows that aren't editable (e.g. builtin services).
|
||||
// Viewer-role accounts automatically get delete disabled — mutations
|
||||
// are blocked at the API level too, but hiding the button avoids
|
||||
// confusing "access denied" errors for read-only users.
|
||||
interface ActionButtonsProps {
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
@@ -28,6 +32,14 @@ export default function ActionButtons({
|
||||
editDisabledReason, deleteDisabledReason,
|
||||
}: ActionButtonsProps) {
|
||||
const { t } = useTranslation()
|
||||
const role = useAuthStore((s) => s.user?.role)
|
||||
const isViewer = role === 'viewer'
|
||||
|
||||
const delDisabled = deleteDisabled || isViewer
|
||||
const delDisabledReason = isViewer
|
||||
? t('auth.viewerBadge')
|
||||
: deleteDisabledReason
|
||||
|
||||
return (
|
||||
<Space size={4}>
|
||||
{onEdit && (
|
||||
@@ -42,8 +54,8 @@ export default function ActionButtons({
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDelete && (
|
||||
deleteDisabled ? (
|
||||
<Tooltip title={deleteDisabledReason ?? t('common.delete')}>
|
||||
delDisabled ? (
|
||||
<Tooltip title={delDisabledReason ?? t('common.delete')}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled />
|
||||
</Tooltip>
|
||||
) : (
|
||||
|
||||
@@ -14,7 +14,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/dashboard': 'nav.dashboard',
|
||||
'/domains': 'nav.domains',
|
||||
'/backends': 'nav.backends',
|
||||
'/routing-rules': 'nav.routing',
|
||||
'/routing-rules': 'nav.routing',
|
||||
'/networks': 'nav.networks',
|
||||
'/ip-addresses': 'nav.ipAddresses',
|
||||
'/firewall/live': 'nav.firewallLive',
|
||||
|
||||
@@ -6,6 +6,7 @@ import apiClient, { isEnvelope } from '../../api/client'
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
BellOutlined,
|
||||
BranchesOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
@@ -53,8 +54,9 @@ const NAV: NavSection[] = [
|
||||
{
|
||||
labelKey: 'nav.section.routing',
|
||||
items: [
|
||||
{ path: '/domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
|
||||
{ path: '/backends', labelKey: 'nav.backends', icon: <DatabaseOutlined /> },
|
||||
{ path: '/domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
|
||||
{ path: '/backends', labelKey: 'nav.backends', icon: <DatabaseOutlined /> },
|
||||
{ path: '/routing-rules', labelKey: 'nav.routing', icon: <BranchesOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
"tlsCertExpired": "abgelaufen",
|
||||
"tlsCertError": "Fehler",
|
||||
"tlsCertNone": "fehlt",
|
||||
"tlsCertNoneHint": "Kein eigener TLS-Cert für diese Domain — HAProxy serviert das Bootstrap-Self-Signed-Cert. Im SSL-Tab issuen.",
|
||||
"tlsCertNoneHint": "Kein eigener TLS-Cert für diese Domain — HAProxy serviert das Bootstrap-Self-Signed-Cert. Domain öffnen und \"Zertifikat ausstellen\" klicken.",
|
||||
"hstsMaxAge": "HSTS max-age (Sek.)",
|
||||
"hstsMaxAgeHint": "Wie lange Browser den HTTPS-Zwang cachen. Empfehlung 31536000 (1 Jahr).",
|
||||
"hstsSubdomains": "includeSubDomains",
|
||||
@@ -312,7 +312,12 @@
|
||||
"actions": "Aktionen",
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"deleteConfirm": "Domain {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Domain {{name}} wirklich löschen?",
|
||||
"maintenanceTag": "Wartung",
|
||||
"rateLimitTag": "Rate-Limit",
|
||||
"routingRulesTitle": "Routing-Regeln — {{name}}",
|
||||
"routingRulesHint": "Pfad-Präfix → Backend-Zuordnungen für diese Domain. Niedrigste Prioritätszahl gewinnt; nicht gematchte Anfragen gehen an das Primary-Backend.",
|
||||
"routingRulesEmpty": "Keine Routing-Regeln — alle Anfragen gehen an das Primary-Backend."
|
||||
},
|
||||
"backends": {
|
||||
"title": "Backends",
|
||||
@@ -330,6 +335,7 @@
|
||||
"usedBy": "Genutzt von",
|
||||
"noDomain": "keine Domain",
|
||||
"attachedDomains": "Domains",
|
||||
"attachedToOther": "derzeit: #{{id}}",
|
||||
"attachedDomainsHint": "Domains, die dieses Backend als Primary verwenden. Auswahl umkonfiguriert die Domains direkt — gleiche Quelle wie der Backend-Picker im Domain-Modal.",
|
||||
"selectDomains": "Domains wählen",
|
||||
"lbAlgo": "Load-Balancing",
|
||||
@@ -566,6 +572,9 @@
|
||||
"ipv6Hint": "Erfordert IPv6-Konfiguration auf den Netzwerk-Interfaces dieses Servers. HAProxy wird nach der Änderung automatisch neu geladen.",
|
||||
"ipv6Toggled": "IPv6-Einstellung gespeichert.",
|
||||
"ipv6Failed": "IPv6-Toggle fehlgeschlagen",
|
||||
"configPreviewCardTitle": "Config-Vorschau",
|
||||
"configPreviewBtn": "Vorschau laden",
|
||||
"configPreviewHint": "Rendert die gewählte Service-Config aus dem aktuellen DB-State. Nur Lesezugriff — es wird nichts auf Disk geschrieben oder neu geladen.",
|
||||
"passwordCardTitle": "Admin-Passwort ändern",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
@@ -730,6 +739,8 @@
|
||||
"title": "Aktuelle Alerts",
|
||||
"viewAll": "Alle anzeigen"
|
||||
},
|
||||
"downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP",
|
||||
"maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus",
|
||||
"onboardingTitle": "Willkommen bei EdgeGuard",
|
||||
"onboardingIntro": "Frische Box — hier die nächsten Schritte um Customer-Traffic zu routen:",
|
||||
"onboardingStep1": "Backend-Pool anlegen (App-Server hinter HAProxy)",
|
||||
|
||||
@@ -273,7 +273,7 @@
|
||||
"tlsCertExpired": "expired",
|
||||
"tlsCertError": "error",
|
||||
"tlsCertNone": "missing",
|
||||
"tlsCertNoneHint": "No own TLS cert for this domain — HAProxy serves the bootstrap self-signed cert. Issue one in the SSL tab.",
|
||||
"tlsCertNoneHint": "No own TLS cert for this domain — HAProxy serves the bootstrap self-signed cert. Open the domain and click \"Issue certificate\".",
|
||||
"hstsMaxAge": "HSTS max-age (sec)",
|
||||
"hstsMaxAgeHint": "How long browsers cache the HTTPS-only mandate. Recommended: 31536000 (1 year).",
|
||||
"hstsSubdomains": "includeSubDomains",
|
||||
@@ -312,7 +312,12 @@
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Really delete domain {{name}}?"
|
||||
"deleteConfirm": "Really delete domain {{name}}?",
|
||||
"maintenanceTag": "Maintenance",
|
||||
"rateLimitTag": "Rate limited",
|
||||
"routingRulesTitle": "Routing rules — {{name}}",
|
||||
"routingRulesHint": "Path-prefix → backend mappings for this domain. Lowest priority number wins; unmatched requests go to the primary backend.",
|
||||
"routingRulesEmpty": "No routing rules — all requests go to the primary backend."
|
||||
},
|
||||
"backends": {
|
||||
"title": "Backends",
|
||||
@@ -330,6 +335,7 @@
|
||||
"usedBy": "Used by",
|
||||
"noDomain": "no domain",
|
||||
"attachedDomains": "Domains",
|
||||
"attachedToOther": "currently: #{{id}}",
|
||||
"attachedDomainsHint": "Domains that use this backend as their primary. Selecting domains here reconfigures them directly — same source of truth as the Domain modal's backend picker.",
|
||||
"selectDomains": "Select domains",
|
||||
"lbAlgo": "Load balancing",
|
||||
@@ -566,6 +572,9 @@
|
||||
"ipv6Hint": "Requires IPv6 to be configured on this server's network interfaces. HAProxy reloads automatically after changing this setting.",
|
||||
"ipv6Toggled": "IPv6 setting saved.",
|
||||
"ipv6Failed": "IPv6 toggle failed",
|
||||
"configPreviewCardTitle": "Config preview",
|
||||
"configPreviewBtn": "Load preview",
|
||||
"configPreviewHint": "Renders the selected service config from the current DB state. Read-only — nothing is written to disk or reloaded.",
|
||||
"passwordCardTitle": "Change admin password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
@@ -730,6 +739,8 @@
|
||||
"title": "Recent alerts",
|
||||
"viewAll": "View all"
|
||||
},
|
||||
"downBackendsAlert": "{{count}} backend(s) completely down — no server UP",
|
||||
"maintenanceAlert": "{{count}} domain(s) in maintenance mode",
|
||||
"onboardingTitle": "Welcome to EdgeGuard",
|
||||
"onboardingIntro": "Fresh box — here are the next steps to route customer traffic:",
|
||||
"onboardingStep1": "Create a backend pool (app servers behind HAProxy)",
|
||||
|
||||
@@ -38,7 +38,15 @@ interface ServerFormValues {
|
||||
name: string; address: string; port: number
|
||||
weight: number; backup: boolean; active: boolean
|
||||
}
|
||||
interface DomainFull { id: number; name: string; primary_backend_id?: number | null }
|
||||
interface DomainFull {
|
||||
id: number; name: string; active: boolean
|
||||
primary_backend_id?: number | null
|
||||
http_to_https: boolean; hsts_enabled: boolean
|
||||
hsts_max_age: number; hsts_subdomains: boolean; hsts_preload: boolean
|
||||
maintenance_mode: boolean; maintenance_message?: string | null
|
||||
www_redirect: string; rate_limit_rps: number
|
||||
max_body_kb: number; disable_h3: boolean; notes?: string | null
|
||||
}
|
||||
|
||||
async function getBackend(id: number): Promise<Backend | null> {
|
||||
const r = await apiClient.get(`/backends/${id}`)
|
||||
@@ -172,9 +180,9 @@ export default function BackendDetailPage() {
|
||||
<Form.Item label={t('backends.lbAlgo')} name="lb_algorithm" rules={[{ required: true }]}
|
||||
extra={t('backends.lbAlgoHint')}>
|
||||
<Select options={[
|
||||
{ value: 'roundrobin', label: 'roundrobin — gleichmäßige Verteilung' },
|
||||
{ value: 'leastconn', label: 'leastconn — wenigste aktive Verbindungen' },
|
||||
{ value: 'source', label: 'source — sticky per Source-IP-Hash' },
|
||||
{ value: 'roundrobin', label: 'roundrobin' },
|
||||
{ value: 'leastconn', label: 'leastconn' },
|
||||
{ value: 'source', label: 'source' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('backends.healthCheck')} name="health_check_path"
|
||||
@@ -200,7 +208,7 @@ export default function BackendDetailPage() {
|
||||
options={(domains ?? []).map(d => ({
|
||||
value: d.id,
|
||||
label: d.primary_backend_id && d.primary_backend_id !== backendID
|
||||
? `${d.name} (derzeit: #${d.primary_backend_id})`
|
||||
? `${d.name} (${t('backends.attachedToOther', { id: d.primary_backend_id })})`
|
||||
: d.name,
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,6 @@ import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
@@ -25,6 +24,7 @@ interface Backend {
|
||||
health_check_path?: string | null
|
||||
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
||||
websocket: boolean
|
||||
force_http1: boolean
|
||||
active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -79,6 +79,15 @@ interface DomainFull {
|
||||
primary_backend_id?: number | null
|
||||
http_to_https: boolean
|
||||
hsts_enabled: boolean
|
||||
hsts_max_age: number
|
||||
hsts_subdomains: boolean
|
||||
hsts_preload: boolean
|
||||
maintenance_mode: boolean
|
||||
maintenance_message?: string | null
|
||||
www_redirect: string
|
||||
rate_limit_rps: number
|
||||
max_body_kb: number
|
||||
disable_h3: boolean
|
||||
notes?: string | null
|
||||
}
|
||||
async function listDomains(): Promise<DomainFull[]> {
|
||||
@@ -171,20 +180,12 @@ export default function BackendsPage() {
|
||||
for (const id of adds) {
|
||||
const d = all.find(x => x.id === id)
|
||||
if (!d) continue
|
||||
puts.push(apiClient.put(`/domains/${id}`, {
|
||||
name: d.name, active: d.active,
|
||||
http_to_https: d.http_to_https, hsts_enabled: d.hsts_enabled,
|
||||
notes: d.notes ?? '', primary_backend_id: backendID,
|
||||
}))
|
||||
puts.push(apiClient.put(`/domains/${id}`, { ...d, primary_backend_id: backendID }))
|
||||
}
|
||||
for (const id of removes) {
|
||||
const d = all.find(x => x.id === id)
|
||||
if (!d) continue
|
||||
puts.push(apiClient.put(`/domains/${id}`, {
|
||||
name: d.name, active: d.active,
|
||||
http_to_https: d.http_to_https, hsts_enabled: d.hsts_enabled,
|
||||
notes: d.notes ?? '', primary_backend_id: null,
|
||||
}))
|
||||
puts.push(apiClient.put(`/domains/${id}`, { ...d, primary_backend_id: null }))
|
||||
}
|
||||
if (puts.length > 0) await Promise.all(puts)
|
||||
}
|
||||
@@ -212,6 +213,14 @@ export default function BackendsPage() {
|
||||
mutationFn: async (id: number) => { await apiClient.delete(`/backends/${id}`) },
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backends'] }) },
|
||||
})
|
||||
const quickToggle = useMutation({
|
||||
mutationFn: async ({ id, row, checked }: { id: number; row: Backend; checked: boolean }) => {
|
||||
const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row
|
||||
await apiClient.put(`/backends/${id}`, { ...body, active: checked })
|
||||
},
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backends'] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const columns: ColumnsType<Backend> = [
|
||||
{ title: t('backends.name'), dataIndex: 'name', key: 'name' },
|
||||
@@ -260,7 +269,17 @@ export default function BackendsPage() {
|
||||
)
|
||||
},
|
||||
},
|
||||
{ title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('backends.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||
render: (v: boolean, row: Backend) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v}
|
||||
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
||||
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
@@ -335,9 +354,9 @@ export default function BackendsPage() {
|
||||
<Form.Item label={t('backends.lbAlgo')} name="lb_algorithm" rules={[{ required: true }]}
|
||||
extra={t('backends.lbAlgoHint')}>
|
||||
<Select options={[
|
||||
{ value: 'roundrobin', label: 'roundrobin — gleichmäßige Verteilung' },
|
||||
{ value: 'leastconn', label: 'leastconn — wenigste aktive Verbindungen' },
|
||||
{ value: 'source', label: 'source — sticky per Source-IP-Hash' },
|
||||
{ value: 'roundrobin', label: 'roundrobin' },
|
||||
{ value: 'leastconn', label: 'leastconn' },
|
||||
{ value: 'source', label: 'source' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
|
||||
@@ -397,6 +416,14 @@ function ServerPanel({ backendID }: { backendID: number }) {
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const quickToggle = useMutation({
|
||||
mutationFn: async ({ id, row, checked }: { id: number; row: BackendServer; checked: boolean }) => {
|
||||
const { id: _id, ...body } = row
|
||||
await apiClient.put(`/backend-servers/${id}`, { ...body, active: checked })
|
||||
},
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<BackendServer> = [
|
||||
{ title: t('backends.server.name'), dataIndex: 'name' },
|
||||
@@ -407,8 +434,17 @@ function ServerPanel({ backendID }: { backendID: number }) {
|
||||
{ title: t('backends.server.weight'), dataIndex: 'weight', width: 80 },
|
||||
{ title: t('backends.server.backup'), dataIndex: 'backup',
|
||||
render: (v: boolean) => v ? <Tag color="purple">Backup</Tag> : '—', width: 100 },
|
||||
{ title: t('backends.active'), dataIndex: 'active',
|
||||
render: (v: boolean) => <StatusDot active={v} />, width: 80 },
|
||||
{
|
||||
title: t('backends.active'), dataIndex: 'active', width: 80,
|
||||
render: (v: boolean, row: BackendServer) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v}
|
||||
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
||||
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 160,
|
||||
render: (_, r) => (
|
||||
|
||||
@@ -10,7 +10,6 @@ import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -122,6 +121,14 @@ function ZonesTab() {
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'zones'] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const quickToggle = useMutation({
|
||||
mutationFn: async ({ id, row, checked }: { id: number; row: Zone; checked: boolean }) => {
|
||||
const { id: _id, ...body } = row
|
||||
await apiClient.put(`/dns/zones/${id}`, { ...body, active: checked })
|
||||
},
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'zones'] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<Zone> = [
|
||||
{ title: t('dns.zone.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
||||
@@ -130,7 +137,17 @@ function ZonesTab() {
|
||||
{ title: t('dns.zone.forwardTo'), dataIndex: 'forward_to', key: 'forward_to',
|
||||
render: (v?: string | null) => v ? <Text code style={{ fontSize: 11 }}>{v}</Text> : '—' },
|
||||
{ title: t('dns.zone.description'), dataIndex: 'description', key: 'description', render: (v?: string | null) => v ?? '—' },
|
||||
{ title: t('common.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('common.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||
render: (v: boolean, row: Zone) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v}
|
||||
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
||||
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
@@ -266,13 +283,31 @@ function RecordsDrawer({ zone, onClose }: RecordsDrawerProps) {
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const quickToggle = useMutation({
|
||||
mutationFn: async ({ id, row, checked }: { id: number; row: DNSRecord; checked: boolean }) => {
|
||||
const { id: _id, ...body } = row
|
||||
await apiClient.put(`/dns/records/${id}`, { ...body, active: checked })
|
||||
},
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<DNSRecord> = [
|
||||
{ title: t('dns.record.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
||||
{ title: t('dns.record.type'), dataIndex: 'record_type', key: 'record_type', render: (s: string) => <Tag>{s}</Tag> },
|
||||
{ title: t('dns.record.value'), dataIndex: 'value', key: 'value', render: (s: string) => <Text code style={{ fontSize: 12 }}>{s}</Text> },
|
||||
{ title: t('dns.record.ttl'), dataIndex: 'ttl', key: 'ttl', width: 80 },
|
||||
{ title: t('common.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('common.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||
render: (v: boolean, row: DNSRecord) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v}
|
||||
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
||||
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
|
||||
@@ -63,7 +63,7 @@ function useAuditLive(keep = 15) {
|
||||
|
||||
// ── Wire shapes ───────────────────────────────────────────────
|
||||
|
||||
interface Domain { id: number; active: boolean; primary_backend_id?: number | null }
|
||||
interface Domain { id: number; name: string; active: boolean; primary_backend_id?: number | null; maintenance_mode: boolean }
|
||||
interface Backend { id: number; active: boolean; name: string }
|
||||
interface Iface { id: number; active: boolean; name: string; type: string }
|
||||
interface FwRule { id: number; enabled: boolean; action: string }
|
||||
@@ -251,6 +251,33 @@ export default function DashboardPage() {
|
||||
&& Date.now() / 1000 - s.last_handshake_unix < 180).length
|
||||
|
||||
const now = Date.now()
|
||||
const maintenanceDomains = (domains.data ?? []).filter(d => d.maintenance_mode)
|
||||
|
||||
// Backends die komplett DOWN sind (mind. 1 Server mit echtem Check,
|
||||
// aber kein einziger UP) — liefert friendly Backend-Namen.
|
||||
const downBackends = (() => {
|
||||
const stats = haproxyBackends.data ?? []
|
||||
const bklist = backends.data ?? []
|
||||
if (!stats.length || !bklist.length) return []
|
||||
// gruppiere Stat-Zeilen nach HAProxy-Backend-Name (eg_backend_<id>)
|
||||
const byBackend = new Map<string, typeof stats>()
|
||||
for (const s of stats) {
|
||||
const arr = byBackend.get(s.backend) ?? []; arr.push(s)
|
||||
byBackend.set(s.backend, arr)
|
||||
}
|
||||
const down: string[] = []
|
||||
for (const [haName, servers] of byBackend) {
|
||||
const hasRealCheck = servers.some(s => s.status !== 'no check')
|
||||
if (hasRealCheck && !servers.some(s => s.status === 'UP')) {
|
||||
const m = /^eg_backend_(\d+)$/.exec(haName)
|
||||
const id = m ? Number(m[1]) : null
|
||||
const b = id != null ? bklist.find(x => x.id === id) : null
|
||||
down.push(b?.name ?? haName)
|
||||
}
|
||||
}
|
||||
return down
|
||||
})()
|
||||
|
||||
const certsSoon = (tlsCerts.data ?? []).filter(c => {
|
||||
if (!c.not_after) return false
|
||||
const exp = new Date(c.not_after).getTime()
|
||||
@@ -303,6 +330,34 @@ export default function DashboardPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{downBackends.length > 0 && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="mb-12"
|
||||
message={t('dashboard.downBackendsAlert', { count: downBackends.length })}
|
||||
description={
|
||||
<Space wrap size={4}>
|
||||
{downBackends.map(name => <Link key={name} to="/backends">{name}</Link>)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{maintenanceDomains.length > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="mb-12"
|
||||
message={t('dashboard.maintenanceAlert', { count: maintenanceDomains.length })}
|
||||
description={
|
||||
<Space wrap size={4}>
|
||||
{maintenanceDomains.map(d => <Link key={d.id} to={`/domains/${d.id}`}>{d.name}</Link>)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── KPI tiles (compact strip) ──────────────────── */}
|
||||
<Row gutter={[12, 12]} className="mb-12">
|
||||
<KPI icon={<GlobalOutlined />} label={t('dashboard.kpi.domains')} value={activeDomains} total={(domains.data ?? []).length} />
|
||||
|
||||
@@ -50,12 +50,21 @@ interface ResponseHeader {
|
||||
id: number; domain_id: number; name: string; value: string; position: number
|
||||
}
|
||||
|
||||
interface RoutingRule {
|
||||
id: number; domain_id: number; path_prefix: string
|
||||
backend_id: number; priority: number; active: boolean
|
||||
}
|
||||
|
||||
interface BackendLiteWithAddr { id: number; name: string; address: string; port: number }
|
||||
|
||||
interface TLSCertLite {
|
||||
domain: string
|
||||
status: 'pending' | 'active' | 'renewing' | 'expired' | 'error'
|
||||
not_after?: string | null
|
||||
}
|
||||
|
||||
interface HAProxyStat { backend: string; server: string; status: string }
|
||||
|
||||
async function getDomain(id: number): Promise<Domain | null> {
|
||||
const r = await apiClient.get(`/domains/${id}`)
|
||||
if (!isEnvelope(r.data)) return null
|
||||
@@ -71,11 +80,28 @@ async function listHeaders(domainID: number): Promise<ResponseHeader[]> {
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { headers?: ResponseHeader[] }).headers ?? []
|
||||
}
|
||||
async function listDomainRules(domainID: number): Promise<RoutingRule[]> {
|
||||
const r = await apiClient.get(`/domains/${domainID}/routing-rules`)
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { routing_rules?: RoutingRule[] }).routing_rules ?? []
|
||||
}
|
||||
async function listBackendsWithAddr(): Promise<BackendLiteWithAddr[]> {
|
||||
const r = await apiClient.get('/backends')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { backends?: BackendLiteWithAddr[] }).backends ?? []
|
||||
}
|
||||
async function listCerts(): Promise<TLSCertLite[]> {
|
||||
const r = await apiClient.get('/tls-certs')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
|
||||
}
|
||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
||||
try {
|
||||
const r = await apiClient.get('/haproxy/stats')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export default function DomainDetailPage() {
|
||||
const { t } = useTranslation()
|
||||
@@ -91,6 +117,11 @@ export default function DomainDetailPage() {
|
||||
})
|
||||
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
||||
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||
const { data: haproxyStats } = useQuery({
|
||||
queryKey: ['haproxy', 'stats'],
|
||||
queryFn: listHAProxyStats,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
const [form] = Form.useForm<DomainFormValues>()
|
||||
|
||||
@@ -106,10 +137,30 @@ export default function DomainDetailPage() {
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const issueCert = useMutation({
|
||||
mutationFn: async (domainName: string) => {
|
||||
await apiClient.post('/tls-certs/issue', { domain: domainName })
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('ssl.issueSuccess'))
|
||||
void qc.invalidateQueries({ queryKey: ['tls-certs'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
if (isLoading || !domain) return null
|
||||
|
||||
const cert = certs?.find(c => c.domain === domain.name)
|
||||
|
||||
// Primary backend live health from HAProxy stats — UP/DOWN/null
|
||||
const backendHealth = (() => {
|
||||
if (!domain.primary_backend_id || !haproxyStats?.length) return null
|
||||
const name = `eg_backend_${domain.primary_backend_id}`
|
||||
const servers = haproxyStats.filter(s => s.backend === name)
|
||||
if (!servers.length) return null
|
||||
return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN'
|
||||
})()
|
||||
|
||||
const certBadge = () => {
|
||||
if (!cert) return <Tag icon={<LockOutlined />} color="default">{t('domains.tlsCertNone')}</Tag>
|
||||
if (cert.status === 'expired') return <Tag icon={<LockOutlined />} color="red">{t('domains.tlsCertExpired')}</Tag>
|
||||
@@ -138,14 +189,53 @@ export default function DomainDetailPage() {
|
||||
<PageHeader
|
||||
icon={<GlobalOutlined />}
|
||||
title={domain.name}
|
||||
subtitle={<Space size={6}><StatusDot active={domain.active} />{certBadge()}</Space>}
|
||||
subtitle={
|
||||
<Space size={6}>
|
||||
<StatusDot active={domain.active} />
|
||||
{certBadge()}
|
||||
{backendHealth === 'UP' && <Tag color="green" style={{ margin: 0 }}>backend UP</Tag>}
|
||||
{backendHealth === 'DOWN' && <Tag color="red" style={{ margin: 0 }}>backend DOWN</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/domains')}>
|
||||
{t('domains.backToList')}
|
||||
</Button>
|
||||
<Space>
|
||||
{cert ? (
|
||||
<Popconfirm
|
||||
title={t('ssl.renewConfirmTitle')}
|
||||
description={t('ssl.renewConfirmDesc', { domain: domain.name })}
|
||||
onConfirm={() => issueCert.mutate(domain.name)}
|
||||
okText={t('common.yes')} cancelText={t('common.no')}
|
||||
>
|
||||
<Button
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
loading={issueCert.isPending}
|
||||
>
|
||||
{t('ssl.renewBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Button
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
type="primary"
|
||||
loading={issueCert.isPending}
|
||||
onClick={() => issueCert.mutate(domain.name)}
|
||||
>
|
||||
{t('ssl.issueButton')}
|
||||
</Button>
|
||||
)}
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/domains')}>
|
||||
{t('domains.backToList')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Row gutter={[24, 0]}>
|
||||
<Col xs={24}>
|
||||
<RoutingRulesPanel domainID={domainID} domainName={domain.name} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card size="small" title={t('domains.settingsCard')} className="mb-16">
|
||||
@@ -273,6 +363,145 @@ export default function DomainDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Routing Rules Panel ─────────────────────────────────────────────────
|
||||
|
||||
function RoutingRulesPanel({ domainID, domainName }: { domainID: number; domainName: string }) {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<RoutingRule | null>(null)
|
||||
const [rForm] = Form.useForm<{ path_prefix: string; backend_id: number; priority: number; active: boolean }>()
|
||||
|
||||
const { data: rules, isLoading } = useQuery({
|
||||
queryKey: ['domain-routing-rules', domainID],
|
||||
queryFn: () => listDomainRules(domainID),
|
||||
})
|
||||
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackendsWithAddr })
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['domain-routing-rules', domainID] })
|
||||
void qc.invalidateQueries({ queryKey: ['routing-rules'] })
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (v: { path_prefix: string; backend_id: number; priority: number; active: boolean }) =>
|
||||
apiClient.post('/routing-rules', { ...v, domain_id: domainID }),
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
setOpen(false); rForm.resetFields(); invalidate()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: async ({ id, v }: { id: number; v: { path_prefix: string; backend_id: number; priority: number; active: boolean } }) =>
|
||||
apiClient.put(`/routing-rules/${id}`, { ...v, domain_id: domainID }),
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
setEditing(null); rForm.resetFields(); invalidate()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/routing-rules/${id}`),
|
||||
onSuccess: invalidate,
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const backendLabel = (id: number) => {
|
||||
const b = backends?.find(x => x.id === id)
|
||||
return b ? `${b.name} (${b.address}:${b.port})` : `#${id}`
|
||||
}
|
||||
|
||||
const columns: ColumnsType<RoutingRule> = [
|
||||
{ title: t('routing.pathPrefix'), dataIndex: 'path_prefix', key: 'path' },
|
||||
{ title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend', render: (id: number) => backendLabel(id) },
|
||||
{ title: t('routing.priority'), dataIndex: 'priority', key: 'priority', width: 90 },
|
||||
{
|
||||
title: t('routing.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||
render: (v: boolean) => <StatusDot active={v} />,
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 90,
|
||||
render: (_, r) => (
|
||||
<Space size={4}>
|
||||
<Button type="text" size="small" icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(r)
|
||||
rForm.setFieldsValue({ path_prefix: r.path_prefix, backend_id: r.backend_id, priority: r.priority, active: r.active })
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('routing.deleteConfirm')}
|
||||
okText={t('common.yes')} cancelText={t('common.no')}
|
||||
onConfirm={() => del.mutate(r.id)}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
size="small"
|
||||
title={t('domains.routingRulesTitle', { name: domainName })}
|
||||
className="mb-16"
|
||||
extra={
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => {
|
||||
setOpen(true); rForm.resetFields()
|
||||
rForm.setFieldsValue({ path_prefix: '/', priority: 100, active: true })
|
||||
}}>
|
||||
{t('routing.addRule')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
|
||||
{t('domains.routingRulesHint')}
|
||||
</Text>
|
||||
<Table
|
||||
rowKey="id" size="small" loading={isLoading}
|
||||
dataSource={rules ?? []} columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('domains.routingRulesEmpty') }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={editing ? t('routing.editRule') : t('routing.addRule')}
|
||||
open={open || editing !== null}
|
||||
onCancel={() => { setOpen(false); setEditing(null); rForm.resetFields() }}
|
||||
onOk={() => { void rForm.submit() }}
|
||||
confirmLoading={create.isPending || update.isPending}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form
|
||||
form={rForm} layout="vertical"
|
||||
onFinish={(v) => editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)}
|
||||
>
|
||||
<Form.Item label={t('routing.pathPrefix')} name="path_prefix" rules={[{ required: true }]}>
|
||||
<Input placeholder="/" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('routing.backend')} name="backend_id" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch optionFilterProp="label"
|
||||
placeholder={t('routing.selectBackend')}
|
||||
options={(backends ?? []).map(b => ({ value: b.id, label: `${b.name} (${b.address}:${b.port})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('routing.priority')} name="priority" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('routing.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Response Headers Panel ──────────────────────────────────────────────
|
||||
|
||||
function HeadersPanel({ domainID, domainName }: { domainID: number; domainName: string }) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { GlobalOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { GlobalOutlined, PlusOutlined, StopOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -28,6 +28,7 @@ interface Domain {
|
||||
www_redirect: '' | 'to-naked' | 'to-www'
|
||||
rate_limit_rps: number
|
||||
max_body_kb: number
|
||||
disable_h3: boolean
|
||||
notes?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -176,9 +177,38 @@ export default function DomainsPage() {
|
||||
void qc.invalidateQueries({ queryKey: ['domains'] })
|
||||
},
|
||||
})
|
||||
const quickToggle = useMutation({
|
||||
mutationFn: async ({ id, row, checked }: { id: number; row: Domain; checked: boolean }) => {
|
||||
const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row
|
||||
await apiClient.put(`/domains/${id}`, { ...body, active: checked })
|
||||
},
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['domains'] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const columns: ColumnsType<Domain> = [
|
||||
{ title: t('domains.name'), dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: t('domains.name'), dataIndex: 'name', key: 'name',
|
||||
render: (name: string, row: Domain) => (
|
||||
<Space size={4} wrap>
|
||||
<span>{name}</span>
|
||||
{row.maintenance_mode && (
|
||||
<Tooltip title={row.maintenance_message || t('domains.maintenanceTag')}>
|
||||
<Tag icon={<StopOutlined />} color="orange" style={{ margin: 0 }}>
|
||||
{t('domains.maintenanceTag')}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.rate_limit_rps > 0 && (
|
||||
<Tooltip title={`${row.rate_limit_rps} req/s`}>
|
||||
<Tag icon={<ThunderboltOutlined />} color="purple" style={{ margin: 0, fontSize: 11 }}>
|
||||
{t('domains.rateLimitTag')}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('domains.primaryBackend'), dataIndex: 'primary_backend_id', key: 'primary_backend_id',
|
||||
render: (id?: number | null) => {
|
||||
@@ -196,7 +226,17 @@ export default function DomainsPage() {
|
||||
)
|
||||
},
|
||||
},
|
||||
{ title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('domains.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||
render: (v: boolean, row: Domain) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v}
|
||||
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
||||
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => <StatusDot active={v} activeLabel="HTTPS" inactiveLabel="HTTP" /> },
|
||||
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
|
||||
@@ -81,6 +81,13 @@ export default function NATRulesTab() {
|
||||
mutationFn: async (id: number) => { await apiClient.delete(`/firewall/nat-rules/${id}`) },
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
|
||||
})
|
||||
const quickToggle = useMutation({
|
||||
mutationFn: async ({ id, row, checked }: { id: number; row: NATRule; checked: boolean }) => {
|
||||
await apiClient.put(`/firewall/nat-rules/${id}`, { ...row, enabled: checked })
|
||||
},
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const renderTarget = (r: NATRule) => {
|
||||
if (r.kind === 'masquerade') return <Tag color="gold">{r.out_zone ?? '?'}-iface IP</Tag>
|
||||
@@ -105,7 +112,17 @@ export default function NATRulesTab() {
|
||||
),
|
||||
},
|
||||
{ title: t('fw.nat.target'), key: 'target', render: (_, r) => renderTarget(r) },
|
||||
{ title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', render: (v: boolean) => v ? '✓' : '—' },
|
||||
{
|
||||
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
|
||||
render: (v: boolean, row: NATRule) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v}
|
||||
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
||||
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('common.edit'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
|
||||
@@ -170,6 +170,13 @@ export default function RulesTab() {
|
||||
mutationFn: async (id: number) => { await apiClient.delete(`/firewall/rules/${id}`) },
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) },
|
||||
})
|
||||
const quickToggle = useMutation({
|
||||
mutationFn: async ({ id, row, checked }: { id: number; row: FwRule; checked: boolean }) => {
|
||||
await apiClient.put(`/firewall/rules/${id}`, { ...row, enabled: checked })
|
||||
},
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const editFromRow = (r: FwRule) => {
|
||||
setEditing(r)
|
||||
@@ -209,7 +216,17 @@ export default function RulesTab() {
|
||||
title: t('fw.rule.service'), key: 'svc',
|
||||
render: (_, r) => renderService(r.service_object_id, r.service_group_id),
|
||||
},
|
||||
{ title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', render: (v: boolean) => v ? '✓' : '—' },
|
||||
{
|
||||
title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
|
||||
render: (v: boolean, row: FwRule) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v}
|
||||
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
||||
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: t('fw.rule.name'), dataIndex: 'name', key: 'name', render: (v?: string) => v ?? '—' },
|
||||
{
|
||||
title: t('fw.rule.hits'), key: 'hits', width: 90,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Form, Input, InputNumber, Modal, Select, Switch, Tag, Typography, message,
|
||||
Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { CloudServerOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -12,7 +12,6 @@ import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -29,6 +28,8 @@ interface ACL {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
|
||||
|
||||
interface FormValues {
|
||||
name: string
|
||||
acl_type: string
|
||||
@@ -69,6 +70,16 @@ export default function ForwardProxyPage() {
|
||||
const qc = useQueryClient()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['fwd-proxy', 'acls'], queryFn: listACLs })
|
||||
|
||||
const { data: services } = 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: 30_000,
|
||||
})
|
||||
const squid = services?.find(s => s.unit === 'squid.service' || s.unit === 'squid')
|
||||
|
||||
const [editing, setEditing] = useState<ACL | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
@@ -90,6 +101,13 @@ export default function ForwardProxyPage() {
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'acls'] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const quickToggle = useMutation({
|
||||
mutationFn: async ({ id, row, checked }: { id: number; row: ACL; checked: boolean }) => {
|
||||
await apiClient.put(`/forward-proxy/acls/${id}`, { ...row, active: checked })
|
||||
},
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'acls'] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<ACL> = [
|
||||
{ title: t('fwd.priority'), dataIndex: 'priority', key: 'priority', width: 90 },
|
||||
@@ -102,8 +120,17 @@ export default function ForwardProxyPage() {
|
||||
render: (s: string) => <Text code style={{ fontSize: 12 }}>{s}</Text> },
|
||||
{ title: t('fwd.comment'), dataIndex: 'comment', key: 'comment',
|
||||
render: (v?: string | null) => v ?? '—' },
|
||||
{ title: t('common.active'), dataIndex: 'active', key: 'active',
|
||||
render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('common.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||
render: (v: boolean, row: ACL) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v}
|
||||
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
||||
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
@@ -134,6 +161,17 @@ export default function ForwardProxyPage() {
|
||||
icon={<CloudServerOutlined />}
|
||||
title={t('fwd.title')}
|
||||
subtitle={t('fwd.intro')}
|
||||
extra={squid && (
|
||||
<Tag
|
||||
icon={squid.active ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||
color={squid.active ? 'green' : 'red'}
|
||||
>
|
||||
<Space size={4}>
|
||||
<span>squid</span>
|
||||
<span style={{ fontWeight: 400, opacity: 0.85 }}>{squid.state}</span>
|
||||
</Space>
|
||||
</Tag>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
|
||||
@@ -8,7 +8,6 @@ import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
@@ -123,6 +122,14 @@ export default function RoutingRulesPage() {
|
||||
mutationFn: async (id: number) => { await apiClient.delete(`/routing-rules/${id}`) },
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['routing-rules'] }) },
|
||||
})
|
||||
const quickToggle = useMutation({
|
||||
mutationFn: async ({ id, row, checked }: { id: number; row: RoutingRule; checked: boolean }) => {
|
||||
const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row
|
||||
await apiClient.put(`/routing-rules/${id}`, { ...body, active: checked })
|
||||
},
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['routing-rules'] }) },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const columns: ColumnsType<RoutingRule> = [
|
||||
{ title: t('routing.domain'), dataIndex: 'domain_id', key: 'domain', render: (id: number) => domainName(id) },
|
||||
@@ -145,7 +152,17 @@ export default function RoutingRulesPage() {
|
||||
},
|
||||
},
|
||||
{ title: t('routing.priority'), dataIndex: 'priority', key: 'priority' },
|
||||
{ title: t('routing.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('routing.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||
render: (v: boolean, row: RoutingRule) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={v}
|
||||
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
||||
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Space, Spin, Switch, Typography, message } from 'antd'
|
||||
import { CloudDownloadOutlined, CloudSyncOutlined, DatabaseOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Select, Space, Spin, Switch, Typography, message } from 'antd'
|
||||
import { CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, DatabaseOutlined, 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'
|
||||
@@ -260,6 +260,20 @@ export default function SettingsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -649,6 +663,47 @@ export default function SettingsPage() {
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><CodeOutlined /> {t('settings.configPreviewCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Select
|
||||
value={previewGen}
|
||||
onChange={(v) => setPreviewGen(v)}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'haproxy', label: 'haproxy' },
|
||||
{ value: 'nftables', label: 'nftables' },
|
||||
{ value: 'squid', label: 'squid' },
|
||||
{ value: 'unbound', label: 'unbound' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={previewLoading}
|
||||
onClick={() => { void loadPreview() }}
|
||||
>
|
||||
{t('settings.configPreviewBtn')}
|
||||
</Button>
|
||||
</Space>
|
||||
{previewData?.content && (
|
||||
<pre style={{
|
||||
marginTop: 4, padding: 10, background: '#f8fafc',
|
||||
fontSize: 11, lineHeight: 1.5, overflow: 'auto', maxHeight: 400,
|
||||
border: '1px solid #e2e8f0', borderRadius: 4, whiteSpace: 'pre',
|
||||
}}>
|
||||
{previewData.content}
|
||||
</pre>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('settings.configPreviewHint')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
|
||||
<Form<ChangePasswordValues>
|
||||
form={pwForm}
|
||||
|
||||
Reference in New Issue
Block a user