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,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