Menu

Change language

GitHub repo

Dns Record Manager

blockdevops

Cloudflare/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

$npx uipkge-ng@latest add dns-record-manager
Manifest:https://uipkge.dev/r/angular/dns-record-manager.jsonInstalls to:src/app/components/blocks/dns-record-manager/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

DnsRecord
interface DnsRecord {
  id: string
  type: DnsRecordType
  name: string
  content: string
  ttl: string
  proxied: boolean
  priority?: number
  comment?: string
}

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">(&#64; 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

Raw manifest:https://uipkge.dev/r/angular/dns-record-manager.json