2 Commits

Author SHA1 Message Date
Debian
e18037375f fix(i18n): relative-time strings vollständig lokalisiert
relativeTime() in Dashboard, relTime() in WireGuard Clients+Servers und
CertExpiry() im Cluster-Page haben bisher hardcodiertes Deutsch
(„vor 5s", „abgelaufen vor X Tagen") gerendert, unabhängig von der
gewählten UI-Sprache.

Neu: gemeinsame i18n-Keys common.relTime.{Xs,Xm,Xh,Xd,never} in EN+DE,
sowie cluster.{certDaysRemaining,certExpiredDaysAgo}. Alle Helper-
Funktionen nehmen jetzt t() als Parameter und sind damit sprachunabhängig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 12:02:27 +02:00
Debian
3c95bc58d9 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>
2026-05-24 11:53:21 +02:00
8 changed files with 107 additions and 25 deletions

View File

@@ -1 +1 @@
1.1.75 1.1.76

View File

@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
) )
var version = "1.1.75" var version = "1.1.76"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -436,6 +436,8 @@
"renewSelfConfirm": "Peer-Cert mit lokaler CA neu signieren (1 Jahr)? edgeguard-api Restart danach erforderlich.", "renewSelfConfirm": "Peer-Cert mit lokaler CA neu signieren (1 Jahr)? edgeguard-api Restart danach erforderlich.",
"certRenewedRestartHint": "Cert erneuert — bitte sudo systemctl restart edgeguard-api ausführen.", "certRenewedRestartHint": "Cert erneuert — bitte sudo systemctl restart edgeguard-api ausführen.",
"certRenewFailed": "Cert-Erneuerung fehlgeschlagen", "certRenewFailed": "Cert-Erneuerung fehlgeschlagen",
"certDaysRemaining": "{{n}} Tage",
"certExpiredDaysAgo": "abgelaufen vor {{n}} Tagen",
"removePeerBtn": "Entfernen", "removePeerBtn": "Entfernen",
"removePeerConfirmTitle": "Peer wirklich aus dem Cluster entfernen?", "removePeerConfirmTitle": "Peer wirklich aus dem Cluster entfernen?",
"removePeerConfirmDesc": "{{fqdn}} wird aus ha_nodes gelöscht. Firewall-Renderer entfernt seine IP aus dem peer_ipv4-Set. Auf der Peer-Seite läuft edgeguard-api weiter; manuell stoppen + cluster-tls löschen für vollständigen Decommission.", "removePeerConfirmDesc": "{{fqdn}} wird aus ha_nodes gelöscht. Firewall-Renderer entfernt seine IP aus dem peer_ipv4-Set. Auf der Peer-Seite läuft edgeguard-api weiter; manuell stoppen + cluster-tls löschen für vollständigen Decommission.",
@@ -910,7 +912,14 @@
"copy": "Kopieren", "copy": "Kopieren",
"copied": "Kopiert", "copied": "Kopiert",
"close": "Schließen", "close": "Schließen",
"refresh": "Aktualisieren" "refresh": "Aktualisieren",
"relTime": {
"Xs": "vor {{n}}s",
"Xm": "vor {{n}}m",
"Xh": "vor {{n}}h",
"Xd": "vor {{n}}d",
"never": "nie"
}
}, },
"license": { "license": {
"title": "Lizenz", "title": "Lizenz",

View File

@@ -436,6 +436,8 @@
"renewSelfConfirm": "Re-sign peer cert with the local CA (1 year)? edgeguard-api restart required after.", "renewSelfConfirm": "Re-sign peer cert with the local CA (1 year)? edgeguard-api restart required after.",
"certRenewedRestartHint": "Cert renewed — please run sudo systemctl restart edgeguard-api.", "certRenewedRestartHint": "Cert renewed — please run sudo systemctl restart edgeguard-api.",
"certRenewFailed": "Cert renewal failed", "certRenewFailed": "Cert renewal failed",
"certDaysRemaining": "{{n}} days",
"certExpiredDaysAgo": "expired {{n}} d ago",
"removePeerBtn": "Remove", "removePeerBtn": "Remove",
"removePeerConfirmTitle": "Really remove peer from the cluster?", "removePeerConfirmTitle": "Really remove peer from the cluster?",
"removePeerConfirmDesc": "{{fqdn}} is deleted from ha_nodes. Firewall renderer drops its IP from the peer_ipv4 set. On the peer side edgeguard-api keeps running; manually stop it + delete cluster-tls for a full decommission.", "removePeerConfirmDesc": "{{fqdn}} is deleted from ha_nodes. Firewall renderer drops its IP from the peer_ipv4 set. On the peer side edgeguard-api keeps running; manually stop it + delete cluster-tls for a full decommission.",
@@ -910,7 +912,14 @@
"copy": "Copy", "copy": "Copy",
"copied": "Copied", "copied": "Copied",
"close": "Close", "close": "Close",
"refresh": "Refresh" "refresh": "Refresh",
"relTime": {
"Xs": "{{n}}s ago",
"Xm": "{{n}}m ago",
"Xh": "{{n}}h ago",
"Xd": "{{n}}d ago",
"never": "never"
}
}, },
"license": { "license": {
"title": "License", "title": "License",

View File

@@ -541,12 +541,15 @@ export default function ClusterPage() {
// CertExpiry rendert "<n> Tage" mit Farbcode: rot < 30, orange < 90, // CertExpiry rendert "<n> Tage" mit Farbcode: rot < 30, orange < 90,
// grün sonst. Tooltip zeigt das absolute NotAfter-Datum. // grün sonst. Tooltip zeigt das absolute NotAfter-Datum.
function CertExpiry({ days, until }: { days: number; until: string }) { function CertExpiry({ days, until }: { days: number; until: string }) {
const { t } = useTranslation()
let color: string | undefined let color: string | undefined
if (days < 0) color = '#cf1322' // already expired if (days < 0) color = '#cf1322' // already expired
else if (days < 30) color = '#cf1322' // critical else if (days < 30) color = '#cf1322' // critical
else if (days < 90) color = '#d4651a' // warning else if (days < 90) color = '#d4651a' // warning
else color = '#52c41a' // healthy else color = '#52c41a' // healthy
const label = days < 0 ? `abgelaufen vor ${-days} Tagen` : `${days} Tage` const label = days < 0
? t('cluster.certExpiredDaysAgo', { n: -days })
: t('cluster.certDaysRemaining', { n: days })
return ( return (
<Tooltip title={new Date(until).toLocaleString()}> <Tooltip title={new Date(until).toLocaleString()}>
<Tag color={color === '#52c41a' ? 'green' : color === '#d4651a' ? 'orange' : 'red'}> <Tag color={color === '#52c41a' ? 'green' : color === '#d4651a' ? 'orange' : 'red'}>

View File

@@ -131,13 +131,13 @@ function formatBytes(n: number): string {
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ } while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
return `${v.toFixed(1)} ${u[i]}` return `${v.toFixed(1)} ${u[i]}`
} }
function relativeTime(unix: number): string { function relativeTime(unix: number, t: (k: string, v?: Record<string, unknown>) => string): string {
if (!unix) return 'nie' if (!unix) return t('common.relTime.never')
const sec = Math.max(0, Math.floor(Date.now() / 1000) - unix) const sec = Math.max(0, Math.floor(Date.now() / 1000) - unix)
if (sec < 60) return `vor ${sec}s` if (sec < 60) return t('common.relTime.Xs', { n: sec })
if (sec < 3600) return `vor ${Math.floor(sec / 60)}m` if (sec < 3600) return t('common.relTime.Xm', { n: Math.floor(sec / 60) })
if (sec < 86400) return `vor ${Math.floor(sec / 3600)}h` if (sec < 86400) return t('common.relTime.Xh', { n: Math.floor(sec / 3600) })
return `vor ${Math.floor(sec / 86400)}d` return t('common.relTime.Xd', { n: Math.floor(sec / 86400) })
} }
function formatUptime(sec: number): string { function formatUptime(sec: number): string {
if (sec < 60) return `${sec}s` if (sec < 60) return `${sec}s`
@@ -148,10 +148,10 @@ function formatUptime(sec: number): string {
if (h > 0) return `${h}h ${m}m` if (h > 0) return `${h}h ${m}m`
return `${m}m` return `${m}m`
} }
function relativeFromIso(iso: string): string { function relativeFromIso(iso: string, t: (k: string, v?: Record<string, unknown>) => string): string {
const t = new Date(iso).getTime() const ts = new Date(iso).getTime()
if (!t) return '' if (!ts) return ''
return relativeTime(Math.floor(t / 1000)) return relativeTime(Math.floor(ts / 1000), t)
} }
// ── Page ────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────
@@ -438,7 +438,7 @@ export default function DashboardPage() {
<Tag color="blue" style={{ margin: 0, fontSize: 10 }}>{e.action}</Tag> <Tag color="blue" style={{ margin: 0, fontSize: 10 }}>{e.action}</Tag>
<Text style={{ fontSize: 12 }}><b>{e.actor}</b></Text> <Text style={{ fontSize: 12 }}><b>{e.actor}</b></Text>
{e.subject && <Text type="secondary" style={{ fontSize: 11 }}>{e.subject}</Text>} {e.subject && <Text type="secondary" style={{ fontSize: 11 }}>{e.subject}</Text>}
<Text type="secondary" style={{ fontSize: 11, marginLeft: 'auto' }}>{relativeFromIso(e.created_at)}</Text> <Text type="secondary" style={{ fontSize: 11, marginLeft: 'auto' }}>{relativeFromIso(e.created_at, t)}</Text>
</Space> </Space>
</div> </div>
))} ))}
@@ -493,7 +493,7 @@ export default function DashboardPage() {
{status.map(s => ( {status.map(s => (
<div key={s.peer_public_key} style={{ fontSize: 12, color: '#64748B', marginTop: 2 }}> <div key={s.peer_public_key} style={{ fontSize: 12, color: '#64748B', marginTop: 2 }}>
{s.endpoint && <span>{s.endpoint} · </span>} {s.endpoint && <span>{s.endpoint} · </span>}
<span>{relativeTime(s.last_handshake_unix)}</span> <span>{relativeTime(s.last_handshake_unix, t)}</span>
{' · ▼'}{formatBytes(s.transfer_rx)}{' · ▲'}{formatBytes(s.transfer_tx)} {' · ▼'}{formatBytes(s.transfer_rx)}{' · ▲'}{formatBytes(s.transfer_tx)}
</div> </div>
))} ))}

View File

@@ -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, t: (k: string, v?: Record<string, unknown>) => string): string {
if (!unix) return t('common.relTime.never')
const sec = Math.max(0, Math.floor(Date.now() / 1000) - unix)
if (sec < 60) return t('common.relTime.Xs', { n: sec })
if (sec < 3600) return t('common.relTime.Xm', { n: Math.floor(sec / 60) })
if (sec < 86400) return t('common.relTime.Xh', { n: Math.floor(sec / 3600) })
return t('common.relTime.Xd', { n: Math.floor(sec / 86400) })
}
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)}
</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,

