Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc4f234cc5 | ||
|
|
821f342476 |
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { useCreateCommunityMutation } from '@/queries/directories'
|
||||
import type { BgpCommunityCreate } from '@/types/api'
|
||||
|
||||
interface CommunityCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CommunityCreateDialog({ open, onOpenChange }: CommunityCreateDialogProps) {
|
||||
const createMutation = useCreateCommunityMutation()
|
||||
const [community, setCommunity] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setCommunity('')
|
||||
setTitle('')
|
||||
}, [open])
|
||||
|
||||
async function save() {
|
||||
const value = community.trim()
|
||||
if (!value) {
|
||||
toast.error('Укажите community')
|
||||
return
|
||||
}
|
||||
const body: BgpCommunityCreate = { community: value }
|
||||
const t = title.trim()
|
||||
if (t) body.title = t
|
||||
try {
|
||||
await createMutation.mutateAsync(body)
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новое сообщество BGP"
|
||||
description="Тег для префиксов в фильтрах BIRD"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="comm-value">Community</Label>
|
||||
<Input
|
||||
id="comm-value"
|
||||
placeholder="65000:100"
|
||||
value={community}
|
||||
onChange={(e) => setCommunity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="comm-title">Название (опционально)</Label>
|
||||
<Input
|
||||
id="comm-title"
|
||||
placeholder="Отображаемое имя"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { useCreateDohProfileMutation } from '@/queries/directories'
|
||||
import type { DohProfileCreate } from '@/types/api'
|
||||
|
||||
interface DohProfileCreateDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function DohProfileCreateDialog({ open, onOpenChange }: DohProfileCreateDialogProps) {
|
||||
const createMutation = useCreateDohProfileMutation()
|
||||
const [name, setName] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [timeoutMs, setTimeoutMs] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName('')
|
||||
setUrl('')
|
||||
setTimeoutMs('')
|
||||
}, [open])
|
||||
|
||||
async function save() {
|
||||
const trimmedUrl = url.trim()
|
||||
if (!trimmedUrl) {
|
||||
toast.error('Укажите URL DoH')
|
||||
return
|
||||
}
|
||||
let urlOk = true
|
||||
try {
|
||||
new URL(trimmedUrl)
|
||||
} catch {
|
||||
urlOk = false
|
||||
}
|
||||
if (!urlOk) {
|
||||
toast.error('Некорректный URL')
|
||||
return
|
||||
}
|
||||
|
||||
const body: DohProfileCreate = { url: trimmedUrl }
|
||||
const n = name.trim()
|
||||
if (n) body.name = n
|
||||
if (timeoutMs.trim() !== '') {
|
||||
const ms = Number(timeoutMs)
|
||||
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
||||
toast.error('Timeout должен быть целым числом > 0')
|
||||
return
|
||||
}
|
||||
body.timeout_ms = ms
|
||||
}
|
||||
|
||||
try {
|
||||
await createMutation.mutateAsync(body)
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Новый DoH-профиль"
|
||||
description="Резолвер DNS-over-HTTPS для доменных модулей"
|
||||
className="sm:max-w-sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={createMutation.isPending} onClick={() => void save()}>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-name">Имя (опционально)</Label>
|
||||
<Input
|
||||
id="doh-name"
|
||||
placeholder="Control D / AdGuard"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-url">URL</Label>
|
||||
<Input
|
||||
id="doh-url"
|
||||
type="url"
|
||||
placeholder="https://dns.example/dns-query"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="doh-timeout">Timeout, мс (опционально)</Label>
|
||||
<Input
|
||||
id="doh-timeout"
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="5000"
|
||||
value={timeoutMs}
|
||||
onChange={(e) => setTimeoutMs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Checkbox } from '@evobgp/ui/components/checkbox'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { FormDrawer } from '@/components/form-drawer'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { CommunitySelect } from '@/components/modules/community-select'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import {
|
||||
dohProfileShortLabel,
|
||||
moduleDohProfileIds,
|
||||
} from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels'
|
||||
import { useUpdateModuleMutation } from '@/queries/modules'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
DohProfile,
|
||||
DohResolverPolicy,
|
||||
ModulePatch,
|
||||
ModuleRow,
|
||||
} from '@/types/api'
|
||||
|
||||
interface ModuleEditDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
mod: ModuleRow
|
||||
communities: BgpCommunity[]
|
||||
dohProfiles: DohProfile[]
|
||||
}
|
||||
|
||||
const DOH_POLICY_ITEMS: { value: DohResolverPolicy; label: string }[] = [
|
||||
{ value: 'primary_only', label: dohPolicyRu('primary_only') },
|
||||
{ value: 'failover', label: dohPolicyRu('failover') },
|
||||
{ value: 'union', label: dohPolicyRu('union') },
|
||||
]
|
||||
|
||||
export function ModuleEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mod,
|
||||
communities,
|
||||
dohProfiles,
|
||||
}: ModuleEditDialogProps) {
|
||||
const updateMutation = useUpdateModuleMutation()
|
||||
const isDomains = mod.type === 'DOMAINS'
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [priority, setPriority] = useState('0')
|
||||
const [refreshIntervalSec, setRefreshIntervalSec] = useState('')
|
||||
const [cronExpr, setCronExpr] = useState('')
|
||||
const [defaultCommunityId, setDefaultCommunityId] = useState<string | null>(null)
|
||||
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
|
||||
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setName(mod.name ?? '')
|
||||
setEnabled(mod.enabled !== false)
|
||||
setPriority(String(mod.priority ?? 0))
|
||||
setRefreshIntervalSec(
|
||||
mod.refresh_interval_sec === null || mod.refresh_interval_sec === undefined
|
||||
? ''
|
||||
: String(mod.refresh_interval_sec),
|
||||
)
|
||||
setCronExpr(mod.cron_expr ?? '')
|
||||
setDefaultCommunityId(mod.default_community_id ?? null)
|
||||
setDohResolverPolicy(mod.doh_resolver_policy ?? 'primary_only')
|
||||
setDohProfileIds(moduleDohProfileIds(mod))
|
||||
}, [mod, open])
|
||||
|
||||
function toggleDohProfile(id: string, checked: boolean) {
|
||||
setDohProfileIds((prev) => {
|
||||
if (checked) {
|
||||
if (prev.includes(id)) return prev
|
||||
return [...prev, id]
|
||||
}
|
||||
return prev.filter((x) => x !== id)
|
||||
})
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const trimmedName = name.trim()
|
||||
if (!trimmedName) {
|
||||
toast.error('Укажите название модуля')
|
||||
return
|
||||
}
|
||||
|
||||
const priorityNum = Number(priority)
|
||||
if (!Number.isFinite(priorityNum) || !Number.isInteger(priorityNum)) {
|
||||
toast.error('Приоритет должен быть целым числом')
|
||||
return
|
||||
}
|
||||
|
||||
let refresh: number | null = null
|
||||
if (refreshIntervalSec.trim() !== '') {
|
||||
const n = Number(refreshIntervalSec)
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
||||
toast.error('Интервал обновления должен быть целым числом ≥ 0')
|
||||
return
|
||||
}
|
||||
refresh = n
|
||||
}
|
||||
|
||||
const body: ModulePatch = {
|
||||
name: trimmedName,
|
||||
enabled,
|
||||
priority: priorityNum,
|
||||
refresh_interval_sec: refresh,
|
||||
cron_expr: cronExpr.trim() || null,
|
||||
default_community_id: defaultCommunityId,
|
||||
}
|
||||
|
||||
if (isDomains) {
|
||||
body.doh_resolver_policy = dohResolverPolicy
|
||||
body.doh_profile_ids = dohProfileIds
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: mod.id, body })
|
||||
onOpenChange(false)
|
||||
} catch {
|
||||
// toast in mutation
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Редактировать модуль"
|
||||
description={`${moduleTypeRu(mod.type)} · ${mod.type}`}
|
||||
className="sm:max-w-md"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton type="button" loading={updateMutation.isPending} onClick={() => void save()}>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-name">Название</Label>
|
||||
<Input
|
||||
id="mod-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Имя модуля"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="grid min-w-0 flex-1 gap-1 pr-2">
|
||||
<Label htmlFor="mod-enabled">Включён</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Выключенный модуль не участвует в refresh и apply.
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="mod-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v) => setEnabled(v === true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-priority">Приоритет</Label>
|
||||
<Input
|
||||
id="mod-priority"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-interval">Интервал обновления (сек)</Label>
|
||||
<Input
|
||||
id="mod-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="пусто = по умолчанию"
|
||||
value={refreshIntervalSec}
|
||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="mod-cron">Cron (опционально)</Label>
|
||||
<Input
|
||||
id="mod-cron"
|
||||
placeholder="0 * * * *"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CommunitySelect
|
||||
id="mod-community"
|
||||
label="Community по умолчанию"
|
||||
value={defaultCommunityId}
|
||||
onValueChange={setDefaultCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
|
||||
{isDomains ? (
|
||||
<>
|
||||
<SelectField
|
||||
id="mod-doh-policy"
|
||||
label="Политика DoH"
|
||||
items={DOH_POLICY_ITEMS}
|
||||
value={dohResolverPolicy}
|
||||
onValueChange={(v) => setDohResolverPolicy(v as DohResolverPolicy)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>DoH профили</Label>
|
||||
{dohProfiles.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||
{dohProfiles.map((p) => {
|
||||
const checked = dohProfileIds.includes(p.id)
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
htmlFor={`mod-doh-${p.id}`}
|
||||
className="flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`mod-doh-${p.id}`}
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleDohProfile(p.id, v === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{dohProfileShortLabel(p.id, dohProfiles)}</span>
|
||||
<span className="text-muted-foreground truncate text-xs" title={p.url}>
|
||||
{p.url}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</FormDrawer>
|
||||
)
|
||||
}
|
||||
@@ -2,29 +2,25 @@ import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
||||
|
||||
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
import {
|
||||
communityLabel,
|
||||
dohProfileLabel,
|
||||
moduleDohProfileIds,
|
||||
} from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||
import { communityLabel, moduleDohProfileIds } from '@/lib/modules/helpers'
|
||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||
import type { AsEntry, BgpCommunity, ModuleRow } from '@/types/api'
|
||||
|
||||
interface ModuleKpiCardsProps {
|
||||
mod: ModuleRow | null
|
||||
communities: BgpCommunity[]
|
||||
dohProfiles: DohProfile[]
|
||||
asEntries: AsEntry[]
|
||||
entriesCount?: number
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function ModuleKpiCards({
|
||||
mod,
|
||||
communities,
|
||||
dohProfiles,
|
||||
asEntries,
|
||||
entriesCount = 0,
|
||||
loading = false,
|
||||
}: ModuleKpiCardsProps) {
|
||||
if (loading || !mod) {
|
||||
@@ -33,6 +29,49 @@ export function ModuleKpiCards({
|
||||
|
||||
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||
const dohIds = moduleDohProfileIds(mod)
|
||||
const isDomains = mod.type === 'DOMAINS'
|
||||
const isAsPrefixes = mod.type === 'AS_PREFIXES'
|
||||
const community = communityLabel(mod.default_community_id, communities)
|
||||
|
||||
const entriesFooter = isAsPrefixes ? (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{asEntries.length} AS · в модуле
|
||||
</Badge>
|
||||
) : isDomains ? (
|
||||
<Badge variant="outline" size="sm">
|
||||
{mod.default_community_id ? community : 'без community'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{entriesCount} · в модуле
|
||||
</Badge>
|
||||
)
|
||||
|
||||
const policyItem: KpiStatItem = isDomains
|
||||
? {
|
||||
id: 'doh',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||
label: 'DoH',
|
||||
footer: (
|
||||
<Badge variant={dohIds.length > 0 ? 'info-light' : 'outline'} size="sm">
|
||||
{dohIds.length > 0 ? dohPolicyRu(mod.doh_resolver_policy) : 'без DoH'}
|
||||
</Badge>
|
||||
),
|
||||
}
|
||||
: {
|
||||
id: 'community',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: community,
|
||||
label: 'Community',
|
||||
footer: (
|
||||
<Badge variant="outline" size="sm">
|
||||
по умолчанию
|
||||
</Badge>
|
||||
),
|
||||
}
|
||||
|
||||
const items: KpiStatItem[] = [
|
||||
{
|
||||
@@ -63,29 +102,11 @@ export function ModuleKpiCards({
|
||||
id: 'prefixes',
|
||||
icon: <Tags aria-hidden />,
|
||||
iconClassName: 'text-success',
|
||||
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
|
||||
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
|
||||
footer: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{asEntries.length} AS · в модуле
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'policy',
|
||||
icon: <Globe aria-hidden />,
|
||||
iconClassName: 'text-primary',
|
||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||
label: communityLabel(mod.default_community_id, communities),
|
||||
footer: (
|
||||
<Badge variant="info-light" size="sm">
|
||||
{dohPolicyRu(mod.doh_resolver_policy)}
|
||||
{dohIds.length > 0
|
||||
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
|
||||
: ' · без DoH'}
|
||||
</Badge>
|
||||
),
|
||||
value: isAsPrefixes ? String(asPrefixTotal) : String(entriesCount),
|
||||
label: isAsPrefixes ? 'Префиксы AS' : 'Записи модуля',
|
||||
footer: entriesFooter,
|
||||
},
|
||||
policyItem,
|
||||
]
|
||||
|
||||
return <KpiStatGrid items={items} aria-label="KPI модуля" />
|
||||
|
||||
@@ -308,6 +308,31 @@ export function sessionCanWriteModules(session: {
|
||||
return role === 'editor' || role === 'operator'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether session may create/update directories (`bgp:directories:write`).
|
||||
* Mirrors backend `requirePerm` for JWT (is_admin / permissions) and API-key editor+.
|
||||
*/
|
||||
export function sessionCanWriteDirectories(session: {
|
||||
role?: string
|
||||
kind?: string
|
||||
is_admin?: boolean
|
||||
permissions?: readonly string[]
|
||||
} | null | undefined): boolean {
|
||||
if (!session) return false
|
||||
const jwtPath =
|
||||
session.kind === 'jwt' ||
|
||||
session.is_admin === true ||
|
||||
(session.permissions?.length ?? 0) > 0
|
||||
if (jwtPath) {
|
||||
return (
|
||||
session.is_admin === true ||
|
||||
hasPermission(session.permissions ?? [], 'bgp:directories:write')
|
||||
)
|
||||
}
|
||||
const role = (session.role ?? '').toLowerCase()
|
||||
return role === 'editor' || role === 'operator'
|
||||
}
|
||||
|
||||
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
||||
export function permissionForPath(pathname: string): string | null {
|
||||
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||
|
||||
@@ -36,6 +36,19 @@ export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
|
||||
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : `${id.slice(0, 8)}…`
|
||||
}
|
||||
|
||||
/** Short label for forms / chips — name preferred, else hostname from URL. */
|
||||
export function dohProfileShortLabel(id: string, dohProfiles: DohProfile[]): string {
|
||||
const p = dohProfiles.find((d) => d.id === id)
|
||||
if (!p) return `${id.slice(0, 8)}…`
|
||||
const name = p.name?.trim()
|
||||
if (name) return name
|
||||
try {
|
||||
return new URL(p.url).hostname
|
||||
} catch {
|
||||
return p.url
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
|
||||
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api'
|
||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||
import type {
|
||||
BgpCommunity,
|
||||
BgpCommunityCreate,
|
||||
CommunitiesResponse,
|
||||
DohProfile,
|
||||
DohProfileCreate,
|
||||
DohProfilesResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
export const directoriesKeys = {
|
||||
all: ['directories'] as const,
|
||||
@@ -23,3 +32,31 @@ export function directoriesDohQueryOptions() {
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateCommunityMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: BgpCommunityCreate) =>
|
||||
apiMutate<BgpCommunity>('/v1/communities', 'POST', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('Сообщество создано')
|
||||
void qc.invalidateQueries({ queryKey: directoriesKeys.communities() })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось создать сообщество'),
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateDohProfileMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: DohProfileCreate) =>
|
||||
apiMutate<DohProfile>('/v1/doh-profiles', 'POST', body, { idempotent: false }),
|
||||
onSuccess: () => {
|
||||
toast.success('DoH-профиль создан')
|
||||
void qc.invalidateQueries({ queryKey: directoriesKeys.doh() })
|
||||
},
|
||||
onError: (e) =>
|
||||
toast.error(e instanceof Error ? e.message : 'Не удалось создать DoH-профиль'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { apiJSON } from '@/lib/api-client'
|
||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||
import { overviewKeys } from '@/queries/overview'
|
||||
import type {
|
||||
ModulePatch,
|
||||
ModuleRow,
|
||||
ModulesResponse,
|
||||
Page,
|
||||
@@ -16,6 +20,14 @@ export const modulesKeys = {
|
||||
ipRangeEntries: (id: string) => [...modulesKeys.all, 'ip-range-entries', id] as const,
|
||||
}
|
||||
|
||||
function invalidateModules(qc: ReturnType<typeof useQueryClient>, id?: string) {
|
||||
void qc.invalidateQueries({ queryKey: modulesKeys.all })
|
||||
void qc.invalidateQueries({ queryKey: overviewKeys.modules() })
|
||||
if (id) {
|
||||
void qc.invalidateQueries({ queryKey: modulesKeys.detail(id) })
|
||||
}
|
||||
}
|
||||
|
||||
export function modulesListQueryOptions() {
|
||||
return queryOptions<ModulesResponse>({
|
||||
queryKey: modulesKeys.list(),
|
||||
@@ -51,3 +63,16 @@ export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) {
|
||||
queryFn: () => apiJSON<ModuleEntriesPage>(path),
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateModuleMutation() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: ModulePatch }) =>
|
||||
apiMutate<ModuleRow>(`/v1/modules/${id}`, 'PATCH', body, { idempotent: false }),
|
||||
onSuccess: (_data, vars) => {
|
||||
toast.success('Модуль обновлён')
|
||||
invalidateModules(qc, vars.id)
|
||||
},
|
||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось обновить модуль'),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
|
||||
import { BookText, Globe, Plus, RefreshCw, Tags } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { CommunityCreateDialog } from '@/components/directories/community-create-dialog'
|
||||
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
|
||||
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
|
||||
import { DohProfileCreateDialog } from '@/components/directories/doh-profile-create-dialog'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||
import { sessionCanWriteDirectories } from '@/lib/auth'
|
||||
import { authSessionQueryOptions } from '@/queries/auth'
|
||||
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
|
||||
|
||||
export const Route = createFileRoute('/_auth/directories')({
|
||||
@@ -18,6 +23,12 @@ export const Route = createFileRoute('/_auth/directories')({
|
||||
})
|
||||
|
||||
function DirectoriesComponent() {
|
||||
const [communityOpen, setCommunityOpen] = useState(false)
|
||||
const [dohOpen, setDohOpen] = useState(false)
|
||||
|
||||
const sessionQ = useQuery(authSessionQueryOptions())
|
||||
const canWrite = sessionCanWriteDirectories(sessionQ.data)
|
||||
|
||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||
|
||||
@@ -58,6 +69,20 @@ function DirectoriesComponent() {
|
||||
},
|
||||
]
|
||||
|
||||
const addCommunityButton = canWrite ? (
|
||||
<Button size="sm" type="button" onClick={() => setCommunityOpen(true)}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
const addDohButton = canWrite ? (
|
||||
<Button size="sm" type="button" onClick={() => setDohOpen(true)}>
|
||||
<Plus />
|
||||
Добавить
|
||||
</Button>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<PageHeader
|
||||
@@ -92,6 +117,7 @@ function DirectoriesComponent() {
|
||||
<FrameDataGrid
|
||||
title="Сообщества BGP"
|
||||
description="Теги для префиксов в фильтрах BIRD"
|
||||
actions={addCommunityButton}
|
||||
>
|
||||
<QueryState
|
||||
data={communities}
|
||||
@@ -100,12 +126,13 @@ function DirectoriesComponent() {
|
||||
error={communitiesQ.error}
|
||||
empty={communities.length === 0}
|
||||
emptyTitle="Нет сообществ"
|
||||
emptyAction={addCommunityButton}
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => communitiesQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
{(rows) => (
|
||||
<DirectoriesCommunitiesGrid
|
||||
items={items}
|
||||
items={rows}
|
||||
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
@@ -114,7 +141,11 @@ function DirectoriesComponent() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="doh" className="mt-0">
|
||||
<FrameDataGrid title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
|
||||
<FrameDataGrid
|
||||
title="DoH профили"
|
||||
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
||||
actions={addDohButton}
|
||||
>
|
||||
<QueryState
|
||||
data={dohProfiles}
|
||||
isLoading={dohQ.isLoading}
|
||||
@@ -122,12 +153,13 @@ function DirectoriesComponent() {
|
||||
error={dohQ.error}
|
||||
empty={dohProfiles.length === 0}
|
||||
emptyTitle="Нет DoH профилей"
|
||||
emptyAction={addDohButton}
|
||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||
onRetry={() => dohQ.refetch()}
|
||||
>
|
||||
{(items) => (
|
||||
{(rows) => (
|
||||
<DirectoriesDohGrid
|
||||
items={items}
|
||||
items={rows}
|
||||
isLoading={dohQ.isFetching && !dohQ.isLoading}
|
||||
/>
|
||||
)}
|
||||
@@ -135,6 +167,13 @@ function DirectoriesComponent() {
|
||||
</FrameDataGrid>
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
|
||||
{canWrite ? (
|
||||
<>
|
||||
<CommunityCreateDialog open={communityOpen} onOpenChange={setCommunityOpen} />
|
||||
<DohProfileCreateDialog open={dohOpen} onOpenChange={setDohOpen} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, RefreshCw } from 'lucide-react'
|
||||
import { ArrowLeft, Pencil, RefreshCw } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
|
||||
import { ModuleEditDialog } from '@/components/modules/module-edit-dialog'
|
||||
import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
|
||||
import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { ModuleEntriesSection } from '@/components/modules/module-entries-section'
|
||||
import { ModuleKpiCards } from '@/components/modules/module-kpi-cards'
|
||||
import { sessionCanWriteModules } from '@/lib/auth'
|
||||
import { moduleTypeRu } from '@/lib/ui-labels'
|
||||
import { authSessionQueryOptions } from '@/queries/auth'
|
||||
import {
|
||||
directoriesCommunitiesQueryOptions,
|
||||
directoriesDohQueryOptions,
|
||||
@@ -25,8 +29,12 @@ export const Route = createFileRoute('/_auth/modules/$moduleId')({
|
||||
function ModuleDetailComponent() {
|
||||
const { moduleId } = Route.useParams()
|
||||
const queryClient = useQueryClient()
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
|
||||
const detail = useQuery(moduleDetailQueryOptions(moduleId))
|
||||
const mod = detail.data
|
||||
const sessionQ = useQuery(authSessionQueryOptions())
|
||||
const canWrite = sessionCanWriteModules(sessionQ.data)
|
||||
|
||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||
@@ -57,6 +65,7 @@ function ModuleDetailComponent() {
|
||||
const communities = communitiesQ.data?.items ?? []
|
||||
const dohProfiles = dohQ.data?.items ?? []
|
||||
const asEntries = mod?.type === 'AS_PREFIXES' ? (asEntriesQ.data ?? []) : []
|
||||
const entriesCount = entriesQuery.data?.items.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -69,6 +78,12 @@ function ModuleDetailComponent() {
|
||||
<ArrowLeft />
|
||||
К списку
|
||||
</Button>
|
||||
{canWrite && mod ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
|
||||
<Pencil />
|
||||
Редактировать
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -103,8 +118,8 @@ function ModuleDetailComponent() {
|
||||
<ModuleKpiCards
|
||||
mod={m}
|
||||
communities={communities}
|
||||
dohProfiles={dohProfiles}
|
||||
asEntries={asEntries}
|
||||
entriesCount={entriesCount}
|
||||
loading={detail.isLoading || communitiesQ.isLoading}
|
||||
/>
|
||||
|
||||
@@ -119,6 +134,16 @@ function ModuleDetailComponent() {
|
||||
onRetry={() => entriesQuery.refetch()}
|
||||
onChanged={onEntriesChanged}
|
||||
/>
|
||||
|
||||
{canWrite ? (
|
||||
<ModuleEditDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
mod={m}
|
||||
communities={communities}
|
||||
dohProfiles={dohProfiles}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user