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:
Debian
2026-05-24 22:09:37 +02:00
parent ac068bc9dd
commit 8d7a43bc8c
10 changed files with 230 additions and 13 deletions

View File

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

View File

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

View File

@@ -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>
</>
)
}