From b3048be769ce7512c4d5be8f8813d88bb20383b1 Mon Sep 17 00:00:00 2001 From: lorentz Date: Wed, 5 Aug 2026 20:52:25 -0400 Subject: [PATCH] feat(24-07): zones, records, and history page shell for /admin/sync/route53 - Four-tab detail page (Zones / Records / History / Schedule) following the veeam/pax8 sync-detail-page shape - Sync Now trigger with bounded polling and 409/503 handling - Records tab: zone selector, type/search filters, actions cell gated on the D-01 writable-type allowlist (NS/SOA render read-only) - History tab: per-record change ledger with pulse_crud vs sync_detected_drift source badges (D-06) - Zone/history detail drill-downs via the existing DetailModal component --- app/admin/sync/route53/page.tsx | 789 ++++++++++++++++++++++++++++++++ 1 file changed, 789 insertions(+) create mode 100644 app/admin/sync/route53/page.tsx diff --git a/app/admin/sync/route53/page.tsx b/app/admin/sync/route53/page.tsx new file mode 100644 index 0000000..9d7ad41 --- /dev/null +++ b/app/admin/sync/route53/page.tsx @@ -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(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(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); + 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([]); + const [zonesLoading, setZonesLoading] = useState(true); + const [zonePage, setZonePage] = useState(1); + const [zoneDetail, setZoneDetail] = useState(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([]); + 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(null); + const [historyRows, setHistoryRows] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyPage, setHistoryPage] = useState(1); + const [historyDetail, setHistoryDetail] = useState(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(undefined); + const [deleteOpen, setDeleteOpen] = useState(false); + const [deletingRecord, setDeletingRecord] = useState(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[] = [ + { key: 'name', label: 'Zone Name', sortable: true }, + { + key: 'id', + label: 'Zone ID', + render: (v: string) => {v}, + }, + { + key: 'privateZone', + label: 'Private', + render: (v: boolean) => ( + {v ? 'Private' : 'Public'} + ), + }, + { + key: 'recordCount', + label: 'Records', + render: (v: number) => {v ?? 0}, + }, + { + key: 'syncedAt', + label: 'Synced At', + render: (v: string) => ( + {fmtDate(v, tz)} + ), + }, + { + key: 'authoritativeNameServers', + label: 'Details', + render: (_v: unknown, row: Route53Zone) => ( + + ), + }, + ]; + + const recordColumns: Column[] = [ + { key: 'name', label: 'Name', sortable: true }, + { + key: 'type', + label: 'Type', + render: (v: string) => ( + {v} + ), + }, + { key: 'ttl', label: 'TTL', render: (v: number | null) => {v ?? '—'} }, + { + key: 'resourceRecords', + label: 'Values', + render: (v: Array<{ value: string }> | null) => { + const joined = (v ?? []).map((r) => r.value).join(', '); + return ( + + {joined || '—'} + + ); + }, + }, + { + key: 'recordKey', + label: 'Actions', + render: (_v: unknown, row: Route53Record) => { + const writable = isWritableType(row.type); + return ( +
+ + {writable ? ( + <> + + + + ) : ( + + + Read-only + + )} +
+ ); + }, + }, + ]; + + const historyColumns: Column[] = [ + { + key: 'changedAt', + label: 'Changed At', + sortable: true, + render: (v: string) => ( + {fmtDate(v, tz)} + ), + }, + { key: 'recordName', label: 'Record' }, + { + key: 'recordType', + label: 'Type', + render: (v: string) => {v}, + }, + { + key: 'changeAction', + label: 'Action', + render: (v: string) => {v}, + }, + { + key: 'source', + label: 'Source', + render: (v: Route53HistorySource) => ( + {sourceLabel(v)} + ), + }, + { + key: 'changedByEmail', + label: 'Actor', + render: (v: string | null) => {v ?? '—'}, + }, + ]; + + // ── 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 ( +
+
+ + + +
+ AWS Route 53 +
+

AWS Route 53

+

Hosted zones, DNS records, change history

+
+
+
+ +
+ ); + } + + return ( +
+
+ + + +
+ AWS Route 53 +
+

AWS Route 53

+

Hosted zones, DNS records, change history

+
+
+
+ +
+
+ +
+
+

+ {zoneCount.toLocaleString()} zones · {recordCount.toLocaleString()} records ·{' '} + {historyRowCount.toLocaleString()} history rows +

+

+ Last sync:{' '} + {lastSyncHistory ? ( + <> + + {lastSyncHistory.status} + + {fmtDate(lastSyncHistory.completed_at ?? lastSyncHistory.started_at, tz)} + + ) : ( + 'Never' + )} +

+
+
+ + + + Zones + Records + History + Schedule + + + {/* ── Zones tab ── */} + + + + + {/* ── Records tab ── */} + +
+
+ +
+
+ +
+ +
+ + {!selectedZoneId ? ( + + ) : ( + + )} +
+ + {/* ── History tab ── */} + + {!selectedRecord ? ( + + ) : ( + <> +
+

+ History for {selectedRecord.name}{' '} + {selectedRecord.type} +

+ +
+ { + setHistoryDetail(row); + setHistoryDetailOpen(true); + }} + isLoading={historyLoading} + emptyTitle="No history yet" + emptyDescription="Changes to this record — from Pulse or detected drift — will appear here." + /> + + )} +
+ + {/* ── Schedule tab ── */} + + + +
+ + + + + + + + +
+ ); +}