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:
@@ -39,7 +39,7 @@ import (
|
|||||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.11"
|
var version = "1.0.12"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.11"
|
var version = "1.0.12"
|
||||||
|
|
||||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.11"
|
var version = "1.0.12"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Printf("edgeguard-scheduler %s starting", version)
|
log.Printf("edgeguard-scheduler %s starting", version)
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
qrcode "github.com/skip2/go-qrcode"
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
@@ -76,6 +78,63 @@ func (h *WireguardHandler) Register(rg *gin.RouterGroup) {
|
|||||||
// embeds the same text so mobile apps can import directly.
|
// embeds the same text so mobile apps can import directly.
|
||||||
g.GET("/peers/:pid/config", h.PeerConfig)
|
g.GET("/peers/:pid/config", h.PeerConfig)
|
||||||
g.GET("/peers/:pid/qr", h.PeerQR)
|
g.GET("/peers/:pid/qr", h.PeerQR)
|
||||||
|
|
||||||
|
// Live runtime status from `wg show <iface> dump`. Returns one
|
||||||
|
// row per (iface, peer) with last_handshake + transfer counters.
|
||||||
|
// Polled by the UI every 10s; no DB write.
|
||||||
|
g.GET("/status", h.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live wg-show status ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
// wgStatus is the wire shape returned to the UI. We don't update
|
||||||
|
// the DB rows from this — kernel state is the source of truth at
|
||||||
|
// the moment of the call, the DB is metadata.
|
||||||
|
type wgStatus struct {
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
PeerPublicKey string `json:"peer_public_key"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
AllowedIPs string `json:"allowed_ips,omitempty"`
|
||||||
|
LastHandshake int64 `json:"last_handshake_unix"` // 0 = never
|
||||||
|
TransferRX int64 `json:"transfer_rx"`
|
||||||
|
TransferTX int64 `json:"transfer_tx"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WireguardHandler) Status(c *gin.Context) {
|
||||||
|
// `wg show all dump` per iface — output:
|
||||||
|
// line 1: iface_private_key, iface_pubkey, listen_port, fwmark
|
||||||
|
// line 2..N: pubkey, psk, endpoint, allowed_ips, latest_handshake, rx, tx, persistent_keepalive
|
||||||
|
out, err := exec.CommandContext(c.Request.Context(), "sudo", "-n", "/usr/bin/wg", "show", "all", "dump").Output()
|
||||||
|
if err != nil {
|
||||||
|
// wg not installed or no ifaces up — return empty list, not error.
|
||||||
|
response.OK(c, gin.H{"status": []wgStatus{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows := []wgStatus{}
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Split(line, "\t")
|
||||||
|
// Lines starting an iface have 5 columns; peer lines have 9.
|
||||||
|
if len(fields) != 9 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ifaceName := fields[0]
|
||||||
|
hs, _ := strconv.ParseInt(fields[5], 10, 64)
|
||||||
|
rx, _ := strconv.ParseInt(fields[6], 10, 64)
|
||||||
|
tx, _ := strconv.ParseInt(fields[7], 10, 64)
|
||||||
|
rows = append(rows, wgStatus{
|
||||||
|
Interface: ifaceName,
|
||||||
|
PeerPublicKey: fields[2],
|
||||||
|
Endpoint: fields[3],
|
||||||
|
AllowedIPs: fields[4],
|
||||||
|
LastHandshake: hs,
|
||||||
|
TransferRX: rx,
|
||||||
|
TransferTX: tx,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"status": rows})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Keygen ────────────────────────────────────────────────────────
|
// ── Keygen ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -246,8 +246,8 @@ func parseWGConf(path string) (*parsedConf, error) {
|
|||||||
}
|
}
|
||||||
out.Peers = append(out.Peers, *currentPeer)
|
out.Peers = append(out.Peers, *currentPeer)
|
||||||
currentPeer = nil
|
currentPeer = nil
|
||||||
|
nextName = ""
|
||||||
}
|
}
|
||||||
nextName = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
sc := bufio.NewScanner(f)
|
sc := bufio.NewScanner(f)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "edgeguard-management-ui",
|
"name": "edgeguard-management-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.11",
|
"version": "1.0.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { NavLink } from 'react-router-dom'
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
BranchesOutlined,
|
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
DatabaseOutlined,
|
DatabaseOutlined,
|
||||||
@@ -41,9 +40,10 @@ const NAV: NavSection[] = [
|
|||||||
{
|
{
|
||||||
labelKey: 'nav.section.routing',
|
labelKey: 'nav.section.routing',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
|
{ path: '/domains', labelKey: 'nav.domains', icon: <GlobalOutlined /> },
|
||||||
{ path: '/backends', labelKey: 'nav.backends', icon: <DatabaseOutlined /> },
|
{ path: '/backends', labelKey: 'nav.backends', icon: <DatabaseOutlined /> },
|
||||||
{ path: '/routing-rules', labelKey: 'nav.routing', icon: <BranchesOutlined /> },
|
// /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) {
|
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
@@ -179,6 +179,7 @@
|
|||||||
},
|
},
|
||||||
"domains": {
|
"domains": {
|
||||||
"title": "Domains",
|
"title": "Domains",
|
||||||
|
"intro": "Verwalte FQDNs, die HAProxy terminiert. Optionales Primary-Backend als Catch-all; Pfad-Routing via Routing-Regeln.",
|
||||||
"addDomain": "Domain hinzufügen",
|
"addDomain": "Domain hinzufügen",
|
||||||
"editDomain": "Domain bearbeiten",
|
"editDomain": "Domain bearbeiten",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
},
|
},
|
||||||
"backends": {
|
"backends": {
|
||||||
"title": "Backends",
|
"title": "Backends",
|
||||||
|
"intro": "Upstream-Server, an die HAProxy weiterroutet. Health-Check-Pfad optional aktiviert TCP+HTTP-Probes alle 5s.",
|
||||||
"addBackend": "Backend hinzufügen",
|
"addBackend": "Backend hinzufügen",
|
||||||
"editBackend": "Backend bearbeiten",
|
"editBackend": "Backend bearbeiten",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -216,6 +218,7 @@
|
|||||||
},
|
},
|
||||||
"routing": {
|
"routing": {
|
||||||
"title": "Routing-Regeln",
|
"title": "Routing-Regeln",
|
||||||
|
"intro": "Pfad-Präfix → Backend-Mapping pro Domain. Niedrige Priority gewinnt; Catch-all per Domain.primary_backend.",
|
||||||
"addRule": "Regel hinzufügen",
|
"addRule": "Regel hinzufügen",
|
||||||
"editRule": "Regel bearbeiten",
|
"editRule": "Regel bearbeiten",
|
||||||
"domain": "Domain",
|
"domain": "Domain",
|
||||||
@@ -342,7 +345,54 @@
|
|||||||
"pskOff": "kein PSK",
|
"pskOff": "kein PSK",
|
||||||
"downloadConf": "wg-quick.conf herunterladen",
|
"downloadConf": "wg-quick.conf herunterladen",
|
||||||
"qrTitle": "WireGuard-QR",
|
"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": {
|
"common": {
|
||||||
|
|||||||
@@ -179,6 +179,7 @@
|
|||||||
},
|
},
|
||||||
"domains": {
|
"domains": {
|
||||||
"title": "Domains",
|
"title": "Domains",
|
||||||
|
"intro": "Manage FQDNs that HAProxy terminates. Optional primary backend as catch-all; path-based routing via routing rules.",
|
||||||
"addDomain": "Add domain",
|
"addDomain": "Add domain",
|
||||||
"editDomain": "Edit domain",
|
"editDomain": "Edit domain",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
},
|
},
|
||||||
"backends": {
|
"backends": {
|
||||||
"title": "Backends",
|
"title": "Backends",
|
||||||
|
"intro": "Upstream servers HAProxy proxies to. Optional health-check path enables TCP + HTTP probes every 5s.",
|
||||||
"addBackend": "Add backend",
|
"addBackend": "Add backend",
|
||||||
"editBackend": "Edit backend",
|
"editBackend": "Edit backend",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -216,6 +218,7 @@
|
|||||||
},
|
},
|
||||||
"routing": {
|
"routing": {
|
||||||
"title": "Routing rules",
|
"title": "Routing rules",
|
||||||
|
"intro": "Path-prefix → backend mapping per domain. Lowest priority wins; catch-all via domain.primary_backend.",
|
||||||
"addRule": "Add rule",
|
"addRule": "Add rule",
|
||||||
"editRule": "Edit rule",
|
"editRule": "Edit rule",
|
||||||
"domain": "Domain",
|
"domain": "Domain",
|
||||||
@@ -342,7 +345,54 @@
|
|||||||
"pskOff": "no PSK",
|
"pskOff": "no PSK",
|
||||||
"downloadConf": "Download wg-quick.conf",
|
"downloadConf": "Download wg-quick.conf",
|
||||||
"qrTitle": "WireGuard QR",
|
"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": {
|
"common": {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState } from 'react'
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { DatabaseOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import DataTable from '../../components/DataTable'
|
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'
|
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>
|
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) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<ActionButtons
|
||||||
<Button size="small" onClick={() => {
|
onEdit={() => {
|
||||||
setEditing(row)
|
setEditing(row)
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -181,29 +185,35 @@ export default function BackendsPage() {
|
|||||||
active: row.active,
|
active: row.active,
|
||||||
domain_ids: domainsForBackend(row.id).map(d => d.id),
|
domain_ids: domainsForBackend(row.id).map(d => d.id),
|
||||||
})
|
})
|
||||||
}}>{t('common.edit')}</Button>
|
}}
|
||||||
<Popconfirm
|
onDelete={() => del.mutate(row.id)}
|
||||||
title={t('backends.deleteConfirm', { name: row.name })}
|
deleteConfirm={t('backends.deleteConfirm', { name: row.name })}
|
||||||
onConfirm={() => del.mutate(row.id)}
|
/>
|
||||||
>
|
|
||||||
<Button size="small" danger>{t('common.delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3}>{t('backends.title')}</Typography.Title>
|
<PageHeader
|
||||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
icon={<DatabaseOutlined />}
|
||||||
setCreating(true)
|
title={t('backends.title')}
|
||||||
form.resetFields()
|
subtitle={t('backends.intro')}
|
||||||
form.setFieldsValue({ scheme: 'http', port: 8080, active: true })
|
/>
|
||||||
}}>
|
<DataTable
|
||||||
{t('backends.addBackend')}
|
rowKey="id"
|
||||||
</Button>
|
loading={isLoading}
|
||||||
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
|
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
|
<Modal
|
||||||
title={editing ? t('backends.editBackend') : t('backends.addBackend')}
|
title={editing ? t('backends.editBackend') : t('backends.addBackend')}
|
||||||
open={editing !== null || creating}
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { ApartmentOutlined } from '@ant-design/icons'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import DataTable from '../../components/DataTable'
|
import DataTable from '../../components/DataTable'
|
||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
|
||||||
import apiClient, { isEnvelope } from '../../api/client'
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
|
|
||||||
@@ -58,17 +60,13 @@ export default function ClusterPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3}>{t('cluster.title')}</Typography.Title>
|
<PageHeader
|
||||||
<Typography.Paragraph type="secondary">
|
icon={<ApartmentOutlined />}
|
||||||
{t('cluster.intro', { count: data?.nodes.length ?? 0 })}
|
title={t('cluster.title')}
|
||||||
</Typography.Paragraph>
|
subtitle={t('cluster.intro', { count: data?.nodes.length ?? 0 })}
|
||||||
<Card>
|
/>
|
||||||
<DataTable
|
<Card size="small">
|
||||||
rowKey="id"
|
<DataTable rowKey="id" columns={columns} dataSource={data?.nodes ?? []} />
|
||||||
columns={columns}
|
|
||||||
dataSource={data?.nodes ?? []}
|
|
||||||
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState } from 'react'
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { GlobalOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import DataTable from '../../components/DataTable'
|
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'
|
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',
|
title: t('domains.primaryBackend'), dataIndex: 'primary_backend_id', key: 'primary_backend_id',
|
||||||
render: (id?: number | null) => {
|
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)
|
const b = backendById(id)
|
||||||
return b
|
return b
|
||||||
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
|
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
|
||||||
: <Tag color="orange">#{id}</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.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) => v ? t('common.yes') : t('common.no') },
|
{ 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) => v ? t('common.yes') : t('common.no') },
|
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => <StatusDot active={v} /> },
|
||||||
{
|
{
|
||||||
title: t('domains.actions'),
|
title: t('common.actions'), key: 'actions',
|
||||||
key: 'actions',
|
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<ActionButtons
|
||||||
<Button size="small" onClick={() => {
|
onEdit={() => {
|
||||||
setEditing(row)
|
setEditing(row)
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -128,34 +131,34 @@ export default function DomainsPage() {
|
|||||||
primary_backend_id: row.primary_backend_id ?? null,
|
primary_backend_id: row.primary_backend_id ?? null,
|
||||||
notes: row.notes ?? '',
|
notes: row.notes ?? '',
|
||||||
})
|
})
|
||||||
}}>{t('domains.edit')}</Button>
|
}}
|
||||||
<Popconfirm
|
onDelete={() => del.mutate(row.id)}
|
||||||
title={t('domains.deleteConfirm', { name: row.name })}
|
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
|
||||||
onConfirm={() => del.mutate(row.id)}
|
/>
|
||||||
>
|
|
||||||
<Button size="small" danger>{t('domains.delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3}>{t('domains.title')}</Typography.Title>
|
<PageHeader
|
||||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
icon={<GlobalOutlined />}
|
||||||
setCreating(true)
|
title={t('domains.title')}
|
||||||
form.resetFields()
|
subtitle={t('domains.intro')}
|
||||||
form.setFieldsValue({ active: true, http_to_https: true, hsts_enabled: false })
|
/>
|
||||||
}}>
|
|
||||||
{t('domains.addDomain')}
|
|
||||||
</Button>
|
|
||||||
<DataTable
|
<DataTable
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
dataSource={data ?? []}
|
dataSource={data ?? []}
|
||||||
columns={columns}
|
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
|
<Modal
|
||||||
title={editing ? t('domains.editDomain') : t('domains.addDomain')}
|
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 { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
import AddressObjectsTab from './AddressObjects'
|
import AddressObjectsTab from './AddressObjects'
|
||||||
import AddressGroupsTab from './AddressGroups'
|
import AddressGroupsTab from './AddressGroups'
|
||||||
import ServicesTab from './Services'
|
import ServicesTab from './Services'
|
||||||
@@ -24,8 +26,11 @@ export default function FirewallPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3}>{t('fw.title')}</Typography.Title>
|
<PageHeader
|
||||||
<Typography.Paragraph type="secondary">{t('fw.intro')}</Typography.Paragraph>
|
icon={<FireOutlined />}
|
||||||
|
title={t('fw.title')}
|
||||||
|
subtitle={t('fw.intro')}
|
||||||
|
/>
|
||||||
<Tabs items={tabs} defaultActiveKey="rules" />
|
<Tabs items={tabs} defaultActiveKey="rules" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useState } from 'react'
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
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>
|
? <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.description'), dataIndex: 'description', key: 'desc', render: (v?: string) => v ?? '—' },
|
||||||
{
|
{
|
||||||
title: t('ips.actions'), key: 'actions',
|
title: t('common.actions'), key: 'actions',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<ActionButtons
|
||||||
<Button size="small" onClick={() => {
|
onEdit={() => {
|
||||||
setEditing(row)
|
setEditing(row)
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
interface_id: row.interface_id,
|
interface_id: row.interface_id,
|
||||||
@@ -147,24 +151,23 @@ export default function IPAddressesPage() {
|
|||||||
description: row.description ?? undefined,
|
description: row.description ?? undefined,
|
||||||
active: row.active,
|
active: row.active,
|
||||||
})
|
})
|
||||||
}}>{t('common.edit')}</Button>
|
}}
|
||||||
<Popconfirm
|
onDelete={() => del.mutate(row.id)}
|
||||||
title={t('ips.deleteConfirm', { addr: row.address })}
|
deleteConfirm={t('ips.deleteConfirm', { addr: row.address })}
|
||||||
onConfirm={() => del.mutate(row.id)}
|
/>
|
||||||
>
|
|
||||||
<Button size="small" danger>{t('common.delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3}>{t('ips.title')}</Typography.Title>
|
<PageHeader
|
||||||
<Typography.Paragraph type="secondary">{t('ips.intro')}</Typography.Paragraph>
|
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
|
{(sysAddrs ?? []).length === 0
|
||||||
? <Typography.Text type="secondary">—</Typography.Text>
|
? <Typography.Text type="secondary">—</Typography.Text>
|
||||||
: (
|
: (
|
||||||
@@ -184,13 +187,20 @@ export default function IPAddressesPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 8 }}>{t('ips.managedTitle')}</Typography.Title>
|
<Typography.Title level={5} style={{ marginTop: 8 }}>{t('ips.managedTitle')}</Typography.Title>
|
||||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
<DataTable
|
||||||
setCreating(true); form.resetFields()
|
rowKey="id"
|
||||||
form.setFieldsValue({ prefix: 24, is_vip: false, active: true })
|
loading={isLoading}
|
||||||
}}>
|
dataSource={ips ?? []}
|
||||||
{t('ips.addAddress')}
|
columns={columns}
|
||||||
</Button>
|
extraActions={
|
||||||
<Table rowKey="id" loading={isLoading} dataSource={ips ?? []} columns={columns} />
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
|
setCreating(true); form.resetFields()
|
||||||
|
form.setFieldsValue({ prefix: 24, is_vip: false, active: true })
|
||||||
|
}}>
|
||||||
|
{t('ips.addAddress')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? t('ips.editAddress') : t('ips.addAddress')}
|
title={editing ? t('ips.editAddress') : t('ips.addAddress')}
|
||||||
open={editing !== null || creating}
|
open={editing !== null || creating}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState } from 'react'
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { ClusterOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import DataTable from '../../components/DataTable'
|
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'
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
|
|
||||||
@@ -124,12 +128,12 @@ export default function NetworksPage() {
|
|||||||
render: (r: string) => <Tag color={roleColor(r)}>{r.toUpperCase()}</Tag>,
|
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.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) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<ActionButtons
|
||||||
<Button size="small" onClick={() => {
|
onEdit={() => {
|
||||||
setEditing(row)
|
setEditing(row)
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
name: row.name, type: row.type, parent: row.parent ?? undefined,
|
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,
|
mtu: row.mtu ?? undefined, active: row.active,
|
||||||
description: row.description ?? undefined,
|
description: row.description ?? undefined,
|
||||||
})
|
})
|
||||||
}}>{t('common.edit')}</Button>
|
}}
|
||||||
<Popconfirm
|
onDelete={() => del.mutate(row.id)}
|
||||||
title={t('networks.deleteConfirm', { name: row.name })}
|
deleteConfirm={t('networks.deleteConfirm', { name: row.name })}
|
||||||
onConfirm={() => del.mutate(row.id)}
|
/>
|
||||||
>
|
|
||||||
<Button size="small" danger>{t('common.delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3}>{t('networks.title')}</Typography.Title>
|
<PageHeader
|
||||||
<Typography.Paragraph type="secondary">{t('networks.intro')}</Typography.Paragraph>
|
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>
|
<Space wrap>
|
||||||
{(sys ?? []).map((i) => {
|
{(sys ?? []).map((i) => {
|
||||||
const v4 = (i.addr_info ?? []).filter((a) => a.family === 'inet').map((a) => `${a.local}/${a.prefixlen}`)
|
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>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
<DataTable
|
||||||
setCreating(true); form.resetFields()
|
rowKey="id"
|
||||||
form.setFieldsValue({ type: 'ethernet', role: 'lan', active: true })
|
loading={isLoading}
|
||||||
}}>
|
dataSource={ifs ?? []}
|
||||||
{t('networks.addInterface')}
|
columns={columns}
|
||||||
</Button>
|
extraActions={
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
<DataTable rowKey="id" loading={isLoading} dataSource={ifs ?? []} columns={columns} />
|
setCreating(true); form.resetFields()
|
||||||
|
form.setFieldsValue({ type: 'ethernet', role: 'lan', active: true })
|
||||||
|
}}>
|
||||||
|
{t('networks.addInterface')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editing ? t('networks.editInterface') : t('networks.addInterface')}
|
title={editing ? t('networks.editInterface') : t('networks.addInterface')}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState } from 'react'
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { BranchesOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import DataTable from '../../components/DataTable'
|
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'
|
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.pathPrefix'), dataIndex: 'path_prefix', key: 'path' },
|
||||||
{ title: t('routing.backend'), dataIndex: 'backend_id', key: 'backend', render: (id: number) => backendLabel(id) },
|
{ 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.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) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<ActionButtons
|
||||||
<Button size="small" onClick={() => {
|
onEdit={() => {
|
||||||
setEditing(row)
|
setEditing(row)
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
domain_id: row.domain_id,
|
domain_id: row.domain_id,
|
||||||
@@ -107,29 +111,35 @@ export default function RoutingRulesPage() {
|
|||||||
priority: row.priority,
|
priority: row.priority,
|
||||||
active: row.active,
|
active: row.active,
|
||||||
})
|
})
|
||||||
}}>{t('common.edit')}</Button>
|
}}
|
||||||
<Popconfirm
|
onDelete={() => del.mutate(row.id)}
|
||||||
title={t('routing.deleteConfirm')}
|
deleteConfirm={t('routing.deleteConfirm')}
|
||||||
onConfirm={() => del.mutate(row.id)}
|
/>
|
||||||
>
|
|
||||||
<Button size="small" danger>{t('common.delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
</Space>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3}>{t('routing.title')}</Typography.Title>
|
<PageHeader
|
||||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => {
|
icon={<BranchesOutlined />}
|
||||||
setCreating(true)
|
title={t('routing.title')}
|
||||||
form.resetFields()
|
subtitle={t('routing.intro')}
|
||||||
form.setFieldsValue({ priority: 100, path_prefix: '/', active: true })
|
/>
|
||||||
}}>
|
<DataTable
|
||||||
{t('routing.addRule')}
|
rowKey="id"
|
||||||
</Button>
|
loading={isLoading}
|
||||||
<DataTable rowKey="id" loading={isLoading} dataSource={rules ?? []} columns={columns} />
|
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
|
<Modal
|
||||||
title={editing ? t('routing.editRule') : t('routing.addRule')}
|
title={editing ? t('routing.editRule') : t('routing.addRule')}
|
||||||
open={editing !== null || creating}
|
open={editing !== null || creating}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { useState } from 'react'
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
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) => (
|
render: (_, row) => (
|
||||||
<Popconfirm
|
<ActionButtons
|
||||||
title={t('ssl.deleteConfirm', { domain: row.domain })}
|
onDelete={() => delMut.mutate(row.id)}
|
||||||
onConfirm={() => delMut.mutate(row.id)}
|
deleteConfirm={t('ssl.deleteConfirm', { domain: row.domain })}
|
||||||
>
|
/>
|
||||||
<Button size="small" danger>{t('common.delete')}</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -187,8 +188,11 @@ export default function SSLPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3}>{t('ssl.title')}</Typography.Title>
|
<PageHeader
|
||||||
<Typography.Paragraph type="secondary">{t('ssl.intro')}</Typography.Paragraph>
|
icon={<SafetyCertificateOutlined />}
|
||||||
|
title={t('ssl.title')}
|
||||||
|
subtitle={t('ssl.intro')}
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs items={tabs} defaultActiveKey="letsencrypt" />
|
<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 { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import apiClient, { isEnvelope } from '../../api/client'
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
|
||||||
interface SetupStatus {
|
interface SetupStatus {
|
||||||
completed: boolean
|
completed: boolean
|
||||||
@@ -42,17 +44,20 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={3}>{t('settings.title')}</Typography.Title>
|
<PageHeader
|
||||||
<Typography.Paragraph type="secondary">{t('settings.intro')}</Typography.Paragraph>
|
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 column={1}>
|
||||||
<Descriptions.Item label={t('settings.version')}>{health?.version ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label={t('settings.version')}>{health?.version ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label={t('settings.status')}>{health?.status ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label={t('settings.status')}>{health?.status ?? '—'}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title={t('settings.setupInfo')}>
|
<Card title={t('settings.setupInfo')} size="small">
|
||||||
<Descriptions column={1}>
|
<Descriptions column={1}>
|
||||||
<Descriptions.Item label={t('settings.adminEmail')}>{setupStatus?.admin_email ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label={t('settings.adminEmail')}>{setupStatus?.admin_email ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label={t('settings.fqdn')}>{setupStatus?.fqdn ?? '—'}</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 ?? []
|
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 }
|
interface FwZoneLite { name: string; builtin: boolean }
|
||||||
async function listZones(): Promise<FwZoneLite[]> {
|
async function listZones(): Promise<FwZoneLite[]> {
|
||||||
const r = await apiClient.get('/firewall/zones')
|
const r = await apiClient.get('/firewall/zones')
|
||||||
@@ -270,6 +300,14 @@ function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
|
|||||||
queryFn: () => listPeers(ifaceID),
|
queryFn: () => listPeers(ifaceID),
|
||||||
enabled: open,
|
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 [editing, setEditing] = useState<WGPeer | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
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>,
|
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',
|
title: t('wg.peer.lastHandshake'), key: 'last_handshake',
|
||||||
render: (s?: string | null) => s ? new Date(s).toLocaleString() : <Tag>{t('wg.peer.never')}</Tag>,
|
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} /> },
|
{ title: t('common.active'), dataIndex: 'enabled', key: 'enabled', render: (v: boolean) => <StatusDot active={v} /> },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop wg-quick@*.service
|
|||||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl start wg-quick@*.service
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl start wg-quick@*.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart wg-quick@*.service
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart wg-quick@*.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop wg-quick@*.service
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop wg-quick@*.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show *
|
||||||
SUDOERS
|
SUDOERS
|
||||||
chmod 0440 /etc/sudoers.d/edgeguard
|
chmod 0440 /etc/sudoers.d/edgeguard
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user