diff --git a/apps/api/src/routes/fleet.ts b/apps/api/src/routes/fleet.ts index 79d3ec4..4929693 100644 --- a/apps/api/src/routes/fleet.ts +++ b/apps/api/src/routes/fleet.ts @@ -35,6 +35,7 @@ import { } from "@cdnmanager/db"; import { AppError } from "../errors.js"; import { + buildDnsPreviewRecords, buildHostname, isValidIpv4, isValidIpv6, @@ -322,15 +323,30 @@ export const fleetRoutes: FastifyPluginAsync = async (app) => { const loc = listLocations(app.db).find((l) => l.id === q.locationId); if (!loc) throw AppError.notFound("location not found"); const indexNum = Number(q.indexNum ?? "1") || 1; + const hostname = buildHostname({ + template: q.template || zone.namingTemplate, + locationCode: loc.code, + role: q.role, + indexNum, + zoneName: zone.name, + providerTag: q.providerTag, + }); + const aliases = q.nodeId + ? listAliases(app.db, { zoneId: zone.id }).filter( + (a) => a.targetNodeId === q.nodeId, + ) + : []; + const records = buildDnsPreviewRecords({ + hostname, + ttl: zone.defaultTtl, + ipv4: q.ipv4, + ipv6: q.ipv6, + aliases: aliases.map((a) => ({ name: a.name, purpose: a.purpose })), + }); return { - hostname: buildHostname({ - template: q.template || zone.namingTemplate, - locationCode: loc.code, - role: q.role, - indexNum, - zoneName: zone.name, - providerTag: q.providerTag, - }), + hostname, + ttl: zone.defaultTtl, + records, }; }); }; diff --git a/apps/api/src/services/naming.ts b/apps/api/src/services/naming.ts index fce432b..f568ff0 100644 --- a/apps/api/src/services/naming.ts +++ b/apps/api/src/services/naming.ts @@ -24,6 +24,56 @@ export function buildHostname(opts: { return host.replace(/\.$/, ""); } +export type DnsPreviewRecord = { + type: "A" | "AAAA" | "CNAME"; + name: string; + content: string; + ttl: number; + /** purpose / note (e.g. alias purpose) */ + note?: string; +}; + +/** Desired-state DNS for node form preview (A/AAAA + CNAME aliases → hostname). */ +export function buildDnsPreviewRecords(opts: { + hostname: string; + ttl: number; + ipv4?: string | null; + ipv6?: string | null; + aliases?: Array<{ name: string; purpose?: string | null }>; +}): DnsPreviewRecord[] { + const records: DnsPreviewRecord[] = []; + const ipv4 = opts.ipv4?.trim(); + const ipv6 = opts.ipv6?.trim(); + if (ipv4) { + records.push({ + type: "A", + name: opts.hostname, + content: ipv4, + ttl: opts.ttl, + note: "dns-only", + }); + } + if (ipv6) { + records.push({ + type: "AAAA", + name: opts.hostname, + content: ipv6, + ttl: opts.ttl, + note: "dns-only", + }); + } + for (const alias of opts.aliases ?? []) { + records.push({ + type: "CNAME", + name: alias.name, + content: opts.hostname, + ttl: opts.ttl, + note: alias.purpose ?? undefined, + }); + } + return records; +} + export function normalizeFqdn(name: string): string { return name.trim().toLowerCase().replace(/\.$/, ""); } diff --git a/apps/api/test/fleet.test.ts b/apps/api/test/fleet.test.ts index 73b53de..4a2b30c 100644 --- a/apps/api/test/fleet.test.ts +++ b/apps/api/test/fleet.test.ts @@ -112,5 +112,44 @@ describe("fleet api", () => { }); expect(topo.statusCode).toBe(200); expect(topo.json().nodes).toHaveLength(1); + + const preview = await app.inject({ + method: "GET", + url: `/api/v1/naming/preview?${new URLSearchParams({ + zoneId: zone.id, + locationId: msk.id, + role: "gw", + indexNum: "2", + ipv4: "198.51.100.10", + ipv6: "2001:db8::10", + nodeId: node.id, + })}`, + headers: authHeader, + }); + expect(preview.statusCode).toBe(200); + const body = preview.json() as { + hostname: string; + records: Array<{ type: string; name: string; content: string }>; + }; + expect(body.hostname).toBe("msk-gw02.rtnt.top"); + expect(body.records).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "A", + name: "msk-gw02.rtnt.top", + content: "198.51.100.10", + }), + expect.objectContaining({ + type: "AAAA", + name: "msk-gw02.rtnt.top", + content: "2001:db8::10", + }), + expect.objectContaining({ + type: "CNAME", + name: "msk.rtnt.top", + content: "msk-gw02.rtnt.top", + }), + ]), + ); }); }); diff --git a/apps/web/src/queries/fleet.ts b/apps/web/src/queries/fleet.ts index 0d59ff3..714e197 100644 --- a/apps/web/src/queries/fleet.ts +++ b/apps/web/src/queries/fleet.ts @@ -180,6 +180,9 @@ export async function previewHostname(params: { role: string indexNum?: number providerTag?: string + ipv4?: string + ipv6?: string + nodeId?: string }) { const p = new URLSearchParams({ zoneId: params.zoneId, @@ -188,5 +191,18 @@ export async function previewHostname(params: { indexNum: String(params.indexNum ?? 1), }) if (params.providerTag) p.set('providerTag', params.providerTag) - return api.get<{ hostname: string }>(`/api/v1/naming/preview?${p}`) + if (params.ipv4) p.set('ipv4', params.ipv4) + if (params.ipv6) p.set('ipv6', params.ipv6) + if (params.nodeId) p.set('nodeId', params.nodeId) + return api.get<{ + hostname: string + ttl: number + records: Array<{ + type: 'A' | 'AAAA' | 'CNAME' + name: string + content: string + ttl: number + note?: string + }> + }>(`/api/v1/naming/preview?${p}`) } diff --git a/apps/web/src/routes/_auth/nodes.tsx b/apps/web/src/routes/_auth/nodes.tsx index df0736b..c15085d 100644 --- a/apps/web/src/routes/_auth/nodes.tsx +++ b/apps/web/src/routes/_auth/nodes.tsx @@ -1,5 +1,5 @@ import { createFileRoute, Link } from '@tanstack/react-router' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useForm } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' @@ -86,7 +86,16 @@ function NodesPage() { const [sheetOpen, setSheetOpen] = useState(false) const [editing, setEditing] = useState(null) const [deleteId, setDeleteId] = useState(null) - const [preview, setPreview] = useState('') + const [previewHost, setPreviewHost] = useState('') + const [previewRecords, setPreviewRecords] = useState< + Array<{ + type: 'A' | 'AAAA' | 'CNAME' + name: string + content: string + ttl: number + note?: string + }> + >([]) const [countryName, setCountryName] = useState('') const [locationQuery, setLocationQuery] = useState('') @@ -110,6 +119,8 @@ function NodesPage() { const watchRole = form.watch('role') const watchIndex = form.watch('indexNum') const watchProvider = form.watch('providerTag') + const watchIpv4 = form.watch('ipv4') + const watchIpv6 = form.watch('ipv6') const countryCode = countryCodeFromName(countryName) @@ -161,22 +172,59 @@ function NodesPage() { return match?.id ?? '' } - async function refreshPreview() { - if (!watchZone || !watchLoc || !watchRole) return + async function refreshPreview( + overrides?: Partial<{ + zoneId: string + locationId: string + role: NodeRole + indexNum: number + providerTag: string + ipv4: string + ipv6: string + }>, + ) { + const values = { ...form.getValues(), ...overrides } + if (!values.zoneId || !values.locationId || !values.role) { + setPreviewHost('') + setPreviewRecords([]) + return + } try { const res = await previewHostname({ - zoneId: watchZone, - locationId: watchLoc, - role: watchRole, - indexNum: Number(watchIndex) || 1, - providerTag: watchProvider || undefined, + zoneId: values.zoneId, + locationId: values.locationId, + role: values.role, + indexNum: Number(values.indexNum) || 1, + providerTag: values.providerTag || undefined, + ipv4: values.ipv4 || undefined, + ipv6: values.ipv6 || undefined, + nodeId: editing?.id, }) - setPreview(res.hostname) + setPreviewHost(res.hostname) + setPreviewRecords(res.records ?? []) } catch { - setPreview('') + setPreviewHost('') + setPreviewRecords([]) } } + useEffect(() => { + if (!sheetOpen) return + void refreshPreview() + // form + editing captured via refreshPreview closures; watches drive re-run + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional field watches + }, [ + sheetOpen, + watchZone, + watchLoc, + watchRole, + watchIndex, + watchProvider, + watchIpv4, + watchIpv6, + editing?.id, + ]) + const saveMutation = useMutation({ mutationFn: async (values: FormValues) => { if (editing) { @@ -341,7 +389,8 @@ function NodesPage() { notes: n.notes ?? '', hostname: n.hostname, }) - setPreview(n.hostname) + setPreviewHost(n.hostname) + setPreviewRecords([]) setSheetOpen(true) }} > @@ -383,9 +432,9 @@ function NodesPage() { notes: '', hostname: '', }) - setPreview('') + setPreviewHost('') + setPreviewRecords([]) setSheetOpen(true) - void refreshPreview() }} disabled={zones.length === 0} > @@ -453,8 +502,7 @@ function NodesPage() { { - form.setValue('zoneId', v ?? '') - void refreshPreview() + form.setValue('zoneId', v ?? '', { shouldDirty: true }) }} placeholder="Зона" options={zones.map((z) => ({ value: z.id, label: z.name }))} @@ -474,9 +522,9 @@ function NodesPage() { if (!nextCode || !currentLoc || currentLoc.country !== nextCode) { form.setValue('locationId', '') setLocationQuery('') - setPreview('') + setPreviewHost('') + setPreviewRecords([]) } - void refreshPreview() }} options={countryOptions} searchPlaceholder="Поиск страны…" @@ -494,7 +542,11 @@ function NodesPage() { form.setValue('locationId', id) const loc = locations.find((l) => l.id === id) if (loc?.country) setCountryName(countryNameFromCode(loc.country)) - void refreshPreview() + if (id) void refreshPreview({ locationId: id }) + else { + setPreviewHost('') + setPreviewRecords([]) + } }} options={locationOptions} searchPlaceholder="Поиск локации…" @@ -510,8 +562,9 @@ function NodesPage() { { - form.setValue('role', (v as NodeRole) ?? 'gw') - void refreshPreview() + const role = (v as NodeRole) ?? 'gw' + form.setValue('role', role) + void refreshPreview({ role }) }} options={ROLES.map((r) => ({ value: r.value, label: r.label }))} /> @@ -522,27 +575,17 @@ function NodesPage() { type="number" min={1} max={99} - {...form.register('indexNum', { valueAsNumber: true })} - onBlur={() => void refreshPreview()} + {...form.register('indexNum', { + valueAsNumber: true, + onChange: (e) => { + const n = Number(e.target.value) || 1 + void refreshPreview({ indexNum: n }) + }, + })} /> -
- - Preview: - {preview || '—'} - -
-
@@ -559,6 +602,48 @@ function NodesPage() {
+ +
+
+ + Preview FQDN: + {previewHost || '—'} + +
+ {previewRecords.length > 0 ? ( +
    + {previewRecords.map((r) => ( +
  • + + {r.type} + + + {r.name} → {r.content} + + {r.note ? ( + ({r.note}) + ) : null} +
  • + ))} +
+ ) : previewHost ? ( +

+ Укажите IPv4/IPv6 — появятся A/AAAA. При редактировании — CNAME + алиасов на эту ноду. +

+ ) : null} +