feat(ui): SSL-Domain-Picker — Management-FQDN + Cluster-Nodes + Free-Text

Vorher: SSL-Issue-Form bot nur die operator-managed /domains an.
Wenn der Operator ein Cert für die Management-FQDN (utm-1.netcell-it.de
aus setup.json) wollte, war diese nicht in der Auswahl — er hätte
sie erst als Domain-Row anlegen müssen.

Jetzt: AutoComplete (statt Select) mit drei Quellen kombiniert:
* Management-FQDN aus /setup/status — als erste Option mit Hint
* Alle Cluster-Node-FQDNs aus /cluster/nodes
* Operator-/domains
Plus: jede beliebige FQDN ist eintippbar (DNS muss zeigen).

(combobox-mode in AntD ist deprecated — AutoComplete ist die
empfohlene Variante für free-text-with-suggestions.)

Version 1.0.15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-10 21:46:27 +02:00
parent a0ab929b9a
commit 5f8d06e8ba
9 changed files with 58 additions and 13 deletions

View File

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

View File

@@ -249,7 +249,10 @@
"uploadIntro": "Eigenes Zertifikat hochladen. Format: PEM-encoded. Cert + optional Chain + Private Key. EdgeGuard prüft die Cert/Key-Übereinstimmung vor dem Schreiben.",
"uploadHint": "Tipp: bei Let's-Encrypt-Renewals nicht hier hochladen — den LE-Tab nutzen.",
"domain": "Domain",
"selectDomain": "Domain wählen",
"selectDomain": "Domain wählen oder eintippen",
"domainExtra": "Inkludiert Management-FQDN (aus Setup), Cluster-Knoten und Operator-Domains. Du kannst auch eine andere Domain tippen, sofern DNS schon auf die Box zeigt.",
"fqdnHintMgmt": "Management-FQDN",
"fqdnHintCluster": "Cluster · {{role}}",
"issuer": "Issuer",
"status": "Status",
"expiresIn": "Gültig noch",

View File

@@ -249,7 +249,10 @@
"uploadIntro": "Upload your own certificate. Format: PEM-encoded. Cert + optional chain + private key. EdgeGuard validates cert/key match before writing.",
"uploadHint": "Tip: for Let's Encrypt renewals don't upload here — use the LE tab.",
"domain": "Domain",
"selectDomain": "Select domain",
"selectDomain": "Pick or type a domain",
"domainExtra": "Includes the management FQDN (from Setup), cluster nodes and operator domains. You can also type any other domain — as long as DNS resolves to this box.",
"fqdnHintMgmt": "management FQDN",
"fqdnHintCluster": "cluster · {{role}}",
"issuer": "Issuer",
"status": "Status",
"expiresIn": "Expires in",

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Alert, Button, Card, Form, Input, Select, Space, Tabs, Tag, Typography, message } from 'antd'
import { Alert, AutoComplete, Button, Card, Form, Input, Space, Tabs, Tag, Typography, message } from 'antd'
import { SafetyCertificateOutlined } from '@ant-design/icons'
import PageHeader from '../../components/PageHeader'
import ActionButtons from '../../components/ActionButtons'
@@ -47,6 +47,23 @@ async function listDomains(): Promise<Domain[]> {
return (r.data.data as { domains?: Domain[] }).domains ?? []
}
interface SetupStatus { completed: boolean; fqdn?: string }
async function fetchSetupStatus(): Promise<SetupStatus | null> {
try {
const r = await apiClient.get('/setup/status')
return isEnvelope(r.data) ? (r.data.data as SetupStatus) : null
} catch { return null }
}
interface ClusterNode { id: string; fqdn: string; role: string }
async function listClusterNodes(): Promise<ClusterNode[]> {
try {
const r = await apiClient.get('/cluster/nodes')
if (!isEnvelope(r.data)) return []
return (r.data.data as { nodes?: ClusterNode[] }).nodes ?? []
} catch { return [] }
}
const STATUS_COLORS: Record<TLSCert['status'], string> = {
active: 'green',
renewing: 'blue',
@@ -68,6 +85,24 @@ export default function SSLPage() {
const { data: certs, isLoading } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
const { data: setup } = useQuery({ queryKey: ['setup-status'], queryFn: fetchSetupStatus })
const { data: nodes } = useQuery({ queryKey: ['cluster', 'nodes'], queryFn: listClusterNodes })
// Domain-Picker-Optionen: operator-managed Domains + Management-FQDN
// (utm-1.netcell-it.de aus setup.json) + alle Cluster-Node-FQDNs.
// Plus Combobox-Mode, sodass der Operator jede beliebige Domain
// tippen kann (z.B. wenn DNS schon zeigt aber Domain noch nicht
// unter /domains angelegt wurde).
const domainOptions: { value: string; label: string }[] = []
const seen = new Set<string>()
const add = (name: string, hint?: string) => {
if (!name || seen.has(name)) return
seen.add(name)
domainOptions.push({ value: name, label: hint ? `${name}${hint}` : name })
}
if (setup?.fqdn) add(setup.fqdn, t('ssl.fqdnHintMgmt'))
for (const n of nodes ?? []) add(n.fqdn, t('ssl.fqdnHintCluster', { role: n.role }))
for (const d of domains ?? []) add(d.name)
const [issueForm] = Form.useForm<IssueValues>()
const [uploadForm] = Form.useForm<UploadValues>()
@@ -141,11 +176,15 @@ export default function SSLPage() {
<Typography.Paragraph type="secondary">{t('ssl.leIntro')}</Typography.Paragraph>
{issueErr && <Alert type="error" closable showIcon style={{ marginBottom: 12 }} message={issueErr} onClose={() => setIssueErr(null)} />}
<Form form={issueForm} layout="vertical" onFinish={(v) => issueMut.mutate(v)}>
<Form.Item label={t('ssl.domain')} name="domain" rules={[{ required: true }]}>
<Select
showSearch
<Form.Item
label={t('ssl.domain')} name="domain" rules={[{ required: true }]}
extra={t('ssl.domainExtra')}
>
<AutoComplete
placeholder={t('ssl.selectDomain')}
options={(domains ?? []).map((d) => ({ value: d.name, label: d.name }))}
options={domainOptions}
filterOption={(input, opt) => String(opt?.value ?? '').toLowerCase().includes(input.toLowerCase())}
allowClear
/>
</Form.Item>
<Button type="primary" htmlType="submit" loading={issueMut.isPending}>