import { useMemo, useState } from 'react' import { Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message, Divider, } from 'antd' import type { ColumnsType } from 'antd/es/table' import { ArrowDownOutlined, ArrowUpOutlined, BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import apiClient, { isEnvelope } from '../../api/client' import { useAuthStore } from '../../stores/auth' import DataTable from '../../components/DataTable' import EmptyState from '../../components/EmptyState' import PageHeader from '../../components/PageHeader' import ActionButtons from '../../components/ActionButtons' const { Text } = Typography interface ACL { id: number name: string acl_type: string value: string action: 'allow' | 'deny' priority: number active: boolean comment?: string | null created_at: string updated_at: string } interface ServiceStatus { label: string; unit: string; active: boolean; state: string } interface ProxySettings { id: number listen_addresses: string listen_port: number cache_mem_mb: number cache_dir_mb: number max_obj_size_mb: number connect_timeout: number read_timeout: number request_timeout: number } interface FormValues { name: string acl_type: string value: string action: 'allow' | 'deny' priority: number active: boolean comment?: string } const ACL_TYPE_KEYS = [ 'src', 'dst', 'dstdomain', 'srcdomain', 'port', 'proto', 'method', 'time', 'url_regex', 'urlpath_regex', 'dstdom_regex', 'srcdom_regex', 'browser', ] as const async function listACLs(): Promise { const r = await apiClient.get('/forward-proxy/acls') if (!isEnvelope(r.data)) return [] return (r.data.data as { acls?: ACL[] }).acls ?? [] } interface SquidStats { client_requests: number cache_hits: number cache_hit_pct: number client_errors: number bytes_in: number bytes_out: number server_requests: number server_errors: number } function fmtBytes(n: number): string { if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB' if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB' if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB' return n + ' B' } export default function ForwardProxyPage() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' 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 { data: statsData, refetch: refetchStats } = useQuery({ queryKey: ['fwd-proxy', 'stats'], queryFn: async () => { const r = await apiClient.get('/forward-proxy/stats') if (!isEnvelope(r.data)) return null const d = r.data.data as { stats?: SquidStats; error?: string } if (d.error) return null return d.stats ?? null }, refetchInterval: 30_000, }) const { data: settings, isLoading: settingsLoading } = useQuery({ queryKey: ['fwd-proxy', 'settings'], queryFn: async () => { const r = await apiClient.get('/forward-proxy/settings') if (!isEnvelope(r.data)) return null return r.data.data as ProxySettings }, }) const [settingsForm] = Form.useForm() const saveSettings = useMutation({ mutationFn: async (v: ProxySettings) => { await apiClient.put('/forward-proxy/settings', v) }, onSuccess: () => { message.success(t('common.save')) void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'settings'] }) }, onError: () => message.error(t('fwd.settings.saveFailed')), }) const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() const upsert = useMutation({ mutationFn: async (v: FormValues) => { if (editing) return (await apiClient.put(`/forward-proxy/acls/${editing.id}`, v)).data return (await apiClient.post('/forward-proxy/acls', v)).data }, onSuccess: () => { message.success(t('common.save')) setEditing(null); setCreating(false); form.resetFields() void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'acls'] }) }, onError: (e: Error) => message.error(e.message), }) const del = useMutation({ mutationFn: async (id: number) => { await apiClient.delete(`/forward-proxy/acls/${id}`) }, 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 sortedACLs = useMemo( () => [...(data ?? [])].sort((a, b) => a.priority - b.priority), [data], ) const swapACLs = useMutation({ mutationFn: async ({ a, b }: { a: ACL; b: ACL }) => { const stripMeta = ({ id: _id, created_at: _ca, updated_at: _ua, ...r }: ACL) => r await apiClient.put(`/forward-proxy/acls/${a.id}`, { ...stripMeta(a), priority: b.priority }) await apiClient.put(`/forward-proxy/acls/${b.id}`, { ...stripMeta(b), priority: a.priority }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'acls'] }) }, onError: (e: Error) => message.error(e.message), }) const cols: ColumnsType = [ { title: t('fwd.priority'), dataIndex: 'priority', key: 'priority', width: 90 }, { title: t('fwd.name'), dataIndex: 'name', key: 'name', render: (s: string) => {s} }, { title: t('fwd.action'), dataIndex: 'action', key: 'action', render: (a: string) => {a.toUpperCase()} }, { title: t('fwd.aclType'), dataIndex: 'acl_type', key: 'acl_type', render: (s: string) => {s} }, { title: t('fwd.value'), dataIndex: 'value', key: 'value', render: (s: string) => {s} }, { title: t('fwd.comment'), dataIndex: 'comment', key: 'comment', render: (v?: string | null) => v ?? '—' }, { title: t('common.active'), dataIndex: 'active', key: 'active', width: 80, render: (v: boolean, row: ACL) => ( quickToggle.mutate({ id: row.id, row, checked })} /> ), }, { title: '', key: 'move', width: 64, render: (_, row) => { const idx = sortedACLs.findIndex(r => r.id === row.id) const swapping = swapACLs.isPending return ( } emptyContent={ } title={t('fwd.emptyTitle')} description={t('fwd.emptyDesc')} action={ } /> } /> { setEditing(null); setCreating(false); form.resetFields() }} onOk={() => { void form.submit() }} confirmLoading={upsert.isPending} width={620} destroyOnClose >
upsert.mutate(v)}> ({ value: k, label: t(`fwd.aclTypes.${k}`) }))} showSearch optionFilterProp="value" />
) }