Dns Record Manager
blockdevopsCloudflare/Vercel style DNS records management table and nameserver inspector with type badges, proxy toggle, copyable values, zone file exporter, and add/edit record panel.
Installation
$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/dns-record-manager.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/dns-record-manager.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/dns-record-manager.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/dns-record-manager.jsonNamed registry:
npx shadcn-vue@latest add @uipkge/dns-record-managerInstalls to:app/components/blocks/Variants
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
domain | string | 'uipkge.dev' | optional |
nameservers | string[] | () => ['ns1.uipkge.net' | optional |
dnssecEnabled | boolean | — | optional |
initialRecords | DnsRecord[] | — | optional |
initialAddOpen | boolean | — | optional |
class | string | — | optional |
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
DnsRecordinterface DnsRecord {
id: string
type: DnsRecordType
name: string
content: string
ttl: string
proxied: boolean
priority?: number
comment?: string
}npm dependencies
Files installed (1)
app/components/blocks/DnsRecordManager.vue32.5 kB
<script setup lang="ts"> import { computed, ref, watch } from 'vue' import { Check, Cloud, CloudOff, Copy, Download, Edit2, FileCode, Globe, MoreHorizontal, Plus, Search, ShieldAlert, ShieldCheck, Trash2, X, } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Separator } from '@/components/ui/separator' import { Switch } from '@/components/ui/switch' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' export type DnsRecordType = 'A' | 'AAAA' | 'CNAME' | 'MX' | 'TXT' | 'CAA' export interface DnsRecord { id: string type: DnsRecordType name: string content: string ttl: string proxied: boolean priority?: number comment?: string } interface Props { domain?: string nameservers?: string[] dnssecEnabled?: boolean initialRecords?: DnsRecord[] initialAddOpen?: boolean class?: string } const props = withDefaults(defineProps<Props>(), { domain: 'uipkge.dev', nameservers: () => ['ns1.uipkge.net', 'ns2.uipkge.net'], dnssecEnabled: true, initialRecords: undefined, initialAddOpen: false, }) const defaultRecords: DnsRecord[] = [ { id: 'rec-1', type: 'A', name: '@', content: '76.76.21.21', ttl: 'Auto', proxied: true, comment: 'Apex production load balancer', }, { id: 'rec-2', type: 'CNAME', name: 'www', content: 'cname.vercel-dns.com', ttl: 'Auto', proxied: true, comment: 'Primary web redirect', }, { id: 'rec-3', type: 'CNAME', name: 'api', content: 'api-gateway.uipkge.net', ttl: '300s', proxied: false, comment: 'Direct REST gateway route', }, { id: 'rec-4', type: 'AAAA', name: '@', content: '2606:4700:3038::6815:1515', ttl: 'Auto', proxied: true, comment: 'IPv6 edge ingress', }, { id: 'rec-5', type: 'MX', name: 'mail', content: 'aspmx.l.google.com', ttl: '3600s', proxied: false, priority: 10, comment: 'Google Workspace MX', }, { id: 'rec-6', type: 'TXT', name: '_dmarc', content: 'v=spf1 include:_spf.google.com ~all', ttl: '3600s', proxied: false, comment: 'Sender Policy Framework verification', }, { id: 'rec-7', type: 'CAA', name: '@', content: '0 issue "letsencrypt.org"', ttl: 'Auto', proxied: false, comment: 'Certificate authority restriction', }, ] const records = ref<DnsRecord[]>( props.initialRecords ? props.initialRecords.map((r) => ({ ...r })) : defaultRecords.map((r) => ({ ...r })), ) const searchQuery = ref('') const selectedTypeFilter = ref<string>('ALL') const isAddOpen = ref(props.initialAddOpen) const isZoneFileOpen = ref(false) const editingId = ref<string | null>(null) // Form fields const formType = ref<DnsRecordType>('A') const formName = ref('') const formContent = ref('') const formTtl = ref('Auto') const formProxied = ref(true) const formPriority = ref(10) const formComment = ref('') const typeBadgeStyles: Record<DnsRecordType, { bg: string; text: string; border: string }> = { A: { bg: 'bg-info/10', text: 'text-info', border: 'border-info/30', }, AAAA: { bg: 'bg-chart-1/10', text: 'text-chart-1', border: 'border-chart-1/30', }, CNAME: { bg: 'bg-chart-2/10', text: 'text-chart-2', border: 'border-chart-2/30', }, MX: { bg: 'bg-warning/10', text: 'text-warning', border: 'border-warning/30', }, TXT: { bg: 'bg-success/10', text: 'text-success', border: 'border-success/30', }, CAA: { bg: 'bg-info/10', text: 'text-info', border: 'border-info/30', }, } const placeholderMap: Record<DnsRecordType, string> = { A: 'e.g. 76.76.21.21', AAAA: 'e.g. 2606:4700:3038::6815:1515', CNAME: 'e.g. cname.vercel-dns.com', MX: 'e.g. mail.google.com', TXT: 'e.g. v=spf1 include:_spf.google.com ~all', CAA: 'e.g. 0 issue "letsencrypt.org"', } // When type changes, auto-set proxy capability watch(formType, (newType) => { if (['MX', 'TXT', 'CAA'].includes(newType)) { formProxied.value = false } else if (!editingId.value) { formProxied.value = true } }) const filteredRecords = computed(() => { const query = searchQuery.value.trim().toLowerCase() return records.value.filter((record) => { const matchesType = selectedTypeFilter.value === 'ALL' || record.type === selectedTypeFilter.value const matchesQuery = !query || record.name.toLowerCase().includes(query) || record.content.toLowerCase().includes(query) || record.type.toLowerCase().includes(query) || (record.comment && record.comment.toLowerCase().includes(query)) return matchesType && matchesQuery }) }) const copiedKey = ref<string | null>(null) let copyTimeout: number | undefined async function copyText(key: string, text: string) { try { await navigator.clipboard.writeText(text) copiedKey.value = key window.clearTimeout(copyTimeout) copyTimeout = window.setTimeout(() => { copiedKey.value = null }, 1600) } catch { // Clipboard unavailable } } function toggleProxy(id: string) { const rec = records.value.find((r) => r.id === id) if (rec && ['A', 'AAAA', 'CNAME'].includes(rec.type)) { rec.proxied = !rec.proxied } } function deleteRecord(id: string) { records.value = records.value.filter((r) => r.id !== id) if (editingId.value === id) { cancelForm() } } function startAdd() { editingId.value = null formType.value = 'A' formName.value = '' formContent.value = '' formTtl.value = 'Auto' formProxied.value = true formPriority.value = 10 formComment.value = '' isAddOpen.value = true } function startEdit(record: DnsRecord) { editingId.value = record.id formType.value = record.type formName.value = record.name formContent.value = record.content formTtl.value = record.ttl formProxied.value = record.proxied formPriority.value = record.priority ?? 10 formComment.value = record.comment ?? '' isAddOpen.value = true } function cancelForm() { isAddOpen.value = false editingId.value = null } function saveRecord() { const name = formName.value.trim() || '@' const content = formContent.value.trim() if (!content) return if (editingId.value) { const idx = records.value.findIndex((r) => r.id === editingId.value) if (idx !== -1) { records.value[idx] = { ...records.value[idx], type: formType.value, name, content, ttl: formTtl.value, proxied: ['A', 'AAAA', 'CNAME'].includes(formType.value) ? formProxied.value : false, priority: formType.value === 'MX' ? Number(formPriority.value) : undefined, comment: formComment.value.trim() || undefined, } } } else { const newRecord: DnsRecord = { id: `rec-${Date.now()}`, type: formType.value, name, content, ttl: formTtl.value, proxied: ['A', 'AAAA', 'CNAME'].includes(formType.value) ? formProxied.value : false, priority: formType.value === 'MX' ? Number(formPriority.value) : undefined, comment: formComment.value.trim() || undefined, } records.value.unshift(newRecord) } cancelForm() } const zoneFileContent = computed(() => { const date = new Date().toISOString().split('T')[0].replace(/-/g, '') const lines: string[] = [ `; BIND zone file for ${props.domain}`, `; Generated on ${new Date().toUTCString()}`, `$ORIGIN ${props.domain}.`, `$TTL 3600`, ``, `; SOA Record`, `@ IN SOA ${props.nameservers[0]}. hostmaster.${props.domain}. (`, ` ${date}01 ; Serial`, ` 7200 ; Refresh (2 hours)`, ` 3600 ; Retry (1 hour)`, ` 1209600 ; Expire (2 weeks)`, ` 3600 ; Minimum TTL (1 hour)`, `)`, ``, `; Nameservers`, ] props.nameservers.forEach((ns) => { lines.push(`@ IN NS ${ns}.`) }) lines.push('', '; Resource Records') for (const r of records.value) { const recName = (r.name === '@' ? '@' : r.name).padEnd(12, ' ') const ttlValue = r.ttl === 'Auto' ? '3600' : r.ttl.replace('s', '') const ttlStr = ttlValue.padEnd(6, ' ') const typeStr = r.type.padEnd(7, ' ') if (r.type === 'MX') { const prio = String(r.priority ?? 10).padEnd(4, ' ') lines.push(`${recName} ${ttlStr} IN ${typeStr} ${prio} ${r.content}.`) } else if (r.type === 'TXT') { lines.push(`${recName} ${ttlStr} IN ${typeStr} "${r.content}"`) } else if (r.type === 'CNAME') { lines.push(`${recName} ${ttlStr} IN ${typeStr} ${r.content}.`) } else { lines.push(`${recName} ${ttlStr} IN ${typeStr} ${r.content}`) } } return lines.join('\n') }) function downloadZoneFile() { const blob = new Blob([zoneFileContent.value], { type: 'text/plain;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${props.domain}.zone` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } function resolveHostname(recordName: string): string { if (recordName === '@') return props.domain return `${recordName}.${props.domain}` } </script> <template> <div data-slot="dns-record-manager" :class="cn('w-full space-y-6', props.class)"> <!-- Domain Header & Status Card --> <Card class="border-border bg-card shadow-xs"> <CardHeader class="pb-4"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="space-y-1.5"> <div class="flex flex-wrap items-center gap-2.5"> <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg"> <Globe class="size-4.5" /> </div> <h2 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl"> {{ props.domain }} </h2> <Badge v-if="props.dnssecEnabled" variant="outline" class="border-success/30 bg-success/10 text-success gap-1.5 py-0.5" > <span class="bg-success size-1.5 rounded-full" /> <ShieldCheck class="size-3.5" /> Active · DNSSEC Enabled </Badge> <Badge v-else variant="outline" class="border-warning/30 bg-warning/10 text-warning gap-1.5 py-0.5"> <span class="bg-warning size-1.5 rounded-full" /> <ShieldAlert class="size-3.5" /> Active · DNSSEC Inactive </Badge> </div> <p class="text-muted-foreground text-xs sm:text-sm"> Authoritative DNS routing and global edge proxy management for {{ props.domain }}. </p> </div> <!-- Header CTA Buttons --> <div class="flex items-center gap-2"> <Button variant="outline" size="sm" class="h-8 gap-1.5 text-xs font-medium" @click="isZoneFileOpen = !isZoneFileOpen" > <FileCode class="size-3.5" /> {{ isZoneFileOpen ? 'Hide Zone File' : 'Export Zone File' }} </Button> <Button size="sm" class="h-8 gap-1.5 text-xs font-medium" @click="startAdd"> <Plus class="size-3.5" /> Add Record </Button> </div> </div> <Separator class="my-4" /> <!-- Nameserver Inspector Bar --> <div class="flex flex-wrap items-center justify-between gap-3 text-xs"> <div class="flex flex-wrap items-center gap-2"> <span class="text-muted-foreground font-medium">Nameservers:</span> <div class="flex flex-wrap items-center gap-1.5"> <button v-for="ns in props.nameservers" :key="ns" type="button" :aria-label="`Copy nameserver ${ns}`" class="border-border bg-muted/60 hover:bg-muted focus-visible:ring-ring/50 group text-foreground flex min-h-6 items-center gap-1.5 rounded-md border px-2.5 py-1 font-mono text-xs transition-colors focus-visible:ring-[2px] focus-visible:outline-none" @click="copyText(ns, ns)" > <span>{{ ns }}</span> <Check v-if="copiedKey === ns" class="text-success size-3" /> <Copy v-else class="text-muted-foreground group-hover:text-foreground size-3" /> </button> </div> </div> <div class="text-muted-foreground flex items-center gap-2"> <span class="bg-success size-2 animate-pulse rounded-full" /> <span>Propagation: 100% Synced</span> </div> </div> </CardHeader> </Card> <!-- Zone File Export Card (Collapsible) --> <Card v-if="isZoneFileOpen" class="border-border bg-card shadow-xs"> <CardHeader class="pb-3"> <div class="flex items-center justify-between"> <div> <CardTitle class="text-sm font-semibold sm:text-base">BIND RFC 1035 Zone File</CardTitle> <CardDescription class="text-xs"> Complete DNS zone mapping for {{ props.domain }} ready for import into Route53, Bind9, or Cloudflare. </CardDescription> </div> <Button variant="ghost" size="icon-sm" class="size-7" aria-label="Close zone file viewer" @click="isZoneFileOpen = false" > <X class="size-4" /> </Button> </div> </CardHeader> <CardContent class="space-y-3 pt-0"> <div class="relative"> <pre class="border-border bg-muted/70 text-foreground max-h-56 overflow-x-auto rounded-lg border p-3 font-mono text-xs leading-relaxed select-all" >{{ zoneFileContent }}</pre> </div> <div class="flex items-center justify-end gap-2"> <Button variant="outline" size="sm" class="h-8 gap-1.5 text-xs" @click="copyText('zone-file', zoneFileContent)" > <Check v-if="copiedKey === 'zone-file'" class="text-success size-3.5" /> <Copy v-else class="size-3.5" /> {{ copiedKey === 'zone-file' ? 'Copied Zone File' : 'Copy Zone Content' }} </Button> <Button aria-label="Download attachment" size="sm" class="h-8 gap-1.5 text-xs" @click="downloadZoneFile"> <Download class="size-3.5" /> Download .zone File </Button> </div> </CardContent> </Card> <!-- Add / Edit DNS Record Card --> <Card v-if="isAddOpen" class="border-primary/30 bg-muted/20 shadow-xs"> <CardHeader class="pb-4"> <div class="flex items-center justify-between"> <div> <CardTitle class="text-base font-semibold"> {{ editingId ? 'Edit DNS Record' : 'Add New DNS Record' }} </CardTitle> <CardDescription class="text-xs"> {{ editingId ? 'Update configuration, target destination, or edge proxy status.' : `Configure host routing, MX exchangers, or verification TXT tags for ${props.domain}.` }} </CardDescription> </div> <Button variant="ghost" size="icon-sm" class="size-7" aria-label="Cancel record form" @click="cancelForm"> <X class="size-4" /> </Button> </div> </CardHeader> <CardContent class="space-y-4 pt-0"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-12"> <!-- Type Select --> <div class="sm:col-span-3"> <label for="dns-type" class="text-foreground mb-1.5 block text-xs font-medium"> Type </label> <Select v-model="formType"> <SelectTrigger id="dns-type" class="w-full [&_svg]:shrink-0 [&>span]:truncate"> <SelectValue placeholder="Record type" /> </SelectTrigger> <SelectContent> <SelectItem value="A">A (IPv4 Address)</SelectItem> <SelectItem value="AAAA">AAAA (IPv6 Address)</SelectItem> <SelectItem value="CNAME">CNAME (Canonical Alias)</SelectItem> <SelectItem value="MX">MX (Mail Exchanger)</SelectItem> <SelectItem value="TXT">TXT (Text Record)</SelectItem> <SelectItem value="CAA">CAA (Cert Authority)</SelectItem> </SelectContent> </Select> </div> <!-- Name Input --> <div class="sm:col-span-5"> <label for="dns-name" class="text-foreground mb-1.5 block text-xs font-medium"> Name / Subdomain <span class="text-muted-foreground font-normal">(@ for root)</span> </label> <Input id="dns-name" v-model="formName" placeholder="@ or subdomain" class="font-mono text-xs" /> </div> <!-- TTL Select --> <div class="sm:col-span-4"> <label for="dns-ttl" class="text-foreground mb-1.5 block text-xs font-medium"> TTL </label> <Select v-model="formTtl"> <SelectTrigger id="dns-ttl" class="w-full [&_svg]:shrink-0 [&>span]:truncate"> <SelectValue placeholder="TTL" /> </SelectTrigger> <SelectContent> <SelectItem value="Auto">Auto (Automatic)</SelectItem> <SelectItem value="60s">1 min (60s)</SelectItem> <SelectItem value="300s">5 mins (300s)</SelectItem> <SelectItem value="1800s">30 mins (1800s)</SelectItem> <SelectItem value="3600s">1 hour (3600s)</SelectItem> <SelectItem value="86400s">1 day (86400s)</SelectItem> </SelectContent> </Select> </div> </div> <div class="grid grid-cols-1 gap-3" :class="formType === 'MX' ? 'sm:grid-cols-12' : ''"> <!-- Content / Target Input --> <div :class="formType === 'MX' ? 'sm:col-span-9' : 'w-full'"> <label for="dns-content" class="text-foreground mb-1.5 block text-xs font-medium"> {{ formType === 'A' ? 'IPv4 Address' : formType === 'AAAA' ? 'IPv6 Address' : formType === 'CNAME' ? 'Target Domain' : formType === 'MX' ? 'Mail Server Hostname' : formType === 'TXT' ? 'TXT Content / Value' : 'Target / Tag Value' }} </label> <Input id="dns-content" v-model="formContent" :placeholder="placeholderMap[formType]" class="font-mono text-xs" /> </div> <!-- Priority Input for MX --> <div v-if="formType === 'MX'" class="sm:col-span-3"> <label for="dns-priority" class="text-foreground mb-1.5 block text-xs font-medium"> Priority </label> <Input id="dns-priority" v-model="formPriority" type="number" min="0" max="65535" placeholder="10" class="font-mono text-xs" /> </div> </div> <!-- Optional Comment --> <div> <label for="dns-comment" class="text-foreground mb-1.5 block text-xs font-medium"> Comment <span class="text-muted-foreground font-normal">(optional note)</span> </label> <Input id="dns-comment" v-model="formComment" placeholder="e.g. Production ingress or verification tag" class="text-xs" /> </div> <!-- Proxy Switch (for A, AAAA, CNAME) --> <div v-if="['A', 'AAAA', 'CNAME'].includes(formType)" class="border-border bg-background/80 flex items-center justify-between rounded-lg border p-3" > <div class="space-y-0.5"> <div class="flex items-center gap-2"> <Cloud class="text-warning size-4" /> <span class="text-foreground text-xs font-medium">Proxy Status</span> </div> <p class="text-muted-foreground text-xs"> {{ formProxied ? 'Proxied: Accelerate traffic and protect origin IP behind Cloudflare edge.' : 'DNS only: Direct traffic route without edge proxy caching.' }} </p> </div> <Switch id="proxy-toggle" v-model="formProxied" /> </div> <!-- Form Actions --> <div class="flex items-center justify-end gap-2 pt-2"> <Button variant="outline" size="sm" class="h-8 text-xs" @click="cancelForm"> Cancel </Button> <Button size="sm" class="h-8 text-xs" :disabled="!formContent.trim()" @click="saveRecord"> {{ editingId ? 'Update Record' : 'Save Record' }} </Button> </div> </CardContent> </Card> <!-- DNS Records Management Section --> <div class="space-y-3"> <!-- Toolbar Filter & Search --> <div class="flex flex-col gap-2.5 sm:flex-row sm:items-center sm:justify-between"> <div class="relative w-full max-w-sm"> <Search class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <Input v-model="searchQuery" placeholder="Search records by name, target, or content..." class="h-8.5 pl-8 text-xs" /> </div> <!-- Type Filter Buttons / Select --> <div class="flex items-center gap-1.5 overflow-x-auto pb-1 sm:pb-0"> <button type="button" :class=" cn( 'min-h-6 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', selectedTypeFilter === 'ALL' ? 'bg-primary text-primary-foreground shadow-xs' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground', ) " @click="selectedTypeFilter = 'ALL'" > All ({{ records.length }}) </button> <button v-for="t in ['A', 'CNAME', 'MX', 'TXT', 'AAAA', 'CAA'] as DnsRecordType[]" :key="t" type="button" :class=" cn( 'min-h-6 min-w-6 rounded-md px-2 py-1 font-mono text-xs font-medium transition-colors', selectedTypeFilter === t ? 'bg-primary text-primary-foreground shadow-xs' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground', ) " @click="selectedTypeFilter = t" > {{ t }} </button> </div> </div> <!-- DNS Records Table --> <div class="border-border bg-card overflow-hidden rounded-lg border shadow-xs"> <div class="overflow-x-auto"> <Table> <TableHeader> <TableRow class="bg-muted/40"> <TableHead class="w-24">Type</TableHead> <TableHead class="min-w-[140px]">Name</TableHead> <TableHead class="min-w-[240px]">Target Content</TableHead> <TableHead class="w-28 text-center">Proxy Status</TableHead> <TableHead class="w-20">TTL</TableHead> <TableHead class="w-12 text-right"><span class="sr-only">Actions</span></TableHead> </TableRow> </TableHeader> <TableBody> <TableRow v-for="record in filteredRecords" :key="record.id" class="hover:bg-muted/30 transition-colors" :class="editingId === record.id ? 'bg-primary/5' : ''" > <!-- Record Type Badge --> <TableCell> <span :class=" cn( 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 font-mono text-xs font-bold', typeBadgeStyles[record.type].bg, typeBadgeStyles[record.type].text, typeBadgeStyles[record.type].border, ) " > {{ record.type }} </span> </TableCell> <!-- Name & Hostname helper --> <TableCell> <div class="flex flex-col"> <span class="text-foreground font-mono text-xs font-semibold"> {{ record.name }} </span> <span class="text-muted-foreground truncate font-mono text-xs"> {{ resolveHostname(record.name) }} </span> </div> </TableCell> <!-- Target Content with Copy button --> <TableCell> <div class="flex items-center gap-2"> <div class="min-w-0 flex-1"> <div class="flex items-center gap-1.5"> <span v-if="record.type === 'MX'" class="border-border bg-muted text-muted-foreground rounded px-1 font-mono text-xs" > Pri {{ record.priority }} </span> <code class="text-foreground/90 font-mono text-xs break-all select-all"> {{ record.content }} </code> </div> <p v-if="record.comment" class="text-muted-foreground mt-0.5 truncate text-xs"> {{ record.comment }} </p> </div> <Button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-foreground size-7 shrink-0" :aria-label="`Copy content for ${record.name}`" @click="copyText(record.id, record.content)" > <Check v-if="copiedKey === record.id" class="text-success size-3.5" /> <Copy v-else class="size-3.5" /> </Button> </div> </TableCell> <!-- Proxy Status Switch / Cloud badge --> <TableCell class="text-center"> <div v-if="['A', 'AAAA', 'CNAME'].includes(record.type)" class="flex justify-center"> <button type="button" :aria-label="`Toggle proxy status for ${record.name}, currently ${record.proxied ? 'proxied' : 'dns only'}`" :class=" cn( 'group inline-flex min-h-6 items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:ring-[2px] focus-visible:outline-none', record.proxied ? 'border-warning/30 bg-warning/10 text-warning hover:bg-warning/20 text-warning' : 'border-border bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground', ) " @click="toggleProxy(record.id)" > <Cloud v-if="record.proxied" class="fill-warning text-warning size-3.5" /> <CloudOff v-else class="text-muted-foreground group-hover:text-foreground size-3.5" /> <span>{{ record.proxied ? 'Proxied' : 'DNS only' }}</span> </button> </div> <div v-else class="flex justify-center"> <span class="text-muted-foreground/60 inline-flex items-center gap-1 font-mono text-xs"> <CloudOff class="size-3" /> DNS only </span> </div> </TableCell> <!-- TTL Badge --> <TableCell> <Badge variant="outline" class="border-border bg-muted/40 font-mono text-xs font-normal"> {{ record.ttl }} </Badge> </TableCell> <!-- Row Actions Dropdown --> <TableCell class="text-right"> <DropdownMenu> <DropdownMenuTrigger as-child> <Button variant="ghost" size="icon-sm" class="text-muted-foreground size-7 p-0"> <MoreHorizontal class="size-4" /> <span class="sr-only">Open record actions</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" class="w-44"> <DropdownMenuItem @click="startEdit(record)"> <Edit2 class="mr-2 size-3.5" /> Edit Record </DropdownMenuItem> <DropdownMenuItem @click="copyText(record.id, record.content)"> <Copy class="mr-2 size-3.5" /> Copy Target </DropdownMenuItem> <DropdownMenuItem @click="copyText(`${record.id}-bind`, `${record.name} IN ${record.type} ${record.content}`)" > <FileCode class="mr-2 size-3.5" /> Copy BIND Row </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem class="text-destructive focus:text-destructive" @click="deleteRecord(record.id)" > <Trash2 class="mr-2 size-3.5" /> Delete Record </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </TableCell> </TableRow> <!-- Empty State --> <TableRow v-if="filteredRecords.length === 0"> <TableCell colspan="6" class="h-32 text-center"> <div class="flex flex-col items-center justify-center gap-1.5 text-center"> <Globe class="text-muted-foreground/50 size-8" /> <p class="text-foreground text-sm font-medium">No DNS records found</p> <p class="text-muted-foreground max-w-sm text-xs"> {{ searchQuery ? 'No records match your search filter query.' : 'No records configured for this domain yet. Add your first record to begin routing.' }} </p> <Button size="sm" class="mt-2 h-7 gap-1 text-xs" @click="startAdd"> <Plus class="size-3" /> Add DNS Record </Button> </div> </TableCell> </TableRow> </TableBody> </Table> </div> </div> <!-- Table Footer info --> <div class="text-muted-foreground flex flex-wrap items-center justify-between gap-2 px-1 text-xs"> <p> Showing <span class="text-foreground font-medium">{{ filteredRecords.length }}</span> of <span class="text-foreground font-medium">{{ records.length }}</span> configured records </p> <div class="flex items-center gap-4"> <span class="flex items-center gap-1.5"> <span class="bg-warning size-2 rounded-full" /> {{ records.filter((r) => r.proxied).length }} Proxied </span> <span class="flex items-center gap-1.5"> <span class="bg-muted-foreground size-2 rounded-full" /> {{ records.filter((r) => !r.proxied).length }} DNS Only </span> </div> </div> </div> </div> </template>
Raw manifest:https://uipkge.dev/r/vue/dns-record-manager.json