feat: Networks-Members für bridge/bond + System-Rules-Card + Theme-Revert
* Migration 0011: members JSONB für network_interfaces. Bridge/bond brauchen ≥1 Member (NOT VALID-Constraint, schont bestehende Rows). vlan/wireguard/ethernet ignorieren das Feld. * Backend-Validation pro Typ: vlan→parent+vlan_id, bridge/bond→members, ethernet/wireguard→keins. Repo serialisiert via JSONB. * Form Networks: Members-Multi-Select für bridge/bond, Composition- Spalte zeigt vlan-tag bzw. Member-Liste. * Firewall-Rules-Tab zeigt jetzt SystemRulesCard ganz oben — Anti- Lockout (SSH/443), stateful baseline, default-deny-Erklärung. * Theme-Tokens 1:1 mail-gateway: fontSize 13, controlHeight 34 (vorher zu dichtes 12/28). Density kommt vom DataTable size="small". * Makefile publish-amd64 lädt jetzt auch edgeguard-ui_*_all.deb und edgeguard_*_all.deb hoch (vorher nur api). * Version 1.0.0 → 1.0.3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
9
Makefile
9
Makefile
@@ -87,10 +87,11 @@ GITEA_DEB_URL := https://git.netcell-it.de/api/packages/projekte/debian/pool/tri
|
||||
publish-amd64: deb-amd64
|
||||
@TOK="$$(cat $$HOME/.gitea-token | tr -d '\n')"; \
|
||||
if [ -z "$$TOK" ]; then echo "publish: ~/.gitea-token is empty"; exit 1; fi; \
|
||||
echo " -> publish edgeguard-api_$(VERSION)_amd64.deb"; \
|
||||
curl -sS -H "Authorization: token $$TOK" \
|
||||
--upload-file build/deb/edgeguard-api_$(VERSION)_amd64.deb \
|
||||
$(GITEA_DEB_URL)
|
||||
for f in edgeguard-api_$(VERSION)_amd64.deb edgeguard-ui_$(VERSION)_all.deb edgeguard_$(VERSION)_all.deb; do \
|
||||
echo " -> publish $$f"; \
|
||||
curl -sS -H "Authorization: token $$TOK" --upload-file build/deb/$$f $(GITEA_DEB_URL); \
|
||||
echo ""; \
|
||||
done
|
||||
|
||||
publish-arm64: deb-arm64
|
||||
@TOK="$$(cat $$HOME/.gitea-token | tr -d '\n')"; \
|
||||
|
||||
@@ -35,7 +35,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
)
|
||||
|
||||
var version = "1.0.0"
|
||||
var version = "1.0.3"
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var version = "1.0.0"
|
||||
var version = "1.0.3"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var version = "1.0.0"
|
||||
var version = "1.0.3"
|
||||
|
||||
func main() {
|
||||
log.Printf("edgeguard-scheduler %s starting", version)
|
||||
|
||||
31
internal/database/migrations/0011_network_members.sql
Normal file
31
internal/database/migrations/0011_network_members.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Multi-member support for bridge/bond. `parent` (single TEXT) was
|
||||
-- enough for VLANs but bridges and bonds aggregate N kernel ifaces.
|
||||
-- We store the member-list as a JSONB array of iface-names so the
|
||||
-- runtime renderer can emit the correct systemd-networkd .network
|
||||
-- files (Bridge=br0 on each member etc.).
|
||||
ALTER TABLE network_interfaces
|
||||
ADD COLUMN IF NOT EXISTS members JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
-- Bridge and bond MUST have ≥ 1 member; vlan/wireguard/ethernet
|
||||
-- ignore the field. NOT VALID skips the back-fill check on the
|
||||
-- existing rows — they may have been declared in an earlier release
|
||||
-- before the field existed and we don't want to break the upgrade.
|
||||
-- Inserts and UPDATEs from now on are validated normally.
|
||||
ALTER TABLE network_interfaces
|
||||
DROP CONSTRAINT IF EXISTS network_interfaces_members_check;
|
||||
ALTER TABLE network_interfaces
|
||||
ADD CONSTRAINT network_interfaces_members_check CHECK (
|
||||
type NOT IN ('bridge', 'bond')
|
||||
OR jsonb_array_length(members) >= 1
|
||||
) NOT VALID;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE network_interfaces DROP CONSTRAINT IF EXISTS network_interfaces_members_check;
|
||||
ALTER TABLE network_interfaces DROP COLUMN IF EXISTS members;
|
||||
-- +goose StatementEnd
|
||||
@@ -66,6 +66,10 @@ func (h *NetworksHandler) Create(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateInterface(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
@@ -85,6 +89,10 @@ func (h *NetworksHandler) Update(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateInterface(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.Update(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, networkifs.ErrNotFound) {
|
||||
@@ -116,6 +124,40 @@ func (h *NetworksHandler) Delete(c *gin.Context) {
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
// validateInterface enforces the per-type rules that the SQL CHECK
|
||||
// constraints alone can't express in a friendly way:
|
||||
// - vlan: parent + vlan_id required
|
||||
// - bridge / bond: ≥ 1 member required, vlan_id forbidden
|
||||
// - ethernet / wireguard: parent + members + vlan_id ignored
|
||||
//
|
||||
// The caller normalises empty members to nil before calling so the
|
||||
// repo always receives [] (NOT NULL).
|
||||
func validateInterface(i *models.NetworkInterface) error {
|
||||
switch i.Type {
|
||||
case "vlan":
|
||||
if i.Parent == nil || *i.Parent == "" {
|
||||
return errors.New("vlan requires parent")
|
||||
}
|
||||
if i.VLANID == nil {
|
||||
return errors.New("vlan requires vlan_id")
|
||||
}
|
||||
i.Members = nil
|
||||
case "bridge", "bond":
|
||||
if len(i.Members) == 0 {
|
||||
return errors.New(i.Type + " requires at least one member interface")
|
||||
}
|
||||
i.Parent = nil
|
||||
i.VLANID = nil
|
||||
case "ethernet", "wireguard":
|
||||
i.Parent = nil
|
||||
i.VLANID = nil
|
||||
i.Members = nil
|
||||
default:
|
||||
return errors.New("unknown interface type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListIPs surfaces the addresses bound to a single interface — UI
|
||||
// uses this for the per-interface IP-list tab.
|
||||
func (h *NetworksHandler) ListIPs(c *gin.Context) {
|
||||
|
||||
@@ -8,6 +8,7 @@ type NetworkInterface struct {
|
||||
Type string `gorm:"column:type" json:"type"`
|
||||
Parent *string `gorm:"column:parent" json:"parent,omitempty"`
|
||||
VLANID *int `gorm:"column:vlan_id" json:"vlan_id,omitempty"`
|
||||
Members []string `gorm:"column:members;type:jsonb" json:"members"`
|
||||
Role string `gorm:"column:role" json:"role"`
|
||||
MTU *int `gorm:"column:mtu" json:"mtu,omitempty"`
|
||||
Active bool `gorm:"column:active" json:"active"`
|
||||
|
||||
@@ -5,6 +5,7 @@ package networkifs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -22,7 +23,7 @@ type Repo struct {
|
||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||
|
||||
const baseSelect = `
|
||||
SELECT id, name, type, parent, vlan_id, role, mtu, active, description,
|
||||
SELECT id, name, type, parent, vlan_id, members, role, mtu, active, description,
|
||||
created_at, updated_at
|
||||
FROM network_interfaces
|
||||
`
|
||||
@@ -58,24 +59,24 @@ func (r *Repo) Get(ctx context.Context, id int64) (*models.NetworkInterface, err
|
||||
|
||||
func (r *Repo) Create(ctx context.Context, i models.NetworkInterface) (*models.NetworkInterface, error) {
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO network_interfaces (name, type, parent, vlan_id, role, mtu, active, description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, name, type, parent, vlan_id, role, mtu, active, description,
|
||||
INSERT INTO network_interfaces (name, type, parent, vlan_id, members, role, mtu, active, description)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9)
|
||||
RETURNING id, name, type, parent, vlan_id, members, role, mtu, active, description,
|
||||
created_at, updated_at`,
|
||||
i.Name, i.Type, i.Parent, i.VLANID, i.Role, i.MTU, i.Active, i.Description)
|
||||
i.Name, i.Type, i.Parent, i.VLANID, membersJSON(i.Members), i.Role, i.MTU, i.Active, i.Description)
|
||||
return scan(row)
|
||||
}
|
||||
|
||||
func (r *Repo) Update(ctx context.Context, id int64, i models.NetworkInterface) (*models.NetworkInterface, error) {
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
UPDATE network_interfaces SET
|
||||
name = $1, type = $2, parent = $3, vlan_id = $4,
|
||||
role = $5, mtu = $6, active = $7, description = $8,
|
||||
name = $1, type = $2, parent = $3, vlan_id = $4, members = $5::jsonb,
|
||||
role = $6, mtu = $7, active = $8, description = $9,
|
||||
updated_at = NOW()
|
||||
WHERE id = $9
|
||||
RETURNING id, name, type, parent, vlan_id, role, mtu, active, description,
|
||||
WHERE id = $10
|
||||
RETURNING id, name, type, parent, vlan_id, members, role, mtu, active, description,
|
||||
created_at, updated_at`,
|
||||
i.Name, i.Type, i.Parent, i.VLANID, i.Role, i.MTU, i.Active, i.Description, id)
|
||||
i.Name, i.Type, i.Parent, i.VLANID, membersJSON(i.Members), i.Role, i.MTU, i.Active, i.Description, id)
|
||||
out, err := scan(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -97,14 +98,36 @@ func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// membersJSON normalises a Go slice into a JSON array literal that
|
||||
// Postgres can cast to JSONB. nil → "[]" so the NOT NULL constraint
|
||||
// always holds.
|
||||
func membersJSON(m []string) string {
|
||||
if m == nil {
|
||||
return "[]"
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func scan(row interface{ Scan(...any) error }) (*models.NetworkInterface, error) {
|
||||
var i models.NetworkInterface
|
||||
var (
|
||||
i models.NetworkInterface
|
||||
raw []byte
|
||||
)
|
||||
if err := row.Scan(
|
||||
&i.ID, &i.Name, &i.Type, &i.Parent, &i.VLANID,
|
||||
&i.ID, &i.Name, &i.Type, &i.Parent, &i.VLANID, &raw,
|
||||
&i.Role, &i.MTU, &i.Active, &i.Description,
|
||||
&i.CreatedAt, &i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &i.Members); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if i.Members == nil {
|
||||
i.Members = []string{}
|
||||
}
|
||||
return &i, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "edgeguard-management-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -29,9 +29,9 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
// Theme tokens 1:1 wie mail-gateway/enconf — colorPrimary, font,
|
||||
// borderRadius, controlHeight. enterprise.css ergänzt mit eigenen
|
||||
// Layout-Klassen (.app-layout, .sidebar, .header, …).
|
||||
// Theme tokens 1:1 wie mail-gateway/enconf — Body-Density bekommen
|
||||
// wir über AntD `size="small"` auf den Tables (DataTable hat das per
|
||||
// default), nicht über kleinere Theme-Tokens.
|
||||
const antdTheme = {
|
||||
token: {
|
||||
colorPrimary: '#0EA5E9',
|
||||
|
||||
@@ -109,6 +109,7 @@ export default function DataTable<T extends object>(
|
||||
</div>
|
||||
)}
|
||||
<Table<T>
|
||||
size="small"
|
||||
{...rest}
|
||||
dataSource={filtered}
|
||||
columns={enhancedCols}
|
||||
|
||||
@@ -68,7 +68,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.0'
|
||||
const VERSION = '1.0.3'
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -80,6 +80,16 @@
|
||||
"comment": "Kommentar",
|
||||
"add": "NAT-Regel hinzufügen", "edit": "NAT-Regel bearbeiten",
|
||||
"deleteConfirm": "Diese NAT-Regel wirklich löschen?"
|
||||
},
|
||||
"sys": {
|
||||
"title": "System-Regeln (immer aktiv)",
|
||||
"chain": "Chain", "match": "Match", "action": "Aktion", "note": "Hinweis",
|
||||
"policy": "Default-Policy",
|
||||
"policyValue": "Eingang DROP — alles muss explizit erlaubt werden.",
|
||||
"order": "Auswertung",
|
||||
"orderValue": "System-Regeln zuerst, danach Operator-Regeln top-down (priority asc, first-match).",
|
||||
"lockout": "Anti-Lockout",
|
||||
"lockoutValue": "SSH (22) und Management-UI (443) sind immer erreichbar — können auch vom Operator nicht versehentlich gesperrt werden."
|
||||
}
|
||||
},
|
||||
"networks": {
|
||||
@@ -91,8 +101,15 @@
|
||||
"name": "Name",
|
||||
"type": "Typ",
|
||||
"parent": "Parent-Interface",
|
||||
"selectParent": "Parent wählen",
|
||||
"vlan": "VLAN",
|
||||
"vlanId": "VLAN-ID",
|
||||
"composition": "Zusammensetzung",
|
||||
"members": "Member-Interfaces",
|
||||
"selectMembers": "Physische Interfaces wählen",
|
||||
"membersRequired": "Mindestens ein Member-Interface erforderlich",
|
||||
"membersHintBridge": "Eine Bridge bündelt mehrere physische Ports auf L2 — typisch zwei Ports für einen Software-Switch.",
|
||||
"membersHintBond": "Ein Bond aggregiert mehrere physische Ports zu einem logischen Link (LACP / active-backup).",
|
||||
"role": "Rolle",
|
||||
"mtu": "MTU",
|
||||
"active": "Aktiv",
|
||||
|
||||
@@ -80,6 +80,16 @@
|
||||
"comment": "Comment",
|
||||
"add": "Add NAT rule", "edit": "Edit NAT rule",
|
||||
"deleteConfirm": "Really delete this NAT rule?"
|
||||
},
|
||||
"sys": {
|
||||
"title": "System rules (always active)",
|
||||
"chain": "Chain", "match": "Match", "action": "Action", "note": "Note",
|
||||
"policy": "Default policy",
|
||||
"policyValue": "Input DROP — everything must be explicitly allowed.",
|
||||
"order": "Evaluation",
|
||||
"orderValue": "System rules first, then operator rules top-down (priority asc, first-match).",
|
||||
"lockout": "Anti-lockout",
|
||||
"lockoutValue": "SSH (22) and the management UI (443) are always reachable — even the operator can't accidentally lock themselves out."
|
||||
}
|
||||
},
|
||||
"networks": {
|
||||
@@ -94,6 +104,12 @@
|
||||
"selectParent": "Select parent",
|
||||
"vlan": "VLAN",
|
||||
"vlanId": "VLAN ID",
|
||||
"composition": "Composition",
|
||||
"members": "Member interfaces",
|
||||
"selectMembers": "Select physical interfaces",
|
||||
"membersRequired": "At least one member interface is required",
|
||||
"membersHintBridge": "A bridge joins multiple physical ports at L2 — typically two ports for a software switch.",
|
||||
"membersHintBond": "A bond aggregates multiple physical ports into one logical link (LACP / active-backup).",
|
||||
"role": "Role",
|
||||
"mtu": "MTU",
|
||||
"active": "Active",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import SystemRulesCard from './SystemRules'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import type { AddressGroup, AddressObject, FwRule, FwService, ServiceGroup, Zone } from './types'
|
||||
@@ -186,6 +187,7 @@ export default function RulesTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemRulesCard />
|
||||
<Button type="primary" className="mb-16" onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
|
||||
70
management-ui/src/pages/Firewall/SystemRules.tsx
Normal file
70
management-ui/src/pages/Firewall/SystemRules.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Alert, Card, Space, Table, Tag, Typography } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// SystemRulesCard documents the baseline nftables ruleset that
|
||||
// EdgeGuard installs unconditionally — anti-lockout, stateful
|
||||
// session handling, public ingress, cluster mTLS. These rules sit
|
||||
// in the kernel ruleset BEFORE any operator-defined rule and can
|
||||
// not be overruled from the UI (they live in the renderer's nft
|
||||
// template). Showing them here closes the "wait, where does the
|
||||
// implicit drop come from?"-gap.
|
||||
|
||||
interface SystemRule {
|
||||
key: string
|
||||
chain: string
|
||||
match: string
|
||||
action: string
|
||||
note?: string
|
||||
}
|
||||
|
||||
const ROWS: SystemRule[] = [
|
||||
{ key: 'a1', chain: 'input', match: 'tcp dport 22 (rate-limit 10/min)', action: 'accept', note: 'anti-lockout: SSH' },
|
||||
{ key: 'a2', chain: 'input', match: 'tcp dport 443', action: 'accept', note: 'anti-lockout: Management-UI' },
|
||||
{ key: 'b1', chain: 'input', match: 'ct state established,related', action: 'accept', note: 'stateful baseline' },
|
||||
{ key: 'b2', chain: 'input', match: 'ct state invalid', action: 'drop', note: 'stateful baseline' },
|
||||
{ key: 'b3', chain: 'input', match: 'iif lo', action: 'accept', note: 'loopback' },
|
||||
{ key: 'c1', chain: 'input', match: 'icmp/icmpv6 (echo, dest-unreach, time-exc.)', action: 'accept', note: 'PMTUD + diag' },
|
||||
{ key: 'd1', chain: 'input', match: 'tcp dport 80', action: 'accept', note: 'HAProxy ACME + redirect' },
|
||||
{ key: 'e1', chain: 'input', match: 'tcp dport 8443 ip saddr @peer_ipv4/v6', action: 'accept', note: 'cluster mTLS (peers only)' },
|
||||
]
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
accept: 'green', drop: 'red', reject: 'orange',
|
||||
}
|
||||
|
||||
export default function SystemRulesCard() {
|
||||
const { t } = useTranslation()
|
||||
const cols: ColumnsType<SystemRule> = [
|
||||
{ title: t('fw.sys.chain'), dataIndex: 'chain', key: 'chain', width: 80, render: (s: string) => <Tag>{s}</Tag> },
|
||||
{ title: t('fw.sys.match'), dataIndex: 'match', key: 'match', render: (s: string) => <code>{s}</code> },
|
||||
{
|
||||
title: t('fw.sys.action'), dataIndex: 'action', key: 'action', width: 90,
|
||||
render: (a: string) => <Tag color={ACTION_COLORS[a] ?? 'default'}>{a.toUpperCase()}</Tag>,
|
||||
},
|
||||
{ title: t('fw.sys.note'), dataIndex: 'note', key: 'note', render: (v?: string) => v ? <Typography.Text type="secondary">{v}</Typography.Text> : '—' },
|
||||
]
|
||||
return (
|
||||
<Card size="small" title={t('fw.sys.title')} className="mb-12">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-12"
|
||||
message={
|
||||
<Space direction="vertical" size={2}>
|
||||
<span><b>{t('fw.sys.policy')}:</b> {t('fw.sys.policyValue')}</span>
|
||||
<span><b>{t('fw.sys.order')}:</b> {t('fw.sys.orderValue')}</span>
|
||||
<span><b>{t('fw.sys.lockout')}:</b> {t('fw.sys.lockoutValue')}</span>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="key"
|
||||
columns={cols}
|
||||
dataSource={ROWS}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface NetworkInterface {
|
||||
type: 'ethernet' | 'vlan' | 'bond' | 'bridge' | 'wireguard'
|
||||
parent?: string | null
|
||||
vlan_id?: number | null
|
||||
members: string[]
|
||||
role: 'wan' | 'lan' | 'dmz' | 'mgmt' | 'cluster'
|
||||
mtu?: number | null
|
||||
active: boolean
|
||||
@@ -26,6 +27,7 @@ interface IfaceFormValues {
|
||||
type: NetworkInterface['type']
|
||||
parent?: string
|
||||
vlan_id?: number
|
||||
members?: string[]
|
||||
role: NetworkInterface['role']
|
||||
mtu?: number
|
||||
active: boolean
|
||||
@@ -92,8 +94,14 @@ export default function NetworksPage() {
|
||||
{ title: t('networks.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
||||
{ title: t('networks.type'), dataIndex: 'type', key: 'type' },
|
||||
{
|
||||
title: t('networks.vlan'), key: 'vlan',
|
||||
render: (_, row) => row.type === 'vlan' ? <span>{row.parent}.{row.vlan_id}</span> : '—',
|
||||
title: t('networks.composition'), key: 'composition',
|
||||
render: (_, row) => {
|
||||
if (row.type === 'vlan') return <span><code>{row.parent}</code>.{row.vlan_id}</span>
|
||||
if (row.type === 'bridge' || row.type === 'bond') {
|
||||
return <Space size={4} wrap>{(row.members ?? []).map((m) => <Tag key={m}>{m}</Tag>)}</Space>
|
||||
}
|
||||
return '—'
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('networks.role'), dataIndex: 'role', key: 'role',
|
||||
@@ -109,7 +117,8 @@ export default function NetworksPage() {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
name: row.name, type: row.type, parent: row.parent ?? undefined,
|
||||
vlan_id: row.vlan_id ?? undefined, role: row.role,
|
||||
vlan_id: row.vlan_id ?? undefined, members: row.members ?? [],
|
||||
role: row.role,
|
||||
mtu: row.mtu ?? undefined, active: row.active,
|
||||
description: row.description ?? undefined,
|
||||
})
|
||||
@@ -183,22 +192,42 @@ export default function NetworksPage() {
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(p, c) => p.type !== c.type}>
|
||||
{({ getFieldValue }) => getFieldValue('type') === 'vlan' ? (
|
||||
{({ getFieldValue }) => {
|
||||
const tp = getFieldValue('type') as NetworkInterface['type'] | undefined
|
||||
const sysOptions = (sys ?? [])
|
||||
.filter((i) => i.ifname !== 'lo')
|
||||
.map((i) => ({ value: i.ifname, label: i.ifname }))
|
||||
if (tp === 'vlan') {
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('networks.parent')} name="parent" rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder={t('networks.selectParent')}
|
||||
showSearch
|
||||
options={(sys ?? [])
|
||||
.filter((i) => i.ifname !== 'lo')
|
||||
.map((i) => ({ value: i.ifname, label: i.ifname }))}
|
||||
/>
|
||||
<Select placeholder={t('networks.selectParent')} showSearch options={sysOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('networks.vlanId')} name="vlan_id" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={4094} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
)
|
||||
}
|
||||
if (tp === 'bridge' || tp === 'bond') {
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('networks.members')}
|
||||
name="members"
|
||||
rules={[{ required: true, type: 'array', min: 1, message: t('networks.membersRequired') }]}
|
||||
extra={tp === 'bridge' ? t('networks.membersHintBridge') : t('networks.membersHintBond')}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={t('networks.selectMembers')}
|
||||
showSearch
|
||||
options={sysOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item label={t('networks.role')} name="role" rules={[{ required: true }]}>
|
||||
<Select options={(['wan','lan','dmz','mgmt','cluster'] as const).map(r => ({ value: r, label: r.toUpperCase() }))} />
|
||||
|
||||
@@ -269,7 +269,7 @@ h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
/* === CONTENT AREA === */
|
||||
.content-area {
|
||||
padding: 16px;
|
||||
padding: 12px 16px;
|
||||
flex: 1;
|
||||
overflow-x: hidden;
|
||||
min-width: 0;
|
||||
|
||||
Reference in New Issue
Block a user