CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 57s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m27s
Updated various components including AccessApiKeysCard, FirewallClientsGrid, FirewallRulesGrid, ModuleEntriesSection, and DirectoriesComponent to replace traditional card and table structures with the new DataGridCard component. This change enhances the user interface by providing a more consistent layout and improved loading states. Additionally, integrated QueryState for better handling of loading and error scenarios across these components, streamlining the overall user experience.
256 lines
7.9 KiB
TypeScript
256 lines
7.9 KiB
TypeScript
import { useState } from 'react'
|
|
import { Plus } 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 { DataGridCard } from '@/components/data-grid-shell'
|
|
import { QueryState } from '@/components/query-state'
|
|
import { TableSkeleton } from '@/components/skeletons'
|
|
import { ModuleEntriesGrid, type ModuleEntryDeleteTarget } from '@/components/modules/module-entries-grid'
|
|
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 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 = ModuleEntryDeleteTarget
|
|
|
|
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 (
|
|
<>
|
|
<DataGridCard
|
|
title={meta.title}
|
|
description={meta.description}
|
|
actions={
|
|
<Button size="sm" type="button" onClick={openCreate}>
|
|
<Plus />
|
|
Добавить
|
|
</Button>
|
|
}
|
|
>
|
|
<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) => (
|
|
<ModuleEntriesGrid
|
|
mod={mod}
|
|
rows={rows}
|
|
communities={communities}
|
|
isLoading={isLoading}
|
|
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>
|
|
</DataGridCard>
|
|
|
|
{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}`
|
|
}
|
|
}
|