chore: merge executor worktree (worktree-agent-ad21740c19f57464b) — plan 24-07
This commit is contained in:
commit
b9d5a4823f
4 changed files with 1327 additions and 2 deletions
|
|
@ -0,0 +1,166 @@
|
||||||
|
---
|
||||||
|
phase: 24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud
|
||||||
|
plan: 07
|
||||||
|
subsystem: aws-route53
|
||||||
|
tags: [route53, admin-ui, dns, crud, audit-history]
|
||||||
|
|
||||||
|
requires:
|
||||||
|
- phase: 24-04
|
||||||
|
provides: "isRoute53Configured / health check with D-12 NS-delegation comparison"
|
||||||
|
- phase: 24-05
|
||||||
|
provides: "/api/route53/* read + CRUD write routes, audit lifecycle"
|
||||||
|
- phase: 24-06
|
||||||
|
provides: "route53-incremental/route53-full scheduler entries, /admin/sync tile"
|
||||||
|
provides:
|
||||||
|
- "app/admin/sync/route53/page.tsx — Zones / Records / History / Schedule tabbed detail page"
|
||||||
|
- "components/admin/route53/record-editor-dialog.tsx — RecordEditorDialog (create/edit) + RecordDeleteConfirm (immediate delete)"
|
||||||
|
affects: [phase-24-verification]
|
||||||
|
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Client-side pagination over full-list API responses (zones/records/history endpoints have no offset/limit params) via a local paginate() slice helper"
|
||||||
|
- "Row-click-to-select-and-switch-tab (Zones row -> Records tab pre-filtered; Records row action -> History tab pre-filtered) instead of a nested drill-down route"
|
||||||
|
- "DetailModal's generic Fields/Raw-tab fallback used as-is for zone and history-row drill-downs — no bespoke JSON-diff UI (per 24-RESEARCH.md 'Don't Hand-Roll')"
|
||||||
|
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- app/admin/sync/route53/page.tsx
|
||||||
|
- components/admin/route53/record-editor-dialog.tsx
|
||||||
|
modified:
|
||||||
|
- .planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-VALIDATION.md
|
||||||
|
|
||||||
|
key-decisions:
|
||||||
|
- "GET /api/route53/zones, /records, and /history return { items: [...] }, not a bare array as the plan's <interfaces> block stated — page.tsx reads .items from each response (ground truth from the actual plan 24-05 route implementations, not the plan's interface doc)."
|
||||||
|
- "The plan's History tab spec calls for 'a zone-wide view when no record is selected', but the only history endpoint implemented in plan 24-05 is per-record (GET .../records/{recordId}/history) — there is no zone-wide history route and this plan's files_modified does not authorize adding one. Implemented instead: History tab shows an EmptyState prompting the operator to pick a record's History action on the Records tab; once selected, the tab shows that record's full ledger with a Clear control to return to the prompt."
|
||||||
|
- "GET /api/route53/sync never actually returns 503 for the unconfigured case in the current plan-24-05 implementation (only the POST trigger and the CRUD write routes do) — the page still checks for a 503 on the GET response per the plan's literal acceptance criteria (defensive / forward-compatible), and separately surfaces a 503 from the POST Sync Now action via a toast + the same unconfigured empty-state."
|
||||||
|
|
||||||
|
requirements-completed: [] # SC-2/SC-4/SC-6 not yet marked complete — Task 3 (live checkpoint) is unresolved, see below
|
||||||
|
|
||||||
|
duration: "~55 min, 2 of 3 tasks (Task 3 is a blocking human checkpoint)"
|
||||||
|
completed: "2026-08-05"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 24 Plan 7: Route 53 Admin Detail Page + Record Editor Summary — PARTIAL (checkpoint pending)
|
||||||
|
|
||||||
|
Built the `/admin/sync/route53` four-tab detail page and the create/edit/delete record dialog
|
||||||
|
that drive the plan 24-05 CRUD routes. Task 3 (the nine-step live AWS end-to-end verification)
|
||||||
|
is a blocking `checkpoint:human-verify` and has **not** been run — this plan is not complete
|
||||||
|
until a human runs it against a real AWS account and reports back.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Duration:** ~55 min (Tasks 1-2 only)
|
||||||
|
- **Tasks:** 2 of 3 completed; Task 3 blocked awaiting human verification
|
||||||
|
- **Files modified:** 2 created, 1 doc updated
|
||||||
|
|
||||||
|
## Accomplishments
|
||||||
|
|
||||||
|
- `/admin/sync/route53` renders Zones / Records / History / Schedule tabs following the
|
||||||
|
`veeam`/`pax8` sync-detail-page shape, with a Sync Now trigger (bounded 3s/20-poll loop),
|
||||||
|
a status line (zone/record/history-row counts + last sync status), and a graceful
|
||||||
|
unconfigured empty-state.
|
||||||
|
- Records tab actions cell gates on the D-01 writable-type allowlist (`A`/`AAAA`/`CNAME`/
|
||||||
|
`MX`/`TXT`/`SRV`); NS/SOA rows render a muted "Read-only" label with an explanatory
|
||||||
|
`title`, with the real 400 gate remaining server-side (plan 24-05).
|
||||||
|
- History tab renders `pulse_crud` vs `sync_detected_drift` with visually distinct
|
||||||
|
`StatusBadge` tones (D-06), plus change-action (create/update/delete) and actor columns.
|
||||||
|
- `RecordEditorDialog` (create/edit) and `RecordDeleteConfirm` (single misclick-guard,
|
||||||
|
immediate delete, D-03) both wired to the plan 24-05 write routes, with the submit/delete
|
||||||
|
controls disabled while their own request is in flight (T-24-20) and server 400/409/502
|
||||||
|
messages rendered inline without closing the dialog.
|
||||||
|
- Confirmed the dev server is live on port 3100 and `/admin/sync/route53` correctly
|
||||||
|
redirects an unauthenticated request to `/auth/sign-in` (middleware working as expected)
|
||||||
|
ahead of handing off the checkpoint.
|
||||||
|
|
||||||
|
## Task Commits
|
||||||
|
|
||||||
|
1. **Task 1: Zones, records, and history page shell** - `b3048be` (feat)
|
||||||
|
2. **Task 2: Record editor dialog with create, edit, and immediate delete** - `fadfcb8` (feat)
|
||||||
|
3. **Task 3: End-to-end phase verification** - NOT STARTED (blocking `checkpoint:human-verify`; requires a real AWS account and a running, authenticated browser session — see "Checkpoint Status" below)
|
||||||
|
|
||||||
|
**Plan metadata:** this commit (docs: partial plan summary + validation update)
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `app/admin/sync/route53/page.tsx` — four-tab detail page (789 lines)
|
||||||
|
- `components/admin/route53/record-editor-dialog.tsx` — `RecordEditorDialog` + `RecordDeleteConfirm` (370 lines)
|
||||||
|
- `.planning/phases/24-aws-route-53-dns-sync-track-changes-crud-operations-full-aud/24-VALIDATION.md` — marked 24-07-T1/24-07-T2 rows green; 24-07-T3 (the checkpoint) left pending
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
See `key-decisions` in frontmatter: (1) API responses are `{ items: [...] }` envelopes, not
|
||||||
|
bare arrays as the plan's interfaces block stated — followed the actual plan-24-05 route
|
||||||
|
implementations; (2) no zone-wide history endpoint exists, so the History tab requires a
|
||||||
|
record selection first rather than showing an unfiltered zone view; (3) the unconfigured-503
|
||||||
|
check is implemented on the sync-status GET per the plan's literal spec even though the
|
||||||
|
current route never emits it, and is backed up by handling a 503 from the POST trigger.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Grep-checked acceptance criteria false-positived on doc-comment prose**
|
||||||
|
- **Found during:** Task 1 verification (`grep -c dangerouslySetInnerHTML` / `grep -c swr\|react-query`)
|
||||||
|
- **Issue:** the file-header doc comment explained the security posture by naming the literal
|
||||||
|
strings the acceptance grep checks are zero occurrences of (e.g. "no dangerouslySetInnerHTML",
|
||||||
|
"no SWR/react-query"), which made both grep counts 1 instead of 0 even though no actual
|
||||||
|
usage exists — the same false-positive pattern documented in 24-05's SUMMARY.
|
||||||
|
- **Fix:** reworded the two affected doc-comment lines to describe the same guarantee without
|
||||||
|
the literal grepped substrings ("no raw-HTML injection helper", "no client-side data-fetching
|
||||||
|
library").
|
||||||
|
- **Files modified:** `app/admin/sync/route53/page.tsx`
|
||||||
|
- **Verification:** `grep -c dangerouslySetInnerHTML` and `grep -c "swr\|react-query\|useSWR"` both return 0; `npx tsc --noEmit --pretty` and `npm run build` both clean after the edit.
|
||||||
|
- **Committed in:** `b3048be` (part of Task 1 commit — caught before commit, not a follow-up fix)
|
||||||
|
|
||||||
|
### Out-of-Scope Discovery (logged, not fixed)
|
||||||
|
|
||||||
|
None beyond what's already documented in earlier phase-24 plans' `deferred-items.md`.
|
||||||
|
|
||||||
|
## Checkpoint Status: BLOCKED — awaiting human verification
|
||||||
|
|
||||||
|
Task 3 is `<task type="checkpoint:human-verify" gate="blocking">`. Per the executor's
|
||||||
|
worktree-agent instructions, this is a live, 9-step verification against a real AWS Route 53
|
||||||
|
account (creating/updating/deleting an actual DNS TXT record, curl auth-gating checks against
|
||||||
|
a real session cookie, toggling `/admin/integrations`, and a live console DNS edit for drift
|
||||||
|
detection). None of these steps were fabricated or run unilaterally.
|
||||||
|
|
||||||
|
**Confirmed before handoff:**
|
||||||
|
- The dev server responds on port 3100.
|
||||||
|
- `GET /admin/sync/route53` (unauthenticated) returns `307` to `/auth/sign-in?callbackUrl=%2Fadmin%2Fsync%2Froute53` — middleware gating is active as expected.
|
||||||
|
|
||||||
|
**The nine verification steps (verbatim from `24-07-PLAN.md` Task 3 `<how-to-verify>`)** still
|
||||||
|
need to be run by a human against a real AWS account and Pulse admin session:
|
||||||
|
|
||||||
|
1. Sync (SC-1) — `/admin/sync` tile → Sync Now → non-zero zone/record counts + a `completed` `sync_history` row.
|
||||||
|
2. Create (SC-2/SC-3/SC-4) — new TXT record → success toast with propagation status → confirm in AWS console → one `create`/`pulse_crud` history row.
|
||||||
|
3. Update — edit the value → confirm in AWS console → a second `update` history row with the original value in `before_value`.
|
||||||
|
4. Delete (D-03) — delete → single confirmation, executes immediately → confirm gone from AWS console → a third `delete` history row.
|
||||||
|
5. Audit completeness (SC-3/D-07) — `route53_audit_log` has 3 `committed` rows with the operator's email.
|
||||||
|
6. Failure logging (D-07/T-24-01) — NS write via curl → 400; a genuine AWS-side rejection (e.g. apex CNAME) → 502 + a `failed` audit row with a sanitized `error_message`.
|
||||||
|
7. Auth gating (D-04/T-24-02) — `user`-role session → create curl returns 403; `GET /api/route53/zones` still 200 for that session.
|
||||||
|
8. Drift detection (D-06) — edit a record directly in the AWS console, Sync Now → a `sync_detected_drift` history row with a null actor.
|
||||||
|
9. Health check (D-12/SC-6) — `/admin/integrations` shows an AWS Route 53 row with live status (and any NS-delegation mismatch); toggling Route 53 off suppresses the health row while `POST /api/route53/sync` still succeeds (D-10).
|
||||||
|
|
||||||
|
**Resume signal (from the plan):** reply "approved" if all nine steps behave as described, or
|
||||||
|
list which step numbers failed and what was observed instead.
|
||||||
|
|
||||||
|
**24-VALIDATION.md status:** the three Manual-Only Verifications rows this checkpoint is meant
|
||||||
|
to exercise (write-route auth gating, live AWS round-trip, D-10 display-only disable) have
|
||||||
|
**not** been updated with observed outcomes yet — that update is deferred until the human
|
||||||
|
completes the nine steps above and reports back. Do not mark 24-07 (or the phase) complete
|
||||||
|
until that happens.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: app/admin/sync/route53/page.tsx
|
||||||
|
- FOUND: components/admin/route53/record-editor-dialog.tsx
|
||||||
|
- Commit `b3048be` present in `git log`
|
||||||
|
- Commit `fadfcb8` present in `git log`
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
None beyond what's already covered by this plan's own `<threat_model>` (T-24-25, T-24-02,
|
||||||
|
T-24-20, T-24-01, T-24-26) — all addressed as designed in "Accomplishments" above. No new
|
||||||
|
network endpoints, auth paths, or schema changes were introduced; both new files only call
|
||||||
|
the already-existing `/api/route53/*` surface from plan 24-05.
|
||||||
|
|
@ -61,8 +61,8 @@ AWS error sanitization, change-batch construction, propagation polling, NS compa
|
||||||
| 24-05-T3m | 24-05 | 3 | SC-2 (D-04) | **T-24-02** | `requireAdmin()` returns 403 for a `user`-role session hitting a write route directly | manual / smoke | none automated — see Manual-Only table row 1 | ❌ manual by convention | ⬜ pending |
|
| 24-05-T3m | 24-05 | 3 | SC-2 (D-04) | **T-24-02** | `requireAdmin()` returns 403 for a `user`-role session hitting a write route directly | manual / smoke | none automated — see Manual-Only table row 1 | ❌ manual by convention | ⬜ pending |
|
||||||
| 24-06-T1 | 24-06 | 3 | SC-1, SC-6 | T-24-21, T-24-22, T-24-24 | Both sync types dispatch via dynamic import and gate on `isRoute53Configured()` only, never on `integration_settings` (D-10) | grep gate + typecheck | `npx tsc --noEmit --pretty && test $(grep -A12 "config.sync_type === 'route53" lib/services/sync-scheduler.ts \| grep -c integration_settings) -eq 0` | ➕ modified by task | ⬜ pending |
|
| 24-06-T1 | 24-06 | 3 | SC-1, SC-6 | T-24-21, T-24-22, T-24-24 | Both sync types dispatch via dynamic import and gate on `isRoute53Configured()` only, never on `integration_settings` (D-10) | grep gate + typecheck | `npx tsc --noEmit --pretty && test $(grep -A12 "config.sync_type === 'route53" lib/services/sync-scheduler.ts \| grep -c integration_settings) -eq 0` | ➕ modified by task | ⬜ pending |
|
||||||
| 24-06-T2 | 24-06 | 3 | SC-6 | T-24-23 | Tile entry uses a valid `COLOR_MAP` key; logo asset contains no `script` or `xlink:href` | grep gate + build | `npm run build && test $(grep -ci 'script\|xlink:href' public/logos/route53.svg) -eq 0` | ➕ created by task | ⬜ pending |
|
| 24-06-T2 | 24-06 | 3 | SC-6 | T-24-23 | Tile entry uses a valid `COLOR_MAP` key; logo asset contains no `script` or `xlink:href` | grep gate + build | `npm run build && test $(grep -ci 'script\|xlink:href' public/logos/route53.svg) -eq 0` | ➕ created by task | ⬜ pending |
|
||||||
| 24-07-T1 | 24-07 | 4 | SC-4, SC-6 | T-24-25 | Page renders record values and history before/after as escaped text; no `dangerouslySetInnerHTML`; no SWR/react-query | grep gate + build | `npm run build && test $(grep -c 'dangerouslySetInnerHTML' app/admin/sync/route53/page.tsx) -eq 0` | ➕ created by task | ⬜ pending |
|
| 24-07-T1 | 24-07 | 4 | SC-4, SC-6 | T-24-25 | Page renders record values and history before/after as escaped text; no `dangerouslySetInnerHTML`; no SWR/react-query | grep gate + build | `npm run build && test $(grep -c 'dangerouslySetInnerHTML' app/admin/sync/route53/page.tsx) -eq 0` | ✅ created by task | ✅ green |
|
||||||
| 24-07-T2 | 24-07 | 4 | SC-2 | **T-24-01**, T-24-20, T-24-26 | Type selector offers only the six writable types; submit disabled while in flight; no approval-workflow state introduced | grep gate + build | `npm run build && test $(grep -c "'NS'\|'SOA'" components/admin/route53/record-editor-dialog.tsx) -eq 0` | ➕ created by task | ⬜ pending |
|
| 24-07-T2 | 24-07 | 4 | SC-2 | **T-24-01**, T-24-20, T-24-26 | Type selector offers only the six writable types; submit disabled while in flight; no approval-workflow state introduced | grep gate + build | `npm run build && test $(grep -c "'NS'\|'SOA'" components/admin/route53/record-editor-dialog.tsx) -eq 0` | ✅ created by task | ✅ green |
|
||||||
| 24-07-T3 | 24-07 | 4 | SC-1..SC-6 | all | Full live round-trip: sync, create/update/delete against real AWS, audit completeness, failure logging, auth gating, drift detection, D-12 health, D-10 display-only disable | manual / checkpoint | none automated — blocking `checkpoint:human-verify`, 9 steps | ❌ manual by necessity | ⬜ pending |
|
| 24-07-T3 | 24-07 | 4 | SC-1..SC-6 | all | Full live round-trip: sync, create/update/delete against real AWS, audit completeness, failure logging, auth gating, drift detection, D-12 health, D-10 display-only disable | manual / checkpoint | none automated — blocking `checkpoint:human-verify`, 9 steps | ❌ manual by necessity | ⬜ pending |
|
||||||
|
|
||||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||||
|
|
|
||||||
789
app/admin/sync/route53/page.tsx
Normal file
789
app/admin/sync/route53/page.tsx
Normal file
|
|
@ -0,0 +1,789 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /admin/sync/route53 — AWS Route 53 DNS sync detail page (D-09).
|
||||||
|
*
|
||||||
|
* Follows the app/admin/sync/veeam/page.tsx shape: 'use client', plain
|
||||||
|
* useState/useEffect/fetch — no client-side data-fetching library, no
|
||||||
|
* server-only actions (CLAUDE.md) — a header with a manual "Sync Now"
|
||||||
|
* trigger, and tabbed detail views built on the shared DataTable /
|
||||||
|
* DetailModal / SyncScheduler vocabulary.
|
||||||
|
*
|
||||||
|
* Record values (from AWS) and history before/after JSONB are rendered only
|
||||||
|
* through React's default text escaping — no raw-HTML injection helper, no
|
||||||
|
* anchor/src injection from record content (T-24-25).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
|
import { EmptyState } from '@/components/ui/empty-state';
|
||||||
|
import DataTable, { type Column } from '@/components/admin/DataTable';
|
||||||
|
import DetailModal from '@/components/admin/DetailModal';
|
||||||
|
import SyncScheduler from '@/components/admin/SyncScheduler';
|
||||||
|
import {
|
||||||
|
RecordEditorDialog,
|
||||||
|
RecordDeleteConfirm,
|
||||||
|
} from '@/components/admin/route53/record-editor-dialog';
|
||||||
|
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
History as HistoryIcon,
|
||||||
|
Globe,
|
||||||
|
ShieldOff,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type {
|
||||||
|
Route53Zone,
|
||||||
|
Route53Record,
|
||||||
|
Route53RecordHistory,
|
||||||
|
Route53HistorySource,
|
||||||
|
} from '@/lib/types/route53';
|
||||||
|
|
||||||
|
// ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** D-01: the closed allowlist of writable record types — must match
|
||||||
|
* lib/services/route53-record-validation.ts WRITABLE_RECORD_TYPES exactly.
|
||||||
|
* NS and SOA are deliberately absent from this list; they render read-only. */
|
||||||
|
const WRITABLE_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV'] as const;
|
||||||
|
const RECORD_TYPE_FILTERS = [...WRITABLE_TYPES, 'NS', 'SOA'];
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
const HISTORY_PAGE_SIZE = 25;
|
||||||
|
const SYNC_POLL_INTERVAL_MS = 3000;
|
||||||
|
const SYNC_POLL_MAX_ATTEMPTS = 20;
|
||||||
|
|
||||||
|
interface SyncHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
sync_type: string;
|
||||||
|
status: string;
|
||||||
|
started_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
triggered_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncStatusResponse {
|
||||||
|
inProgress: boolean;
|
||||||
|
counts: { zones: number; records: number; historyRows: number };
|
||||||
|
history: SyncHistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmtDate(d: string | null | undefined, tz: string): string {
|
||||||
|
if (!d) return 'Never';
|
||||||
|
return new Date(d).toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: tz,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginate<T>(rows: T[], page: number, pageSize: number): T[] {
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return rows.slice(start, start + pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWritableType(type: string): boolean {
|
||||||
|
return (WRITABLE_TYPES as readonly string[]).includes(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncStatusTone(status: string): 'ok' | 'error' | 'info' | 'warn' {
|
||||||
|
if (status === 'completed') return 'ok';
|
||||||
|
if (status === 'failed') return 'error';
|
||||||
|
if (status === 'started') return 'info';
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeActionTone(action: string): 'ok' | 'info' | 'error' | 'neutral' {
|
||||||
|
if (action === 'create') return 'ok';
|
||||||
|
if (action === 'update') return 'info';
|
||||||
|
if (action === 'delete') return 'error';
|
||||||
|
return 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** D-06: visually distinguish a Pulse-initiated write from a change AWS
|
||||||
|
* detected outside Pulse — this is the whole point of drift detection. */
|
||||||
|
function sourceTone(source: Route53HistorySource): 'info' | 'warn' {
|
||||||
|
return source === 'sync_detected_drift' ? 'warn' : 'info';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(source: Route53HistorySource): string {
|
||||||
|
return source === 'sync_detected_drift' ? 'Drift detected' : 'Pulse';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function Route53SyncPage() {
|
||||||
|
const tz = useUserTimezone();
|
||||||
|
const [activeTab, setActiveTab] = useState('zones');
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
|
||||||
|
// ── Sync status ──
|
||||||
|
const [syncStatus, setSyncStatus] = useState<SyncStatusResponse | null>(null);
|
||||||
|
const [unconfigured, setUnconfigured] = useState(false);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
|
const fetchSyncStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/route53/sync');
|
||||||
|
if (res.status === 503) {
|
||||||
|
setUnconfigured(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
setUnconfigured(false);
|
||||||
|
setSyncStatus(await res.json());
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch Route 53 sync status:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSyncStatus();
|
||||||
|
}, [fetchSyncStatus, refreshKey]);
|
||||||
|
|
||||||
|
const handleSyncNow = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/route53/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ syncType: 'full' }),
|
||||||
|
});
|
||||||
|
if (res.status === 503) {
|
||||||
|
setUnconfigured(true);
|
||||||
|
toast.error('AWS Route 53 is not configured');
|
||||||
|
setSyncing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.status === 409) {
|
||||||
|
toast.info('Route 53 sync is already in progress');
|
||||||
|
setSyncing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}) as Record<string, string>);
|
||||||
|
toast.error(body.message || body.error || 'Failed to start Route 53 sync');
|
||||||
|
setSyncing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Route 53 sync started');
|
||||||
|
|
||||||
|
let attempts = 0;
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
attempts += 1;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/route53/sync');
|
||||||
|
if (r.ok) {
|
||||||
|
const d: SyncStatusResponse = await r.json();
|
||||||
|
setSyncStatus(d);
|
||||||
|
if (!d.inProgress) {
|
||||||
|
clearInterval(poll);
|
||||||
|
setSyncing(false);
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* keep polling */
|
||||||
|
}
|
||||||
|
if (attempts >= SYNC_POLL_MAX_ATTEMPTS) {
|
||||||
|
clearInterval(poll);
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}, SYNC_POLL_INTERVAL_MS);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error('Failed to start Route 53 sync');
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Zones tab ──
|
||||||
|
const [zones, setZones] = useState<Route53Zone[]>([]);
|
||||||
|
const [zonesLoading, setZonesLoading] = useState(true);
|
||||||
|
const [zonePage, setZonePage] = useState(1);
|
||||||
|
const [zoneDetail, setZoneDetail] = useState<Route53Zone | null>(null);
|
||||||
|
const [zoneDetailOpen, setZoneDetailOpen] = useState(false);
|
||||||
|
|
||||||
|
const fetchZones = useCallback(async () => {
|
||||||
|
setZonesLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/route53/zones');
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
setZones(d.items ?? []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch Route 53 zones:', err);
|
||||||
|
} finally {
|
||||||
|
setZonesLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!unconfigured) fetchZones();
|
||||||
|
}, [fetchZones, refreshKey, unconfigured]);
|
||||||
|
|
||||||
|
// ── Records tab ──
|
||||||
|
const [selectedZoneId, setSelectedZoneId] = useState('');
|
||||||
|
const [records, setRecords] = useState<Route53Record[]>([]);
|
||||||
|
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||||
|
const [recordPage, setRecordPage] = useState(1);
|
||||||
|
const [typeFilter, setTypeFilter] = useState('all');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
const fetchRecords = useCallback(async (zoneId: string, type: string, search: string) => {
|
||||||
|
if (!zoneId) {
|
||||||
|
setRecords([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRecordsLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (type && type !== 'all') params.set('type', type);
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
const qs = params.toString();
|
||||||
|
const res = await fetch(`/api/route53/zones/${zoneId}/records${qs ? `?${qs}` : ''}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
setRecords(d.items ?? []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch Route 53 records:', err);
|
||||||
|
} finally {
|
||||||
|
setRecordsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedZoneId) fetchRecords(selectedZoneId, typeFilter, searchQuery);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedZoneId, typeFilter, refreshKey]);
|
||||||
|
|
||||||
|
const handleSelectZone = (zone: Route53Zone) => {
|
||||||
|
setSelectedZoneId(zone.id);
|
||||||
|
setRecordPage(1);
|
||||||
|
setSearchQuery('');
|
||||||
|
setTypeFilter('all');
|
||||||
|
setActiveTab('records');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoneSelectChange = (zoneId: string) => {
|
||||||
|
setSelectedZoneId(zoneId);
|
||||||
|
setRecordPage(1);
|
||||||
|
setSearchQuery('');
|
||||||
|
setTypeFilter('all');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecordSearch = (q: string) => {
|
||||||
|
setSearchQuery(q);
|
||||||
|
setRecordPage(1);
|
||||||
|
fetchRecords(selectedZoneId, typeFilter, q);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── History tab ──
|
||||||
|
const [selectedRecord, setSelectedRecord] = useState<Route53Record | null>(null);
|
||||||
|
const [historyRows, setHistoryRows] = useState<Route53RecordHistory[]>([]);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [historyPage, setHistoryPage] = useState(1);
|
||||||
|
const [historyDetail, setHistoryDetail] = useState<Route53RecordHistory | null>(null);
|
||||||
|
const [historyDetailOpen, setHistoryDetailOpen] = useState(false);
|
||||||
|
|
||||||
|
const fetchHistory = useCallback(async (zoneId: string, recordKey: string) => {
|
||||||
|
setHistoryLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/route53/zones/${zoneId}/records/${encodeURIComponent(recordKey)}/history?limit=100`
|
||||||
|
);
|
||||||
|
if (res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
setHistoryRows(d.items ?? []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch Route 53 record history:', err);
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewHistory = (record: Route53Record) => {
|
||||||
|
setSelectedRecord(record);
|
||||||
|
setHistoryPage(1);
|
||||||
|
setActiveTab('history');
|
||||||
|
fetchHistory(record.zoneId, record.recordKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearHistorySelection = () => {
|
||||||
|
setSelectedRecord(null);
|
||||||
|
setHistoryRows([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedRecord) fetchHistory(selectedRecord.zoneId, selectedRecord.recordKey);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [refreshKey]);
|
||||||
|
|
||||||
|
// ── Record editor / delete dialogs ──
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
|
const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create');
|
||||||
|
const [editingRecord, setEditingRecord] = useState<Route53Record | undefined>(undefined);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [deletingRecord, setDeletingRecord] = useState<Route53Record | undefined>(undefined);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditorMode('create');
|
||||||
|
setEditingRecord(undefined);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
const openEdit = (record: Route53Record) => {
|
||||||
|
setEditorMode('edit');
|
||||||
|
setEditingRecord(record);
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
const openDelete = (record: Route53Record) => {
|
||||||
|
setDeletingRecord(record);
|
||||||
|
setDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaved = () => {
|
||||||
|
fetchRecords(selectedZoneId, typeFilter, searchQuery);
|
||||||
|
fetchZones();
|
||||||
|
if (selectedRecord) fetchHistory(selectedRecord.zoneId, selectedRecord.recordKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Column definitions ──
|
||||||
|
|
||||||
|
const zoneColumns: Column<Route53Zone>[] = [
|
||||||
|
{ key: 'name', label: 'Zone Name', sortable: true },
|
||||||
|
{
|
||||||
|
key: 'id',
|
||||||
|
label: 'Zone ID',
|
||||||
|
render: (v: string) => <span className="font-mono text-xs">{v}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'privateZone',
|
||||||
|
label: 'Private',
|
||||||
|
render: (v: boolean) => (
|
||||||
|
<StatusBadge tone={v ? 'info' : 'neutral'}>{v ? 'Private' : 'Public'}</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recordCount',
|
||||||
|
label: 'Records',
|
||||||
|
render: (v: number) => <span className="num">{v ?? 0}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'syncedAt',
|
||||||
|
label: 'Synced At',
|
||||||
|
render: (v: string) => (
|
||||||
|
<span className="text-xs text-muted-foreground">{fmtDate(v, tz)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'authoritativeNameServers',
|
||||||
|
label: 'Details',
|
||||||
|
render: (_v: unknown, row: Route53Zone) => (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setZoneDetail(row);
|
||||||
|
setZoneDetailOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
NS records
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const recordColumns: Column<Route53Record>[] = [
|
||||||
|
{ key: 'name', label: 'Name', sortable: true },
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: 'Type',
|
||||||
|
render: (v: string) => (
|
||||||
|
<StatusBadge tone={isWritableType(v) ? 'info' : 'neutral'}>{v}</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'ttl', label: 'TTL', render: (v: number | null) => <span className="num">{v ?? '—'}</span> },
|
||||||
|
{
|
||||||
|
key: 'resourceRecords',
|
||||||
|
label: 'Values',
|
||||||
|
render: (v: Array<{ value: string }> | null) => {
|
||||||
|
const joined = (v ?? []).map((r) => r.value).join(', ');
|
||||||
|
return (
|
||||||
|
<span className="text-xs truncate max-w-xs inline-block align-middle" title={joined}>
|
||||||
|
{joined || '—'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'recordKey',
|
||||||
|
label: 'Actions',
|
||||||
|
render: (_v: unknown, row: Route53Record) => {
|
||||||
|
const writable = isWritableType(row.type);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleViewHistory(row);
|
||||||
|
}}
|
||||||
|
title="View change history"
|
||||||
|
>
|
||||||
|
<HistoryIcon className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
{writable ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEdit(row);
|
||||||
|
}}
|
||||||
|
title="Edit record"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openDelete(row);
|
||||||
|
}}
|
||||||
|
title="Delete record"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
||||||
|
title="NS and SOA are zone-delegation records excluded from the Pulse write path (D-01)"
|
||||||
|
>
|
||||||
|
<ShieldOff className="w-3.5 h-3.5" />
|
||||||
|
Read-only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const historyColumns: Column<Route53RecordHistory>[] = [
|
||||||
|
{
|
||||||
|
key: 'changedAt',
|
||||||
|
label: 'Changed At',
|
||||||
|
sortable: true,
|
||||||
|
render: (v: string) => (
|
||||||
|
<span className="text-xs text-muted-foreground">{fmtDate(v, tz)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'recordName', label: 'Record' },
|
||||||
|
{
|
||||||
|
key: 'recordType',
|
||||||
|
label: 'Type',
|
||||||
|
render: (v: string) => <StatusBadge tone="neutral">{v}</StatusBadge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'changeAction',
|
||||||
|
label: 'Action',
|
||||||
|
render: (v: string) => <StatusBadge tone={changeActionTone(v)}>{v}</StatusBadge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'source',
|
||||||
|
label: 'Source',
|
||||||
|
render: (v: Route53HistorySource) => (
|
||||||
|
<StatusBadge tone={sourceTone(v)}>{sourceLabel(v)}</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'changedByEmail',
|
||||||
|
label: 'Actor',
|
||||||
|
render: (v: string | null) => <span className="text-xs">{v ?? '—'}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Derived render values ──
|
||||||
|
|
||||||
|
const lastSyncHistory = syncStatus?.history?.[0];
|
||||||
|
const zoneCount = syncStatus?.counts?.zones ?? 0;
|
||||||
|
const recordCount = syncStatus?.counts?.records ?? 0;
|
||||||
|
const historyRowCount = syncStatus?.counts?.historyRows ?? 0;
|
||||||
|
|
||||||
|
if (unconfigured) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin/sync">
|
||||||
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Integrations
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img src="/logos/route53.svg" alt="AWS Route 53" className="w-9 h-9 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">AWS Route 53</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Hosted zones, DNS records, change history</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldOff}
|
||||||
|
title="AWS Route 53 is not configured"
|
||||||
|
description="AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are not set for this container. Credentials are injected via Bitwarden Secrets Manager (BWS) at the container entrypoint — see docker-entrypoint.sh — not set directly in .env."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto py-4 md:py-8 px-4 space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/admin/sync">
|
||||||
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Integrations
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img src="/logos/route53.svg" alt="AWS Route 53" className="w-9 h-9 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">AWS Route 53</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Hosted zones, DNS records, change history</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto">
|
||||||
|
<Button onClick={handleSyncNow} disabled={syncing || syncStatus?.inProgress}>
|
||||||
|
{syncing || syncStatus?.inProgress ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Sync Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4 bg-muted/30">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{zoneCount.toLocaleString()} zones · {recordCount.toLocaleString()} records ·{' '}
|
||||||
|
{historyRowCount.toLocaleString()} history rows
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
Last sync:{' '}
|
||||||
|
{lastSyncHistory ? (
|
||||||
|
<>
|
||||||
|
<StatusBadge tone={syncStatusTone(lastSyncHistory.status)}>
|
||||||
|
{lastSyncHistory.status}
|
||||||
|
</StatusBadge>
|
||||||
|
{fmtDate(lastSyncHistory.completed_at ?? lastSyncHistory.started_at, tz)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Never'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
|
<TabsList className="grid w-full max-w-2xl grid-cols-4">
|
||||||
|
<TabsTrigger value="zones">Zones</TabsTrigger>
|
||||||
|
<TabsTrigger value="records">Records</TabsTrigger>
|
||||||
|
<TabsTrigger value="history">History</TabsTrigger>
|
||||||
|
<TabsTrigger value="schedule">Schedule</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ── Zones tab ── */}
|
||||||
|
<TabsContent value="zones" className="mt-6">
|
||||||
|
<DataTable
|
||||||
|
columns={zoneColumns}
|
||||||
|
data={paginate(zones, zonePage, PAGE_SIZE)}
|
||||||
|
totalCount={zones.length}
|
||||||
|
page={zonePage}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setZonePage}
|
||||||
|
onRowClick={handleSelectZone}
|
||||||
|
isLoading={zonesLoading}
|
||||||
|
emptyTitle="No hosted zones synced yet"
|
||||||
|
emptyDescription="Run a sync to pull hosted zones from AWS Route 53."
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Records tab ── */}
|
||||||
|
<TabsContent value="records" className="mt-6 space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="w-64">
|
||||||
|
<Select value={selectedZoneId} onValueChange={handleZoneSelectChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a zone" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{zones.map((z) => (
|
||||||
|
<SelectItem key={z.id} value={z.id}>
|
||||||
|
{z.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<Select
|
||||||
|
value={typeFilter}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setTypeFilter(v);
|
||||||
|
setRecordPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="All types" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All types</SelectItem>
|
||||||
|
{RECORD_TYPE_FILTERS.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Button onClick={openCreate} disabled={!selectedZoneId} className="ml-auto">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
New record
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!selectedZoneId ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Globe}
|
||||||
|
title="Select a zone"
|
||||||
|
description="Choose a hosted zone above to view its DNS records."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={recordColumns}
|
||||||
|
data={paginate(records, recordPage, PAGE_SIZE)}
|
||||||
|
totalCount={records.length}
|
||||||
|
page={recordPage}
|
||||||
|
pageSize={PAGE_SIZE}
|
||||||
|
onPageChange={setRecordPage}
|
||||||
|
onSearch={handleRecordSearch}
|
||||||
|
isLoading={recordsLoading}
|
||||||
|
emptyTitle="No records found"
|
||||||
|
emptyDescription="Adjust the type filter or search, or create a new record."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── History tab ── */}
|
||||||
|
<TabsContent value="history" className="mt-6 space-y-4">
|
||||||
|
{!selectedRecord ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={HistoryIcon}
|
||||||
|
title="No record selected"
|
||||||
|
description="Pick a record's History action on the Records tab to view its append-only change ledger (create/update/delete, before/after values, actor, and whether the change came from Pulse or was detected as drift from an external edit)."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||||
|
<p className="text-sm">
|
||||||
|
History for <span className="font-medium">{selectedRecord.name}</span>{' '}
|
||||||
|
<StatusBadge tone="neutral">{selectedRecord.type}</StatusBadge>
|
||||||
|
</p>
|
||||||
|
<Button variant="ghost" size="sm" onClick={clearHistorySelection}>
|
||||||
|
<X className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={historyColumns}
|
||||||
|
data={paginate(historyRows, historyPage, HISTORY_PAGE_SIZE)}
|
||||||
|
totalCount={historyRows.length}
|
||||||
|
page={historyPage}
|
||||||
|
pageSize={HISTORY_PAGE_SIZE}
|
||||||
|
onPageChange={setHistoryPage}
|
||||||
|
onRowClick={(row) => {
|
||||||
|
setHistoryDetail(row);
|
||||||
|
setHistoryDetailOpen(true);
|
||||||
|
}}
|
||||||
|
isLoading={historyLoading}
|
||||||
|
emptyTitle="No history yet"
|
||||||
|
emptyDescription="Changes to this record — from Pulse or detected drift — will appear here."
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ── Schedule tab ── */}
|
||||||
|
<TabsContent value="schedule" className="mt-6">
|
||||||
|
<SyncScheduler />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<DetailModal
|
||||||
|
open={zoneDetailOpen}
|
||||||
|
onOpenChange={setZoneDetailOpen}
|
||||||
|
title={zoneDetail?.name ?? 'Hosted Zone'}
|
||||||
|
data={zoneDetail}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DetailModal
|
||||||
|
open={historyDetailOpen}
|
||||||
|
onOpenChange={setHistoryDetailOpen}
|
||||||
|
title={historyDetail ? `${historyDetail.recordName} — ${historyDetail.changeAction}` : 'Record History'}
|
||||||
|
data={historyDetail}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RecordEditorDialog
|
||||||
|
open={editorOpen}
|
||||||
|
onOpenChange={setEditorOpen}
|
||||||
|
zoneId={selectedZoneId}
|
||||||
|
mode={editorMode}
|
||||||
|
record={editingRecord}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RecordDeleteConfirm
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
zoneId={selectedZoneId}
|
||||||
|
record={deletingRecord}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
370
components/admin/route53/record-editor-dialog.tsx
Normal file
370
components/admin/route53/record-editor-dialog.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RecordEditorDialog / RecordDeleteConfirm — create, edit, and delete forms
|
||||||
|
* for AWS Route 53 DNS records, driving the plan 24-05 CRUD routes.
|
||||||
|
*
|
||||||
|
* Plain useState form fields — no react-hook-form (CLAUDE.md scopes that to
|
||||||
|
* admin/auth forms; this matches the surrounding /admin/sync/* pages' plain-
|
||||||
|
* state style).
|
||||||
|
*
|
||||||
|
* D-01: the type selector offers only the six writable record types. NS and
|
||||||
|
* SOA never appear here — the server-side validator is the real gate
|
||||||
|
* (lib/services/route53-record-validation.ts), this is UI consistency only.
|
||||||
|
*
|
||||||
|
* D-03: RecordDeleteConfirm's single confirmation dialog is a misclick guard
|
||||||
|
* only. Deletion executes immediately on confirm — do not add a typed-name
|
||||||
|
* check, a second reviewer step, or any staged/pending state; doing so would
|
||||||
|
* turn this into exactly the multi-step gate D-03 rules out.
|
||||||
|
*
|
||||||
|
* T-24-20: both the save and delete controls disable themselves while their
|
||||||
|
* own request is in flight, preventing a double-submit against the same
|
||||||
|
* hosted zone from producing AWS's PriorRequestNotComplete.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Loader2, Plus, X, Trash2 } from 'lucide-react';
|
||||||
|
import type { Route53Record, Route53WritableType } from '@/lib/types/route53';
|
||||||
|
|
||||||
|
/** D-01: closed allowlist — must match
|
||||||
|
* lib/services/route53-record-validation.ts WRITABLE_RECORD_TYPES exactly.
|
||||||
|
* NS and SOA are zone-delegation records and are never offered here. */
|
||||||
|
const RECORD_TYPES: Route53WritableType[] = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV'];
|
||||||
|
const DEFAULT_TTL = 300;
|
||||||
|
const MAX_RESOURCE_RECORDS = 100;
|
||||||
|
|
||||||
|
interface WriteResponseBody {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
propagationStatus?: 'INSYNC' | 'PENDING';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RecordEditorDialog (create / edit) ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RecordEditorDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
zoneId: string;
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
record?: Route53Record;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordEditorDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
zoneId,
|
||||||
|
mode,
|
||||||
|
record,
|
||||||
|
onSaved,
|
||||||
|
}: RecordEditorDialogProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [type, setType] = useState<Route53WritableType>('A');
|
||||||
|
const [ttl, setTtl] = useState(DEFAULT_TTL);
|
||||||
|
const [values, setValues] = useState<string[]>(['']);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (mode === 'edit' && record) {
|
||||||
|
setName(record.name);
|
||||||
|
setType((record.type as Route53WritableType) ?? 'A');
|
||||||
|
setTtl(record.ttl ?? DEFAULT_TTL);
|
||||||
|
const existing = (record.resourceRecords ?? []).map((r) => r.value);
|
||||||
|
setValues(existing.length > 0 ? existing : ['']);
|
||||||
|
} else {
|
||||||
|
setName('');
|
||||||
|
setType('A');
|
||||||
|
setTtl(DEFAULT_TTL);
|
||||||
|
setValues(['']);
|
||||||
|
}
|
||||||
|
setFormError(null);
|
||||||
|
}, [open, mode, record]);
|
||||||
|
|
||||||
|
const updateValue = (idx: number, v: string) => {
|
||||||
|
setValues((prev) => prev.map((existing, i) => (i === idx ? v : existing)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addValue = () => {
|
||||||
|
setValues((prev) => (prev.length >= MAX_RESOURCE_RECORDS ? prev : [...prev, '']));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeValue = (idx: number) => {
|
||||||
|
setValues((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== idx)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
setFormError(null);
|
||||||
|
try {
|
||||||
|
const resourceRecords = values
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter((v) => v.length > 0)
|
||||||
|
.map((v) => ({ value: v }));
|
||||||
|
|
||||||
|
const payload = { name, type, ttl: Number(ttl), resourceRecords };
|
||||||
|
const url =
|
||||||
|
mode === 'create'
|
||||||
|
? `/api/route53/zones/${zoneId}/records`
|
||||||
|
: `/api/route53/zones/${zoneId}/records/${encodeURIComponent(record!.recordKey)}`;
|
||||||
|
const method = mode === 'create' ? 'POST' : 'PATCH';
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const body: WriteResponseBody = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const propagationLabel = body.propagationStatus === 'INSYNC' ? 'Propagated' : 'Submitted — propagating';
|
||||||
|
toast.success(`Record ${mode === 'create' ? 'created' : 'updated'} — ${propagationLabel}`);
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = body.message || body.error || 'Failed to save record';
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to save record';
|
||||||
|
setFormError(message);
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{mode === 'create' ? 'New DNS record' : 'Edit DNS record'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{mode === 'create'
|
||||||
|
? 'Creates a resource record set in AWS Route 53.'
|
||||||
|
: 'Name and type cannot be changed here — renaming a record set is a delete-plus-create, not an update.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="record-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="record-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="www.example.com"
|
||||||
|
disabled={mode === 'edit'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="record-type">Type</Label>
|
||||||
|
<Select
|
||||||
|
value={type}
|
||||||
|
onValueChange={(v) => setType(v as Route53WritableType)}
|
||||||
|
disabled={mode === 'edit'}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="record-type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RECORD_TYPES.map((t) => (
|
||||||
|
<SelectItem key={t} value={t}>
|
||||||
|
{t}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="record-ttl">TTL (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
id="record-ttl"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={2147483647}
|
||||||
|
value={ttl}
|
||||||
|
onChange={(e) => setTtl(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Values</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{values.map((v, idx) => (
|
||||||
|
<div key={idx} className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={v}
|
||||||
|
onChange={(e) => updateValue(idx, e.target.value)}
|
||||||
|
placeholder={type === 'MX' ? '10 mail.example.com' : 'value'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeValue(idx)}
|
||||||
|
disabled={values.length <= 1}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addValue}
|
||||||
|
disabled={values.length >= MAX_RESOURCE_RECORDS}
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
Add value
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<div className="text-sm text-destructive bg-destructive/10 rounded p-2">{formError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={submitting || !name.trim()}>
|
||||||
|
{submitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
{mode === 'create' ? 'Create record' : 'Save changes'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RecordDeleteConfirm ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RecordDeleteConfirmProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
zoneId: string;
|
||||||
|
record?: Route53Record;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordDeleteConfirm({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
zoneId,
|
||||||
|
record,
|
||||||
|
onSaved,
|
||||||
|
}: RecordDeleteConfirmProps) {
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setError(null);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!record) return null;
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/route53/zones/${zoneId}/records/${encodeURIComponent(record.recordKey)}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
const body: WriteResponseBody = await res.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
toast.success('Record deleted');
|
||||||
|
onSaved();
|
||||||
|
onOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = body.message || body.error || 'Failed to delete record';
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to delete record';
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const values = (record.resourceRecords ?? []).map((r) => r.value).join(', ') || '—';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete DNS record</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This dialog is a single misclick guard — confirming deletes the record from AWS
|
||||||
|
Route 53 immediately, with no further review step.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="rounded-md border p-3 text-sm space-y-1">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Name:</span> {record.name}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Type:</span> {record.type}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">TTL:</span> {record.ttl ?? '—'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Values:</span> {values}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-destructive bg-destructive/10 rounded p-2">{error}</div>}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete} disabled={submitting}>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Delete record
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue