feat(ui): Backend-Modal — Domains zum Backend zuweisen

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) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-10 18:09:08 +02:00
parent 51ea1fc802
commit 237c4c7541
9 changed files with 92 additions and 11 deletions

View File

@@ -1 +1 @@
1.0.6 1.0.7

View File

@@ -35,7 +35,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
) )
var version = "1.0.6" var version = "1.0.7"
func main() { func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR") addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -9,7 +9,7 @@ import (
"os" "os"
) )
var version = "1.0.6" var version = "1.0.7"
const usage = `edgeguard-ctl — EdgeGuard CLI const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -5,7 +5,7 @@ import (
"time" "time"
) )
var version = "1.0.6" var version = "1.0.7"
func main() { func main() {
log.Printf("edgeguard-scheduler %s starting", version) log.Printf("edgeguard-scheduler %s starting", version)

View File

@@ -1,7 +1,7 @@
{ {
"name": "edgeguard-management-ui", "name": "edgeguard-management-ui",
"private": true, "private": true,
"version": "1.0.6", "version": "1.0.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -68,7 +68,7 @@ const NAV: NavSection[] = [
}, },
] ]
const VERSION = '1.0.6' const VERSION = '1.0.7'
export default function Sidebar({ isOpen, onClose }: SidebarProps) { export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const { t } = useTranslation() const { t } = useTranslation()

View File

@@ -207,6 +207,9 @@
"active": "Aktiv", "active": "Aktiv",
"usedBy": "Genutzt von", "usedBy": "Genutzt von",
"noDomain": "keine Domain", "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", "actions": "Aktionen",
"deleteConfirm": "Backend {{name}} wirklich löschen?" "deleteConfirm": "Backend {{name}} wirklich löschen?"
}, },

View File

@@ -207,6 +207,9 @@
"active": "Active", "active": "Active",
"usedBy": "Used by", "usedBy": "Used by",
"noDomain": "no domain", "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", "actions": "Actions",
"deleteConfirm": "Really delete backend {{name}}?" "deleteConfirm": "Really delete backend {{name}}?"
}, },

View File

@@ -26,6 +26,7 @@ interface BackendFormValues {
port: number port: number
health_check_path?: string health_check_path?: string
active: boolean active: boolean
domain_ids?: number[]
} }
async function listBackends(): Promise<Backend[]> { async function listBackends(): Promise<Backend[]> {
@@ -35,16 +36,25 @@ async function listBackends(): Promise<Backend[]> {
return payload.backends ?? [] 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 id: number
name: string name: string
active: boolean active: boolean
primary_backend_id?: number | null 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<DomainLite[]> { async function listDomains(): Promise<DomainFull[]> {
const r = await apiClient.get('/domains') const r = await apiClient.get('/domains')
if (!isEnvelope(r.data)) return [] 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() { export default function BackendsPage() {
@@ -65,27 +75,72 @@ export default function BackendsPage() {
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [form] = Form.useForm<BackendFormValues>() const [form] = Form.useForm<BackendFormValues>()
// 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<unknown>[] = []
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({ const create = useMutation({
mutationFn: async (v: BackendFormValues) => { 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: () => { onSuccess: () => {
message.success(t('common.save')) message.success(t('common.save'))
setCreating(false) setCreating(false)
form.resetFields() form.resetFields()
void qc.invalidateQueries({ queryKey: ['backends'] }) void qc.invalidateQueries({ queryKey: ['backends'] })
void qc.invalidateQueries({ queryKey: ['domains'] })
}, },
}) })
const update = useMutation({ const update = useMutation({
mutationFn: async ({ id, v }: { id: number; v: BackendFormValues }) => { 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: () => { onSuccess: () => {
message.success(t('common.save')) message.success(t('common.save'))
setEditing(null) setEditing(null)
form.resetFields() form.resetFields()
void qc.invalidateQueries({ queryKey: ['backends'] }) void qc.invalidateQueries({ queryKey: ['backends'] })
void qc.invalidateQueries({ queryKey: ['domains'] })
}, },
}) })
@@ -124,6 +179,7 @@ export default function BackendsPage() {
port: row.port, port: row.port,
health_check_path: row.health_check_path ?? undefined, health_check_path: row.health_check_path ?? undefined,
active: row.active, active: row.active,
domain_ids: domainsForBackend(row.id).map(d => d.id),
}) })
}}>{t('common.edit')}</Button> }}>{t('common.edit')}</Button>
<Popconfirm <Popconfirm
@@ -181,6 +237,25 @@ export default function BackendsPage() {
<Form.Item label={t('backends.active')} name="active" valuePropName="checked"> <Form.Item label={t('backends.active')} name="active" valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item
label={t('backends.attachedDomains')}
name="domain_ids"
extra={t('backends.attachedDomainsHint')}
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder={t('backends.selectDomains')}
options={(domains ?? []).map(d => ({
value: d.id,
label: d.primary_backend_id && d.primary_backend_id !== editing?.id
? `${d.name} (zur Zeit: #${d.primary_backend_id})`
: d.name,
}))}
/>
</Form.Item>
</Form> </Form>
</Modal> </Modal>
</div> </div>