View File

@@ -79,13 +79,13 @@ function fmtBytes(n: number): string {
while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ } while (v >= 1024 && i < u.length - 1) { v /= 1024; i++ }
return `${v.toFixed(1)} ${u[i]}` return `${v.toFixed(1)} ${u[i]}`
} }
function relTime(unix: number, never: string): string { function relTime(unix: number, t: (k: string, v?: Record<string, unknown>) => string): string {
if (!unix) return never if (!unix) return t('common.relTime.never')
const sec = Math.max(0, Math.floor(Date.now() / 1000) - unix) const sec = Math.max(0, Math.floor(Date.now() / 1000) - unix)
if (sec < 60) return `vor ${sec}s` if (sec < 60) return t('common.relTime.Xs', { n: sec })
if (sec < 3600) return `vor ${Math.floor(sec / 60)}m` if (sec < 3600) return t('common.relTime.Xm', { n: Math.floor(sec / 60) })
if (sec < 86400) return `vor ${Math.floor(sec / 3600)}h` if (sec < 86400) return t('common.relTime.Xh', { n: Math.floor(sec / 3600) })
return `vor ${Math.floor(sec / 86400)}d` return t('common.relTime.Xd', { n: Math.floor(sec / 86400) })
} }
interface FwZoneLite { name: string; builtin: boolean } interface FwZoneLite { name: string; builtin: boolean }
@@ -405,7 +405,7 @@ function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
inactiveLabel={t('wg.peer.offline')} inactiveLabel={t('wg.peer.offline')}
/> />
<Text type="secondary" style={{ fontSize: 11 }}> <Text type="secondary" style={{ fontSize: 11 }}>
{live ? relTime(live.last_handshake_unix, t('wg.peer.never')) : t('wg.peer.never')} {live ? relTime(live.last_handshake_unix, t) : t('common.relTime.never')}
</Text> </Text>
</Space> </Space>
) )