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 uipkge-ng@latest add dns-record-manager$npx uipkge-ng@latest add dns-record-manager$yarn dlx uipkge-ng@latest add dns-record-manager$bunx uipkge-ng@latest add dns-record-managerManifest:
https://uipkge.dev/r/angular/dns-record-manager.jsonInstalls to:src/app/components/blocks/dns-record-manager/Variants
Loading interactive previews…
Loading interactive previews…
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 (2)
src/app/components/blocks/dns-record-manager/dns-record-manager.component.ts38.5 kB
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, computed, signal } from '@angular/core' import { Check, Cloud, CloudOff, Copy, Download, Edit2, FileCode, Globe, LucideAngularModule, MoreHorizontal, Plus, Search, ShieldAlert, ShieldCheck, Trash2, X, } from 'lucide-angular' import { cn } from '@/lib/utils' import { UiBadgeComponent } from '@/ui/badge/badge.component' import { UiButtonComponent } from '@/ui/button/button.component' import { UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, } from '@/ui/card/card.component' import { UiDropdownMenuComponent, UiDropdownMenuContentComponent, UiDropdownMenuItemComponent, UiDropdownMenuSeparatorComponent, UiDropdownMenuTriggerComponent, } from '@/ui/dropdown-menu/dropdown-menu.component' import { UiInputComponent } from '@/ui/input/input.component' import { UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, } from '@/ui/select/select.component' import { UiSeparatorComponent } from '@/ui/separator/separator.component' import { UiSwitchComponent } from '@/ui/switch/switch.component' import { UiTableBodyComponent, UiTableCellComponent, UiTableComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, } from '@/ui/table/table.component' 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 } export interface DnsRecordManagerProps { domain?: string nameservers?: string[] dnssecEnabled?: boolean initialRecords?: DnsRecord[] initialAddOpen?: boolean className?: string } 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 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"', } const contentLabelMap: Record<DnsRecordType, string> = { A: 'IPv4 Address', AAAA: 'IPv6 Address', CNAME: 'Target Domain', MX: 'Mail Server Hostname', TXT: 'TXT Content / Value', CAA: 'Target / Tag Value', } const PROXYABLE: DnsRecordType[] = ['A', 'AAAA', 'CNAME'] /** * Angular port of the React DnsRecordManager block: a domain header card (DNSSEC badge, * zone-file / add-record actions, copyable nameservers), an optional BIND zone-file viewer, * an add/edit record form, and a filterable records table with proxy toggles and a row * action menu. Edit the sample records below. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-dns-record-manager, [ui-dns-record-manager]', standalone: true, host: { '[attr.data-slot]': '"dns-record-manager"', '[class]': 'hostClass', }, imports: [ LucideAngularModule, UiBadgeComponent, UiButtonComponent, UiCardComponent, UiCardContentComponent, UiCardDescriptionComponent, UiCardHeaderComponent, UiCardTitleComponent, UiDropdownMenuComponent, UiDropdownMenuContentComponent, UiDropdownMenuItemComponent, UiDropdownMenuSeparatorComponent, UiDropdownMenuTriggerComponent, UiInputComponent, UiSelectComponent, UiSelectContentComponent, UiSelectItemComponent, UiSelectTriggerComponent, UiSelectValueComponent, UiSeparatorComponent, UiSwitchComponent, UiTableComponent, UiTableBodyComponent, UiTableCellComponent, UiTableHeadComponent, UiTableHeaderComponent, UiTableRowComponent, ], template: ` <!-- Domain Header & Status Card --> <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header 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"> <lucide-icon [img]="Globe" class="size-4.5" /> </div> <h2 class="text-foreground text-xl font-bold tracking-tight sm:text-2xl">{{ domain }}</h2> @if (dnssecEnabled) { <span ui-badge 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"></span> <lucide-icon [img]="ShieldCheck" class="size-3.5" /> Active · DNSSEC Enabled </span> } @else { <span ui-badge 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"></span> <lucide-icon [img]="ShieldAlert" class="size-3.5" /> Active · DNSSEC Inactive </span> } </div> <p class="text-muted-foreground text-xs sm:text-sm"> Authoritative DNS routing and global edge proxy management for {{ domain }}. </p> </div> <!-- Header CTA Buttons --> <div class="flex items-center gap-2"> <button ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs font-medium" (click)="isZoneFileOpen.set(!isZoneFileOpen())" > <lucide-icon [img]="FileCode" class="size-3.5" /> {{ isZoneFileOpen() ? 'Hide Zone File' : 'Export Zone File' }} </button> <button ui-button size="sm" class="h-8 gap-1.5 text-xs font-medium" (click)="startAdd()"> <lucide-icon [img]="Plus" class="size-3.5" /> Add Record </button> </div> </div> <div ui-separator class="my-4"></div> <!-- 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"> @for (ns of nameservers; track ns) { <button type="button" [attr.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> @if (copiedKey() === ns) { <lucide-icon [img]="Check" class="text-success size-3" /> } @else { <lucide-icon [img]="Copy" 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> <span>Propagation: 100% Synced</span> </div> </div> </div> </div> <!-- Zone File Export Card (Collapsible) --> @if (isZoneFileOpen()) { <div ui-card class="border-border bg-card shadow-xs"> <div ui-card-header class="pb-3"> <div class="flex items-center justify-between"> <div> <div ui-card-title class="text-sm font-semibold sm:text-base">BIND RFC 1035 Zone File</div> <div ui-card-description class="text-xs"> Complete DNS zone mapping for {{ domain }} ready for import into Route53, Bind9, or Cloudflare. </div> </div> <button ui-button variant="ghost" size="icon-sm" class="size-7" aria-label="Close zone file viewer" (click)="isZoneFileOpen.set(false)" > <lucide-icon [img]="X" class="size-4" /> </button> </div> </div> <div ui-card-content 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 ui-button variant="outline" size="sm" class="h-8 gap-1.5 text-xs" (click)="copyText('zone-file', zoneFileContent())" > @if (copiedKey() === 'zone-file') { <lucide-icon [img]="Check" class="text-success size-3.5" /> } @else { <lucide-icon [img]="Copy" class="size-3.5" /> } {{ copiedKey() === 'zone-file' ? 'Copied Zone File' : 'Copy Zone Content' }} </button> <button ui-button aria-label="Download attachment" size="sm" class="h-8 gap-1.5 text-xs" (click)="downloadZoneFile()" > <lucide-icon [img]="Download" class="size-3.5" /> Download .zone File </button> </div> </div> </div> } <!-- Add / Edit DNS Record Card --> @if (isAddOpen()) { <div ui-card class="border-primary/30 bg-muted/20 shadow-xs"> <div ui-card-header class="pb-4"> <div class="flex items-center justify-between"> <div> <div ui-card-title class="text-base font-semibold"> {{ editingId() ? 'Edit DNS Record' : 'Add New DNS Record' }} </div> <div ui-card-description class="text-xs"> {{ editingId() ? 'Update configuration, target destination, or edge proxy status.' : 'Configure host routing, MX exchangers, or verification TXT tags for ' + domain + '.' }} </div> </div> <button ui-button variant="ghost" size="icon-sm" class="size-7" aria-label="Cancel record form" (click)="cancelForm()" > <lucide-icon [img]="X" class="size-4" /> </button> </div> </div> <div ui-card-content 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> <ui-select [value]="formType()" (valueChange)="handleTypeChange($event)"> <button ui-select-trigger id="dns-type" class="w-full [&_svg]:shrink-0 [&>span]:truncate"> <ui-select-value placeholder="Record type" /> </button> <ui-select-content> <ui-select-item value="A">A (IPv4 Address)</ui-select-item> <ui-select-item value="AAAA">AAAA (IPv6 Address)</ui-select-item> <ui-select-item value="CNAME">CNAME (Canonical Alias)</ui-select-item> <ui-select-item value="MX">MX (Mail Exchanger)</ui-select-item> <ui-select-item value="TXT">TXT (Text Record)</ui-select-item> <ui-select-item value="CAA">CAA (Cert Authority)</ui-select-item> </ui-select-content> </ui-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> <ui-input id="dns-name" [value]="formName()" (valueChange)="formName.set($event)" 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> <ui-select [value]="formTtl()" (valueChange)="formTtl.set($event)"> <button ui-select-trigger id="dns-ttl" class="w-full [&_svg]:shrink-0 [&>span]:truncate"> <ui-select-value placeholder="TTL" /> </button> <ui-select-content> <ui-select-item value="Auto">Auto (Automatic)</ui-select-item> <ui-select-item value="60s">1 min (60s)</ui-select-item> <ui-select-item value="300s">5 mins (300s)</ui-select-item> <ui-select-item value="1800s">30 mins (1800s)</ui-select-item> <ui-select-item value="3600s">1 hour (3600s)</ui-select-item> <ui-select-item value="86400s">1 day (86400s)</ui-select-item> </ui-select-content> </ui-select> </div> </div> <div [class]="formType() === 'MX' ? 'grid grid-cols-1 gap-3 sm:grid-cols-12' : 'grid grid-cols-1 gap-3'"> <!-- 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"> {{ contentLabel() }} </label> <ui-input id="dns-content" [value]="formContent()" (valueChange)="formContent.set($event)" [placeholder]="contentPlaceholder()" class="font-mono text-xs" /> </div> <!-- Priority Input for MX --> @if (formType() === 'MX') { <div class="sm:col-span-3"> <label for="dns-priority" class="text-foreground mb-1.5 block text-xs font-medium">Priority</label> <ui-input id="dns-priority" type="number" [min]="0" [max]="65535" [value]="formPriority()" (valueChange)="formPriority.set(toNumber($event))" 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> <ui-input id="dns-comment" [value]="formComment()" (valueChange)="formComment.set($event)" placeholder="e.g. Production ingress or verification tag" class="text-xs" /> </div> <!-- Proxy Switch (for A, AAAA, CNAME) --> @if (isProxyable(formType())) { <div 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"> <lucide-icon [img]="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> <button ui-switch id="proxy-toggle" [checked]="formProxied()" (checkedChange)="formProxied.set($event)" ></button> </div> } <!-- Form Actions --> <div class="flex items-center justify-end gap-2 pt-2"> <button ui-button variant="outline" size="sm" class="h-8 text-xs" (click)="cancelForm()">Cancel</button> <button ui-button size="sm" class="h-8 text-xs" [disabled]="!formContent().trim()" (click)="saveRecord()"> {{ editingId() ? 'Update Record' : 'Save Record' }} </button> </div> </div> </div> } <!-- 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"> <lucide-icon [img]="Search" class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2" /> <ui-input [value]="searchQuery()" (valueChange)="searchQuery.set($event)" placeholder="Search records by name, target, or content..." class="h-8.5 pl-8 text-xs" /> </div> <!-- Type Filter Buttons --> <div class="flex items-center gap-1.5 overflow-x-auto pb-1 sm:pb-0"> <button type="button" [class]="filterClass('ALL', 'min-h-6 rounded-md px-2.5 py-1 text-xs font-medium transition-colors')" (click)="selectedTypeFilter.set('ALL')" > All ({{ records().length }}) </button> @for (t of filterTypes; track t) { <button type="button" [class]=" filterClass(t, 'min-h-6 min-w-6 rounded-md px-2 py-1 font-mono text-xs font-medium transition-colors') " (click)="selectedTypeFilter.set(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"> <div ui-table> <thead ui-table-header> <tr ui-table-row class="bg-muted/40"> <th ui-table-head class="w-24">Type</th> <th ui-table-head class="min-w-[140px]">Name</th> <th ui-table-head class="min-w-[240px]">Target Content</th> <th ui-table-head class="w-28 text-center">Proxy Status</th> <th ui-table-head class="w-20">TTL</th> <th ui-table-head class="w-12 text-right"> <span class="sr-only">Actions</span> </th> </tr> </thead> <tbody ui-table-body> @for (record of filteredRecords(); track record.id) { <tr ui-table-row [class]=" editingId() === record.id ? 'hover:bg-muted/30 bg-primary/5 transition-colors' : 'hover:bg-muted/30 transition-colors' " > <!-- Record Type Badge --> <td ui-table-cell> <span [class]="typeBadgeClass(record.type)">{{ record.type }}</span> </td> <!-- Name & Hostname helper --> <td ui-table-cell> <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> </td> <!-- Target Content with Copy button --> <td ui-table-cell> <div class="flex items-center gap-2"> <div class="min-w-0 flex-1"> <div class="flex items-center gap-1.5"> @if (record.type === 'MX') { <span 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> @if (record.comment) { <p class="text-muted-foreground mt-0.5 truncate text-xs">{{ record.comment }}</p> } </div> <button ui-button variant="ghost" size="icon-sm" class="text-muted-foreground hover:text-foreground size-7 shrink-0" [attr.aria-label]="'Copy content for ' + record.name" (click)="copyText(record.id, record.content)" > @if (copiedKey() === record.id) { <lucide-icon [img]="Check" class="text-success size-3.5" /> } @else { <lucide-icon [img]="Copy" class="size-3.5" /> } </button> </div> </td> <!-- Proxy Status Switch / Cloud badge --> <td ui-table-cell class="text-center"> @if (isProxyable(record.type)) { <div class="flex justify-center"> <button type="button" [attr.aria-label]=" 'Toggle proxy status for ' + record.name + ', currently ' + (record.proxied ? 'proxied' : 'dns only') " [class]="proxyButtonClass(record.proxied)" (click)="toggleProxy(record.id)" > @if (record.proxied) { <lucide-icon [img]="Cloud" class="fill-warning text-warning size-3.5" /> } @else { <lucide-icon [img]="CloudOff" class="text-muted-foreground group-hover:text-foreground size-3.5" /> } <span>{{ record.proxied ? 'Proxied' : 'DNS only' }}</span> </button> </div> } @else { <div class="flex justify-center"> <span class="text-muted-foreground/60 inline-flex items-center gap-1 font-mono text-xs"> <lucide-icon [img]="CloudOff" class="size-3" /> DNS only </span> </div> } </td> <!-- TTL Badge --> <td ui-table-cell> <span ui-badge variant="outline" class="border-border bg-muted/40 font-mono text-xs font-normal"> {{ record.ttl }} </span> </td> <!-- Row Actions Dropdown --> <td ui-table-cell class="text-right"> <ui-dropdown-menu> <button ui-button ui-dropdown-menu-trigger variant="ghost" size="icon-sm" class="text-muted-foreground size-7 p-0" > <lucide-icon [img]="MoreHorizontal" class="size-4" /> <span class="sr-only">Open record actions</span> </button> <ui-dropdown-menu-content align="end" class="w-44"> <ui-dropdown-menu-item (select)="startEdit(record)"> <lucide-icon [img]="Edit2" class="mr-2 size-3.5" /> Edit Record </ui-dropdown-menu-item> <ui-dropdown-menu-item (select)="copyText(record.id, record.content)"> <lucide-icon [img]="Copy" class="mr-2 size-3.5" /> Copy Target </ui-dropdown-menu-item> <ui-dropdown-menu-item (select)="copyText(record.id + '-bind', bindRow(record))"> <lucide-icon [img]="FileCode" class="mr-2 size-3.5" /> Copy BIND Row </ui-dropdown-menu-item> <ui-dropdown-menu-separator /> <ui-dropdown-menu-item class="text-destructive focus:text-destructive" (select)="deleteRecord(record.id)" > <lucide-icon [img]="Trash2" class="mr-2 size-3.5" /> Delete Record </ui-dropdown-menu-item> </ui-dropdown-menu-content> </ui-dropdown-menu> </td> </tr> } <!-- Empty State --> @if (filteredRecords().length === 0) { <tr ui-table-row> <td ui-table-cell colspan="6" class="h-32 text-center"> <div class="flex flex-col items-center justify-center gap-1.5 text-center"> <lucide-icon [img]="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 ui-button size="sm" class="mt-2 h-7 gap-1 text-xs" (click)="startAdd()"> <lucide-icon [img]="Plus" class="size-3" /> Add DNS Record </button> </div> </td> </tr> } </tbody> </div> </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"></span> {{ proxiedCount() }} Proxied </span> <span class="flex items-center gap-1.5"> <span class="bg-muted-foreground size-2 rounded-full"></span> {{ dnsOnlyCount() }} DNS Only </span> </div> </div> </div> `, }) export class UiDnsRecordManagerComponent implements OnInit, OnDestroy { @Input() domain = 'uipkge.dev' @Input() nameservers: string[] = ['ns1.uipkge.net', 'ns2.uipkge.net'] @Input() dnssecEnabled = true @Input() initialRecords?: DnsRecord[] @Input() initialAddOpen = false @Input('class') className?: string protected readonly Check = Check protected readonly Cloud = Cloud protected readonly CloudOff = CloudOff protected readonly Copy = Copy protected readonly Download = Download protected readonly Edit2 = Edit2 protected readonly FileCode = FileCode protected readonly Globe = Globe protected readonly MoreHorizontal = MoreHorizontal protected readonly Plus = Plus protected readonly Search = Search protected readonly ShieldAlert = ShieldAlert protected readonly ShieldCheck = ShieldCheck protected readonly Trash2 = Trash2 protected readonly X = X protected readonly filterTypes: DnsRecordType[] = ['A', 'CNAME', 'MX', 'TXT', 'AAAA', 'CAA'] readonly records = signal<DnsRecord[]>(defaultRecords.map((r) => ({ ...r }))) readonly searchQuery = signal('') readonly selectedTypeFilter = signal<string>('ALL') readonly isAddOpen = signal(false) readonly isZoneFileOpen = signal(false) readonly editingId = signal<string | null>(null) // Form states readonly formType = signal<DnsRecordType>('A') readonly formName = signal('') readonly formContent = signal('') readonly formTtl = signal('Auto') readonly formProxied = signal(true) readonly formPriority = signal(10) readonly formComment = signal('') readonly copiedKey = signal<string | null>(null) private copyTimeout: ReturnType<typeof setTimeout> | undefined // Domain / nameservers are plain inputs; bump this so the zone file recomputes after edits. private readonly inputsVersion = signal(0) ngOnInit(): void { this.records.set( this.initialRecords ? this.initialRecords.map((r) => ({ ...r })) : defaultRecords.map((r) => ({ ...r })), ) this.isAddOpen.set(this.initialAddOpen) this.inputsVersion.update((v) => v + 1) } ngOnDestroy(): void { clearTimeout(this.copyTimeout) } get hostClass(): string { return cn('block w-full space-y-6', this.className) } readonly filteredRecords = computed(() => { const query = this.searchQuery().trim().toLowerCase() const filter = this.selectedTypeFilter() return this.records().filter((record) => { const matchesType = filter === 'ALL' || record.type === filter 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 }) }) readonly proxiedCount = computed(() => this.records().filter((r) => r.proxied).length) readonly dnsOnlyCount = computed(() => this.records().filter((r) => !r.proxied).length) readonly zoneFileContent = computed(() => { this.inputsVersion() const domain = this.domain const nameservers = this.nameservers const date = new Date().toISOString().split('T')[0]!.replace(/-/g, '') const lines: string[] = [ `; BIND zone file for ${domain}`, `; Generated on ${new Date().toUTCString()}`, `$ORIGIN ${domain}.`, `$TTL 3600`, ``, `; SOA Record`, `@ IN SOA ${nameservers[0]}. hostmaster.${domain}. (`, ` ${date}01 ; Serial`, ` 7200 ; Refresh (2 hours)`, ` 3600 ; Retry (1 hour)`, ` 1209600 ; Expire (2 weeks)`, ` 3600 ; Minimum TTL (1 hour)`, `)`, ``, `; Nameservers`, ] nameservers.forEach((ns) => { lines.push(`@ IN NS ${ns}.`) }) lines.push('', '; Resource Records') for (const r of this.records()) { 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') }) contentLabel(): string { return contentLabelMap[this.formType()] } contentPlaceholder(): string { return placeholderMap[this.formType()] } isProxyable(type: DnsRecordType): boolean { return PROXYABLE.includes(type) } typeBadgeClass(type: DnsRecordType): string { const s = typeBadgeStyles[type] return cn( 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 font-mono text-xs font-bold', s.bg, s.text, s.border, ) } filterClass(value: string, base: string): string { return cn( base, this.selectedTypeFilter() === value ? 'bg-primary text-primary-foreground shadow-xs' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground', ) } proxyButtonClass(proxied: boolean): string { return 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', 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', ) } toNumber(v: string): number { return Number(v) } bindRow(record: DnsRecord): string { return `${record.name} IN ${record.type} ${record.content}` } resolveHostname(recordName: string): string { if (recordName === '@') return this.domain return `${recordName}.${this.domain}` } async copyText(key: string, text: string): Promise<void> { try { await navigator.clipboard.writeText(text) this.copiedKey.set(key) clearTimeout(this.copyTimeout) this.copyTimeout = setTimeout(() => this.copiedKey.set(null), 1600) } catch { // Clipboard unavailable } } handleTypeChange(value: string): void { const newType = value as DnsRecordType this.formType.set(newType) if (['MX', 'TXT', 'CAA'].includes(newType)) { this.formProxied.set(false) } else if (!this.editingId()) { this.formProxied.set(true) } } toggleProxy(id: string): void { this.records.update((prev) => prev.map((r) => (r.id === id && this.isProxyable(r.type) ? { ...r, proxied: !r.proxied } : r)), ) } deleteRecord(id: string): void { this.records.update((prev) => prev.filter((r) => r.id !== id)) if (this.editingId() === id) this.cancelForm() } startAdd(): void { this.editingId.set(null) this.formType.set('A') this.formName.set('') this.formContent.set('') this.formTtl.set('Auto') this.formProxied.set(true) this.formPriority.set(10) this.formComment.set('') this.isAddOpen.set(true) } startEdit(record: DnsRecord): void { this.editingId.set(record.id) this.formType.set(record.type) this.formName.set(record.name) this.formContent.set(record.content) this.formTtl.set(record.ttl) this.formProxied.set(record.proxied) this.formPriority.set(record.priority ?? 10) this.formComment.set(record.comment ?? '') this.isAddOpen.set(true) } cancelForm(): void { this.isAddOpen.set(false) this.editingId.set(null) } saveRecord(): void { const name = this.formName().trim() || '@' const content = this.formContent().trim() if (!content) return const type = this.formType() const fields = { type, name, content, ttl: this.formTtl(), proxied: this.isProxyable(type) ? this.formProxied() : false, priority: type === 'MX' ? Number(this.formPriority()) : undefined, comment: this.formComment().trim() || undefined, } const editingId = this.editingId() if (editingId) { this.records.update((prev) => prev.map((r) => (r.id === editingId ? { ...r, ...fields } : r))) } else { this.records.update((prev) => [{ id: `rec-${Date.now()}`, ...fields }, ...prev]) } this.cancelForm() } downloadZoneFile(): void { const blob = new Blob([this.zoneFileContent()], { type: 'text/plain;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${this.domain}.zone` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } }src/app/components/blocks/dns-record-manager/index.ts0.2 kB
export { UiDnsRecordManagerComponent } from './dns-record-manager.component' export type { DnsRecord, DnsRecordManagerProps, DnsRecordType } from './dns-record-manager.component'
Raw manifest:https://uipkge.dev/r/angular/dns-record-manager.json