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:
@@ -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