import { useState } from 'react' import { Pencil, Plus, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@evobgp/ui/components/alert-dialog' import { Button } from '@evobgp/ui/components/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@evobgp/ui/components/table' import { QueryState } from '@/components/query-state' import { TableSkeleton } from '@/components/skeletons' import { ModuleAsEntryDialog } from '@/components/modules/module-as-entry-dialog' import { ModuleCdnSourceDialog } from '@/components/modules/module-cdn-source-dialog' import { ModuleDomainEntryDialog } from '@/components/modules/module-domain-entry-dialog' import { ModuleIpRangeEntryDialog } from '@/components/modules/module-ip-range-entry-dialog' import { ApiError, apiMutate } from '@/lib/api-client' import { communityLabel } from '@/lib/modules/helpers' import { formatDateTime } from '@/lib/modules/display' import type { AsEntry, BgpCommunity, CdnSource, DomainEntry, IpRangeEntry, ModuleRow, } from '@/types/api' interface ModuleEntriesSectionProps { moduleId: string mod: ModuleRow items: Record[] communities: BgpCommunity[] isLoading: boolean isError: boolean error: Error | null onRetry: () => void onChanged: () => void | Promise } type DeleteTarget = | { kind: 'domain'; entry: DomainEntry } | { kind: 'ip-range'; entry: IpRangeEntry } | { kind: 'cdn'; entry: CdnSource } | { kind: 'as'; entry: AsEntry } const CARD_META: Record< ModuleRow['type'], { title: string; description: string; emptyTitle: string; emptyDescription: string } > = { DOMAINS: { title: 'Домены', description: 'FQDN для резолвинга через DoH.', emptyTitle: 'Нет доменов', emptyDescription: 'Добавьте FQDN для резолвинга.', }, IP_RANGES: { title: 'IP-диапазоны', description: 'Статические CIDR для анонса.', emptyTitle: 'Нет диапазонов', emptyDescription: 'Добавьте CIDR.', }, CDN_CIDRS: { title: 'CDN-источники', description: 'URL источников для скачивания списков CIDR.', emptyTitle: 'Нет источников', emptyDescription: 'Добавьте CDN-источник.', }, AS_PREFIXES: { title: 'AS-записи', description: 'ASN для получения префиксов через RIPEstat.', emptyTitle: 'Нет записей', emptyDescription: 'Добавьте ASN.', }, } export function ModuleEntriesSection({ moduleId, mod, items, communities, isLoading, isError, error, onRetry, onChanged, }: ModuleEntriesSectionProps) { const meta = CARD_META[mod.type] const [dialogOpen, setDialogOpen] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = useState(false) const [editDomain, setEditDomain] = useState(null) const [editIpRange, setEditIpRange] = useState(null) const [editCdn, setEditCdn] = useState(null) const [editAs, setEditAs] = useState(null) function openCreate() { setEditDomain(null) setEditIpRange(null) setEditCdn(null) setEditAs(null) setDialogOpen(true) } function closeDialog() { setDialogOpen(false) setEditDomain(null) setEditIpRange(null) setEditCdn(null) setEditAs(null) } async function confirmDelete() { if (!deleteTarget) return setDeleting(true) try { const { kind, entry } = deleteTarget const pathByKind = { domain: `/v1/modules/${moduleId}/domain-entries/${entry.id}`, 'ip-range': `/v1/modules/${moduleId}/ip-range-entries/${entry.id}`, cdn: `/v1/modules/${moduleId}/cdn-sources/${entry.id}`, as: `/v1/modules/${moduleId}/as-entries/${entry.id}`, } as const await apiMutate(pathByKind[kind], 'DELETE', undefined, { idempotent: false }) toast.success('Удалено') setDeleteTarget(null) await onChanged() } catch (e) { toast.error(e instanceof ApiError ? e.message : String(e)) } finally { setDeleting(false) } } return ( <>
{meta.title} {meta.description}
} onRetry={onRetry} > {(rows) => ( { if (target.kind === 'domain') setEditDomain(target.entry) if (target.kind === 'ip-range') setEditIpRange(target.entry) if (target.kind === 'cdn') setEditCdn(target.entry) if (target.kind === 'as') setEditAs(target.entry) setDialogOpen(true) }} onDelete={setDeleteTarget} /> )}
{mod.type === 'DOMAINS' ? ( (open ? setDialogOpen(true) : closeDialog())} onSaved={onChanged} /> ) : null} {mod.type === 'IP_RANGES' ? ( (open ? setDialogOpen(true) : closeDialog())} onSaved={onChanged} /> ) : null} {mod.type === 'CDN_CIDRS' ? ( (open ? setDialogOpen(true) : closeDialog())} onSaved={onChanged} /> ) : null} {mod.type === 'AS_PREFIXES' ? ( (open ? setDialogOpen(true) : closeDialog())} onSaved={onChanged} /> ) : null} !open && setDeleteTarget(null)}> Удалить запись? {deleteDescription(deleteTarget)} Отмена void confirmDelete()} > {deleting ? 'Удаление…' : 'Удалить'} ) } function deleteDescription(target: DeleteTarget | null): string { if (!target) return '' switch (target.kind) { case 'domain': return target.entry.fqdn case 'ip-range': return target.entry.prefix case 'cdn': return target.entry.url case 'as': return `AS${target.entry.asn}` } } function EntriesTable({ mod, rows, communities, onEdit, onDelete, }: { mod: ModuleRow rows: Record[] communities: BgpCommunity[] onEdit: (target: DeleteTarget) => void onDelete: (target: DeleteTarget) => void }) { if (mod.type === 'DOMAINS') { const entries = rows as unknown as DomainEntry[] return ( FQDN Community {entries.map((entry) => ( {entry.fqdn} {communityLabel(entry.community_id, communities)} onEdit({ kind: 'domain', entry })} onDelete={() => onDelete({ kind: 'domain', entry })} /> ))}
) } if (mod.type === 'IP_RANGES') { const entries = rows as unknown as IpRangeEntry[] return ( Префикс (CIDR) Community {entries.map((entry) => ( {entry.prefix} {communityLabel(entry.community_id, communities)} onEdit({ kind: 'ip-range', entry })} onDelete={() => onDelete({ kind: 'ip-range', entry })} /> ))}
) } if (mod.type === 'CDN_CIDRS') { const entries = rows as unknown as CdnSource[] return ( URL Тип Community Обновлено {entries.map((entry) => ( {entry.url} {entry.source_kind} {communityLabel(entry.community_id, communities)} {formatDateTime(entry.last_refreshed_at)} onEdit({ kind: 'cdn', entry })} onDelete={() => onDelete({ kind: 'cdn', entry })} /> ))}
) } const entries = rows as unknown as AsEntry[] return ( ASN Имя Префиксов Community {entries.map((entry) => ( {entry.asn} {entry.asn_name ?? '—'} {entry.prefix_count ?? '—'} {communityLabel(entry.community_id, communities)} onEdit({ kind: 'as', entry })} onDelete={() => onDelete({ kind: 'as', entry })} /> ))}
) } function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) { return (
) }