Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32d9dc6acb | ||
|
|
fc4f234cc5 |
@@ -0,0 +1,111 @@
|
|||||||
|
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,
|
||||||
|
useUpdateCommunityMutation,
|
||||||
|
} from '@/queries/directories'
|
||||||
|
import type { BgpCommunity, BgpCommunityCreate, BgpCommunityPatch } from '@/types/api'
|
||||||
|
|
||||||
|
interface CommunityFormDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
editTarget?: BgpCommunity | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @see https://reui.io/preview/base/form-7 */
|
||||||
|
export function CommunityFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
editTarget = null,
|
||||||
|
}: CommunityFormDialogProps) {
|
||||||
|
const createMutation = useCreateCommunityMutation()
|
||||||
|
const updateMutation = useUpdateCommunityMutation()
|
||||||
|
const saving = createMutation.isPending || updateMutation.isPending
|
||||||
|
const [community, setCommunity] = useState('')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (editTarget) {
|
||||||
|
setCommunity(editTarget.community ?? '')
|
||||||
|
setTitle(editTarget.title ?? '')
|
||||||
|
} else {
|
||||||
|
setCommunity('')
|
||||||
|
setTitle('')
|
||||||
|
}
|
||||||
|
}, [editTarget, open])
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const value = community.trim()
|
||||||
|
if (!value) {
|
||||||
|
toast.error('Укажите community')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const titleTrimmed = title.trim()
|
||||||
|
try {
|
||||||
|
if (editTarget) {
|
||||||
|
const body: BgpCommunityPatch = {
|
||||||
|
community: value,
|
||||||
|
title: titleTrimmed || '',
|
||||||
|
}
|
||||||
|
await updateMutation.mutateAsync({ id: editTarget.id, body })
|
||||||
|
} else {
|
||||||
|
const body: BgpCommunityCreate = { community: value }
|
||||||
|
if (titleTrimmed) body.title = titleTrimmed
|
||||||
|
await createMutation.mutateAsync(body)
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
// toast in mutation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormDrawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={editTarget ? 'Редактировать сообщество' : 'Новое сообщество BGP'}
|
||||||
|
description="Тег для префиксов в фильтрах BIRD"
|
||||||
|
className="sm:max-w-sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
|
||||||
|
{editTarget ? 'Сохранить' : 'Создать'}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use CommunityFormDialog */
|
||||||
|
export const CommunityCreateDialog = CommunityFormDialog
|
||||||
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
|
|||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
|
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type { BgpCommunity } from '@/types/api'
|
import type { BgpCommunity } from '@/types/api'
|
||||||
@@ -11,12 +12,16 @@ import type { BgpCommunity } from '@/types/api'
|
|||||||
export function DirectoriesCommunitiesGrid({
|
export function DirectoriesCommunitiesGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
canWrite = false,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
items: BgpCommunity[]
|
items: BgpCommunity[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
canWrite?: boolean
|
||||||
|
onEdit?: (row: BgpCommunity) => void
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo<ColumnDef<BgpCommunity>[]>(
|
const columns = useMemo<ColumnDef<BgpCommunity>[]>(() => {
|
||||||
() => [
|
const cols: ColumnDef<BgpCommunity>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Название" />,
|
||||||
@@ -38,9 +43,23 @@ export function DirectoriesCommunitiesGrid({
|
|||||||
cell: () => <CategoryBadge>community</CategoryBadge>,
|
cell: () => <CategoryBadge>community</CategoryBadge>,
|
||||||
meta: { headerTitle: 'Тип' },
|
meta: { headerTitle: 'Тип' },
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
[],
|
|
||||||
)
|
if (canWrite && onEdit) {
|
||||||
|
cols.push({
|
||||||
|
id: 'actions',
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Действия' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols
|
||||||
|
}, [canWrite, onEdit])
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||||
data: items,
|
data: items,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMemo } from 'react'
|
|||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
import { DataGridSection } from '@/components/data-grid-shell'
|
||||||
|
import { DirectoriesRowActions } from '@/components/directories/directories-row-actions'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
import type { DohProfile } from '@/types/api'
|
import type { DohProfile } from '@/types/api'
|
||||||
@@ -11,12 +12,16 @@ import type { DohProfile } from '@/types/api'
|
|||||||
export function DirectoriesDohGrid({
|
export function DirectoriesDohGrid({
|
||||||
items,
|
items,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
canWrite = false,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
items: DohProfile[]
|
items: DohProfile[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
canWrite?: boolean
|
||||||
|
onEdit?: (row: DohProfile) => void
|
||||||
}) {
|
}) {
|
||||||
const columns = useMemo<ColumnDef<DohProfile>[]>(
|
const columns = useMemo<ColumnDef<DohProfile>[]>(() => {
|
||||||
() => [
|
const cols: ColumnDef<DohProfile>[] = [
|
||||||
{
|
{
|
||||||
id: 'name',
|
id: 'name',
|
||||||
accessorFn: (row) => row.name ?? row.url,
|
accessorFn: (row) => row.name ?? row.url,
|
||||||
@@ -43,9 +48,23 @@ export function DirectoriesDohGrid({
|
|||||||
cell: () => <CategoryBadge>—</CategoryBadge>,
|
cell: () => <CategoryBadge>—</CategoryBadge>,
|
||||||
meta: { headerTitle: 'По умолчанию' },
|
meta: { headerTitle: 'По умолчанию' },
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
[],
|
|
||||||
)
|
if (canWrite && onEdit) {
|
||||||
|
cols.push({
|
||||||
|
id: 'actions',
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
header: () => <span className="sr-only">Действия</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DirectoriesRowActions onEdit={() => onEdit(row.original)} />
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Действия' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return cols
|
||||||
|
}, [canWrite, onEdit])
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||||
data: items,
|
data: items,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { MoreHorizontalIcon, PencilIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@evobgp/ui/components/dropdown-menu'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data-grid row actions — ⋯ menu (ReUI / shadcn DropdownMenu).
|
||||||
|
* @see https://reui.io/preview/base/components/c-dropdown-menu-12
|
||||||
|
* @see https://reui.io/docs/components/base/dropdown-menu
|
||||||
|
*/
|
||||||
|
export function DirectoriesRowActions({ onEdit }: { onEdit: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button type="button" variant="ghost" size="icon-sm" aria-label="Действия">
|
||||||
|
<MoreHorizontalIcon className="size-4" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-40">
|
||||||
|
<DropdownMenuItem onClick={onEdit}>
|
||||||
|
<PencilIcon aria-hidden />
|
||||||
|
Редактировать
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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,
|
||||||
|
useUpdateDohProfileMutation,
|
||||||
|
} from '@/queries/directories'
|
||||||
|
import type { DohProfile, DohProfileCreate, DohProfilePatch } from '@/types/api'
|
||||||
|
|
||||||
|
interface DohProfileFormDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
editTarget?: DohProfile | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @see https://reui.io/preview/base/form-7 */
|
||||||
|
export function DohProfileFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
editTarget = null,
|
||||||
|
}: DohProfileFormDialogProps) {
|
||||||
|
const createMutation = useCreateDohProfileMutation()
|
||||||
|
const updateMutation = useUpdateDohProfileMutation()
|
||||||
|
const saving = createMutation.isPending || updateMutation.isPending
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [timeoutMs, setTimeoutMs] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
if (editTarget) {
|
||||||
|
setName(editTarget.name ?? '')
|
||||||
|
setUrl(editTarget.url ?? '')
|
||||||
|
setTimeoutMs(
|
||||||
|
editTarget.timeout_ms === null || editTarget.timeout_ms === undefined
|
||||||
|
? ''
|
||||||
|
: String(editTarget.timeout_ms),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setName('')
|
||||||
|
setUrl('')
|
||||||
|
setTimeoutMs('')
|
||||||
|
}
|
||||||
|
}, [editTarget, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout: number | null = null
|
||||||
|
if (timeoutMs.trim() !== '') {
|
||||||
|
const ms = Number(timeoutMs)
|
||||||
|
if (!Number.isFinite(ms) || !Number.isInteger(ms) || ms <= 0) {
|
||||||
|
toast.error('Timeout должен быть целым числом > 0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
timeout = ms
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameTrimmed = name.trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editTarget) {
|
||||||
|
const body: DohProfilePatch = {
|
||||||
|
url: trimmedUrl,
|
||||||
|
name: nameTrimmed || undefined,
|
||||||
|
timeout_ms: timeout,
|
||||||
|
}
|
||||||
|
await updateMutation.mutateAsync({ id: editTarget.id, body })
|
||||||
|
} else {
|
||||||
|
const body: DohProfileCreate = { url: trimmedUrl }
|
||||||
|
if (nameTrimmed) body.name = nameTrimmed
|
||||||
|
if (timeout !== null) body.timeout_ms = timeout
|
||||||
|
await createMutation.mutateAsync(body)
|
||||||
|
}
|
||||||
|
onOpenChange(false)
|
||||||
|
} catch {
|
||||||
|
// toast in mutation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormDrawer
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title={editTarget ? 'Редактировать DoH-профиль' : 'Новый DoH-профиль'}
|
||||||
|
description="Резолвер DNS-over-HTTPS для доменных модулей"
|
||||||
|
className="sm:max-w-sm"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton type="button" loading={saving} onClick={() => void save()}>
|
||||||
|
{editTarget ? 'Сохранить' : 'Создать'}
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use DohProfileFormDialog */
|
||||||
|
export const DohProfileCreateDialog = DohProfileFormDialog
|
||||||
@@ -308,6 +308,31 @@ export function sessionCanWriteModules(session: {
|
|||||||
return role === 'editor' || role === 'operator'
|
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. */
|
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
||||||
export function permissionForPath(pathname: string): string | null {
|
export function permissionForPath(pathname: string): string | null {
|
||||||
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { apiJSON } from '@/lib/api-client'
|
import { toast } from 'sonner'
|
||||||
import type { CommunitiesResponse, DohProfilesResponse } from '@/types/api'
|
|
||||||
|
import { apiJSON, apiMutate } from '@/lib/api-client'
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
BgpCommunityCreate,
|
||||||
|
BgpCommunityPatch,
|
||||||
|
CommunitiesResponse,
|
||||||
|
DohProfile,
|
||||||
|
DohProfileCreate,
|
||||||
|
DohProfilePatch,
|
||||||
|
DohProfilesResponse,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
export const directoriesKeys = {
|
export const directoriesKeys = {
|
||||||
all: ['directories'] as const,
|
all: ['directories'] as const,
|
||||||
@@ -23,3 +34,59 @@ export function directoriesDohQueryOptions() {
|
|||||||
staleTime: 30_000,
|
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 useUpdateCommunityMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: BgpCommunityPatch }) =>
|
||||||
|
apiMutate<BgpCommunity>(`/v1/communities/${id}`, 'PATCH', 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-профиль'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateDohProfileMutation() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, body }: { id: string; body: DohProfilePatch }) =>
|
||||||
|
apiMutate<DohProfile>(`/v1/doh-profiles/${id}`, 'PATCH', body, { idempotent: false }),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('DoH-профиль обновлён')
|
||||||
|
void qc.invalidateQueries({ queryKey: directoriesKeys.doh() })
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось обновить DoH-профиль'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
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 { Button } from '@evobgp/ui/components/button'
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||||
import { FrameDataGrid, KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
import {
|
||||||
|
CommunityFormDialog,
|
||||||
|
} from '@/components/directories/community-create-dialog'
|
||||||
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
|
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
|
||||||
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
|
import { DirectoriesDohGrid } from '@/components/directories/directories-doh-grid'
|
||||||
|
import {
|
||||||
|
DohProfileFormDialog,
|
||||||
|
} from '@/components/directories/doh-profile-create-dialog'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
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 { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { sessionCanWriteDirectories } from '@/lib/auth'
|
||||||
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
|
import { directoriesCommunitiesQueryOptions, directoriesDohQueryOptions } from '@/queries/directories'
|
||||||
|
import type { BgpCommunity, DohProfile } from '@/types/api'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/directories')({
|
export const Route = createFileRoute('/_auth/directories')({
|
||||||
component: DirectoriesComponent,
|
component: DirectoriesComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
function DirectoriesComponent() {
|
function DirectoriesComponent() {
|
||||||
|
const [communityOpen, setCommunityOpen] = useState(false)
|
||||||
|
const [communityEdit, setCommunityEdit] = useState<BgpCommunity | null>(null)
|
||||||
|
const [dohOpen, setDohOpen] = useState(false)
|
||||||
|
const [dohEdit, setDohEdit] = useState<DohProfile | null>(null)
|
||||||
|
|
||||||
|
const sessionQ = useQuery(authSessionQueryOptions())
|
||||||
|
const canWrite = sessionCanWriteDirectories(sessionQ.data)
|
||||||
|
|
||||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
||||||
const dohQ = useQuery(directoriesDohQueryOptions())
|
const dohQ = useQuery(directoriesDohQueryOptions())
|
||||||
|
|
||||||
@@ -58,6 +76,40 @@ function DirectoriesComponent() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function openCreateCommunity() {
|
||||||
|
setCommunityEdit(null)
|
||||||
|
setCommunityOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditCommunity(row: BgpCommunity) {
|
||||||
|
setCommunityEdit(row)
|
||||||
|
setCommunityOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDoh() {
|
||||||
|
setDohEdit(null)
|
||||||
|
setDohOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDoh(row: DohProfile) {
|
||||||
|
setDohEdit(row)
|
||||||
|
setDohOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCommunityButton = canWrite ? (
|
||||||
|
<Button size="sm" type="button" onClick={openCreateCommunity}>
|
||||||
|
<Plus />
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
const addDohButton = canWrite ? (
|
||||||
|
<Button size="sm" type="button" onClick={openCreateDoh}>
|
||||||
|
<Plus />
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -92,6 +144,7 @@ function DirectoriesComponent() {
|
|||||||
<FrameDataGrid
|
<FrameDataGrid
|
||||||
title="Сообщества BGP"
|
title="Сообщества BGP"
|
||||||
description="Теги для префиксов в фильтрах BIRD"
|
description="Теги для префиксов в фильтрах BIRD"
|
||||||
|
actions={addCommunityButton}
|
||||||
>
|
>
|
||||||
<QueryState
|
<QueryState
|
||||||
data={communities}
|
data={communities}
|
||||||
@@ -100,13 +153,16 @@ function DirectoriesComponent() {
|
|||||||
error={communitiesQ.error}
|
error={communitiesQ.error}
|
||||||
empty={communities.length === 0}
|
empty={communities.length === 0}
|
||||||
emptyTitle="Нет сообществ"
|
emptyTitle="Нет сообществ"
|
||||||
|
emptyAction={addCommunityButton}
|
||||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||||
onRetry={() => communitiesQ.refetch()}
|
onRetry={() => communitiesQ.refetch()}
|
||||||
>
|
>
|
||||||
{(items) => (
|
{(rows) => (
|
||||||
<DirectoriesCommunitiesGrid
|
<DirectoriesCommunitiesGrid
|
||||||
items={items}
|
items={rows}
|
||||||
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
|
isLoading={communitiesQ.isFetching && !communitiesQ.isLoading}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onEdit={openEditCommunity}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
@@ -114,7 +170,11 @@ function DirectoriesComponent() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="doh" className="mt-0">
|
<TabsContent value="doh" className="mt-0">
|
||||||
<FrameDataGrid title="DoH профили" description="Резолверы DNS-over-HTTPS для доменных модулей">
|
<FrameDataGrid
|
||||||
|
title="DoH профили"
|
||||||
|
description="Резолверы DNS-over-HTTPS для доменных модулей"
|
||||||
|
actions={addDohButton}
|
||||||
|
>
|
||||||
<QueryState
|
<QueryState
|
||||||
data={dohProfiles}
|
data={dohProfiles}
|
||||||
isLoading={dohQ.isLoading}
|
isLoading={dohQ.isLoading}
|
||||||
@@ -122,19 +182,43 @@ function DirectoriesComponent() {
|
|||||||
error={dohQ.error}
|
error={dohQ.error}
|
||||||
empty={dohProfiles.length === 0}
|
empty={dohProfiles.length === 0}
|
||||||
emptyTitle="Нет DoH профилей"
|
emptyTitle="Нет DoH профилей"
|
||||||
|
emptyAction={addDohButton}
|
||||||
skeleton={<TableSkeleton rows={4} cols={3} />}
|
skeleton={<TableSkeleton rows={4} cols={3} />}
|
||||||
onRetry={() => dohQ.refetch()}
|
onRetry={() => dohQ.refetch()}
|
||||||
>
|
>
|
||||||
{(items) => (
|
{(rows) => (
|
||||||
<DirectoriesDohGrid
|
<DirectoriesDohGrid
|
||||||
items={items}
|
items={rows}
|
||||||
isLoading={dohQ.isFetching && !dohQ.isLoading}
|
isLoading={dohQ.isFetching && !dohQ.isLoading}
|
||||||
|
canWrite={canWrite}
|
||||||
|
onEdit={openEditDoh}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</QueryState>
|
</QueryState>
|
||||||
</FrameDataGrid>
|
</FrameDataGrid>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</BadgeTabs>
|
</BadgeTabs>
|
||||||
|
|
||||||
|
{canWrite ? (
|
||||||
|
<>
|
||||||
|
<CommunityFormDialog
|
||||||
|
open={communityOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCommunityOpen(open)
|
||||||
|
if (!open) setCommunityEdit(null)
|
||||||
|
}}
|
||||||
|
editTarget={communityEdit}
|
||||||
|
/>
|
||||||
|
<DohProfileFormDialog
|
||||||
|
open={dohOpen}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDohOpen(open)
|
||||||
|
if (!open) setDohEdit(null)
|
||||||
|
}}
|
||||||
|
editTarget={dohEdit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user