feat(waf): Phase 5 — WAF-UI (per-Domain Konfiguration) — v1.2.71
- pages/WAF/index.tsx: neue WAF-Seite mit Status-Strip, Domänen-Tabelle (Toggle/Mode/PL) + Konfigurations-Drawer pro Domain (enabled, mode, paranoia_level 1-4, rule_exclusions, trusted_proxies, custom_rules). Quick-Toggle ohne Drawer; Hinweis: erst Detection, dann Blocking. - App.tsx: /waf Route + lazy import - Sidebar.tsx: WAF im Security-Bereich - i18n EN + DE Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ const LicensePage = lazy(() => import('./pages/License'))
|
|||||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||||
const UsersPage = lazy(() => import('./pages/Users'))
|
const UsersPage = lazy(() => import('./pages/Users'))
|
||||||
const CrowdSecPage = lazy(() => import('./pages/CrowdSec'))
|
const CrowdSecPage = lazy(() => import('./pages/CrowdSec'))
|
||||||
|
const WAFPage = lazy(() => import('./pages/WAF'))
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@@ -145,6 +146,7 @@ export default function App() {
|
|||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/crowdsec" element={<CrowdSecPage />} />
|
<Route path="/crowdsec" element={<CrowdSecPage />} />
|
||||||
|
<Route path="/waf" element={<WAFPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ const NAV: NavSection[] = [
|
|||||||
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
|
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
|
||||||
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
||||||
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
|
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
|
||||||
|
{ path: '/waf', labelKey: 'nav.waf', icon: <SafetyCertificateOutlined /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
"network": "Netzwerk",
|
"network": "Netzwerk",
|
||||||
"security": "Sicherheit",
|
"security": "Sicherheit",
|
||||||
"system": "System"
|
"system": "System"
|
||||||
}
|
},
|
||||||
|
"waf": "WAF"
|
||||||
},
|
},
|
||||||
"fw": {
|
"fw": {
|
||||||
"title": "Firewall",
|
"title": "Firewall",
|
||||||
@@ -1759,5 +1760,46 @@
|
|||||||
"disabled": "Nicht installiert",
|
"disabled": "Nicht installiert",
|
||||||
"confirmRemove": "Collection {{name}} wirklich entfernen?"
|
"confirmRemove": "Collection {{name}} wirklich entfernen?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"waf": {
|
||||||
|
"title": "Web Application Firewall",
|
||||||
|
"intro": "Domänen-spezifische HTTP-Request-Inspektion via Coraza/OWASP CRS. Standard: für alle Domains deaktiviert.",
|
||||||
|
"configure": "Konfigurieren",
|
||||||
|
"toggleFailed": "WAF-Status konnte nicht geändert werden.",
|
||||||
|
"defaultOffHint": "WAF ist standardmäßig für alle Domains deaktiviert. Unten pro Domain aktivieren und konfigurieren.",
|
||||||
|
"col": {
|
||||||
|
"domain": "Domain",
|
||||||
|
"status": "WAF",
|
||||||
|
"mode": "Modus",
|
||||||
|
"paranoia": "Paranoia"
|
||||||
|
},
|
||||||
|
"stat": {
|
||||||
|
"protected": "Geschützt",
|
||||||
|
"blocking": "Blocking",
|
||||||
|
"detection": "Nur Erkennung"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"detection": "Erkennung",
|
||||||
|
"blocking": "Blocking"
|
||||||
|
},
|
||||||
|
"pl": {
|
||||||
|
"1": "Basis (empfohlen)",
|
||||||
|
"2": "Standard",
|
||||||
|
"3": "Erweitert",
|
||||||
|
"4": "Maximum (kann Traffic brechen)"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"enabled": "Aktiviert",
|
||||||
|
"mode": "Modus",
|
||||||
|
"paranoia": "Paranoia-Level",
|
||||||
|
"exclusions": "Regel-Ausnahmen",
|
||||||
|
"exclusionsHint": "Kommagetrennte Regel-IDs die deaktiviert werden (z.B. 920350, 941130).",
|
||||||
|
"trustedProxies": "Vertrauenswürdige Proxys",
|
||||||
|
"trustedProxiesHint": "IPs/CIDRs die die WAF-Inspektion umgehen (z.B. interne Load Balancer).",
|
||||||
|
"customRules": "Eigene SecRules",
|
||||||
|
"customRulesHint": "Rohe SecRule-Direktiven die nach dem CRS eingefügt werden. Können CRS-Regeln überschreiben.",
|
||||||
|
"defaultHint": "Standard: Nur-Erkennung, Paranoia-Level 1. Erst auf Blocking wechseln, nachdem Alerts geprüft wurden.",
|
||||||
|
"saveFailed": "WAF-Konfiguration konnte nicht gespeichert werden."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,8 @@
|
|||||||
"network": "Network",
|
"network": "Network",
|
||||||
"security": "Security",
|
"security": "Security",
|
||||||
"system": "System"
|
"system": "System"
|
||||||
}
|
},
|
||||||
|
"waf": "WAF"
|
||||||
},
|
},
|
||||||
"fw": {
|
"fw": {
|
||||||
"title": "Firewall",
|
"title": "Firewall",
|
||||||
@@ -1759,5 +1760,46 @@
|
|||||||
"disabled": "Not installed",
|
"disabled": "Not installed",
|
||||||
"confirmRemove": "Really remove collection {{name}}?"
|
"confirmRemove": "Really remove collection {{name}}?"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"waf": {
|
||||||
|
"title": "Web Application Firewall",
|
||||||
|
"intro": "Per-domain HTTP request inspection via Coraza/OWASP CRS. Default: off for all domains.",
|
||||||
|
"configure": "Configure",
|
||||||
|
"toggleFailed": "Could not change WAF state.",
|
||||||
|
"defaultOffHint": "WAF is disabled by default for all domains. Enable and configure per domain below.",
|
||||||
|
"col": {
|
||||||
|
"domain": "Domain",
|
||||||
|
"status": "WAF",
|
||||||
|
"mode": "Mode",
|
||||||
|
"paranoia": "Paranoia"
|
||||||
|
},
|
||||||
|
"stat": {
|
||||||
|
"protected": "Protected",
|
||||||
|
"blocking": "Blocking",
|
||||||
|
"detection": "Detection only"
|
||||||
|
},
|
||||||
|
"mode": {
|
||||||
|
"detection": "Detection",
|
||||||
|
"blocking": "Blocking"
|
||||||
|
},
|
||||||
|
"pl": {
|
||||||
|
"1": "Basic (recommended)",
|
||||||
|
"2": "Standard",
|
||||||
|
"3": "Advanced",
|
||||||
|
"4": "Maximum (may break traffic)"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"mode": "Mode",
|
||||||
|
"paranoia": "Paranoia Level",
|
||||||
|
"exclusions": "Rule Exclusions",
|
||||||
|
"exclusionsHint": "Comma-separated rule IDs to disable (e.g. 920350, 941130).",
|
||||||
|
"trustedProxies": "Trusted Proxies",
|
||||||
|
"trustedProxiesHint": "IPs/CIDRs that bypass WAF inspection (e.g. internal load balancers).",
|
||||||
|
"customRules": "Custom SecRules",
|
||||||
|
"customRulesHint": "Raw SecRule directives appended after the CRS. Applied last, can override CRS rules.",
|
||||||
|
"defaultHint": "Default: Detection-Only, Paranoia Level 1. Switch to Blocking only after reviewing alerts.",
|
||||||
|
"saveFailed": "Failed to save WAF configuration."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
400
management-ui/src/pages/WAF/index.tsx
Normal file
400
management-ui/src/pages/WAF/index.tsx
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
Alert, Badge, Button, Card, Col, Drawer, Form, Input, Row,
|
||||||
|
Select, Space, Switch, Tag, Tooltip, Typography, message,
|
||||||
|
} from 'antd'
|
||||||
|
import {
|
||||||
|
CheckCircleOutlined, CloseCircleOutlined,
|
||||||
|
SafetyCertificateOutlined, SettingOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
import DataTable from '../../components/DataTable'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
// ---------- Types -----------------------------------------------------------
|
||||||
|
|
||||||
|
interface Domain {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WafConfig {
|
||||||
|
id?: number
|
||||||
|
domain_id: number
|
||||||
|
enabled: boolean
|
||||||
|
mode: 'detection' | 'blocking'
|
||||||
|
paranoia_level: number
|
||||||
|
rule_exclusions: string[]
|
||||||
|
trusted_proxies: string[]
|
||||||
|
custom_rules: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
async function fetchDomains(): Promise<Domain[]> {
|
||||||
|
const r = await apiClient.get('/domains')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { domains?: Domain[] }).domains ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWafConfigs(): Promise<WafConfig[]> {
|
||||||
|
const r = await apiClient.get('/waf/configs')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { configs?: WafConfig[] }).configs ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchWafConfig(domainId: number): Promise<WafConfig> {
|
||||||
|
const r = await apiClient.get(`/waf/configs/${domainId}`)
|
||||||
|
if (isEnvelope(r.data)) return r.data.data.config as WafConfig
|
||||||
|
return defaultConfig(domainId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfig(domainId: number): WafConfig {
|
||||||
|
return {
|
||||||
|
domain_id: domainId,
|
||||||
|
enabled: false,
|
||||||
|
mode: 'detection',
|
||||||
|
paranoia_level: 1,
|
||||||
|
rule_exclusions: [],
|
||||||
|
trusted_proxies: [],
|
||||||
|
custom_rules: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Config Drawer ---------------------------------------------------
|
||||||
|
|
||||||
|
interface ConfigDrawerProps {
|
||||||
|
domainName: string
|
||||||
|
domainId: number | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||||
|
const [form] = Form.useForm<WafConfig>()
|
||||||
|
|
||||||
|
const { data: cfg, isLoading } = useQuery({
|
||||||
|
queryKey: ['waf', 'config', domainId],
|
||||||
|
queryFn: () => fetchWafConfig(domainId!),
|
||||||
|
enabled: domainId !== null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (values: WafConfig) =>
|
||||||
|
apiClient.put(`/waf/configs/${domainId}`, values),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('common.save'))
|
||||||
|
void qc.invalidateQueries({ queryKey: ['waf'] })
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
onError: () => message.error(t('waf.config.saveFailed')),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title={<><SafetyCertificateOutlined style={{ color: '#0EA5E9', marginRight: 8 }} />{domainName}</>}
|
||||||
|
open={domainId !== null}
|
||||||
|
onClose={onClose}
|
||||||
|
width={520}
|
||||||
|
footer={
|
||||||
|
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
|
||||||
|
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={save.isPending}
|
||||||
|
disabled={isViewer}
|
||||||
|
onClick={() => form.submit()}
|
||||||
|
>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!isLoading && cfg && (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
...cfg,
|
||||||
|
rule_exclusions_str: (cfg.rule_exclusions ?? []).join(', '),
|
||||||
|
trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '),
|
||||||
|
}}
|
||||||
|
onFinish={(vals) => {
|
||||||
|
const exclusions = (vals.rule_exclusions_str as unknown as string ?? '')
|
||||||
|
.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||||
|
const proxies = (vals.trusted_proxies_str as unknown as string ?? '')
|
||||||
|
.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||||
|
save.mutate({
|
||||||
|
domain_id: domainId!,
|
||||||
|
enabled: vals.enabled,
|
||||||
|
mode: vals.mode,
|
||||||
|
paranoia_level: vals.paranoia_level,
|
||||||
|
rule_exclusions: exclusions,
|
||||||
|
trusted_proxies: proxies,
|
||||||
|
custom_rules: vals.custom_rules ?? '',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
className="mb-16"
|
||||||
|
message={t('waf.config.defaultHint')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label={t('waf.config.enabled')} name="enabled" valuePropName="checked">
|
||||||
|
<Switch disabled={isViewer} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item label={t('waf.config.mode')} name="mode">
|
||||||
|
<Select disabled={isViewer}>
|
||||||
|
<Select.Option value="detection">
|
||||||
|
<Tag color="blue">{t('waf.mode.detection')}</Tag>
|
||||||
|
</Select.Option>
|
||||||
|
<Select.Option value="blocking">
|
||||||
|
<Tag color="red">{t('waf.mode.blocking')}</Tag>
|
||||||
|
</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Form.Item label={t('waf.config.paranoia')} name="paranoia_level">
|
||||||
|
<Select disabled={isViewer}>
|
||||||
|
{[1, 2, 3, 4].map(pl => (
|
||||||
|
<Select.Option key={pl} value={pl}>
|
||||||
|
PL{pl} — {t(`waf.pl.${pl}`)}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('waf.config.exclusions')}
|
||||||
|
name="rule_exclusions_str"
|
||||||
|
help={t('waf.config.exclusionsHint')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
disabled={isViewer}
|
||||||
|
placeholder="920350, 941130"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('waf.config.trustedProxies')}
|
||||||
|
name="trusted_proxies_str"
|
||||||
|
help={t('waf.config.trustedProxiesHint')}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
disabled={isViewer}
|
||||||
|
placeholder="10.0.0.1, 192.168.1.0/24"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label={t('waf.config.customRules')}
|
||||||
|
name="custom_rules"
|
||||||
|
help={t('waf.config.customRulesHint')}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
disabled={isViewer}
|
||||||
|
rows={5}
|
||||||
|
placeholder={'SecRule REQUEST_URI "@contains /api/" \\\n "id:9000001,phase:1,pass,nolog"'}
|
||||||
|
style={{ fontFamily: 'monospace', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Page ------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function WAFPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||||
|
const [drawerDomain, setDrawerDomain] = useState<{ id: number; name: string } | null>(null)
|
||||||
|
|
||||||
|
const { data: domains } = useQuery({
|
||||||
|
queryKey: ['domains'],
|
||||||
|
queryFn: fetchDomains,
|
||||||
|
})
|
||||||
|
const { data: wafConfigs, isLoading } = useQuery({
|
||||||
|
queryKey: ['waf', 'configs'],
|
||||||
|
queryFn: fetchWafConfigs,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const quickToggle = useMutation({
|
||||||
|
mutationFn: async ({ domainId, enabled }: { domainId: number; enabled: boolean }) => {
|
||||||
|
const existing = wafConfigs?.find(c => c.domain_id === domainId) ?? defaultConfig(domainId)
|
||||||
|
return apiClient.put(`/waf/configs/${domainId}`, { ...existing, enabled })
|
||||||
|
},
|
||||||
|
onSuccess: () => void qc.invalidateQueries({ queryKey: ['waf'] }),
|
||||||
|
onError: () => message.error(t('waf.toggleFailed')),
|
||||||
|
})
|
||||||
|
|
||||||
|
const configMap = new Map<number, WafConfig>(
|
||||||
|
(wafConfigs ?? []).map(c => [c.domain_id, c])
|
||||||
|
)
|
||||||
|
|
||||||
|
const activeDomains = (domains ?? []).filter(d => d.active)
|
||||||
|
const enabledCount = (wafConfigs ?? []).filter(c => c.enabled).length
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: t('waf.col.domain'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (name: string) => (
|
||||||
|
<Text strong style={{ fontSize: 13 }}>{name}</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('waf.col.status'),
|
||||||
|
key: 'status',
|
||||||
|
width: 80,
|
||||||
|
render: (_: unknown, row: Domain) => {
|
||||||
|
const cfg = configMap.get(row.id)
|
||||||
|
const enabled = cfg?.enabled ?? false
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={enabled}
|
||||||
|
disabled={isViewer}
|
||||||
|
loading={quickToggle.isPending && quickToggle.variables?.domainId === row.id}
|
||||||
|
onChange={(checked) => quickToggle.mutate({ domainId: row.id, enabled: checked })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('waf.col.mode'),
|
||||||
|
key: 'mode',
|
||||||
|
width: 130,
|
||||||
|
render: (_: unknown, row: Domain) => {
|
||||||
|
const cfg = configMap.get(row.id)
|
||||||
|
if (!cfg?.enabled) return <Text type="secondary">—</Text>
|
||||||
|
return cfg.mode === 'blocking'
|
||||||
|
? <Tag color="red"><CloseCircleOutlined /> {t('waf.mode.blocking')}</Tag>
|
||||||
|
: <Tag color="blue"><CheckCircleOutlined /> {t('waf.mode.detection')}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('waf.col.paranoia'),
|
||||||
|
key: 'paranoia',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: Domain) => {
|
||||||
|
const cfg = configMap.get(row.id)
|
||||||
|
if (!cfg?.enabled) return <Text type="secondary">—</Text>
|
||||||
|
return <Tag>PL{cfg.paranoia_level}</Tag>
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
width: 110,
|
||||||
|
render: (_: unknown, row: Domain) => (
|
||||||
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('waf.configure')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
disabled={isViewer}
|
||||||
|
onClick={() => setDrawerDomain({ id: row.id, name: row.name })}
|
||||||
|
>
|
||||||
|
{t('waf.configure')}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={<SafetyCertificateOutlined />}
|
||||||
|
title={t('waf.title')}
|
||||||
|
subtitle={t('waf.intro')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Status strip */}
|
||||||
|
<Row gutter={[12, 12]} className="mb-16">
|
||||||
|
<Col xs={12} sm={8} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{t('waf.stat.protected')}
|
||||||
|
</Text>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 600, color: enabledCount > 0 ? '#10B981' : '#94A3B8' }}>
|
||||||
|
{enabledCount}
|
||||||
|
<span style={{ fontSize: 13, color: '#94A3B8', fontWeight: 400 }}> / {activeDomains.length}</span>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{t('waf.stat.blocking')}
|
||||||
|
</Text>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 600, color: '#EF4444' }}>
|
||||||
|
{(wafConfigs ?? []).filter(c => c.enabled && c.mode === 'blocking').length}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8} md={6}>
|
||||||
|
<Card size="small">
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{t('waf.stat.detection')}
|
||||||
|
</Text>
|
||||||
|
<div style={{ fontSize: 22, fontWeight: 600, color: '#0EA5E9' }}>
|
||||||
|
{(wafConfigs ?? []).filter(c => c.enabled && c.mode === 'detection').length}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
className="mb-16"
|
||||||
|
message={t('waf.defaultOffHint')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={activeDomains}
|
||||||
|
columns={columns}
|
||||||
|
rowClassName={(row: Domain) => {
|
||||||
|
const cfg = configMap.get(row.id)
|
||||||
|
return cfg?.enabled ? '' : 'fw-rule-row--disabled'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfigDrawer
|
||||||
|
domainName={drawerDomain?.name ?? ''}
|
||||||
|
domainId={drawerDomain?.id ?? null}
|
||||||
|
onClose={() => setDrawerDomain(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user