CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m3s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m4s
Updated the ModuleDetailComponent to include new queries for communities and DoH profiles, improving data handling. Added a module type alert function for better user guidance and refined the UI to display module type in a more user-friendly manner. Removed unused components and streamlined the refresh functionality for better performance.
438 lines
14 KiB
TypeScript
438 lines
14 KiB
TypeScript
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<string, unknown>[]
|
|
communities: BgpCommunity[]
|
|
isLoading: boolean
|
|
isError: boolean
|
|
error: Error | null
|
|
onRetry: () => void
|
|
onChanged: () => void | Promise<void>
|
|
}
|
|
|
|
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<DeleteTarget | null>(null)
|
|
const [deleting, setDeleting] = useState(false)
|
|
|
|
const [editDomain, setEditDomain] = useState<DomainEntry | null>(null)
|
|
const [editIpRange, setEditIpRange] = useState<IpRangeEntry | null>(null)
|
|
const [editCdn, setEditCdn] = useState<CdnSource | null>(null)
|
|
const [editAs, setEditAs] = useState<AsEntry | null>(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 (
|
|
<>
|
|
<Card>
|
|
<CardHeader className="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="min-w-0 flex-1">
|
|
<CardTitle className="text-base">{meta.title}</CardTitle>
|
|
<CardDescription>{meta.description}</CardDescription>
|
|
</div>
|
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
|
<Button size="sm" onClick={openCreate}>
|
|
<Plus />
|
|
Добавить
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<QueryState
|
|
data={items}
|
|
isLoading={isLoading}
|
|
isError={isError}
|
|
error={error}
|
|
empty={items.length === 0}
|
|
emptyTitle={meta.emptyTitle}
|
|
emptyDescription={meta.emptyDescription}
|
|
skeleton={<TableSkeleton rows={5} cols={3} />}
|
|
onRetry={onRetry}
|
|
>
|
|
{(rows) => (
|
|
<EntriesTable
|
|
mod={mod}
|
|
rows={rows}
|
|
communities={communities}
|
|
onEdit={(target) => {
|
|
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}
|
|
/>
|
|
)}
|
|
</QueryState>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{mod.type === 'DOMAINS' ? (
|
|
<ModuleDomainEntryDialog
|
|
open={dialogOpen}
|
|
moduleId={moduleId}
|
|
edit={editDomain}
|
|
communities={communities}
|
|
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
|
onSaved={onChanged}
|
|
/>
|
|
) : null}
|
|
{mod.type === 'IP_RANGES' ? (
|
|
<ModuleIpRangeEntryDialog
|
|
open={dialogOpen}
|
|
moduleId={moduleId}
|
|
edit={editIpRange}
|
|
communities={communities}
|
|
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
|
onSaved={onChanged}
|
|
/>
|
|
) : null}
|
|
{mod.type === 'CDN_CIDRS' ? (
|
|
<ModuleCdnSourceDialog
|
|
open={dialogOpen}
|
|
moduleId={moduleId}
|
|
edit={editCdn}
|
|
communities={communities}
|
|
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
|
onSaved={onChanged}
|
|
/>
|
|
) : null}
|
|
{mod.type === 'AS_PREFIXES' ? (
|
|
<ModuleAsEntryDialog
|
|
open={dialogOpen}
|
|
moduleId={moduleId}
|
|
edit={editAs}
|
|
communities={communities}
|
|
onOpenChange={(open) => (open ? setDialogOpen(true) : closeDialog())}
|
|
onSaved={onChanged}
|
|
/>
|
|
) : null}
|
|
|
|
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Удалить запись?</AlertDialogTitle>
|
|
<AlertDialogDescription>{deleteDescription(deleteTarget)}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={deleting}>Отмена</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
variant="destructive"
|
|
disabled={deleting}
|
|
onClick={() => void confirmDelete()}
|
|
>
|
|
{deleting ? 'Удаление…' : 'Удалить'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
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<string, unknown>[]
|
|
communities: BgpCommunity[]
|
|
onEdit: (target: DeleteTarget) => void
|
|
onDelete: (target: DeleteTarget) => void
|
|
}) {
|
|
if (mod.type === 'DOMAINS') {
|
|
const entries = rows as unknown as DomainEntry[]
|
|
return (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>FQDN</TableHead>
|
|
<TableHead>Community</TableHead>
|
|
<TableHead className="w-20" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{entries.map((entry) => (
|
|
<TableRow key={entry.id}>
|
|
<TableCell className="font-mono text-sm">{entry.fqdn}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{communityLabel(entry.community_id, communities)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<RowActions
|
|
onEdit={() => onEdit({ kind: 'domain', entry })}
|
|
onDelete={() => onDelete({ kind: 'domain', entry })}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)
|
|
}
|
|
|
|
if (mod.type === 'IP_RANGES') {
|
|
const entries = rows as unknown as IpRangeEntry[]
|
|
return (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Префикс (CIDR)</TableHead>
|
|
<TableHead>Community</TableHead>
|
|
<TableHead className="w-20" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{entries.map((entry) => (
|
|
<TableRow key={entry.id}>
|
|
<TableCell className="font-mono text-sm">{entry.prefix}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{communityLabel(entry.community_id, communities)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<RowActions
|
|
onEdit={() => onEdit({ kind: 'ip-range', entry })}
|
|
onDelete={() => onDelete({ kind: 'ip-range', entry })}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)
|
|
}
|
|
|
|
if (mod.type === 'CDN_CIDRS') {
|
|
const entries = rows as unknown as CdnSource[]
|
|
return (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>URL</TableHead>
|
|
<TableHead>Тип</TableHead>
|
|
<TableHead>Community</TableHead>
|
|
<TableHead>Обновлено</TableHead>
|
|
<TableHead className="w-20" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{entries.map((entry) => (
|
|
<TableRow key={entry.id}>
|
|
<TableCell className="max-w-xs truncate font-mono text-xs">{entry.url}</TableCell>
|
|
<TableCell className="text-sm">{entry.source_kind}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{communityLabel(entry.community_id, communities)}
|
|
</TableCell>
|
|
<TableCell className="text-xs text-muted-foreground">
|
|
{formatDateTime(entry.last_refreshed_at)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<RowActions
|
|
onEdit={() => onEdit({ kind: 'cdn', entry })}
|
|
onDelete={() => onDelete({ kind: 'cdn', entry })}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)
|
|
}
|
|
|
|
const entries = rows as unknown as AsEntry[]
|
|
return (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>ASN</TableHead>
|
|
<TableHead>Имя</TableHead>
|
|
<TableHead>Префиксов</TableHead>
|
|
<TableHead>Community</TableHead>
|
|
<TableHead className="w-20" />
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{entries.map((entry) => (
|
|
<TableRow key={entry.id}>
|
|
<TableCell className="font-mono text-sm">{entry.asn}</TableCell>
|
|
<TableCell className="text-sm">{entry.asn_name ?? '—'}</TableCell>
|
|
<TableCell className="font-mono text-sm">{entry.prefix_count ?? '—'}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{communityLabel(entry.community_id, communities)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<RowActions
|
|
onEdit={() => onEdit({ kind: 'as', entry })}
|
|
onDelete={() => onDelete({ kind: 'as', entry })}
|
|
/>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)
|
|
}
|
|
|
|
function RowActions({ onEdit, onDelete }: { onEdit: () => void; onDelete: () => void }) {
|
|
return (
|
|
<div className="flex justify-end gap-1">
|
|
<Button variant="ghost" size="icon-sm" onClick={onEdit} aria-label="Редактировать">
|
|
<Pencil className="size-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
className="text-destructive"
|
|
onClick={onDelete}
|
|
aria-label="Удалить"
|
|
>
|
|
<Trash2 className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|