From 237c4c7541054a5f63c21ab433dce535e7ca80ab Mon Sep 17 00:00:00 2001 From: Debian Date: Sun, 10 May 2026 18:09:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20Backend-Modal=20=E2=80=94=20Domains?= =?UTF-8?q?=20zum=20Backend=20zuweisen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetrisch zur Backend-Auswahl im Domain-Modal: das Backend-Modal hat jetzt einen Multi-Select für Domains. Auswahl wird beim Speichern gegen den aktuellen Stand diff't und in N parallele PUTs an /domains/:id übersetzt — Add setzt primary_backend_id auf die ID, Remove auf null. Domain bleibt die Quelle der Wahrheit (kein Schema-Change). Die Backend-Seite ist nur eine alternative Edit-Affordance. Version 1.0.7. Co-Authored-By: Claude Opus 4.7 (1M context) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- management-ui/package.json | 2 +- .../src/components/Layout/Sidebar.tsx | 2 +- management-ui/src/i18n/locales/de/common.json | 3 + management-ui/src/i18n/locales/en/common.json | 3 + management-ui/src/pages/Backends/index.tsx | 85 +++++++++++++++++-- 9 files changed, 92 insertions(+), 11 deletions(-) diff --git a/VERSION b/VERSION index af0b7dd..238d6e8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.6 +1.0.7 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 554553d..7882d50 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -35,7 +35,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.6" +var version = "1.0.7" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 5e73db9..a285da1 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.6" +var version = "1.0.7" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index d3a9a45..df278ce 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -5,7 +5,7 @@ import ( "time" ) -var version = "1.0.6" +var version = "1.0.7" func main() { log.Printf("edgeguard-scheduler %s starting", version) diff --git a/management-ui/package.json b/management-ui/package.json index 801d8c9..bea6300 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -1,7 +1,7 @@ { "name": "edgeguard-management-ui", "private": true, - "version": "1.0.6", + "version": "1.0.7", "type": "module", "scripts": { "dev": "vite", diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 583fb00..f83bc97 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -68,7 +68,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.6' +const VERSION = '1.0.7' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index f46f3e6..b8baed0 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -207,6 +207,9 @@ "active": "Aktiv", "usedBy": "Genutzt von", "noDomain": "keine Domain", + "attachedDomains": "Domains", + "attachedDomainsHint": "Domains, die dieses Backend als Primary verwenden. Auswahl umkonfiguriert die Domains direkt — gleiche Quelle wie der Backend-Picker im Domain-Modal.", + "selectDomains": "Domains wählen", "actions": "Aktionen", "deleteConfirm": "Backend {{name}} wirklich löschen?" }, diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 5a9edfa..08dbf10 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -207,6 +207,9 @@ "active": "Active", "usedBy": "Used by", "noDomain": "no domain", + "attachedDomains": "Domains", + "attachedDomainsHint": "Domains that use this backend as their primary. Selecting domains here reconfigures them directly — same source of truth as the Domain modal's backend picker.", + "selectDomains": "Select domains", "actions": "Actions", "deleteConfirm": "Really delete backend {{name}}?" }, diff --git a/management-ui/src/pages/Backends/index.tsx b/management-ui/src/pages/Backends/index.tsx index 9537145..bac951d 100644 --- a/management-ui/src/pages/Backends/index.tsx +++ b/management-ui/src/pages/Backends/index.tsx @@ -26,6 +26,7 @@ interface BackendFormValues { port: number health_check_path?: string active: boolean + domain_ids?: number[] } async function listBackends(): Promise { @@ -35,16 +36,25 @@ async function listBackends(): Promise { return payload.backends ?? [] } -interface DomainLite { +// DomainFull mirrors the API contract; we need the full body to PUT +// the domain back when re-attaching/detaching it (the handler does +// a full-row replace, not a patch). Fields beyond what the form +// shows are passed through verbatim. +interface DomainFull { id: number name: string active: boolean primary_backend_id?: number | null + http_to_https: boolean + hsts_enabled: boolean + notes?: string | null + created_at?: string + updated_at?: string } -async function listDomains(): Promise { +async function listDomains(): Promise { const r = await apiClient.get('/domains') if (!isEnvelope(r.data)) return [] - return (r.data.data as { domains?: DomainLite[] }).domains ?? [] + return (r.data.data as { domains?: DomainFull[] }).domains ?? [] } export default function BackendsPage() { @@ -65,27 +75,72 @@ export default function BackendsPage() { const [creating, setCreating] = useState(false) const [form] = Form.useForm() + // syncDomainAttachments diff-applies the multi-select to the + // domains table — domains that should now point at this backend + // get a PUT with primary_backend_id=backend.id; domains that used + // to point at it but were unchecked get a PUT with null. + // Domains are kept canonical in their own table; this is just the + // reverse-edit affordance the operator asked for. + async function syncDomainAttachments(backendID: number, selected: number[]) { + const all = domains ?? [] + const wasAttached = new Set(all.filter(d => d.primary_backend_id === backendID).map(d => d.id)) + const want = new Set(selected) + const adds = [...want].filter(id => !wasAttached.has(id)) + const removes = [...wasAttached].filter(id => !want.has(id)) + + const puts: Promise[] = [] + for (const id of adds) { + const d = all.find(x => x.id === id) + if (!d) continue + puts.push(apiClient.put(`/domains/${id}`, { + name: d.name, active: d.active, + http_to_https: d.http_to_https, hsts_enabled: d.hsts_enabled, + notes: d.notes ?? '', primary_backend_id: backendID, + })) + } + for (const id of removes) { + const d = all.find(x => x.id === id) + if (!d) continue + puts.push(apiClient.put(`/domains/${id}`, { + name: d.name, active: d.active, + http_to_https: d.http_to_https, hsts_enabled: d.hsts_enabled, + notes: d.notes ?? '', primary_backend_id: null, + })) + } + if (puts.length > 0) await Promise.all(puts) + } + const create = useMutation({ mutationFn: async (v: BackendFormValues) => { - await apiClient.post('/backends', v) + const { domain_ids, ...body } = v + const r = await apiClient.post('/backends', body) + const env = r.data + const created = isEnvelope(env) ? (env.data as Backend) : null + if (created && domain_ids && domain_ids.length > 0) { + await syncDomainAttachments(created.id, domain_ids) + } }, onSuccess: () => { message.success(t('common.save')) setCreating(false) form.resetFields() void qc.invalidateQueries({ queryKey: ['backends'] }) + void qc.invalidateQueries({ queryKey: ['domains'] }) }, }) const update = useMutation({ mutationFn: async ({ id, v }: { id: number; v: BackendFormValues }) => { - await apiClient.put(`/backends/${id}`, v) + const { domain_ids, ...body } = v + await apiClient.put(`/backends/${id}`, body) + await syncDomainAttachments(id, domain_ids ?? []) }, onSuccess: () => { message.success(t('common.save')) setEditing(null) form.resetFields() void qc.invalidateQueries({ queryKey: ['backends'] }) + void qc.invalidateQueries({ queryKey: ['domains'] }) }, }) @@ -124,6 +179,7 @@ export default function BackendsPage() { port: row.port, health_check_path: row.health_check_path ?? undefined, active: row.active, + domain_ids: domainsForBackend(row.id).map(d => d.id), }) }}>{t('common.edit')} + +