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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "edgeguard-management-ui",
|
||||
"private": true,
|
||||
"version": "1.0.6",
|
||||
"version": "1.0.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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?"
|
||||
},
|
||||
|
||||
@@ -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}}?"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ interface BackendFormValues {
|
||||
port: number
|
||||
health_check_path?: string
|
||||
active: boolean
|
||||
domain_ids?: number[]
|
||||
}
|
||||
|
||||
async function listBackends(): Promise<Backend[]> {
|
||||
@@ -35,16 +36,25 @@ async function listBackends(): Promise<Backend[]> {
|
||||
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<DomainLite[]> {
|
||||
async function listDomains(): Promise<DomainFull[]> {
|
||||
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<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({
|
||||
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')}</Button>
|
||||
<Popconfirm
|
||||
@@ -181,6 +237,25 @@ export default function BackendsPage() {
|
||||
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</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>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user