diff --git a/apps/web/src/components/modules/community-select.tsx b/apps/web/src/components/modules/community-select.tsx
new file mode 100644
index 0000000..6b05c3f
--- /dev/null
+++ b/apps/web/src/components/modules/community-select.tsx
@@ -0,0 +1,76 @@
+import { useMemo } from 'react'
+
+import { Label } from '@evobgp/ui/components/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@evobgp/ui/components/select'
+
+import {
+ NONE_OPTION,
+ communityOptionLabel,
+ fromNullableSelect,
+ nullableSelectValue,
+} from '@/lib/modules/helpers'
+import type { BgpCommunity } from '@/types/api'
+
+interface CommunitySelectProps {
+ id?: string
+ label?: string
+ value: string | null
+ onValueChange: (value: string | null) => void
+ communities: BgpCommunity[]
+ nullable?: boolean
+ placeholder?: string
+}
+
+export function CommunitySelect({
+ id,
+ label,
+ value,
+ onValueChange,
+ communities,
+ nullable = false,
+ placeholder = 'Выберите community',
+}: CommunitySelectProps) {
+ const items = useMemo(() => {
+ const communityItems = communities.map((c) => ({
+ value: c.id,
+ label: communityOptionLabel(c),
+ }))
+ if (nullable) {
+ return [{ value: NONE_OPTION, label: 'Не выбрано' }, ...communityItems]
+ }
+ return communityItems
+ }, [communities, nullable])
+
+ const selectValue = nullable ? nullableSelectValue(value) : (value ?? '')
+
+ return (
+
+ {label ? : null}
+
+
+ )
+}
diff --git a/apps/web/src/components/modules/module-as-entry-dialog.tsx b/apps/web/src/components/modules/module-as-entry-dialog.tsx
new file mode 100644
index 0000000..45cb21b
--- /dev/null
+++ b/apps/web/src/components/modules/module-as-entry-dialog.tsx
@@ -0,0 +1,116 @@
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@evobgp/ui/components/dialog'
+import { Input } from '@evobgp/ui/components/input'
+import { Label } from '@evobgp/ui/components/label'
+
+import { LoadingButton } from '@/components/loading-button'
+import { CommunitySelect } from '@/components/modules/community-select'
+import { ApiError, apiMutate } from '@/lib/api-client'
+import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '@/types/api'
+
+interface ModuleAsEntryDialogProps {
+ open: boolean
+ moduleId: string
+ edit: AsEntry | null
+ communities: BgpCommunity[]
+ onOpenChange: (open: boolean) => void
+ onSaved: () => void | Promise
+}
+
+export function ModuleAsEntryDialog({
+ open,
+ moduleId,
+ edit,
+ communities,
+ onOpenChange,
+ onSaved,
+}: ModuleAsEntryDialogProps) {
+ const [saving, setSaving] = useState(false)
+ const [form, setForm] = useState({ asn: 0, community_id: null })
+
+ useEffect(() => {
+ if (!open) return
+ setForm(
+ edit
+ ? { asn: edit.asn, community_id: edit.community_id }
+ : { asn: 0, community_id: null },
+ )
+ }, [open, edit])
+
+ async function save() {
+ const asn = Number(form.asn)
+ if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
+ toast.error('Укажите корректный ASN (1–4294967295)')
+ return
+ }
+ setSaving(true)
+ try {
+ const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id }
+ if (edit) {
+ await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body)
+ toast.success('Запись обновлена')
+ } else {
+ await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate)
+ toast.success('Запись добавлена')
+ }
+ onOpenChange(false)
+ await onSaved()
+ } catch (e) {
+ toast.error(e instanceof ApiError ? e.message : String(e))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/modules/module-cdn-source-dialog.tsx b/apps/web/src/components/modules/module-cdn-source-dialog.tsx
new file mode 100644
index 0000000..c0b7de4
--- /dev/null
+++ b/apps/web/src/components/modules/module-cdn-source-dialog.tsx
@@ -0,0 +1,277 @@
+import { useEffect, useMemo, useState } from 'react'
+import { toast } from 'sonner'
+
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@evobgp/ui/components/dialog'
+import { Input } from '@evobgp/ui/components/input'
+import { Label } from '@evobgp/ui/components/label'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@evobgp/ui/components/select'
+import { Button } from '@evobgp/ui/components/button'
+
+import { LoadingButton } from '@/components/loading-button'
+import { CommunitySelect } from '@/components/modules/community-select'
+import { ApiError, apiMutate } from '@/lib/api-client'
+import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
+import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
+
+const CDN_KIND_ITEMS = [
+ { value: 'plaintext', label: 'plaintext' },
+ { value: 'json', label: 'json' },
+] as const
+
+interface ModuleCdnSourceDialogProps {
+ open: boolean
+ moduleId: string
+ edit: CdnSource | null
+ communities: BgpCommunity[]
+ onOpenChange: (open: boolean) => void
+ onSaved: () => void | Promise
+}
+
+type CdnForm = CdnSourceCreate & { refresh_interval_sec?: number | null }
+
+export function ModuleCdnSourceDialog({
+ open,
+ moduleId,
+ edit,
+ communities,
+ onOpenChange,
+ onSaved,
+}: ModuleCdnSourceDialogProps) {
+ const [saving, setSaving] = useState(false)
+ const [previewLoading, setPreviewLoading] = useState(false)
+ const [previewItems, setPreviewItems] = useState([])
+ const [previewTotal, setPreviewTotal] = useState(0)
+ const [previewTruncated, setPreviewTruncated] = useState(false)
+ const [previewError, setPreviewError] = useState(null)
+ const [previewOk, setPreviewOk] = useState(false)
+ const [form, setForm] = useState({
+ url: '',
+ source_kind: 'plaintext',
+ prefix_path: '',
+ community_id: null,
+ })
+
+ const kindItems = useMemo(() => [...CDN_KIND_ITEMS], [])
+
+ function clearPreview() {
+ setPreviewLoading(false)
+ setPreviewItems([])
+ setPreviewTotal(0)
+ setPreviewTruncated(false)
+ setPreviewError(null)
+ setPreviewOk(false)
+ }
+
+ useEffect(() => {
+ if (!open) {
+ clearPreview()
+ return
+ }
+ setForm(
+ edit
+ ? {
+ url: edit.url,
+ source_kind: normalizeCdnSourceKind(edit.source_kind),
+ prefix_path: edit.prefix_path ?? '',
+ community_id: edit.community_id,
+ refresh_interval_sec: edit.refresh_interval_sec,
+ }
+ : { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null },
+ )
+ clearPreview()
+ }, [open, edit])
+
+ async function previewCdn() {
+ const urlTrim = form.url.trim()
+ if (!urlTrim) {
+ toast.error('Укажите URL')
+ return
+ }
+ setPreviewLoading(true)
+ setPreviewError(null)
+ setPreviewOk(false)
+ try {
+ const res = await apiMutate(
+ `/v1/modules/${moduleId}/cdn-sources/preview`,
+ 'POST',
+ {
+ url: urlTrim,
+ source_kind: form.source_kind,
+ prefix_path: form.prefix_path?.trim() ?? '',
+ },
+ )
+ setPreviewItems(res.items)
+ setPreviewTotal(res.total)
+ setPreviewTruncated(res.truncated)
+ setPreviewOk(true)
+ } catch (e) {
+ setPreviewError(e instanceof ApiError ? e.message : String(e))
+ setPreviewItems([])
+ setPreviewTotal(0)
+ setPreviewTruncated(false)
+ setPreviewOk(false)
+ } finally {
+ setPreviewLoading(false)
+ }
+ }
+
+ async function save() {
+ const urlTrim = form.url.trim()
+ if (!urlTrim) {
+ toast.error('Укажите URL')
+ return
+ }
+ setSaving(true)
+ try {
+ const body = {
+ ...form,
+ url: urlTrim,
+ source_kind: form.source_kind,
+ prefix_path: form.prefix_path?.trim() ?? '',
+ }
+ if (edit) {
+ await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body)
+ toast.success('Источник обновлён')
+ } else {
+ await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body)
+ toast.success('Источник добавлен')
+ }
+ clearPreview()
+ onOpenChange(false)
+ await onSaved()
+ } catch (e) {
+ toast.error(e instanceof ApiError ? e.message : String(e))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/modules/module-domain-entry-dialog.tsx b/apps/web/src/components/modules/module-domain-entry-dialog.tsx
new file mode 100644
index 0000000..c6bc8e7
--- /dev/null
+++ b/apps/web/src/components/modules/module-domain-entry-dialog.tsx
@@ -0,0 +1,108 @@
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@evobgp/ui/components/dialog'
+import { Input } from '@evobgp/ui/components/input'
+import { Label } from '@evobgp/ui/components/label'
+
+import { LoadingButton } from '@/components/loading-button'
+import { CommunitySelect } from '@/components/modules/community-select'
+import { ApiError, apiMutate } from '@/lib/api-client'
+import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '@/types/api'
+
+interface ModuleDomainEntryDialogProps {
+ open: boolean
+ moduleId: string
+ edit: DomainEntry | null
+ communities: BgpCommunity[]
+ onOpenChange: (open: boolean) => void
+ onSaved: () => void | Promise
+}
+
+export function ModuleDomainEntryDialog({
+ open,
+ moduleId,
+ edit,
+ communities,
+ onOpenChange,
+ onSaved,
+}: ModuleDomainEntryDialogProps) {
+ const [saving, setSaving] = useState(false)
+ const [form, setForm] = useState({ fqdn: '', community_id: null })
+
+ useEffect(() => {
+ if (!open) return
+ setForm(
+ edit
+ ? { fqdn: edit.fqdn, community_id: edit.community_id }
+ : { fqdn: '', community_id: null },
+ )
+ }, [open, edit])
+
+ async function save() {
+ if (!form.fqdn.trim()) {
+ toast.error('Укажите FQDN')
+ return
+ }
+ setSaving(true)
+ try {
+ const body = { fqdn: form.fqdn.trim(), community_id: form.community_id }
+ if (edit) {
+ await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', body)
+ toast.success('Домен обновлён')
+ } else {
+ await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', body)
+ toast.success('Домен добавлен')
+ }
+ onOpenChange(false)
+ await onSaved()
+ } catch (e) {
+ toast.error(e instanceof ApiError ? e.message : String(e))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/modules/module-entries-section.tsx b/apps/web/src/components/modules/module-entries-section.tsx
new file mode 100644
index 0000000..c277164
--- /dev/null
+++ b/apps/web/src/components/modules/module-entries-section.tsx
@@ -0,0 +1,437 @@
+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 (
+
+ )
+}
diff --git a/apps/web/src/components/modules/module-ip-range-entry-dialog.tsx b/apps/web/src/components/modules/module-ip-range-entry-dialog.tsx
new file mode 100644
index 0000000..08ee885
--- /dev/null
+++ b/apps/web/src/components/modules/module-ip-range-entry-dialog.tsx
@@ -0,0 +1,108 @@
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@evobgp/ui/components/dialog'
+import { Input } from '@evobgp/ui/components/input'
+import { Label } from '@evobgp/ui/components/label'
+
+import { LoadingButton } from '@/components/loading-button'
+import { CommunitySelect } from '@/components/modules/community-select'
+import { ApiError, apiMutate } from '@/lib/api-client'
+import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '@/types/api'
+
+interface ModuleIpRangeEntryDialogProps {
+ open: boolean
+ moduleId: string
+ edit: IpRangeEntry | null
+ communities: BgpCommunity[]
+ onOpenChange: (open: boolean) => void
+ onSaved: () => void | Promise
+}
+
+export function ModuleIpRangeEntryDialog({
+ open,
+ moduleId,
+ edit,
+ communities,
+ onOpenChange,
+ onSaved,
+}: ModuleIpRangeEntryDialogProps) {
+ const [saving, setSaving] = useState(false)
+ const [form, setForm] = useState({ prefix: '', community_id: '' })
+
+ useEffect(() => {
+ if (!open) return
+ setForm(
+ edit
+ ? { prefix: edit.prefix, community_id: edit.community_id }
+ : { prefix: '', community_id: '' },
+ )
+ }, [open, edit])
+
+ async function save() {
+ if (!form.prefix.trim() || !form.community_id) {
+ toast.error('Укажите префикс и community')
+ return
+ }
+ setSaving(true)
+ try {
+ const body = { prefix: form.prefix.trim(), community_id: form.community_id }
+ if (edit) {
+ await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', body)
+ toast.success('Диапазон обновлён')
+ } else {
+ await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', body)
+ toast.success('Диапазон добавлен')
+ }
+ onOpenChange(false)
+ await onSaved()
+ } catch (e) {
+ toast.error(e instanceof ApiError ? e.message : String(e))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/web/src/components/modules/module-kpi-cards.tsx b/apps/web/src/components/modules/module-kpi-cards.tsx
new file mode 100644
index 0000000..9ca3f1b
--- /dev/null
+++ b/apps/web/src/components/modules/module-kpi-cards.tsx
@@ -0,0 +1,84 @@
+import {
+ ArrowDownUp,
+ Network,
+ RefreshCw,
+ ShieldCheck,
+ Timer,
+} from 'lucide-react'
+
+import { SectionCards, type SectionCardItem } from '@/components/section-cards'
+import { SectionCardsSkeleton } from '@/components/skeletons'
+import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
+import {
+ communityLabel,
+ dohProfileLabel,
+ moduleDohProfileIds,
+} from '@/lib/modules/helpers'
+import { dohPolicyRu } from '@/lib/ui-labels'
+import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
+
+interface ModuleKpiCardsProps {
+ mod: ModuleRow | null
+ communities: BgpCommunity[]
+ dohProfiles: DohProfile[]
+ asEntries: AsEntry[]
+ loading?: boolean
+}
+
+export function ModuleKpiCards({
+ mod,
+ communities,
+ dohProfiles,
+ asEntries,
+ loading = false,
+}: ModuleKpiCardsProps) {
+ if (loading || !mod) {
+ return
+ }
+
+ const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
+ const dohIds = moduleDohProfileIds(mod)
+
+ const items: SectionCardItem[] = [
+ {
+ label: 'Приоритет',
+ value: String(mod.priority ?? 0),
+ hint: 'порядок в сборке ревизии',
+ icon: ,
+ },
+ {
+ label: 'Интервал',
+ value: moduleIntervalLabel(mod),
+ hint: 'refresh_interval_sec / cron',
+ icon: ,
+ },
+ {
+ label: 'DoH',
+ value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
+ hint:
+ mod.type === 'DOMAINS'
+ ? dohIds.length
+ ? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
+ : 'Системный DNS'
+ : 'не применимо',
+ icon: ,
+ },
+ {
+ label: 'Community по умолч.',
+ value: communityLabel(mod.default_community_id, communities),
+ hint: 'для записей без своего community',
+ icon: ,
+ },
+ {
+ label: 'Последнее обновление',
+ value: formatDateTime(mod.last_refreshed_at),
+ hint:
+ mod.type === 'AS_PREFIXES'
+ ? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
+ : 'время последнего refresh',
+ icon: ,
+ },
+ ]
+
+ return
+}
diff --git a/apps/web/src/lib/modules/display.ts b/apps/web/src/lib/modules/display.ts
new file mode 100644
index 0000000..68cebd8
--- /dev/null
+++ b/apps/web/src/lib/modules/display.ts
@@ -0,0 +1,24 @@
+import type { ModuleRow } from '@/types/api'
+
+export function formatDateTime(value: string | null | undefined): string {
+ if (typeof value !== 'string' || value.trim().length === 0) return '—'
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.getTime())) return '—'
+ return parsed.toLocaleString('ru-RU')
+}
+
+export function moduleIntervalLabel(moduleRow: ModuleRow): string {
+ const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : ''
+ const raw = moduleRow.refresh_interval_sec as unknown
+ const interval =
+ typeof raw === 'number'
+ ? raw
+ : typeof raw === 'string' && raw.trim().length > 0
+ ? Number(raw)
+ : null
+ const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : ''
+ if (cron && intervalLabel) return `${intervalLabel} (${cron})`
+ if (cron) return cron
+ if (intervalLabel) return intervalLabel
+ return '—'
+}
diff --git a/apps/web/src/lib/modules/helpers.ts b/apps/web/src/lib/modules/helpers.ts
new file mode 100644
index 0000000..b2c2f4b
--- /dev/null
+++ b/apps/web/src/lib/modules/helpers.ts
@@ -0,0 +1,41 @@
+import type { BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
+
+export const NONE_OPTION = '__none__'
+
+export function communityLabel(id: string | null | undefined, communities: BgpCommunity[]): string {
+ if (!id) return '—'
+ const c = communities.find((x) => x.id === id)
+ if (!c) return `${id.slice(0, 8)}…`
+ const t = c.title?.trim()
+ return t || c.community
+}
+
+export function communityOptionLabel(c: BgpCommunity): string {
+ const t = c.title?.trim()
+ return t || c.community
+}
+
+export function nullableSelectValue(value: string | null | undefined): string {
+ if (value === null || value === undefined || value === '') return NONE_OPTION
+ return value
+}
+
+export function fromNullableSelect(value: string): string | null {
+ if (value === NONE_OPTION || value === '') return null
+ return value
+}
+
+export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
+ if (!modRow) return []
+ if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids
+ return modRow.doh_profile_id ? [modRow.doh_profile_id] : []
+}
+
+export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
+ const p = dohProfiles.find((d) => d.id === id)
+ return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : `${id.slice(0, 8)}…`
+}
+
+export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
+ return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext'
+}
diff --git a/apps/web/src/lib/ui-labels.ts b/apps/web/src/lib/ui-labels.ts
new file mode 100644
index 0000000..35c1f8c
--- /dev/null
+++ b/apps/web/src/lib/ui-labels.ts
@@ -0,0 +1,29 @@
+import type { DohResolverPolicy } from '@/types/api'
+
+export function dohPolicyRu(policy: DohResolverPolicy | string | null | undefined): string {
+ switch (policy) {
+ case 'primary_only':
+ return 'Только первый'
+ case 'failover':
+ return 'Резервирование'
+ case 'union':
+ return 'Объединение'
+ default:
+ return 'Только первый'
+ }
+}
+
+export function moduleTypeRu(type: string): string {
+ switch (type) {
+ case 'AS_PREFIXES':
+ return 'AS (номера)'
+ case 'CDN_CIDRS':
+ return 'CDN CIDR'
+ case 'DOMAINS':
+ return 'Домены'
+ case 'IP_RANGES':
+ return 'IP-диапазоны'
+ default:
+ return type
+ }
+}
diff --git a/apps/web/src/routes/_auth/modules/$moduleId.tsx b/apps/web/src/routes/_auth/modules/$moduleId.tsx
index 1af2a96..b777e4f 100644
--- a/apps/web/src/routes/_auth/modules/$moduleId.tsx
+++ b/apps/web/src/routes/_auth/modules/$moduleId.tsx
@@ -1,44 +1,82 @@
import { createFileRoute, Link } from '@tanstack/react-router'
-import { useQuery } from '@tanstack/react-query'
-import { ArrowLeft, RefreshCw } from 'lucide-react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { ArrowLeft, Info, RefreshCw } from 'lucide-react'
+import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
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 { Badge } from '@/components/reui/badge'
import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { StatusBadge } from '@/components/status-badge'
-import { moduleDetailQueryOptions, moduleEntriesQueryOptions } from '@/queries/modules'
+import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
+import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
+import { moduleTypeRu } from '@/lib/ui-labels'
+import {
+ directoriesCommunitiesQueryOptions,
+ directoriesDohQueryOptions,
+} from '@/queries/directories'
+import { moduleDetailQueryOptions, moduleEntriesQueryOptions, modulesKeys } from '@/queries/modules'
+import type { AsEntry, ModuleRow } from '@/types/api'
export const Route = createFileRoute('/_auth/modules/$moduleId')({
component: ModuleDetailComponent,
})
+function moduleTypeAlert(type: ModuleRow['type']): string {
+ switch (type) {
+ case 'AS_PREFIXES':
+ return 'Модуль AS получает префиксы через RIPEstat по указанным ASN. После refresh счётчики префиксов обновляются в таблице записей.'
+ case 'CDN_CIDRS':
+ return 'Модуль CDN скачивает списки CIDR по URL (plaintext или JSON). Используйте предпросмотр при добавлении источника.'
+ case 'DOMAINS':
+ return 'Модуль доменов резолвит FQDN через DoH-профили и конвертирует IP в префиксы. Политика и профили настраиваются в редактировании модуля.'
+ case 'IP_RANGES':
+ return 'Модуль IP-диапазонов использует статические CIDR без внешнего refresh (сервер может вернуть 204). Записи участвуют в агрегации напрямую.'
+ }
+}
+
function ModuleDetailComponent() {
const { moduleId } = Route.useParams()
+ const queryClient = useQueryClient()
const detail = useQuery(moduleDetailQueryOptions(moduleId))
const mod = detail.data
+ const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
+ const dohQ = useQuery(directoriesDohQueryOptions())
+
const entriesQuery = useQuery({
...moduleEntriesQueryOptions(moduleId, mod?.type ?? 'DOMAINS'),
enabled: !!mod,
})
+ const asEntriesQ = useQuery({
+ ...moduleEntriesQueryOptions(moduleId, 'AS_PREFIXES'),
+ enabled: mod?.type === 'AS_PREFIXES',
+ select: (data) => data.items as unknown as AsEntry[],
+ })
+
+ async function refreshAll() {
+ await Promise.all([detail.refetch(), entriesQuery.refetch(), communitiesQ.refetch(), dohQ.refetch()])
+ }
+
+ async function onEntriesChanged() {
+ await entriesQuery.refetch()
+ if (mod?.type === 'AS_PREFIXES') {
+ await queryClient.invalidateQueries({ queryKey: modulesKeys.asEntries(moduleId) })
+ }
+ await detail.refetch()
+ }
+
+ const communities = communitiesQ.data?.items ?? []
+ const dohProfiles = dohQ.data?.items ?? []
+ const asEntries = mod?.type === 'AS_PREFIXES' ? (asEntriesQ.data ?? []) : []
+
return (
}>
@@ -48,10 +86,7 @@ function ModuleDetailComponent() {