feat(wireguard): Live-Verbindungsstatus für Client-Tunnel
Zeigt Handshake-Zeit und Traffic (10s Polling) — analog zum Peer-Status im Server-Tab. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Alert, Button, Card, Col, Form, Input, InputNumber, Modal,
|
Alert, Button, Card, Col, Form, Input, InputNumber, Modal,
|
||||||
Row, Select, Switch, Tag, Typography, message,
|
Row, Select, Space, Switch, Tag, Tooltip, Typography, message,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import { KeyOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
import { KeyOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
||||||
@@ -12,10 +12,42 @@ import apiClient, { isEnvelope } from '../../api/client'
|
|||||||
import DataTable from '../../components/DataTable'
|
import DataTable from '../../components/DataTable'
|
||||||
import EmptyState from '../../components/EmptyState'
|
import EmptyState from '../../components/EmptyState'
|
||||||
import ActionButtons from '../../components/ActionButtons'
|
import ActionButtons from '../../components/ActionButtons'
|
||||||
|
import StatusDot from '../../components/StatusDot'
|
||||||
import type { WGInterface } from './types'
|
import type { WGInterface } from './types'
|
||||||
|
|
||||||
const { Text } = Typography
|
const { Text } = Typography
|
||||||
|
|
||||||
|
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[]> {
|
||||||
|
try {
|
||||||
|
const r = await apiClient.get('/wireguard/status')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { status?: LiveStatusRow[] }).status ?? []
|
||||||
|
} catch { return [] }
|
||||||
|
}
|
||||||
|
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 `${sec}s ago`
|
||||||
|
if (sec < 3600) return `${Math.floor(sec / 60)}m ago`
|
||||||
|
if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`
|
||||||
|
return `${Math.floor(sec / 86400)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
interface ClientForm {
|
interface ClientForm {
|
||||||
name: string
|
name: string
|
||||||
address_cidr: string
|
address_cidr: string
|
||||||
@@ -52,6 +84,11 @@ export default function ClientsTab() {
|
|||||||
|
|
||||||
const { data: clients, isLoading } = useQuery({ queryKey: ['wg', 'clients'], queryFn: listClients })
|
const { data: clients, isLoading } = useQuery({ queryKey: ['wg', 'clients'], queryFn: listClients })
|
||||||
const { data: zones } = useQuery({ queryKey: ['fw-zones'], queryFn: listZones })
|
const { data: zones } = useQuery({ queryKey: ['fw-zones'], queryFn: listZones })
|
||||||
|
const { data: liveStatus } = useQuery({
|
||||||
|
queryKey: ['wg', 'status'],
|
||||||
|
queryFn: listLiveStatus,
|
||||||
|
refetchInterval: 10_000,
|
||||||
|
})
|
||||||
|
|
||||||
const [editing, setEditing] = useState<WGInterface | null>(null)
|
const [editing, setEditing] = useState<WGInterface | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
@@ -92,6 +129,30 @@ export default function ClientsTab() {
|
|||||||
title: t('wg.iface.peerPublicKey'), dataIndex: 'peer_public_key', key: 'peer_public_key',
|
title: t('wg.iface.peerPublicKey'), dataIndex: 'peer_public_key', key: 'peer_public_key',
|
||||||
render: (k?: string | null) => k ? <Text code copyable={{ text: k }} style={{ fontSize: 11 }}>{k.slice(0, 16)}…</Text> : '—',
|
render: (k?: string | null) => k ? <Text code copyable={{ text: k }} style={{ fontSize: 11 }}>{k.slice(0, 16)}…</Text> : '—',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('wg.peer.lastHandshake'), key: 'live',
|
||||||
|
render: (_, row: WGInterface) => {
|
||||||
|
const live = (liveStatus ?? []).find(s => s.interface === row.name)
|
||||||
|
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')}
|
||||||
|
/>
|
||||||
|
{live && (
|
||||||
|
<Tooltip title={`▼${fmtBytes(live.transfer_rx)} ▲${fmtBytes(live.transfer_tx)}`}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{relTime(live.last_handshake_unix, t('wg.peer.never'))}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
{ title: t('wg.iface.zone'), dataIndex: 'role', key: 'role', render: (r: string) => <Tag>{r}</Tag> },
|
{ title: t('wg.iface.zone'), dataIndex: 'role', key: 'role', render: (r: string) => <Tag>{r}</Tag> },
|
||||||
{
|
{
|
||||||
title: t('common.active'), dataIndex: 'active', key: 'active', width: 80,
|
title: t('common.active'), dataIndex: 'active', key: 'active', width: 80,
|
||||||
|
|||||||
Reference in New Issue
Block a user