feat(api): add Route 53 hosted zones and DNS records to the cloud explorer
CI / checks (pull_request) Successful in 25m40s

This commit is contained in:
Zakaria
2026-10-24 13:07:57 -04:00
parent ac58bfc98b
commit 2b4d4b146d
14 changed files with 917 additions and 0 deletions
@@ -65,6 +65,7 @@ import { LoadBalancerPanel } from "@/components/LoadBalancerPanel";
import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
@@ -979,6 +980,9 @@ export function DynamicResourceView({
{service === "userpools" && cloud === "aws" && (
<CognitoUsersPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "dns" && cloud === "aws" && (
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "compute" && (
<ComputePanel
cloud={cloud}
@@ -0,0 +1,94 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {Route53RecordsPanel} from './Route53RecordsPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
deleteFlatItem: vi.fn(),
getCloudResource: vi.fn(),
listFlatItems: vi.fn(),
putFlatItem: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const zone: CloudResource = {
id: 'ZG9PAE0M9KRWJ4F', name: 'example.test', cloud: 'aws', service: 'dns', type: 'hosted-zone',
region: null, createdAt: null, metadata: {visibility: 'public', recordCount: 3},
}
function item(id: string, body: Record<string, unknown>) {
return {id, collectionId: null, timestamp: null, metadata: {}, body: {ttl: 300, values: [], alias: null, setIdentifier: null, managed: false, editable: true, ...body}}
}
const records = [
item('example.test|NS', {name: 'example.test', type: 'NS', ttl: 172800, values: ['ns-1.awsdns-01.org.'], managed: true}),
item('www.example.test|A', {name: 'www.example.test', type: 'A', values: ['10.0.0.1']}),
item('example.test|TXT', {name: 'example.test', type: 'TXT', values: ['"v=spf1 -all"']}),
item('cdn.example.test|A', {name: 'cdn.example.test', type: 'A', ttl: null, alias: {dnsName: 'd111.cloudfront.net', hostedZoneId: 'Z2'}, editable: false}),
]
function renderPanel() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><Route53RecordsPanel resource={zone} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.getCloudResource.mockResolvedValue({...zone, metadata: {...zone.metadata, nameServers: ['ns-1.awsdns-01.org', 'ns-2.awsdns-02.net']}})
api.listFlatItems.mockResolvedValue({items: records, nextCursor: null})
api.putFlatItem.mockImplementation(async (_cloud, _service, _zone, body) => item('x', {name: `${body.name}.example.test`, type: body.type}))
api.deleteFlatItem.mockResolvedValue(undefined)
})
test('shows the name servers and records, keeping the zone defaults read-only', async () => {
renderPanel()
expect(await screen.findByText('ns-2.awsdns-02.net')).toBeInTheDocument()
expect(await screen.findByText('10.0.0.1')).toBeInTheDocument()
expect(screen.getByText('Alias → d111.cloudfront.net')).toBeInTheDocument()
expect(screen.getByText('zone default')).toBeInTheDocument()
expect(screen.queryByRole('button', {name: 'Delete NS example.test'})).not.toBeInTheDocument()
expect(screen.queryByRole('button', {name: 'Edit A cdn.example.test'})).not.toBeInTheDocument()
expect(screen.getByRole('button', {name: 'Delete A cdn.example.test'})).toBeInTheDocument()
})
test('creates a record from a name inside the zone', async () => {
const user = userEvent.setup()
renderPanel()
const form = await screen.findByRole('form', {name: 'Create record'})
await user.type(within(form).getByLabelText(/Record name/), 'api')
await user.selectOptions(within(form).getByLabelText('Type'), 'CNAME')
await user.clear(within(form).getByLabelText(/TTL/))
await user.type(within(form).getByLabelText(/TTL/), '60')
await user.type(within(form).getByLabelText(/Values/), 'app.example.net')
await user.click(within(form).getByRole('button', {name: 'Create record'}))
expect(api.putFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, {name: 'api', type: 'CNAME', ttl: 60, values: ['app.example.net']})
expect(await screen.findByText(/Created CNAME api.example.test/)).toBeInTheDocument()
})
test('edits a record with its name and type fixed, showing TXT text without quotes', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Edit TXT example.test'}))
const form = screen.getByRole('form', {name: 'Edit record'})
expect(within(form).getByLabelText(/Record name/)).toHaveValue('@')
expect(within(form).getByLabelText(/Record name/)).toBeDisabled()
expect(within(form).getByLabelText('Type')).toBeDisabled()
expect(within(form).getByLabelText(/Values/)).toHaveValue('v=spf1 -all')
})
test('deletes a record by its name and type', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Delete A www.example.test'}))
expect(api.deleteFlatItem).toHaveBeenCalledWith('aws', 'dns', zone.id, 'www.example.test|A')
expect(await screen.findByText('Deleted A www.example.test.')).toBeInTheDocument()
})
@@ -0,0 +1,205 @@
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') : []
}
@@ -4,6 +4,7 @@ import {
Clock,
Container,
Database,
Globe,
HardDrive,
Hammer,
KeyRound,
@@ -31,6 +32,7 @@ import codeBuildIcon from '../../../../aws-icons-svg/Developer-Tools/CodeBuild.s
import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipeline.svg'
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
@@ -87,6 +89,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
iam: ShieldCheck,
kms: Lock,
userpools: UsersRound,
dns: Globe,
loadbalancing: Scale,
parameters: SlidersHorizontal,
configuration: SlidersHorizontal,
@@ -131,6 +134,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
serverless: lambdaIcon,
storage: s3Icon,
userpools: cognitoIcon,
dns: route53Icon,
workflows: stepFunctionsIcon,
}