206 lines
11 KiB
TypeScript
206 lines
11 KiB
TypeScript
import {Pencil, RefreshCw, Trash2} from 'lucide-react'
|
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
|
import {useEffect, useState} from 'react'
|
|
import {deleteFlatItem, getCloudResource, listFlatItems, putFlatItem} from '@/api/cloudProxyClient'
|
|
import {CopyValueButton} from '@/components/CopyValueButton'
|
|
import {useAccountId} from '@/lib/accountStore'
|
|
import type {ChildItem, CloudResource} from '@/types/resource'
|
|
|
|
type Props = {
|
|
resource?: CloudResource
|
|
runtimeReachable: boolean
|
|
}
|
|
|
|
type DnsRecord = {
|
|
id: string
|
|
name: string
|
|
type: string
|
|
ttl: number | null
|
|
values: string[]
|
|
alias: {dnsName: string; hostedZoneId: string} | null
|
|
setIdentifier: string | null
|
|
managed: boolean
|
|
editable: boolean
|
|
}
|
|
|
|
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'CAA', 'NS', 'PTR', 'SPF', 'DS', 'HTTPS', 'SVCB', 'NAPTR', 'SSHFP', 'TLSA']
|
|
const VALUE_HINTS: Record<string, string> = {
|
|
A: 'IPv4 addresses, one per line: 192.0.2.10',
|
|
AAAA: 'IPv6 addresses, one per line: 2001:db8::10',
|
|
CNAME: 'One host name: app.example.net',
|
|
TXT: 'Text, one string per line. Quotes are added for you.',
|
|
MX: 'Priority and mail server, one per line: 10 mail.example.com',
|
|
SRV: 'Priority, weight, port and target: 10 5 5060 sip.example.com',
|
|
CAA: 'Flags, tag and value: 0 issue "amazon.com"',
|
|
}
|
|
/** Every page of a zone is fetched; zones on a local runtime are small. */
|
|
const MAX_PAGES = 20
|
|
|
|
const emptyDraft = {name: '', type: 'A', ttl: '300', values: ''}
|
|
|
|
export function Route53RecordsPanel({resource, runtimeReachable}: Props) {
|
|
const accountId = useAccountId()
|
|
const queryClient = useQueryClient()
|
|
const [draft, setDraft] = useState(emptyDraft)
|
|
const [editing, setEditing] = useState<string | null>(null)
|
|
const [notice, setNotice] = useState('')
|
|
const recordsKey = ['dns-records', accountId, resource?.id]
|
|
|
|
const zoneQuery = useQuery({
|
|
queryKey: ['dns-zone', accountId, resource?.id],
|
|
queryFn: ({signal}) => getCloudResource('aws', 'dns', resource?.id ?? '', signal),
|
|
enabled: Boolean(resource) && runtimeReachable,
|
|
})
|
|
const recordsQuery = useQuery({
|
|
queryKey: recordsKey,
|
|
queryFn: async ({signal}) => {
|
|
const items: ChildItem[] = []
|
|
let cursor: string | undefined
|
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
const result = await listFlatItems('aws', 'dns', resource?.id ?? '', cursor, signal)
|
|
items.push(...result.items)
|
|
if (!result.nextCursor) break
|
|
cursor = result.nextCursor
|
|
}
|
|
return items.map(toRecord)
|
|
},
|
|
enabled: Boolean(resource) && runtimeReachable,
|
|
})
|
|
|
|
useEffect(() => {
|
|
setDraft(emptyDraft)
|
|
setEditing(null)
|
|
setNotice('')
|
|
}, [resource?.id])
|
|
|
|
// The zone list shows each zone's record count.
|
|
const refresh = () => Promise.all([
|
|
queryClient.invalidateQueries({queryKey: recordsKey}),
|
|
queryClient.invalidateQueries({queryKey: ['dns-zone', accountId, resource?.id]}),
|
|
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'dns']}),
|
|
])
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: () => putFlatItem('aws', 'dns', resource?.id ?? '', {
|
|
name: draft.name,
|
|
type: draft.type,
|
|
ttl: Number(draft.ttl),
|
|
values: draft.values.split('\n'),
|
|
}),
|
|
onSuccess: (saved) => {
|
|
setNotice(`${editing ? 'Saved' : 'Created'} ${String(saved.body.type)} ${String(saved.body.name)}.`)
|
|
setDraft(emptyDraft)
|
|
setEditing(null)
|
|
return refresh()
|
|
},
|
|
})
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => deleteFlatItem('aws', 'dns', resource?.id ?? '', id),
|
|
onSuccess: (_result, id) => {
|
|
setNotice(`Deleted ${id.split('|').slice(0, 2).reverse().join(' ')}.`)
|
|
if (editing === id) { setEditing(null); setDraft(emptyDraft) }
|
|
return refresh()
|
|
},
|
|
})
|
|
|
|
if (!resource) {
|
|
return <section className="table-panel"><div className="empty compact"><h3>Select a hosted zone</h3><p>Choose a hosted zone to see and change its DNS records.</p></div></section>
|
|
}
|
|
|
|
const zoneName = resource.name
|
|
const records = recordsQuery.data ?? []
|
|
const nameServers = stringList(zoneQuery.data?.metadata.nameServers)
|
|
const pending = saveMutation.isPending || deleteMutation.isPending
|
|
const disabled = !runtimeReachable || pending
|
|
const error = zoneQuery.error ?? recordsQuery.error ?? saveMutation.error ?? deleteMutation.error
|
|
const edit = (record: DnsRecord) => {
|
|
setNotice('')
|
|
setEditing(record.id)
|
|
setDraft({name: relativeName(record.name, zoneName), type: record.type, ttl: String(record.ttl ?? 300), values: record.values.map((value) => record.type === 'TXT' || record.type === 'SPF' ? unquote(value) : value).join('\n')})
|
|
}
|
|
|
|
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Records in ${zoneName}`}>
|
|
{nameServers.length > 0 && <>
|
|
<div className="input-row resource-table-bar">
|
|
<div><p className="eyebrow">Name servers</p><span className="muted">Delegate {zoneName} to these at your registrar. TheCloud stores records but does not answer DNS queries.</span></div>
|
|
</div>
|
|
<ul className="dns-name-servers">{nameServers.map((server) => <li key={server}><span className="copy-value mono">{server}<CopyValueButton value={server} label="Name server"/></span></li>)}</ul>
|
|
</>}
|
|
|
|
<div className="input-row resource-table-bar">
|
|
<div><p className="eyebrow">Records</p><span className="muted">{records.length} {records.length === 1 ? 'record' : 'records'} in {zoneName}</span></div>
|
|
<button className="button" type="button" disabled={!runtimeReachable || recordsQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{recordsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
|
</div>
|
|
|
|
<form className="dynamic-form" aria-label={editing ? 'Edit record' : 'Create record'} onSubmit={(event) => { event.preventDefault(); setNotice(''); saveMutation.mutate() }}>
|
|
<label className="dynamic-field"><span>Record name</span>
|
|
<div className="dns-name-input"><input className="input" value={draft.name} placeholder="www" onChange={(event) => setDraft({...draft, name: event.target.value})} disabled={disabled || Boolean(editing)} autoComplete="off"/><span className="dns-zone-suffix">.{zoneName}</span></div>
|
|
<small className="muted">Leave empty or use @ for {zoneName} itself.</small>
|
|
</label>
|
|
<label className="dynamic-field"><span>Type</span>
|
|
<select className="input" value={draft.type} onChange={(event) => setDraft({...draft, type: event.target.value})} disabled={disabled || Boolean(editing)}>
|
|
{RECORD_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
|
|
</select>
|
|
</label>
|
|
<label className="dynamic-field"><span>TTL (seconds)</span>
|
|
<input className="input" type="number" min={0} value={draft.ttl} onChange={(event) => setDraft({...draft, ttl: event.target.value})} disabled={disabled} required/>
|
|
</label>
|
|
<label className="dynamic-field dynamic-field--span"><span>Values</span>
|
|
<textarea className="textarea code-textarea small" rows={3} value={draft.values} onChange={(event) => setDraft({...draft, values: event.target.value})} disabled={disabled} required/>
|
|
<small className="muted">{VALUE_HINTS[draft.type] ?? 'One value per line.'}</small>
|
|
</label>
|
|
<div className="dns-form-actions">
|
|
<button className="button primary" type="submit" disabled={disabled || !draft.values.trim()}>{saveMutation.isPending ? 'Saving' : editing ? 'Save record' : 'Create record'}</button>
|
|
{editing && <button className="button" type="button" disabled={pending} onClick={() => { setEditing(null); setDraft(emptyDraft) }}>Cancel</button>}
|
|
</div>
|
|
</form>
|
|
|
|
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
|
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
|
{recordsQuery.isLoading && <div className="muted padded">Loading records</div>}
|
|
{records.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Name</th><th>Type</th><th>TTL</th><th>Value</th><th/></tr></thead><tbody>{records.map((record) => (
|
|
<tr key={record.id} aria-current={editing === record.id ? 'true' : undefined}>
|
|
<td className="mono">{record.name}{record.managed && <span className="badge dns-badge">zone default</span>}{record.setIdentifier && <span className="badge dns-badge">{record.setIdentifier}</span>}</td>
|
|
<td><code>{record.type}</code></td>
|
|
<td>{record.alias ? '—' : record.ttl ?? '—'}</td>
|
|
<td className="dns-values">{record.alias
|
|
? <span className="mono">Alias → {record.alias.dnsName}</span>
|
|
: record.values.map((value, index) => <div key={index} className="mono">{value}</div>)}</td>
|
|
<td><div className="cognito-row-actions">
|
|
{record.editable && !record.managed && <button className="icon-btn" type="button" title="Edit record" aria-label={`Edit ${record.type} ${record.name}`} disabled={disabled} onClick={() => edit(record)}><Pencil size={14}/></button>}
|
|
{!record.managed && <button className="icon-btn danger" type="button" title="Delete record" aria-label={`Delete ${record.type} ${record.name}`} disabled={disabled} onClick={() => { setNotice(''); deleteMutation.mutate(record.id) }}><Trash2 size={14}/></button>}
|
|
</div></td>
|
|
</tr>
|
|
))}</tbody></table></div>}
|
|
</section>
|
|
}
|
|
|
|
function toRecord(item: ChildItem): DnsRecord {
|
|
const body = item.body
|
|
const alias = body.alias && typeof body.alias === 'object' ? body.alias as {dnsName?: unknown; hostedZoneId?: unknown} : null
|
|
return {
|
|
id: item.id,
|
|
name: String(body.name ?? ''),
|
|
type: String(body.type ?? ''),
|
|
ttl: typeof body.ttl === 'number' ? body.ttl : null,
|
|
values: stringList(body.values),
|
|
alias: alias ? {dnsName: String(alias.dnsName ?? ''), hostedZoneId: String(alias.hostedZoneId ?? '')} : null,
|
|
setIdentifier: typeof body.setIdentifier === 'string' ? body.setIdentifier : null,
|
|
managed: body.managed === true,
|
|
editable: body.editable === true,
|
|
}
|
|
}
|
|
|
|
function relativeName(name: string, zone: string): string {
|
|
if (name === zone) return '@'
|
|
return name.endsWith(`.${zone}`) ? name.slice(0, -zone.length - 1) : name
|
|
}
|
|
|
|
function unquote(value: string): string {
|
|
return value.length > 1 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\') : value
|
|
}
|
|
|
|
function stringList(value: unknown): string[] {
|
|
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
|
|
}
|