wulf-pulse/app/admin/sync/route53/page.tsx
lorentz b3048be769 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
2026-08-05 20:52:25 -04:00

789 lines
26 KiB
TypeScript

'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>
);
}