fix(modules): enhance ModuleDetailComponent with additional queries and UI updates
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
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.
This commit is contained in:
@@ -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 (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{label ? <Label htmlFor={id}>{label}</Label> : null}
|
||||
<Select
|
||||
items={items}
|
||||
value={selectValue}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return
|
||||
onValueChange(nullable ? fromNullableSelect(v) : v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
export function ModuleAsEntryDialog({
|
||||
open,
|
||||
moduleId,
|
||||
edit,
|
||||
communities,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: ModuleAsEntryDialogProps) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<AsEntryCreate>({ 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Номер автономной системы и community для политики анонса.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="as-asn">ASN</Label>
|
||||
<Input
|
||||
id="as-asn"
|
||||
type="number"
|
||||
placeholder="12345"
|
||||
value={form.asn || ''}
|
||||
min={1}
|
||||
max={4294967295}
|
||||
onChange={(e) => setForm((s) => ({ ...s, asn: Number(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="as-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
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<string[]>([])
|
||||
const [previewTotal, setPreviewTotal] = useState(0)
|
||||
const [previewTruncated, setPreviewTruncated] = useState(false)
|
||||
const [previewError, setPreviewError] = useState<string | null>(null)
|
||||
const [previewOk, setPreviewOk] = useState(false)
|
||||
const [form, setForm] = useState<CdnForm>({
|
||||
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<CdnPreviewResponse>(
|
||||
`/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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-url">URL</Label>
|
||||
<Input
|
||||
id="cdn-url"
|
||||
placeholder="https://example.com/list.txt"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-kind">Тип источника</Label>
|
||||
<Select
|
||||
items={kindItems}
|
||||
value={form.source_kind}
|
||||
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
|
||||
>
|
||||
<SelectTrigger id="cdn-kind" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{kindItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
|
||||
<Input
|
||||
id="cdn-prefix-path"
|
||||
placeholder="напр. prefixes[] или data.items[].cidr"
|
||||
value={form.prefix_path ?? ''}
|
||||
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
|
||||
/>
|
||||
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="cdn-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
|
||||
<Input
|
||||
id="cdn-interval"
|
||||
type="number"
|
||||
placeholder="3600"
|
||||
value={form.refresh_interval_sec ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({
|
||||
...s,
|
||||
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => void previewCdn()}
|
||||
disabled={previewLoading}
|
||||
>
|
||||
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
|
||||
</Button>
|
||||
{previewError ? (
|
||||
<span className="text-sm text-destructive">{previewError}</span>
|
||||
) : previewOk ? (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Всего: {previewTotal}
|
||||
{previewTruncated ? (
|
||||
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{previewItems.length > 0 ? (
|
||||
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
|
||||
{previewItems.map((item, i) => (
|
||||
<li key={`${i}-${item}`} className="py-0.5">
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
export function ModuleDomainEntryDialog({
|
||||
open,
|
||||
moduleId,
|
||||
edit,
|
||||
communities,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: ModuleDomainEntryDialogProps) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<DomainEntryCreate>({ 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="dom-fqdn">FQDN</Label>
|
||||
<Input
|
||||
id="dom-fqdn"
|
||||
placeholder="example.com"
|
||||
value={form.fqdn}
|
||||
onChange={(e) => setForm((s) => ({ ...s, fqdn: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="dom-comm"
|
||||
label="Community"
|
||||
value={form.community_id ?? null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<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>
|
||||
)
|
||||
}
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
export function ModuleIpRangeEntryDialog({
|
||||
open,
|
||||
moduleId,
|
||||
edit,
|
||||
communities,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
}: ModuleIpRangeEntryDialogProps) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form, setForm] = useState<IpRangeEntryCreate>({ 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="ip-prefix">Префикс (CIDR)</Label>
|
||||
<Input
|
||||
id="ip-prefix"
|
||||
placeholder="203.0.113.0/24"
|
||||
value={form.prefix}
|
||||
onChange={(e) => setForm((s) => ({ ...s, prefix: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<CommunitySelect
|
||||
id="ip-comm"
|
||||
label="Community (обязательно)"
|
||||
value={form.community_id || null}
|
||||
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v ?? '' }))}
|
||||
communities={communities}
|
||||
placeholder="Выберите community"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</LoadingButton>
|
||||
<LoadingButton loading={saving} onClick={() => void save()}>
|
||||
{edit ? 'Сохранить' : 'Добавить'}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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 <SectionCardsSkeleton count={5} />
|
||||
}
|
||||
|
||||
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: <ArrowDownUp className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
label: 'Интервал',
|
||||
value: moduleIntervalLabel(mod),
|
||||
hint: 'refresh_interval_sec / cron',
|
||||
icon: <Timer className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
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: <Network className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
label: 'Community по умолч.',
|
||||
value: communityLabel(mod.default_community_id, communities),
|
||||
hint: 'для записей без своего community',
|
||||
icon: <ShieldCheck className="size-3.5" />,
|
||||
},
|
||||
{
|
||||
label: 'Последнее обновление',
|
||||
value: formatDateTime(mod.last_refreshed_at),
|
||||
hint:
|
||||
mod.type === 'AS_PREFIXES'
|
||||
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
|
||||
: 'время последнего refresh',
|
||||
icon: <RefreshCw className="size-3.5" />,
|
||||
},
|
||||
]
|
||||
|
||||
return <SectionCards items={items} />
|
||||
}
|
||||
Reference in New Issue
Block a user