4 Commits

Author SHA1 Message Date
Debian
ba907f5403 chore: bump version 1.1.74
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:10:07 +02:00
Debian
e419eceab8 feat(ui): Quick-Toggles überall vervollständigt
NTP-Pools, User-Accounts, Backend-Server im Detail-Panel und
Routing-Rules im Domain-Detail-Panel — alle aktiv-Felder sind
jetzt inline schaltbar, kein Modale nötig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:09:48 +02:00
Debian
784533eb99 feat(networks+ips): Quick-Toggles für Netzwerk-Interfaces und IP-Adressen
Inline Switch statt read-only StatusDot — konsistent mit allen anderen Seiten.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:07:32 +02:00
Debian
b93f4e6361 feat(wireguard): Quick-Toggles für Interfaces + Peers
Inline Switch statt read-only StatusDot — konsistent mit allen anderen Seiten.
Server-Interfaces, Client-Interfaces und Peers im Peer-Drawer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:06:19 +02:00
10 changed files with 164 additions and 19 deletions

View File

@@ -1 +1 @@
1.1.73 1.1.74

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.73" var version = "1.1.74"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -12,7 +12,6 @@ import { useTranslation } from 'react-i18next'
import apiClient, { isEnvelope } from '../../api/client' import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader' import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons' import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
const { Text } = Typography const { Text } = Typography
@@ -275,6 +274,14 @@ function ServerPanel({ backendID, haproxyStats }: { backendID: number; haproxySt
}, },
onError: (e: Error) => message.error(e.message), onError: (e: Error) => message.error(e.message),
}) })
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: BackendServer; checked: boolean }) => {
const { id: _id, ...body } = row
await apiClient.put(`/backend-servers/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<BackendServer> = [ const cols: ColumnsType<BackendServer> = [
{ {
@@ -292,7 +299,14 @@ function ServerPanel({ backendID, haproxyStats }: { backendID: number; haproxySt
}, },
{ {
title: t('backends.active'), dataIndex: 'active', width: 80, title: t('backends.active'), dataIndex: 'active', width: 80,
render: (v: boolean) => <StatusDot active={v} />, render: (v: boolean, r: BackendServer) => (
<Switch
size="small"
checked={v}
loading={quickToggle.isPending && quickToggle.variables?.id === r.id}
onChange={(checked) => quickToggle.mutate({ id: r.id, row: r, checked })}
/>
),
}, },
{ {
title: 'Live', key: 'live', width: 160, title: 'Live', key: 'live', width: 160,

View File

@@ -406,6 +406,12 @@ function RoutingRulesPanel({ domainID, domainName }: { domainID: number; domainN
onSuccess: invalidate, onSuccess: invalidate,
onError: (e: Error) => message.error(e.message), onError: (e: Error) => message.error(e.message),
}) })
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: RoutingRule; checked: boolean }) =>
apiClient.put(`/routing-rules/${id}`, { ...row, active: checked }),
onSuccess: invalidate,
onError: (e: Error) => message.error(e.message),
})
const backendLabel = (id: number) => { const backendLabel = (id: number) => {
const b = backends?.find(x => x.id === id) const b = backends?.find(x => x.id === id)
@@ -418,7 +424,14 @@ function RoutingRulesPanel({ domainID, domainName }: { domainID: number; domainN
{ title: t('routing.priority'), dataIndex: 'priority', key: 'priority', width: 90 }, { title: t('routing.priority'), dataIndex: 'priority', key: 'priority', width: 90 },
{ {
title: t('routing.active'), dataIndex: 'active', key: 'active', width: 80, title: t('routing.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean) => <StatusDot active={v} />, render: (v: boolean, r: RoutingRule) => (
<Switch
size="small"
checked={v}
loading={quickToggle.isPending && quickToggle.variables?.id === r.id}
onChange={(checked) => quickToggle.mutate({ id: r.id, row: r, checked })}
/>
),
}, },
{ {
title: t('common.actions'), key: 'a', width: 90, title: t('common.actions'), key: 'a', width: 90,

View File

@@ -4,7 +4,6 @@ import { NodeIndexOutlined, PlusOutlined } from '@ant-design/icons'
import PageHeader from '../../components/PageHeader' import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons' import ActionButtons from '../../components/ActionButtons'
import EmptyState from '../../components/EmptyState' import EmptyState from '../../components/EmptyState'
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'
@@ -120,6 +119,14 @@ export default function IPAddressesPage() {
void qc.invalidateQueries({ queryKey: ['ip-addresses'] }) void qc.invalidateQueries({ queryKey: ['ip-addresses'] })
}, },
}) })
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: IPAddress; checked: boolean }) => {
const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row
await apiClient.put(`/ip-addresses/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ip-addresses'] }) },
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({ const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/ip-addresses/${id}`) }, mutationFn: async (id: number) => { await apiClient.delete(`/ip-addresses/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ip-addresses'] }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ip-addresses'] }) },
@@ -137,7 +144,17 @@ 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) => <StatusDot active={v} /> }, {
title: t('ips.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, row: IPAddress) => (
<Switch
size="small"
checked={v}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/>
),
},
{ 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('common.actions'), key: 'actions', title: t('common.actions'), key: 'actions',

View File

@@ -12,7 +12,6 @@ import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState' import EmptyState from '../../components/EmptyState'
import PageHeader from '../../components/PageHeader' import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons' import ActionButtons from '../../components/ActionButtons'
import StatusDot from '../../components/StatusDot'
const { Text } = Typography const { Text } = Typography
@@ -184,6 +183,14 @@ function PoolsTab() {
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] }) },
onError: (e: Error) => message.error(e.message), onError: (e: Error) => message.error(e.message),
}) })
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: Pool; checked: boolean }) => {
const { id: _id, ...body } = row
await apiClient.put(`/ntp/pools/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<Pool> = [ const cols: ColumnsType<Pool> = [
{ title: t('ntp.pool.kind'), dataIndex: 'kind', key: 'kind', { title: t('ntp.pool.kind'), dataIndex: 'kind', key: 'kind',
@@ -201,8 +208,17 @@ function PoolsTab() {
) }, ) },
{ title: t('ntp.pool.description'), dataIndex: 'description', key: 'description', { title: t('ntp.pool.description'), dataIndex: 'description', key: 'description',
render: (v?: string | null) => v ?? '—' }, render: (v?: string | null) => v ?? '—' },
{ title: t('common.active'), dataIndex: 'active', key: 'active', {
render: (v: boolean) => <StatusDot active={v} /> }, title: t('common.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, row: Pool) => (
<Switch
size="small"
checked={v}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/>
),
},
{ {
title: t('common.actions'), key: 'actions', title: t('common.actions'), key: 'actions',
render: (_, row) => ( render: (_, row) => (

View File

@@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next'
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 apiClient, { isEnvelope } from '../../api/client' import apiClient, { isEnvelope } from '../../api/client'
@@ -107,6 +106,14 @@ export default function InterfacesTab() {
mutationFn: async (id: number) => { await apiClient.delete(`/network-interfaces/${id}`) }, mutationFn: async (id: number) => { await apiClient.delete(`/network-interfaces/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) },
}) })
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: NetworkInterface; checked: boolean }) => {
const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row
await apiClient.put(`/network-interfaces/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) },
onError: (e: Error) => message.error(e.message),
})
// Stable colour palette for role tags. Builtin zones get a fixed // Stable colour palette for role tags. Builtin zones get a fixed
// colour; custom zones cycle through the palette by name hash so // colour; custom zones cycle through the palette by name hash so
@@ -138,7 +145,17 @@ export default function InterfacesTab() {
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) => <StatusDot active={v} /> }, {
title: t('networks.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, row: NetworkInterface) => (
<Switch
size="small"
checked={v}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/>
),
},
{ {
title: t('common.actions'), key: 'actions', title: t('common.actions'), key: 'actions',
render: (_, row) => ( render: (_, row) => (

View File

@@ -13,7 +13,6 @@ import PageHeader from '../../components/PageHeader'
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'
const { Text } = Typography const { Text } = Typography
@@ -74,6 +73,12 @@ export default function UsersPage() {
onSuccess: invalidate, onSuccess: invalidate,
onError: (e: Error) => message.error(e.message), onError: (e: Error) => message.error(e.message),
}) })
const quickToggle = useMutation({
mutationFn: ({ id, row, checked }: { id: number; row: User; checked: boolean }) =>
apiClient.put(`/users/${id}`, { email: row.email, role: row.role, active: checked }),
onSuccess: invalidate,
onError: (e: Error) => message.error(e.message),
})
const roleOptions = [ const roleOptions = [
{ value: 'admin', label: t('users.roleAdmin') }, { value: 'admin', label: t('users.roleAdmin') },
@@ -99,8 +104,15 @@ export default function UsersPage() {
), ),
}, },
{ {
title: t('users.active'), dataIndex: 'active', key: 'active', width: 70, title: t('users.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean) => <StatusDot active={v} />, render: (v: boolean, row: User) => (
<Switch
size="small"
checked={v}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/>
),
}, },
{ {
title: t('users.lastLogin'), key: 'lastLogin', width: 160, title: t('users.lastLogin'), key: 'lastLogin', width: 160,

View File

@@ -12,7 +12,6 @@ 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
@@ -76,6 +75,14 @@ export default function ClientsTab() {
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'clients'] }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'clients'] }) },
onError: (e: Error) => message.error(e.message), onError: (e: Error) => message.error(e.message),
}) })
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: WGInterface; checked: boolean }) => {
const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row
await apiClient.put(`/wireguard/interfaces/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'clients'] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<WGInterface> = [ const cols: ColumnsType<WGInterface> = [
{ title: t('wg.iface.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> }, { title: t('wg.iface.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
@@ -86,7 +93,17 @@ export default function ClientsTab() {
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.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', render: (v: boolean) => <StatusDot active={v} /> }, {
title: t('common.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, row: WGInterface) => (
<Switch
size="small"
checked={v}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/>
),
},
{ {
title: t('common.actions'), key: 'actions', title: t('common.actions'), key: 'actions',
render: (_, row) => ( render: (_, row) => (

View File

@@ -128,6 +128,14 @@ export default function ServersTab() {
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'servers'] }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'servers'] }) },
onError: (e: Error) => message.error(e.message), onError: (e: Error) => message.error(e.message),
}) })
const quickToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: WGInterface; checked: boolean }) => {
const { id: _id, created_at: _ca, updated_at: _ua, ...body } = row
await apiClient.put(`/wireguard/interfaces/${id}`, { ...body, active: checked })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'servers'] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<WGInterface> = [ const cols: ColumnsType<WGInterface> = [
{ title: t('wg.iface.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> }, { title: t('wg.iface.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
@@ -138,7 +146,17 @@ export default function ServersTab() {
render: (k: string) => <Text code copyable={{ text: k }} style={{ fontSize: 11 }}>{k.slice(0, 16)}</Text>, render: (k: string) => <Text code copyable={{ text: k }} style={{ fontSize: 11 }}>{k.slice(0, 16)}</Text>,
}, },
{ 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', render: (v: boolean) => <StatusDot active={v} /> }, {
title: t('common.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, row: WGInterface) => (
<Switch
size="small"
checked={v}
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
/>
),
},
{ {
title: t('common.actions'), key: 'actions', title: t('common.actions'), key: 'actions',
render: (_, row) => ( render: (_, row) => (
@@ -354,6 +372,17 @@ function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'peers', ifaceID] }) }, onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'peers', ifaceID] }) },
onError: (e: Error) => message.error(e.message), onError: (e: Error) => message.error(e.message),
}) })
const peerToggle = useMutation({
mutationFn: async ({ id, row, checked }: { id: number; row: WGPeer; checked: boolean }) => {
await apiClient.put(`/wireguard/peers/${id}`, {
name: row.name, allowed_ips: row.allowed_ips,
keepalive: row.keepalive, enabled: checked,
description: row.description,
})
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['wg', 'peers', ifaceID] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<WGPeer> = [ const cols: ColumnsType<WGPeer> = [
{ title: t('wg.peer.name'), dataIndex: 'name', key: 'name' }, { title: t('wg.peer.name'), dataIndex: 'name', key: 'name' },
@@ -390,7 +419,17 @@ function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
return <Text style={{ fontSize: 11 }}>{fmtBytes(live.transfer_rx)} {fmtBytes(live.transfer_tx)}</Text> 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', width: 80,
render: (v: boolean, row: WGPeer) => (
<Switch
size="small"
checked={v}
loading={peerToggle.isPending && peerToggle.variables?.id === row.id}
onChange={(checked) => peerToggle.mutate({ id: row.id, row, checked })}
/>
),
},
{ {
title: t('common.actions'), key: 'actions', title: t('common.actions'), key: 'actions',
render: (_, row) => ( render: (_, row) => (