feat(wireguard): selektiver .conf-Import — Auswahl einzelner Interfaces per Modal
- GET /wireguard/importable listet alle .conf-Dateien in /etc/wireguard/ mit already_in-Flag (bereits in DB) - POST /wireguard/import nimmt optionale Names-Liste; ohne Namen → alles - edgeguard-ctl wg-import [iface…] importiert nur die genannten Interfaces - UI: Checkbox-Modal mit "Alle neuen auswählen" + Einzelauswahl; bereits importierte Interfaces disabled + grüner Tag Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -701,6 +701,17 @@
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"traffic": "Traffic"
|
||||
},
|
||||
"import": {
|
||||
"btn": ".conf importieren",
|
||||
"modalTitle": "WireGuard-Konfigurationen aus /etc/wireguard importieren",
|
||||
"hint": "Wähle aus, welche .conf-Dateien importiert werden sollen. Bereits importierte Interfaces sind ausgegraut.",
|
||||
"selectAll": "Alle neuen auswählen",
|
||||
"alreadyIn": "bereits importiert",
|
||||
"noFiles": "Keine .conf-Dateien in /etc/wireguard gefunden.",
|
||||
"okBtn": "Ausgewählte importieren",
|
||||
"ok": "{{ifaces}} Interface(s), {{peers}} Peer(s) importiert",
|
||||
"failed": "Import fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -701,6 +701,17 @@
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"traffic": "Traffic"
|
||||
},
|
||||
"import": {
|
||||
"btn": "Import .conf",
|
||||
"modalTitle": "Import WireGuard configs from /etc/wireguard",
|
||||
"hint": "Select which .conf files to import. Already-imported interfaces are greyed out.",
|
||||
"selectAll": "Select all new",
|
||||
"alreadyIn": "already imported",
|
||||
"noFiles": "No .conf files found in /etc/wireguard.",
|
||||
"okBtn": "Import selected",
|
||||
"ok": "Imported {{ifaces}} interface(s), {{peers}} peer(s)",
|
||||
"failed": "Import failed"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Col, Drawer, Form, Input, InputNumber,
|
||||
Alert, Button, Card, Checkbox, Col, Drawer, Form, Input, InputNumber,
|
||||
Modal, Row, Select, Space, Switch, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
DownloadOutlined, KeyOutlined, PlusOutlined, QrcodeOutlined,
|
||||
DownloadOutlined, ImportOutlined, KeyOutlined, PlusOutlined, QrcodeOutlined,
|
||||
TeamOutlined, ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -139,6 +139,38 @@ export default function ServersTab() {
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
// ── Import from /etc/wireguard ───────────────────────────────────
|
||||
interface ImportableEntry { name: string; already_in: boolean }
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const [selectedNames, setSelectedNames] = useState<string[]>([])
|
||||
|
||||
const importableQuery = useQuery({
|
||||
queryKey: ['wg', 'importable'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/wireguard/importable')
|
||||
return isEnvelope(r.data) ? (r.data.data as { entries?: ImportableEntry[] }).entries ?? [] : []
|
||||
},
|
||||
enabled: importOpen,
|
||||
})
|
||||
|
||||
const doImport = useMutation({
|
||||
mutationFn: async (names: string[]) => {
|
||||
const r = await apiClient.post('/wireguard/import', { names })
|
||||
return isEnvelope(r.data) ? (r.data.data as { ifaces_added: number; peers_added: number; skipped?: string[] }) : null
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
message.success(t('wg.import.ok', { ifaces: res?.ifaces_added ?? 0, peers: res?.peers_added ?? 0 }))
|
||||
setImportOpen(false)
|
||||
setSelectedNames([])
|
||||
void qc.invalidateQueries({ queryKey: ['wg', 'servers'] })
|
||||
void qc.invalidateQueries({ queryKey: ['wg', 'importable'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(t('wg.import.failed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const importEntries = importableQuery.data ?? []
|
||||
const importableNew = importEntries.filter(e => !e.already_in)
|
||||
|
||||
const cols: ColumnsType<WGInterface> = [
|
||||
{ title: t('wg.iface.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
||||
{ title: t('wg.iface.address'), dataIndex: 'address_cidr', key: 'address_cidr' },
|
||||
@@ -211,11 +243,18 @@ export default function ServersTab() {
|
||||
dataSource={servers ?? []}
|
||||
columns={cols}
|
||||
extraActions={
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
||||
{t('wg.iface.addServer')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Space>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button icon={<ImportOutlined />} disabled={isViewer} onClick={() => setImportOpen(true)}>
|
||||
{t('wg.import.btn')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
||||
{t('wg.iface.addServer')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
@@ -326,6 +365,54 @@ export default function ServersTab() {
|
||||
iface={peersDrawer}
|
||||
onClose={() => setPeersDrawer(null)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('wg.import.modalTitle')}
|
||||
open={importOpen}
|
||||
onCancel={() => { setImportOpen(false); setSelectedNames([]) }}
|
||||
onOk={() => doImport.mutate(selectedNames)}
|
||||
okText={t('wg.import.okBtn')}
|
||||
okButtonProps={{ disabled: selectedNames.length === 0, loading: doImport.isPending }}
|
||||
cancelText={t('common.cancel')}
|
||||
width={480}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Alert type="info" showIcon className="mb-12" message={t('wg.import.hint')} />
|
||||
{importableQuery.isLoading && <Typography.Text type="secondary">{t('common.loading')}</Typography.Text>}
|
||||
{!importableQuery.isLoading && importEntries.length === 0 && (
|
||||
<Typography.Text type="secondary">{t('wg.import.noFiles')}</Typography.Text>
|
||||
)}
|
||||
{importEntries.length > 0 && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
||||
<Space style={{ marginBottom: 4 }}>
|
||||
<Checkbox
|
||||
indeterminate={selectedNames.length > 0 && selectedNames.length < importableNew.length}
|
||||
checked={importableNew.length > 0 && selectedNames.length === importableNew.length}
|
||||
onChange={(e) => setSelectedNames(e.target.checked ? importableNew.map(x => x.name) : [])}
|
||||
>
|
||||
{t('wg.import.selectAll')}
|
||||
</Checkbox>
|
||||
</Space>
|
||||
{importEntries.map(entry => (
|
||||
<Checkbox
|
||||
key={entry.name}
|
||||
checked={selectedNames.includes(entry.name)}
|
||||
disabled={entry.already_in}
|
||||
onChange={(e) => {
|
||||
setSelectedNames(prev =>
|
||||
e.target.checked ? [...prev, entry.name] : prev.filter(n => n !== entry.name)
|
||||
)
|
||||
}}
|
||||
>
|
||||
<code>{entry.name}.conf</code>
|
||||
{entry.already_in && (
|
||||
<Tag color="green" style={{ marginLeft: 8, fontSize: 11 }}>{t('wg.import.alreadyIn')}</Tag>
|
||||
)}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user