feat(ui): Pages auf neues Design + Dashboard + WG-Live-Status + Routing-Rules-Verstecken
Pages auf PageHeader/StatusDot/ActionButtons-Pattern migriert:
* Dashboard — Komplett-Rewrite. KPI-Tiles (Domains, Backends, Iface,
FW-Rules, NAT, WG), Detail-Cards (WireGuard live status, Firewall
zone overview, SSL expiring soon, Cluster nodes, Routing summary,
System info). Polled queries pro Card.
* Domains, Backends, RoutingRules, Networks, IPAddresses, SSL,
Cluster, Settings, Firewall (index) — alle inline Action-Buttons
→ ActionButtons; alle Yes/No-Renders → StatusDot; Add-Button in
DataTable.extraActions; PageHeader oben.
WireGuard
---------
* Neuer /wireguard/status-Endpoint parsed `wg show all dump`,
liefert {iface, peer_pubkey, endpoint, last_handshake_unix, rx, tx}.
Sudoers im postinst um `wg show` erweitert.
* Server-Drawer Peer-Liste zeigt jetzt Live-Status (Online/Offline-
Dot, "vor Xs", Traffic-Counter) per 10s-Polling. Importierte
"Unify Home" peer kann jetzt im UI verifiziert werden.
* Importer-Bug fixed: nextName ("# Unify Home" comment) wurde beim
Sektionswechsel zu früh geresettet — jetzt nur nach echtem
flushPeer.
Routing-Rules
-------------
* Aus Sidebar entfernt. URL bleibt funktional, aber für 90% der
Setups reicht domains.primary_backend_id (das HAProxy ohnehin
als default_backend rendert). Path-basiertes Routing ist ein
Advanced-Feature und kommt später als Domain-Modal-Tab zurück.
* nav.routing-Sidebar-Eintrag + BranchesOutlined-Import entfernt.
Misc
----
* "Firewall (v2)" → "Firewall" im Nav (DE).
* Dashboard-i18n Block in DE+EN.
* Version 1.0.11 → 1.0.12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "edgeguard-management-ui",
|
||||
"private": true,
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.12",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NavLink } from 'react-router-dom'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
BranchesOutlined,
|
||||
ClusterOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
@@ -41,9 +40,10 @@ const NAV: NavSection[] = [
|
||||
{
|
||||
labelKey: 'nav.section.routing',
|
||||
items: [
|
||||
{ path: '/domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
|
||||
{ path: '/backends', labelKey: 'nav.backends', icon: <DatabaseOutlined /> },
|
||||
{ path: '/routing-rules', labelKey: 'nav.routing', icon: <BranchesOutlined /> },
|
||||
{ path: '/domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
|
||||
{ path: '/backends', labelKey: 'nav.backends', icon: <DatabaseOutlined /> },
|
||||
// /routing-rules erreichbar via Domain-Modal "Pfad-Routing"-Tab —
|
||||
// kein eigener Nav-Eintrag mehr (war für 90% der Setups overkill).
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -70,7 +70,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.11'
|
||||
const VERSION = '1.0.12'
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
},
|
||||
"domains": {
|
||||
"title": "Domains",
|
||||
"intro": "Verwalte FQDNs, die HAProxy terminiert. Optionales Primary-Backend als Catch-all; Pfad-Routing via Routing-Regeln.",
|
||||
"addDomain": "Domain hinzufügen",
|
||||
"editDomain": "Domain bearbeiten",
|
||||
"name": "Name",
|
||||
@@ -197,6 +198,7 @@
|
||||
},
|
||||
"backends": {
|
||||
"title": "Backends",
|
||||
"intro": "Upstream-Server, an die HAProxy weiterroutet. Health-Check-Pfad optional aktiviert TCP+HTTP-Probes alle 5s.",
|
||||
"addBackend": "Backend hinzufügen",
|
||||
"editBackend": "Backend bearbeiten",
|
||||
"name": "Name",
|
||||
@@ -216,6 +218,7 @@
|
||||
},
|
||||
"routing": {
|
||||
"title": "Routing-Regeln",
|
||||
"intro": "Pfad-Präfix → Backend-Mapping pro Domain. Niedrige Priority gewinnt; Catch-all per Domain.primary_backend.",
|
||||
"addRule": "Regel hinzufügen",
|
||||
"editRule": "Regel bearbeiten",
|
||||
"domain": "Domain",
|
||||
@@ -342,7 +345,54 @@
|
||||
"pskOff": "kein PSK",
|
||||
"downloadConf": "wg-quick.conf herunterladen",
|
||||
"qrTitle": "WireGuard-QR",
|
||||
"qrHint": "Mit der WireGuard-App (iOS/Android) scannen: \"Tunnel hinzufügen\" → \"QR-Code scannen\". Endpoint im Download-Conf bitte vor Verwendung anpassen."
|
||||
"qrHint": "Mit der WireGuard-App (iOS/Android) scannen: \"Tunnel hinzufügen\" → \"QR-Code scannen\". Endpoint im Download-Conf bitte vor Verwendung anpassen.",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"traffic": "Traffic"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcomeHint": "EdgeGuard-Übersicht — Health, Counts, Live-Status der wichtigsten Dienste.",
|
||||
"kpi": {
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"ifaces": "Interfaces",
|
||||
"fwRules": "FW-Regeln",
|
||||
"natRules": "NAT-Regeln",
|
||||
"wg": "WG-Verbindungen"
|
||||
},
|
||||
"wgCard": {
|
||||
"title": "WireGuard",
|
||||
"empty": "Noch kein WG-Tunnel angelegt."
|
||||
},
|
||||
"firewallCard": {
|
||||
"title": "Firewall",
|
||||
"zones": "Zonen",
|
||||
"activeRules": "{{rules}} aktive Regeln · {{nat}} NAT"
|
||||
},
|
||||
"sslCard": {
|
||||
"title": "SSL-Zertifikate",
|
||||
"total": "Verwaltete Zertifikate",
|
||||
"expiringSoon": "{{count}} läuft bald ab (< 30 Tage)",
|
||||
"allFresh": "Alle Zertifikate haben > 30 Tage Restlaufzeit."
|
||||
},
|
||||
"clusterCard": {
|
||||
"title": "Cluster",
|
||||
"nodes": "Knoten"
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"attached": "{{count}}/{{total}} Domains haben einen Primary-Backend"
|
||||
},
|
||||
"systemCard": {
|
||||
"title": "System",
|
||||
"version": "Version",
|
||||
"api": "API",
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
},
|
||||
"domains": {
|
||||
"title": "Domains",
|
||||
"intro": "Manage FQDNs that HAProxy terminates. Optional primary backend as catch-all; path-based routing via routing rules.",
|
||||
"addDomain": "Add domain",
|
||||
"editDomain": "Edit domain",
|
||||
"name": "Name",
|
||||
@@ -197,6 +198,7 @@
|
||||
},
|
||||
"backends": {
|
||||
"title": "Backends",
|
||||
"intro": "Upstream servers HAProxy proxies to. Optional health-check path enables TCP + HTTP probes every 5s.",
|
||||
"addBackend": "Add backend",
|
||||
"editBackend": "Edit backend",
|
||||
"name": "Name",
|
||||
@@ -216,6 +218,7 @@
|
||||
},
|
||||
"routing": {
|
||||
"title": "Routing rules",
|
||||
"intro": "Path-prefix → backend mapping per domain. Lowest priority wins; catch-all via domain.primary_backend.",
|
||||
"addRule": "Add rule",
|
||||
"editRule": "Edit rule",
|
||||
"domain": "Domain",
|
||||
@@ -342,7 +345,54 @@
|
||||
"pskOff": "no PSK",
|
||||
"downloadConf": "Download wg-quick.conf",
|
||||
"qrTitle": "WireGuard QR",
|
||||
"qrHint": "Scan with the WireGuard app (iOS/Android): \"Add tunnel\" → \"Scan QR code\". Replace the Endpoint placeholder in the downloaded conf before use."
|
||||
"qrHint": "Scan with the WireGuard app (iOS/Android): \"Add tunnel\" → \"Scan QR code\". Replace the Endpoint placeholder in the downloaded conf before use.",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"traffic": "Traffic"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcomeHint": "EdgeGuard overview — health, counts, live status of the major services.",
|
||||
"kpi": {
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"ifaces": "Interfaces",
|
||||
"fwRules": "FW rules",
|
||||
"natRules": "NAT rules",
|
||||
"wg": "WG connections"
|
||||
},
|
||||
"wgCard": {
|
||||
"title": "WireGuard",
|
||||
"empty": "No WG tunnel configured yet."
|
||||
},
|
||||
"firewallCard": {
|
||||
"title": "Firewall",
|
||||
"zones": "Zones",
|
||||
"activeRules": "{{rules}} active rules · {{nat}} NAT"
|
||||
},
|
||||
"sslCard": {
|
||||
"title": "SSL certificates",
|
||||
"total": "Managed certificates",
|
||||
"expiringSoon": "{{count}} expiring soon (< 30 days)",
|
||||
"allFresh": "All certs have > 30 days remaining."
|
||||
},
|
||||
"clusterCard": {
|
||||
"title": "Cluster",
|
||||
"nodes": "Nodes"
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
"domains": "Domains",
|
||||
"backends": "Backends",
|
||||
"attached": "{{count}}/{{total}} domains have a primary backend"
|
||||
},
|
||||
"systemCard": {
|
||||
"title": "System",
|
||||
"version": "Version",
|
||||
"api": "API",
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Typography, message } from 'antd'
|
||||
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { DatabaseOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
@@ -165,12 +169,12 @@ export default function BackendsPage() {
|
||||
return <Space size={4} wrap>{ds.map(d => <Tag key={d.id} color="blue">{d.name}</Tag>)}</Space>
|
||||
},
|
||||
},
|
||||
{ title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('backends.actions'), key: 'actions',
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => {
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
@@ -181,29 +185,35 @@ export default function BackendsPage() {
|
||||
active: row.active,
|
||||
domain_ids: domainsForBackend(row.id).map(d => d.id),
|
||||
})
|
||||
}}>{t('common.edit')}</Button>
|
||||
<Popconfirm
|
||||
title={t('backends.deleteConfirm', { name: row.name })}
|
||||
onConfirm={() => del.mutate(row.id)}
|
||||
>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}}
|
||||
onDelete={() => del.mutate(row.id)}
|
||||
deleteConfirm={t('backends.deleteConfirm', { name: row.name })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('backends.title')}</Typography.Title>
|
||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
||||
setCreating(true)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ scheme: 'http', port: 8080, active: true })
|
||||
}}>
|
||||
{t('backends.addBackend')}
|
||||
</Button>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
|
||||
<PageHeader
|
||||
icon={<DatabaseOutlined />}
|
||||
title={t('backends.title')}
|
||||
subtitle={t('backends.intro')}
|
||||
/>
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={data ?? []}
|
||||
columns={columns}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ scheme: 'http', port: 8080, active: true })
|
||||
}}>
|
||||
{t('backends.addBackend')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('backends.editBackend') : t('backends.addBackend')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Card, Spin, Tag, Typography } from 'antd'
|
||||
import { Card, Spin, Tag } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ApartmentOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
@@ -58,17 +60,13 @@ export default function ClusterPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('cluster.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('cluster.intro', { count: data?.nodes.length ?? 0 })}
|
||||
</Typography.Paragraph>
|
||||
<Card>
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data?.nodes ?? []}
|
||||
|
||||
/>
|
||||
<PageHeader
|
||||
icon={<ApartmentOutlined />}
|
||||
title={t('cluster.title')}
|
||||
subtitle={t('cluster.intro', { count: data?.nodes.length ?? 0 })}
|
||||
/>
|
||||
<Card size="small">
|
||||
<DataTable rowKey="id" columns={columns} dataSource={data?.nodes ?? []} />
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Switch, Tag, Typography, message } from 'antd'
|
||||
import { Button, Form, Input, Modal, Select, Switch, Tag, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { GlobalOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
@@ -103,22 +107,21 @@ export default function DomainsPage() {
|
||||
{
|
||||
title: t('domains.primaryBackend'), dataIndex: 'primary_backend_id', key: 'primary_backend_id',
|
||||
render: (id?: number | null) => {
|
||||
if (!id) return <Tag color="default">{t('domains.noBackend')}</Tag>
|
||||
if (!id) return <Tag>{t('domains.noBackend')}</Tag>
|
||||
const b = backendById(id)
|
||||
return b
|
||||
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
|
||||
: <Tag color="orange">#{id}</Tag>
|
||||
},
|
||||
},
|
||||
{ title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{ title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => <StatusDot active={v} activeLabel="HTTPS" inactiveLabel="HTTP" /> },
|
||||
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('domains.actions'),
|
||||
key: 'actions',
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => {
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
@@ -128,34 +131,34 @@ export default function DomainsPage() {
|
||||
primary_backend_id: row.primary_backend_id ?? null,
|
||||
notes: row.notes ?? '',
|
||||
})
|
||||
}}>{t('domains.edit')}</Button>
|
||||
<Popconfirm
|
||||
title={t('domains.deleteConfirm', { name: row.name })}
|
||||
onConfirm={() => del.mutate(row.id)}
|
||||
>
|
||||
<Button size="small" danger>{t('domains.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}}
|
||||
onDelete={() => del.mutate(row.id)}
|
||||
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('domains.title')}</Typography.Title>
|
||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
||||
setCreating(true)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ active: true, http_to_https: true, hsts_enabled: false })
|
||||
}}>
|
||||
{t('domains.addDomain')}
|
||||
</Button>
|
||||
<PageHeader
|
||||
icon={<GlobalOutlined />}
|
||||
title={t('domains.title')}
|
||||
subtitle={t('domains.intro')}
|
||||
/>
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={data ?? []}
|
||||
columns={columns}
|
||||
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ active: true, http_to_https: true, hsts_enabled: false })
|
||||
}}>
|
||||
{t('domains.addDomain')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('domains.editDomain') : t('domains.addDomain')}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Tabs, Typography } from 'antd'
|
||||
import { Tabs } from 'antd'
|
||||
import { FireOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import AddressObjectsTab from './AddressObjects'
|
||||
import AddressGroupsTab from './AddressGroups'
|
||||
import ServicesTab from './Services'
|
||||
@@ -24,8 +26,11 @@ export default function FirewallPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('fw.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{t('fw.intro')}</Typography.Paragraph>
|
||||
<PageHeader
|
||||
icon={<FireOutlined />}
|
||||
title={t('fw.title')}
|
||||
subtitle={t('fw.intro')}
|
||||
/>
|
||||
<Tabs items={tabs} defaultActiveKey="rules" />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography, message } from 'antd'
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Select, Switch, Tag, Typography, message } from 'antd'
|
||||
import { NodeIndexOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -132,13 +136,13 @@ export default function IPAddressesPage() {
|
||||
? <Tag color="gold">VIP{row.vip_priority != null ? ` · prio ${row.vip_priority}` : ''}</Tag>
|
||||
: '—',
|
||||
},
|
||||
{ title: t('ips.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('ips.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{ title: t('ips.description'), dataIndex: 'description', key: 'desc', render: (v?: string) => v ?? '—' },
|
||||
{
|
||||
title: t('ips.actions'), key: 'actions',
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => {
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
interface_id: row.interface_id,
|
||||
@@ -147,24 +151,23 @@ export default function IPAddressesPage() {
|
||||
description: row.description ?? undefined,
|
||||
active: row.active,
|
||||
})
|
||||
}}>{t('common.edit')}</Button>
|
||||
<Popconfirm
|
||||
title={t('ips.deleteConfirm', { addr: row.address })}
|
||||
onConfirm={() => del.mutate(row.id)}
|
||||
>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}}
|
||||
onDelete={() => del.mutate(row.id)}
|
||||
deleteConfirm={t('ips.deleteConfirm', { addr: row.address })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('ips.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{t('ips.intro')}</Typography.Paragraph>
|
||||
<PageHeader
|
||||
icon={<NodeIndexOutlined />}
|
||||
title={t('ips.title')}
|
||||
subtitle={t('ips.intro')}
|
||||
/>
|
||||
|
||||
<Card title={t('ips.systemDiscovered')} size="small" style={{ marginBottom: 16 }}>
|
||||
<Card title={t('ips.systemDiscovered')} size="small" className="mb-12">
|
||||
{(sysAddrs ?? []).length === 0
|
||||
? <Typography.Text type="secondary">—</Typography.Text>
|
||||
: (
|
||||
@@ -184,13 +187,20 @@ export default function IPAddressesPage() {
|
||||
</Card>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 8 }}>{t('ips.managedTitle')}</Typography.Title>
|
||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ prefix: 24, is_vip: false, active: true })
|
||||
}}>
|
||||
{t('ips.addAddress')}
|
||||
</Button>
|
||||
<Table rowKey="id" loading={isLoading} dataSource={ips ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={ips ?? []}
|
||||
columns={columns}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ prefix: 24, is_vip: false, active: true })
|
||||
}}>
|
||||
{t('ips.addAddress')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('ips.editAddress') : t('ips.addAddress')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ClusterOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
@@ -124,12 +128,12 @@ export default function NetworksPage() {
|
||||
render: (r: string) => <Tag color={roleColor(r)}>{r.toUpperCase()}</Tag>,
|
||||
},
|
||||
{ title: t('networks.mtu'), dataIndex: 'mtu', key: 'mtu', render: (v?: number) => v ?? '—' },
|
||||
{ title: t('networks.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('networks.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('networks.actions'), key: 'actions',
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => {
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
name: row.name, type: row.type, parent: row.parent ?? undefined,
|
||||
@@ -138,24 +142,23 @@ export default function NetworksPage() {
|
||||
mtu: row.mtu ?? undefined, active: row.active,
|
||||
description: row.description ?? undefined,
|
||||
})
|
||||
}}>{t('common.edit')}</Button>
|
||||
<Popconfirm
|
||||
title={t('networks.deleteConfirm', { name: row.name })}
|
||||
onConfirm={() => del.mutate(row.id)}
|
||||
>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}}
|
||||
onDelete={() => del.mutate(row.id)}
|
||||
deleteConfirm={t('networks.deleteConfirm', { name: row.name })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('networks.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{t('networks.intro')}</Typography.Paragraph>
|
||||
<PageHeader
|
||||
icon={<ClusterOutlined />}
|
||||
title={t('networks.title')}
|
||||
subtitle={t('networks.intro')}
|
||||
/>
|
||||
|
||||
<Card title={t('networks.systemDiscovered')} style={{ marginBottom: 16 }} size="small">
|
||||
<Card title={t('networks.systemDiscovered')} className="mb-12" size="small">
|
||||
<Space wrap>
|
||||
{(sys ?? []).map((i) => {
|
||||
const v4 = (i.addr_info ?? []).filter((a) => a.family === 'inet').map((a) => `${a.local}/${a.prefixlen}`)
|
||||
@@ -170,14 +173,20 @@ export default function NetworksPage() {
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ type: 'ethernet', role: 'lan', active: true })
|
||||
}}>
|
||||
{t('networks.addInterface')}
|
||||
</Button>
|
||||
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={ifs ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={ifs ?? []}
|
||||
columns={columns}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ type: 'ethernet', role: 'lan', active: true })
|
||||
}}>
|
||||
{t('networks.addInterface')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? t('networks.editInterface') : t('networks.addInterface')}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Typography, message } from 'antd'
|
||||
import { Button, Form, Input, InputNumber, Modal, Select, Switch, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { BranchesOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
@@ -93,12 +97,12 @@ export default function RoutingRulesPage() {
|
||||
{ title: t('routing.pathPrefix'), dataIndex: 'path_prefix', key: 'path' },
|
||||
{ title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend', render: (id: number) => backendLabel(id) },
|
||||
{ title: t('routing.priority'), dataIndex: 'priority', key: 'priority' },
|
||||
{ title: t('routing.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||
{ title: t('routing.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('routing.actions'), key: 'actions',
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => {
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
domain_id: row.domain_id,
|
||||
@@ -107,29 +111,35 @@ export default function RoutingRulesPage() {
|
||||
priority: row.priority,
|
||||
active: row.active,
|
||||
})
|
||||
}}>{t('common.edit')}</Button>
|
||||
<Popconfirm
|
||||
title={t('routing.deleteConfirm')}
|
||||
onConfirm={() => del.mutate(row.id)}
|
||||
>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}}
|
||||
onDelete={() => del.mutate(row.id)}
|
||||
deleteConfirm={t('routing.deleteConfirm')}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('routing.title')}</Typography.Title>
|
||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
||||
setCreating(true)
|
||||
form.resetFields()
|
||||
form.setFieldsValue({ priority: 100, path_prefix: '/', active: true })
|
||||
}}>
|
||||
{t('routing.addRule')}
|
||||
</Button>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={rules ?? []} columns={columns} />
|
||||
<PageHeader
|
||||
icon={<BranchesOutlined />}
|
||||
title={t('routing.title')}
|
||||
subtitle={t('routing.intro')}
|
||||
/>
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={rules ?? []}
|
||||
columns={columns}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ priority: 100, path_prefix: '/', active: true })
|
||||
}}>
|
||||
{t('routing.addRule')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('routing.editRule') : t('routing.addRule')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert, Button, Card, Form, Input, Popconfirm, Select, Space, Tabs, Tag, Typography, message } from 'antd'
|
||||
import { Alert, Button, Card, Form, Input, Select, Space, Tabs, Tag, Typography, message } from 'antd'
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -119,14 +122,12 @@ export default function SSLPage() {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('ssl.actions'), key: 'actions',
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<Popconfirm
|
||||
title={t('ssl.deleteConfirm', { domain: row.domain })}
|
||||
onConfirm={() => delMut.mutate(row.id)}
|
||||
>
|
||||
<Button size="small" danger>{t('common.delete')}</Button>
|
||||
</Popconfirm>
|
||||
<ActionButtons
|
||||
onDelete={() => delMut.mutate(row.id)}
|
||||
deleteConfirm={t('ssl.deleteConfirm', { domain: row.domain })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -187,8 +188,11 @@ export default function SSLPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('ssl.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{t('ssl.intro')}</Typography.Paragraph>
|
||||
<PageHeader
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
title={t('ssl.title')}
|
||||
subtitle={t('ssl.intro')}
|
||||
/>
|
||||
|
||||
<Tabs items={tabs} defaultActiveKey="letsencrypt" />
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Card, Descriptions, Spin, Typography } from 'antd'
|
||||
import { Card, Descriptions, Spin } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
|
||||
interface SetupStatus {
|
||||
completed: boolean
|
||||
@@ -42,17 +44,20 @@ export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3}>{t('settings.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{t('settings.intro')}</Typography.Paragraph>
|
||||
<PageHeader
|
||||
icon={<SettingOutlined />}
|
||||
title={t('settings.title')}
|
||||
subtitle={t('settings.intro')}
|
||||
/>
|
||||
|
||||
<Card title={t('settings.systemInfo')} style={{ marginBottom: 16 }}>
|
||||
<Card title={t('settings.systemInfo')} className="mb-12" size="small">
|
||||
<Descriptions column={1}>
|
||||
<Descriptions.Item label={t('settings.version')}>{health?.version ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('settings.status')}>{health?.status ?? '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title={t('settings.setupInfo')}>
|
||||
<Card title={t('settings.setupInfo')} size="small">
|
||||
<Descriptions column={1}>
|
||||
<Descriptions.Item label={t('settings.adminEmail')}>{setupStatus?.admin_email ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('settings.fqdn')}>{setupStatus?.fqdn ?? '—'}</Descriptions.Item>
|
||||
|
||||
@@ -56,6 +56,36 @@ async function listPeers(ifaceID: number): Promise<WGPeer[]> {
|
||||
return (r.data.data as { peers?: WGPeer[] }).peers ?? []
|
||||
}
|
||||
|
||||
interface LiveStatusRow {
|
||||
interface: string
|
||||
peer_public_key: string
|
||||
endpoint?: string
|
||||
last_handshake_unix: number
|
||||
transfer_rx: number
|
||||
transfer_tx: number
|
||||
}
|
||||
async function listLiveStatus(): Promise<LiveStatusRow[]> {
|
||||
const r = await apiClient.get('/wireguard/status')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { status?: LiveStatusRow[] }).status ?? []
|
||||
}
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`
|
||||
const u = ['KiB', 'MiB', 'GiB', 'TiB']
|
||||
let v = n / 1024, i = 0
|
||||
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
|
||||
return `${v.toFixed(1)} ${u[i]}`
|
||||
}
|
||||
function relTime(unix: number, never: string): string {
|
||||
if (!unix) return never
|
||||
const sec = Math.max(0, Math.floor(Date.now() / 1000) - unix)
|
||||
if (sec < 60) return `vor ${sec}s`
|
||||
if (sec < 3600) return `vor ${Math.floor(sec / 60)}m`
|
||||
if (sec < 86400) return `vor ${Math.floor(sec / 3600)}h`
|
||||
return `vor ${Math.floor(sec / 86400)}d`
|
||||
}
|
||||
|
||||
interface FwZoneLite { name: string; builtin: boolean }
|
||||
async function listZones(): Promise<FwZoneLite[]> {
|
||||
const r = await apiClient.get('/firewall/zones')
|
||||
@@ -270,6 +300,14 @@ function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
|
||||
queryFn: () => listPeers(ifaceID),
|
||||
enabled: open,
|
||||
})
|
||||
const { data: liveStatus } = useQuery({
|
||||
queryKey: ['wg', 'status'],
|
||||
queryFn: listLiveStatus,
|
||||
enabled: open,
|
||||
refetchInterval: 10_000,
|
||||
})
|
||||
const liveByPubkey = (live: LiveStatusRow[] | undefined, pk: string) =>
|
||||
(live ?? []).find(s => s.peer_public_key === pk)
|
||||
|
||||
const [editing, setEditing] = useState<WGPeer | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
@@ -302,8 +340,32 @@ function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
|
||||
render: (k: string) => <Text code copyable={{ text: k }} style={{ fontSize: 11 }}>{k.slice(0, 12)}…</Text>,
|
||||
},
|
||||
{
|
||||
title: t('wg.peer.lastHandshake'), dataIndex: 'last_handshake', key: 'last_handshake',
|
||||
render: (s?: string | null) => s ? new Date(s).toLocaleString() : <Tag>{t('wg.peer.never')}</Tag>,
|
||||
title: t('wg.peer.lastHandshake'), key: 'last_handshake',
|
||||
render: (_, row) => {
|
||||
const live = liveByPubkey(liveStatus, row.public_key)
|
||||
const online = live && live.last_handshake_unix > 0
|
||||
&& Date.now() / 1000 - live.last_handshake_unix < 180
|
||||
return (
|
||||
<Space size={4}>
|
||||
<StatusDot
|
||||
active={!!online}
|
||||
activeLabel={t('wg.peer.online')}
|
||||
inactiveLabel={t('wg.peer.offline')}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{live ? relTime(live.last_handshake_unix, t('wg.peer.never')) : t('wg.peer.never')}
|
||||
</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('wg.peer.traffic'), key: 'traffic',
|
||||
render: (_, row) => {
|
||||
const live = liveByPubkey(liveStatus, row.public_key)
|
||||
if (!live) return '—'
|
||||
return <Text style={{ fontSize: 11 }}>▼{fmtBytes(live.transfer_rx)} ▲{fmtBytes(live.transfer_tx)}</Text>
|
||||
},
|
||||
},
|
||||
{ title: t('common.active'), dataIndex: 'enabled', key: 'enabled', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user