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 ( + + + + {edit ? 'Редактировать запись' : 'Новая AS-запись'} + + Номер автономной системы и community для политики анонса. + + +
+
+ + setForm((s) => ({ ...s, asn: Number(e.target.value) }))} + /> +
+ setForm((s) => ({ ...s, community_id: v }))} + communities={communities} + nullable + /> +
+ + onOpenChange(false)}> + Отмена + + void save()}> + {edit ? 'Сохранить' : 'Добавить'} + + +
+
+ ) +} 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 ( + + + + {edit ? 'Редактировать источник' : 'Новый CDN-источник'} + +
+
+ + setForm((s) => ({ ...s, url: e.target.value }))} + /> +
+
+ + +
+
+ + setForm((s) => ({ ...s, prefix_path: e.target.value }))} + /> + {form.source_kind === 'json' && !form.prefix_path?.trim() ? ( +

+ Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов. +

+ ) : null} +
+ setForm((s) => ({ ...s, community_id: v }))} + communities={communities} + nullable + /> +
+ + + setForm((s) => ({ + ...s, + refresh_interval_sec: e.target.value ? Number(e.target.value) : null, + })) + } + /> +
+
+
+ + {previewError ? ( + {previewError} + ) : previewOk ? ( + + Всего: {previewTotal} + {previewTruncated ? ( + (обрезано) + ) : null} + + ) : null} +
+ {previewItems.length > 0 ? ( +
    + {previewItems.map((item, i) => ( +
  • + {item} +
  • + ))} +
+ ) : null} +
+
+ + onOpenChange(false)}> + Отмена + + void save()}> + {edit ? 'Сохранить' : 'Добавить'} + + +
+
+ ) +} 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 ( + + + + {edit ? 'Редактировать домен' : 'Новый домен'} + +
+
+ + setForm((s) => ({ ...s, fqdn: e.target.value }))} + /> +
+ setForm((s) => ({ ...s, community_id: v }))} + communities={communities} + nullable + /> +
+ + onOpenChange(false)}> + Отмена + + void save()}> + {edit ? 'Сохранить' : 'Добавить'} + + +
+
+ ) +} 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 ( + + + + {edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'} + +
+
+ + setForm((s) => ({ ...s, prefix: e.target.value }))} + /> +
+ setForm((s) => ({ ...s, community_id: v ?? '' }))} + communities={communities} + placeholder="Выберите community" + /> +
+ + onOpenChange(false)}> + Отмена + + void save()}> + {edit ? 'Сохранить' : 'Добавить'} + + +
+
+ ) +} 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 (