diff --git a/management-ui/src/pages/Wireguard/Clients.tsx b/management-ui/src/pages/Wireguard/Clients.tsx index a467c5f..eac17c9 100644 --- a/management-ui/src/pages/Wireguard/Clients.tsx +++ b/management-ui/src/pages/Wireguard/Clients.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Alert, Button, Card, Col, Form, Input, InputNumber, Modal, - Row, Select, Switch, Tag, Typography, message, + Row, Select, Space, Switch, Tag, Tooltip, Typography, message, } from 'antd' import type { ColumnsType } from 'antd/es/table' import { KeyOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons' @@ -12,10 +12,42 @@ import apiClient, { isEnvelope } from '../../api/client' import DataTable from '../../components/DataTable' import EmptyState from '../../components/EmptyState' import ActionButtons from '../../components/ActionButtons' +import StatusDot from '../../components/StatusDot' import type { WGInterface } from './types' 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 { + 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 { name: string address_cidr: string @@ -52,6 +84,11 @@ export default function ClientsTab() { const { data: clients, isLoading } = useQuery({ queryKey: ['wg', 'clients'], queryFn: listClients }) 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(null) 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', render: (k?: string | null) => k ? {k.slice(0, 16)}… : '—', }, + { + 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 ( + + + {live && ( + + + {relTime(live.last_handshake_unix, t('wg.peer.never'))} + + + )} + + ) + }, + }, { title: t('wg.iface.zone'), dataIndex: 'role', key: 'role', render: (r: string) => {r} }, { title: t('common.active'), dataIndex: 'active', key: 'active', width: 80,