From 99982ef9d1c44fdeca87f8af59de7de3819ce036 Mon Sep 17 00:00:00 2001 From: Debian Date: Sun, 24 May 2026 08:55:22 +0200 Subject: [PATCH] feat(ui): Quick-Toggles, Config-Preview, Dashboard-Alerts, Domain-Detail-Health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick-Toggle-Switches (kein Modal nötig) für: Backends, Backend-Server, DNS-Zonen, DNS-Records, Domains (active), Firewall-Rules, NAT-Rules, Forward-Proxy ACLs, Routing-Rules. Dashboard: Alert-Banner für komplett ausgefallene Backends (HAProxy-Stats) und Domains im Maintenance-Mode. Domain-Detail: HAProxy-Live-Health-Badge (15s Polling), TLS-Cert ausstellen/erneuern direkt aus dem Detail, Routing-Rules-Panel inline. Config-Preview (Settings): alle 4 Generatoren (haproxy, nftables, squid, unbound) rendern via RenderToString ohne Disk-Write — GET /system/config-preview. ActionButtons: Viewer-Rolle blendet Delete aus (RBAC-Ergänzung). Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 8 +- internal/firewall/firewall.go | 12 + internal/handlers/system.go | 44 ++++ internal/haproxy/haproxy.go | 12 + internal/squid/squid.go | 22 +- internal/unbound/unbound.go | 29 ++- .../src/components/ActionButtons.tsx | 16 +- .../src/components/Layout/AppLayout.tsx | 2 +- .../src/components/Layout/Sidebar.tsx | 6 +- management-ui/src/i18n/locales/de/common.json | 15 +- management-ui/src/i18n/locales/en/common.json | 15 +- management-ui/src/pages/Backends/Detail.tsx | 18 +- management-ui/src/pages/Backends/index.tsx | 70 ++++-- management-ui/src/pages/DNS/index.tsx | 41 ++- management-ui/src/pages/Dashboard/index.tsx | 57 ++++- management-ui/src/pages/Domains/Detail.tsx | 237 +++++++++++++++++- management-ui/src/pages/Domains/index.tsx | 46 +++- management-ui/src/pages/Firewall/NATRules.tsx | 19 +- management-ui/src/pages/Firewall/Rules.tsx | 19 +- .../src/pages/ForwardProxy/index.tsx | 48 +++- .../src/pages/RoutingRules/index.tsx | 21 +- management-ui/src/pages/Settings/index.tsx | 59 ++++- 23 files changed, 755 insertions(+), 63 deletions(-) diff --git a/VERSION b/VERSION index 2733a3e..31d5ab0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.58 +1.1.72 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 2ca316e..da7784c 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.0.78" +var version = "1.1.72" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -267,6 +267,12 @@ func main() { // /var/lib/edgeguard/node-id. systemHdl.WithAudit(auditRepo, nodeID) systemHdl.WithDB(pool) + systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){ + "haproxy": haproxy.New(pool).RenderToString, + "nftables": firewallrender.New(pool).RenderToString, + "squid": squidrender.New(pool).RenderToString, + "unbound": unboundrender.New(pool).RenderToString, + }) setupHdl.WithAudit(auditRepo, nodeID) usersRepo := usersvc.New(pool) authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo) diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index 7a82eb3..e4a867a 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -52,6 +52,18 @@ func New(pool *pgxpool.Pool) *Generator { return &Generator{Pool: pool} } func (g *Generator) Name() string { return "nftables" } +func (g *Generator) RenderToString(ctx context.Context) (string, error) { + view, err := g.loadView(ctx) + if err != nil { + return "", fmt.Errorf("nftables: load state: %w", err) + } + var buf bytes.Buffer + if err := tpl.Execute(&buf, view); err != nil { + return "", fmt.Errorf("nftables: render template: %w", err) + } + return buf.String(), nil +} + func (g *Generator) Render(ctx context.Context) error { view, err := g.loadView(ctx) if err != nil { diff --git a/internal/handlers/system.go b/internal/handlers/system.go index ffbf582..795c6d9 100644 --- a/internal/handlers/system.go +++ b/internal/handlers/system.go @@ -36,6 +36,10 @@ type SystemHandler struct { Audit *audit.Repo NodeID string Pool *pgxpool.Pool + // ConfigPreviewers: generator name → render-to-string func. Wired + // in main.go after generators are constructed. Read-only access — + // no writes, no reloads. + ConfigPreviewers map[string]func(stdcontext.Context) (string, error) } func NewSystemHandler(version string) *SystemHandler { @@ -64,6 +68,13 @@ func (h *SystemHandler) WithDB(pool *pgxpool.Pool) *SystemHandler { return h } +// WithConfigPreviewers injectet die RenderToString-Funktionen der +// Config-Generatoren für den /system/config-preview Endpoint. +func (h *SystemHandler) WithConfigPreviewers(previewers map[string]func(stdcontext.Context) (string, error)) *SystemHandler { + h.ConfigPreviewers = previewers + return h +} + func (h *SystemHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/system") g.GET("/health", h.Health) @@ -87,6 +98,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) { g.GET("/upgrade-status", h.UpgradeStatus) g.GET("/ipv6", h.IPv6) g.POST("/ipv6", h.SetIPv6) + g.GET("/config-preview", h.ConfigPreview) } // RegisterAgent mountet die read-only System-Endpoints auf der mTLS- @@ -680,6 +692,38 @@ func (h *SystemHandler) ToggleAutoUpdate(c *gin.Context) { response.OK(c, gin.H{"enabled": req.Enabled}) } +// ConfigPreview rendert eine Service-Config aus dem aktuellen DB-State +// und gibt sie als Plain-Text zurück — ohne sie auf Disk zu schreiben +// oder den Dienst zu reloaden. Query-Parameter: ?generator=haproxy|nftables|squid|unbound +func (h *SystemHandler) ConfigPreview(c *gin.Context) { + gen := strings.TrimSpace(c.Query("generator")) + if gen == "" { + names := make([]string, 0, len(h.ConfigPreviewers)) + for k := range h.ConfigPreviewers { + names = append(names, k) + } + response.OK(c, gin.H{"generators": names}) + return + } + if h.ConfigPreviewers == nil { + response.Err(c, http.StatusServiceUnavailable, simpleErr("previewers not wired")) + return + } + fn, ok := h.ConfigPreviewers[gen] + if !ok { + response.Err(c, http.StatusBadRequest, simpleErr("unknown generator: "+gen)) + return + } + ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + content, err := fn(ctx) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"generator": gen, "content": content}) +} + func (h *SystemHandler) Health(c *gin.Context) { response.OK(c, gin.H{ "status": "ok", diff --git a/internal/haproxy/haproxy.go b/internal/haproxy/haproxy.go index f360830..edbce50 100644 --- a/internal/haproxy/haproxy.go +++ b/internal/haproxy/haproxy.go @@ -88,6 +88,18 @@ func New(pool *pgxpool.Pool) *Generator { func (g *Generator) Name() string { return "haproxy" } +func (g *Generator) RenderToString(ctx context.Context) (string, error) { + view, err := g.loadView(ctx) + if err != nil { + return "", fmt.Errorf("haproxy: load state: %w", err) + } + var buf bytes.Buffer + if err := tpl.Execute(&buf, view); err != nil { + return "", fmt.Errorf("haproxy: render template: %w", err) + } + return buf.String(), nil +} + func (g *Generator) Render(ctx context.Context) error { view, err := g.loadView(ctx) if err != nil { diff --git a/internal/squid/squid.go b/internal/squid/squid.go index 81dd13d..0d306fd 100644 --- a/internal/squid/squid.go +++ b/internal/squid/squid.go @@ -47,15 +47,31 @@ func New(pool *pgxpool.Pool) *Generator { func (g *Generator) Name() string { return "squid" } -func (g *Generator) Render(ctx context.Context) error { +func (g *Generator) renderBuf(ctx context.Context) (bytes.Buffer, error) { acls, err := g.Repo.List(ctx) if err != nil { - return fmt.Errorf("list acls: %w", err) + return bytes.Buffer{}, fmt.Errorf("list acls: %w", err) } view := View{ListenPort: listenPort, ACLs: acls} var body bytes.Buffer if err := tpl.Execute(&body, view); err != nil { - return fmt.Errorf("template: %w", err) + return bytes.Buffer{}, fmt.Errorf("template: %w", err) + } + return body, nil +} + +func (g *Generator) RenderToString(ctx context.Context) (string, error) { + buf, err := g.renderBuf(ctx) + if err != nil { + return "", err + } + return buf.String(), nil +} + +func (g *Generator) Render(ctx context.Context) error { + body, err := g.renderBuf(ctx) + if err != nil { + return err } if err := os.MkdirAll(filepath.Dir(confPath), 0o755); err != nil { return fmt.Errorf("mkdir: %w", err) diff --git a/internal/unbound/unbound.go b/internal/unbound/unbound.go index 41729bb..d9214e3 100644 --- a/internal/unbound/unbound.go +++ b/internal/unbound/unbound.go @@ -93,16 +93,16 @@ func New(pool *pgxpool.Pool) *Generator { func (g *Generator) Name() string { return "unbound" } -func (g *Generator) Render(ctx context.Context) error { +func (g *Generator) buildView(ctx context.Context) (*View, error) { settings, err := g.Repo.GetSettings(ctx) if err != nil { - return fmt.Errorf("settings: %w", err) + return nil, fmt.Errorf("settings: %w", err) } zones, err := g.Repo.ListZones(ctx) if err != nil { - return fmt.Errorf("zones: %w", err) + return nil, fmt.Errorf("zones: %w", err) } - view := View{ + view := &View{ Settings: settings, ListenAddresses: splitCSV(settings.ListenAddresses), AccessACLs: splitCSV(settings.AccessACL), @@ -117,7 +117,7 @@ func (g *Generator) Render(ctx context.Context) error { case "local": recs, err := g.Repo.ListRecordsForZone(ctx, z.ID) if err != nil { - return fmt.Errorf("records for zone %s: %w", z.Name, err) + return nil, fmt.Errorf("records for zone %s: %w", z.Name, err) } active := make([]recordView, 0, len(recs)) for _, r := range recs { @@ -141,7 +141,26 @@ func (g *Generator) Render(ctx context.Context) error { }) } } + return view, nil +} +func (g *Generator) RenderToString(ctx context.Context) (string, error) { + view, err := g.buildView(ctx) + if err != nil { + return "", err + } + var body bytes.Buffer + if err := tpl.Execute(&body, view); err != nil { + return "", fmt.Errorf("template: %w", err) + } + return body.String(), nil +} + +func (g *Generator) Render(ctx context.Context) error { + view, err := g.buildView(ctx) + if err != nil { + return err + } var body bytes.Buffer if err := tpl.Execute(&body, view); err != nil { return fmt.Errorf("template: %w", err) diff --git a/management-ui/src/components/ActionButtons.tsx b/management-ui/src/components/ActionButtons.tsx index c995e9c..73ee9dc 100644 --- a/management-ui/src/components/ActionButtons.tsx +++ b/management-ui/src/components/ActionButtons.tsx @@ -1,6 +1,7 @@ import { Button, Popconfirm, Space, Tooltip } from 'antd' import { DeleteOutlined, EditOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' +import { useAuthStore } from '../stores/auth' // ActionButtons is the standard "Edit / Delete" pair used at the // end of every CRUD table row. Centralising it means we only style @@ -8,6 +9,9 @@ import { useTranslation } from 'react-i18next' // // Either prop may be omitted to suppress that button — useful for // rows that aren't editable (e.g. builtin services). +// Viewer-role accounts automatically get delete disabled — mutations +// are blocked at the API level too, but hiding the button avoids +// confusing "access denied" errors for read-only users. interface ActionButtonsProps { onEdit?: () => void onDelete?: () => void @@ -28,6 +32,14 @@ export default function ActionButtons({ editDisabledReason, deleteDisabledReason, }: ActionButtonsProps) { const { t } = useTranslation() + const role = useAuthStore((s) => s.user?.role) + const isViewer = role === 'viewer' + + const delDisabled = deleteDisabled || isViewer + const delDisabledReason = isViewer + ? t('auth.viewerBadge') + : deleteDisabledReason + return ( {onEdit && ( @@ -42,8 +54,8 @@ export default function ActionButtons({ )} {onDelete && ( - deleteDisabled ? ( - + delDisabled ? ( + + + {cert ? ( + issueCert.mutate(domain.name)} + okText={t('common.yes')} cancelText={t('common.no')} + > + + + ) : ( + + )} + + } /> + + + + + + @@ -273,6 +363,145 @@ export default function DomainDetailPage() { ) } +// ── Routing Rules Panel ───────────────────────────────────────────────── + +function RoutingRulesPanel({ domainID, domainName }: { domainID: number; domainName: string }) { + const { t } = useTranslation() + const qc = useQueryClient() + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [rForm] = Form.useForm<{ path_prefix: string; backend_id: number; priority: number; active: boolean }>() + + const { data: rules, isLoading } = useQuery({ + queryKey: ['domain-routing-rules', domainID], + queryFn: () => listDomainRules(domainID), + }) + const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackendsWithAddr }) + + const invalidate = () => { + void qc.invalidateQueries({ queryKey: ['domain-routing-rules', domainID] }) + void qc.invalidateQueries({ queryKey: ['routing-rules'] }) + } + + const create = useMutation({ + mutationFn: async (v: { path_prefix: string; backend_id: number; priority: number; active: boolean }) => + apiClient.post('/routing-rules', { ...v, domain_id: domainID }), + onSuccess: () => { + message.success(t('common.save')) + setOpen(false); rForm.resetFields(); invalidate() + }, + onError: (e: Error) => message.error(e.message), + }) + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: { path_prefix: string; backend_id: number; priority: number; active: boolean } }) => + apiClient.put(`/routing-rules/${id}`, { ...v, domain_id: domainID }), + onSuccess: () => { + message.success(t('common.save')) + setEditing(null); rForm.resetFields(); invalidate() + }, + onError: (e: Error) => message.error(e.message), + }) + const del = useMutation({ + mutationFn: async (id: number) => apiClient.delete(`/routing-rules/${id}`), + onSuccess: invalidate, + onError: (e: Error) => message.error(e.message), + }) + + const backendLabel = (id: number) => { + const b = backends?.find(x => x.id === id) + return b ? `${b.name} (${b.address}:${b.port})` : `#${id}` + } + + const columns: ColumnsType = [ + { 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.priority'), dataIndex: 'priority', key: 'priority', width: 90 }, + { + title: t('routing.active'), dataIndex: 'active', key: 'active', width: 80, + render: (v: boolean) => , + }, + { + title: t('common.actions'), key: 'a', width: 90, + render: (_, r) => ( + + + } + > + + {t('domains.routingRulesHint')} + + + + + { setOpen(false); setEditing(null); rForm.resetFields() }} + onOk={() => { void rForm.submit() }} + confirmLoading={create.isPending || update.isPending} + destroyOnHidden + > +
editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)} + > + + + + + setPreviewGen(v)} + style={{ width: 140 }} + options={[ + { value: 'haproxy', label: 'haproxy' }, + { value: 'nftables', label: 'nftables' }, + { value: 'squid', label: 'squid' }, + { value: 'unbound', label: 'unbound' }, + ]} + /> + + + {previewData?.content && ( +
+              {previewData.content}
+            
+ )} + + {t('settings.configPreviewHint')} + + + + {t('settings.passwordCardTitle')}} size="small"> form={pwForm}