feat: WireGuard (server + client + peers + QR) + shared UI components
WireGuard --------- * Migration 0013: wireguard_interfaces (server|client mode, key envelope- encrypted) + wireguard_peers (per-server roster). Drop old empty 0005-Schema (Option-A peer_type, kein Iface-FK), neuer Aufbau mit zwei Tabellen + FK. * internal/services/secrets: Box mit AES-256-GCM, Master-Key in /var/lib/edgeguard/.master_key (lazy-create, 0600). Sealed/Open für PrivateKey + PSK. * internal/services/wireguard: KeyGen (Curve25519 mit clamping), PublicFromPrivate (für Import), InterfacesRepo, PeersRepo, Importer (parst /etc/wireguard/*.conf, server vs. client heuristisch nach ListenPort + Peer-Anzahl). * internal/wireguard: Renderer schreibt /etc/edgeguard/wireguard/<iface>.conf (0600), restartet wg-quick@<iface> via sudo (sudoers im postinst erweitert). Idempotent — re-render nur wenn content geändert. * internal/handlers/wireguard.go: REST CRUD für interfaces+peers, /generate-keypair, /peers/:id/config (text/plain wg-quick conf), /peers/:id/qr (PNG via go-qrcode). Auto-reload nach Mutation. * edgeguard-ctl wg-import [--path /etc/wireguard]: liest existierende conf-Files in die DB. Idempotent (überspringt vorhandene Iface-Namen). Shared UI components (proxy-lb-waf design pattern) -------------------------------------------------- * PageHeader: icon + title + subtitle + extras row, einheitlich oben auf jeder Page. * ActionButtons: Edit + Delete combo mit Popconfirm + Tooltip. * StatusDot: AntD Badge pattern statt "Yes/No" — schneller scanbar in dichten Tabellen. * DataTable: pageSizeOptions [20,50,100,200] + extraActions-Alias + optional renderMobileCard für Card-Liste auf < md Breakpoint. * enterprise.css: .page-header* + .datatable-toolbar Klassen. Frontend WireGuard ------------------ * /vpn/wireguard mit zwei Tabs (Server / Client) im neuen Pattern. * Server-Tab: Modal mit Generate-Keypair-Toggle, Peer-Roster im Drawer per Server. Pro Peer: QR-Code-Modal + .conf-Download. * Client-Tab: Upstream-Card im Modal, full-tunnel-Default (0.0.0.0/0,::/0), Keepalive 25. * i18n DE/EN für wg.* Block + common.* Erweiterung. Misc ---- * Sidebar: WireGuard unter Security-Sektion. * Nav-i18n: "Firewall (v2)" → "Firewall". * Version 1.0.8 → 1.0.11. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
64
management-ui/src/components/ActionButtons.tsx
Normal file
64
management-ui/src/components/ActionButtons.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Button, Popconfirm, Space, Tooltip } from 'antd'
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// ActionButtons is the standard "Edit / Delete" pair used at the
|
||||
// end of every CRUD table row. Centralising it means we only style
|
||||
// the action column once across the app.
|
||||
//
|
||||
// Either prop may be omitted to suppress that button — useful for
|
||||
// rows that aren't editable (e.g. builtin services).
|
||||
interface ActionButtonsProps {
|
||||
onEdit?: () => void
|
||||
onDelete?: () => void
|
||||
deleteConfirm?: string
|
||||
editTooltip?: string
|
||||
deleteTooltip?: string
|
||||
editDisabled?: boolean
|
||||
deleteDisabled?: boolean
|
||||
editDisabledReason?: string
|
||||
deleteDisabledReason?: string
|
||||
}
|
||||
|
||||
export default function ActionButtons({
|
||||
onEdit, onDelete,
|
||||
deleteConfirm,
|
||||
editTooltip, deleteTooltip,
|
||||
editDisabled, deleteDisabled,
|
||||
editDisabledReason, deleteDisabledReason,
|
||||
}: ActionButtonsProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Space size={4}>
|
||||
{onEdit && (
|
||||
<Tooltip title={editDisabled ? editDisabledReason : (editTooltip ?? t('common.edit'))}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
disabled={editDisabled}
|
||||
onClick={onEdit}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDelete && (
|
||||
deleteDisabled ? (
|
||||
<Tooltip title={deleteDisabledReason ?? t('common.delete')}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} disabled />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Popconfirm
|
||||
title={deleteConfirm ?? t('common.deleteConfirm')}
|
||||
okText={t('common.yes')}
|
||||
cancelText={t('common.no')}
|
||||
onConfirm={onDelete}
|
||||
>
|
||||
<Tooltip title={deleteTooltip ?? t('common.delete')}>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Input, Space, Table } from 'antd'
|
||||
import { useMemo, useState, type ReactNode } from 'react'
|
||||
import { Grid, Input, List, Pagination, Space, Table, Typography } from 'antd'
|
||||
import type { ColumnsType, TableProps } from 'antd/es/table'
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const { useBreakpoint } = Grid
|
||||
const { Text } = Typography
|
||||
|
||||
// DataTable wraps AntD's Table and gives every CRUD page the same
|
||||
// baseline UX:
|
||||
//
|
||||
@@ -23,8 +26,15 @@ interface DataTableProps<T> extends Omit<TableProps<T>, 'pagination'> {
|
||||
searchPlaceholder?: string
|
||||
searchable?: boolean
|
||||
// toolbar renders to the right of the search input, useful for
|
||||
// "Add" buttons.
|
||||
toolbar?: React.ReactNode
|
||||
// "Add" buttons. `extraActions` is the proxy-lb-waf-style alias —
|
||||
// both work to keep migration painless.
|
||||
toolbar?: ReactNode
|
||||
extraActions?: ReactNode
|
||||
// renderMobileCard switches from a dense Table (desktop) to a
|
||||
// List of cards (≤ md breakpoint). Mirrors the old EdgeGuard
|
||||
// ProTable mobile mode. Pass undefined to use the default Table
|
||||
// also on mobile (with horizontal scroll).
|
||||
renderMobileCard?: (record: T, index: number) => ReactNode
|
||||
}
|
||||
|
||||
function inferSorter<T>(dataIndex: string | string[] | undefined) {
|
||||
@@ -59,6 +69,8 @@ export default function DataTable<T extends object>(
|
||||
props: DataTableProps<T>,
|
||||
) {
|
||||
const { t } = useTranslation()
|
||||
const screens = useBreakpoint()
|
||||
const isMobile = !screens.md
|
||||
const {
|
||||
dataSource,
|
||||
columns,
|
||||
@@ -66,10 +78,16 @@ export default function DataTable<T extends object>(
|
||||
searchPlaceholder,
|
||||
searchable = true,
|
||||
toolbar,
|
||||
extraActions,
|
||||
renderMobileCard,
|
||||
rowKey,
|
||||
loading,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [perPage, setPerPage] = useState(pageSize)
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search || !dataSource) return dataSource
|
||||
@@ -92,33 +110,71 @@ export default function DataTable<T extends object>(
|
||||
})
|
||||
}, [columns])
|
||||
|
||||
const actions = toolbar ?? extraActions
|
||||
const records = (filtered ?? []) as T[]
|
||||
const start = (page - 1) * perPage
|
||||
const visible = records.slice(start, start + perPage)
|
||||
|
||||
return (
|
||||
<Space direction="vertical" className="w-full mb-12" size="small">
|
||||
{(searchable || toolbar) && (
|
||||
<div className="flex-between mb-12">
|
||||
{(searchable || actions) && (
|
||||
<div className="datatable-toolbar">
|
||||
{searchable && (
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={searchPlaceholder ?? t('common.search')}
|
||||
allowClear
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{ maxWidth: 320 }}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
style={{ maxWidth: isMobile ? '100%' : 320 }}
|
||||
/>
|
||||
)}
|
||||
{toolbar && <div>{toolbar}</div>}
|
||||
{actions && <Space wrap>{actions}</Space>}
|
||||
</div>
|
||||
)}
|
||||
<Table<T>
|
||||
size="small"
|
||||
{...rest}
|
||||
dataSource={filtered}
|
||||
columns={enhancedCols}
|
||||
pagination={{
|
||||
pageSize,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => t('common.totalRows', { count: total }),
|
||||
}}
|
||||
/>
|
||||
|
||||
{isMobile && renderMobileCard ? (
|
||||
<>
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={visible}
|
||||
renderItem={(record, idx) => (
|
||||
<List.Item style={{ padding: 0, marginBottom: 8, border: 'none' }}>
|
||||
{renderMobileCard(record, start + idx)}
|
||||
</List.Item>
|
||||
)}
|
||||
locale={{ emptyText: t('common.noData') }}
|
||||
/>
|
||||
<div style={{ textAlign: 'center', marginTop: 16 }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
pageSize={perPage}
|
||||
total={records.length}
|
||||
onChange={(p, ps) => { setPage(p); setPerPage(ps) }}
|
||||
showSizeChanger
|
||||
size="small"
|
||||
pageSizeOptions={['20', '50', '100', '200']}
|
||||
showTotal={(tot) => <Text type="secondary">{t('common.totalRows', { count: tot })}</Text>}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Table<T>
|
||||
size="small"
|
||||
rowKey={rowKey}
|
||||
loading={loading}
|
||||
{...rest}
|
||||
dataSource={filtered}
|
||||
columns={enhancedCols}
|
||||
pagination={{
|
||||
pageSize: perPage,
|
||||
current: page,
|
||||
onChange: (p, ps) => { setPage(p); setPerPage(ps) },
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
showTotal: (total) => t('common.totalRows', { count: total }),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
NodeIndexOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -56,7 +57,8 @@ const NAV: NavSection[] = [
|
||||
{
|
||||
labelKey: 'nav.section.security',
|
||||
items: [
|
||||
{ path: '/firewall', labelKey: 'nav.firewall', icon: <FireOutlined /> },
|
||||
{ path: '/firewall', labelKey: 'nav.firewall', icon: <FireOutlined /> },
|
||||
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -68,7 +70,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.8'
|
||||
const VERSION = '1.0.11'
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
30
management-ui/src/components/PageHeader.tsx
Normal file
30
management-ui/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Space, Typography } from 'antd'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
// PageHeader is the standard top-of-page block: icon + title +
|
||||
// optional subtitle on the left, optional extras (buttons, status
|
||||
// dots, …) on the right. Lifted from the proxy-lb-waf design system
|
||||
// so every page across EdgeGuard now has the same visual hierarchy.
|
||||
interface PageHeaderProps {
|
||||
icon?: ReactNode
|
||||
title: ReactNode
|
||||
subtitle?: ReactNode
|
||||
extra?: ReactNode
|
||||
}
|
||||
|
||||
export default function PageHeader({ icon, title, subtitle, extra }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="page-header">
|
||||
<div className="page-header-main">
|
||||
<Title level={4} className="page-header-title">
|
||||
{icon && <span className="page-header-icon">{icon}</span>}
|
||||
{title}
|
||||
</Title>
|
||||
{subtitle && <Text type="secondary" className="page-header-subtitle">{subtitle}</Text>}
|
||||
</div>
|
||||
{extra && <Space wrap>{extra}</Space>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
management-ui/src/components/StatusDot.tsx
Normal file
21
management-ui/src/components/StatusDot.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Badge } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// StatusDot replaces the `Yes/No` text columns with the AntD Badge
|
||||
// pattern from the old EdgeGuard — a small coloured dot + label,
|
||||
// scans much faster in long tables.
|
||||
interface StatusDotProps {
|
||||
active: boolean
|
||||
activeLabel?: string
|
||||
inactiveLabel?: string
|
||||
}
|
||||
|
||||
export default function StatusDot({ active, activeLabel, inactiveLabel }: StatusDotProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Badge
|
||||
status={active ? 'success' : 'default'}
|
||||
text={active ? (activeLabel ?? t('common.active')) : (inactiveLabel ?? t('common.inactive'))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user