diff --git a/apps/web/src/components/dashboard/dashboard-modules-grid.tsx b/apps/web/src/components/dashboard/dashboard-modules-grid.tsx index 53dfe84..b74ce7c 100644 --- a/apps/web/src/components/dashboard/dashboard-modules-grid.tsx +++ b/apps/web/src/components/dashboard/dashboard-modules-grid.tsx @@ -167,7 +167,7 @@ export function DashboardModulesGrid({ description="Поиск, сортировка и быстрый переход к настройке" className="min-w-0" actions={ - }> + }> Создать diff --git a/apps/web/src/components/dashboard/dashboard-quick-links.tsx b/apps/web/src/components/dashboard/dashboard-quick-links.tsx index db624ba..0f0e6a1 100644 --- a/apps/web/src/components/dashboard/dashboard-quick-links.tsx +++ b/apps/web/src/components/dashboard/dashboard-quick-links.tsx @@ -16,7 +16,8 @@ const ACTIONS: QuickActionItem[] = [ id: 'new-module', title: 'Создать модуль', description: 'Новый модуль маршрутизации и источники префиксов.', - to: '/modules/new', + to: '/modules', + search: { create: true }, icon: , iconClassName: 'text-primary', }, diff --git a/apps/web/src/components/lookup/lookup-add-step.tsx b/apps/web/src/components/lookup/lookup-add-step.tsx index c2afaee..34cb8e3 100644 --- a/apps/web/src/components/lookup/lookup-add-step.tsx +++ b/apps/web/src/components/lookup/lookup-add-step.tsx @@ -128,7 +128,7 @@ export function LookupAddStep({ Нет подходящего модуля Создайте модуль типа {wantedType}, затем повторите добавление.{' '} - }> + }> Перейти к модулям diff --git a/apps/web/src/components/modules/module-create-dialog.tsx b/apps/web/src/components/modules/module-create-dialog.tsx new file mode 100644 index 0000000..0d1e3ff --- /dev/null +++ b/apps/web/src/components/modules/module-create-dialog.tsx @@ -0,0 +1,289 @@ +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 } from '@/lib/modules/helpers' +import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels' +import { useCreateModuleMutation } from '@/queries/modules' +import type { + BgpCommunity, + DohProfile, + DohResolverPolicy, + ModuleCreate, + ModuleRow, + ModuleType, +} from '@/types/api' + +interface ModuleCreateDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + communities: BgpCommunity[] + dohProfiles: DohProfile[] + onCreated?: (mod: ModuleRow) => void +} + +/** @see https://reui.io/preview/base/form-7 */ +/** @see https://reui.io/preview/base/sheet-8 */ + +const MODULE_TYPE_ITEMS: { value: ModuleType; label: string }[] = [ + { value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') }, + { value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') }, + { value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') }, + { value: 'DOMAINS', label: moduleTypeRu('DOMAINS') }, +] + +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 ModuleCreateDialog({ + open, + onOpenChange, + communities, + dohProfiles, + onCreated, +}: ModuleCreateDialogProps) { + const createMutation = useCreateModuleMutation() + + const [type, setType] = useState('IP_RANGES') + 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(null) + const [dohResolverPolicy, setDohResolverPolicy] = useState('primary_only') + const [dohProfileIds, setDohProfileIds] = useState([]) + + const isDomains = type === 'DOMAINS' + + useEffect(() => { + if (!open) return + setType('IP_RANGES') + setName('') + setEnabled(true) + setPriority('0') + setRefreshIntervalSec('') + setCronExpr('') + setDefaultCommunityId(null) + setDohResolverPolicy('primary_only') + setDohProfileIds([]) + }, [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 | undefined + if (refreshIntervalSec.trim() !== '') { + const n = Number(refreshIntervalSec) + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) { + toast.error('Интервал обновления должен быть целым числом ≥ 0') + return + } + refresh = n + } + + const body: ModuleCreate = { + type, + name: trimmedName, + enabled, + priority: priorityNum, + } + if (refresh !== undefined) { + body.refresh_interval_sec = refresh + } + const cron = cronExpr.trim() + if (cron) { + body.cron_expr = cron + } + if (defaultCommunityId) { + body.default_community_id = defaultCommunityId + } + if (isDomains) { + body.doh_resolver_policy = dohResolverPolicy + body.doh_profile_ids = dohProfileIds + } + + try { + const created = await createMutation.mutateAsync(body) + onOpenChange(false) + onCreated?.(created) + } catch { + // toast in mutation + } + } + + return ( + + onOpenChange(false)}> + Отмена + + void save()}> + Создать + + > + } + > + { + if (v) setType(v) + }} + /> + + + Название + setName(e.target.value)} + placeholder="Имя модуля" + /> + + + + + Включён + + Выключенный модуль не участвует в обновлении и применении. + + + setEnabled(v === true)} + /> + + + + Приоритет + setPriority(e.target.value)} + /> + + + + Интервал обновления (сек) + setRefreshIntervalSec(e.target.value)} + /> + + + + Cron (опционально) + setCronExpr(e.target.value)} + /> + + + + + {isDomains ? ( + <> + { + if (v) setDohResolverPolicy(v) + }} + /> + + + DoH профили + {dohProfiles.length === 0 ? ( + Нет профилей в справочнике + ) : ( + + {dohProfiles.map((p) => { + const checked = dohProfileIds.includes(p.id) + return ( + + toggleDohProfile(p.id, v === true)} + className="mt-0.5" + /> + + + {dohProfileShortLabel(p.id, dohProfiles)} + + + {p.url} + + + + ) + })} + + )} + + > + ) : null} + + ) +} diff --git a/apps/web/src/components/modules/module-create-form.tsx b/apps/web/src/components/modules/module-create-form.tsx deleted file mode 100644 index 0e3aa34..0000000 --- a/apps/web/src/components/modules/module-create-form.tsx +++ /dev/null @@ -1,393 +0,0 @@ -import { Link, useNavigate } from '@tanstack/react-router' -import { useQuery } from '@tanstack/react-query' -import { ArrowLeft, ShieldOff } from 'lucide-react' -import { 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 { Switch } from '@evobgp/ui/components/switch' -import { - ToggleGroup, - ToggleGroupItem, -} from '@evobgp/ui/components/toggle-group' - -import { Alert, AlertDescription, AlertTitle } from '@/components/reui/alert' -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from '@/components/reui/number-field' -import { LoadingButton } from '@/components/loading-button' -import { CommunitySelect } from '@/components/modules/community-select' -import { PageHeader } from '@/components/page-header' -import { PageShell } from '@/components/page-shell' -import { SelectMenu } from '@/components/select-field' -import { SettingRow } from '@/components/settings/setting-row' -import { SettingsCard } from '@/components/settings/settings-card' -import { SettingsFieldGroup } from '@/components/settings/settings-field-group' -import { sessionCanWriteModules } from '@/lib/auth' -import { dohProfileShortLabel } from '@/lib/modules/helpers' -import { dohPolicyRu, moduleTypeRu } from '@/lib/ui-labels' -import { authSessionQueryOptions } from '@/queries/auth' -import { - directoriesCommunitiesQueryOptions, - directoriesDohQueryOptions, -} from '@/queries/directories' -import { useCreateModuleMutation } from '@/queries/modules' -import type { DohResolverPolicy, ModuleCreate, ModuleType } from '@/types/api' - -/** - * Create module — settings-3 / settings-16 Frame rows. - * @see https://reui.io/preview/base/settings-3 - * @see https://reui.io/preview/base/settings-16 - * @see https://reui.io/docs/components/base/frame - * @see https://reui.io/docs/components/base/number-field - */ - -const MODULE_TYPES: ModuleType[] = ['IP_RANGES', 'AS_PREFIXES', 'CDN_CIDRS', 'DOMAINS'] - -const MODULE_TYPE_HINT: Record = { - IP_RANGES: 'Статические префиксы. Тип после создания не меняется.', - AS_PREFIXES: 'Префиксы по номерам AS (RIPEstat). Тип после создания не меняется.', - CDN_CIDRS: 'CIDR-списки с URL-источников. Тип после создания не меняется.', - DOMAINS: 'FQDN → префиксы через DoH. Тип после создания не меняется.', -} - -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 ModuleCreateForm() { - const navigate = useNavigate() - const sessionQ = useQuery(authSessionQueryOptions()) - const canWrite = sessionCanWriteModules(sessionQ.data) - const communitiesQ = useQuery(directoriesCommunitiesQueryOptions()) - const dohQ = useQuery(directoriesDohQueryOptions()) - const createMutation = useCreateModuleMutation() - - const [type, setType] = useState('IP_RANGES') - 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(null) - const [dohResolverPolicy, setDohResolverPolicy] = useState('primary_only') - const [dohProfileIds, setDohProfileIds] = useState([]) - - const isDomains = type === 'DOMAINS' - const communities = communitiesQ.data?.items ?? [] - const dohProfiles = dohQ.data?.items ?? [] - - 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 submit() { - const trimmedName = name.trim() - if (!trimmedName) { - toast.error('Укажите название модуля') - return - } - if (!Number.isInteger(priority)) { - toast.error('Приоритет должен быть целым числом') - return - } - - let refresh: number | undefined - if (refreshIntervalSec.trim() !== '') { - const n = Number(refreshIntervalSec) - if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) { - toast.error('Интервал обновления должен быть целым числом ≥ 0') - return - } - refresh = n - } - - const body: ModuleCreate = { - type, - name: trimmedName, - enabled, - priority, - } - if (refresh !== undefined) { - body.refresh_interval_sec = refresh - } - const cron = cronExpr.trim() - if (cron) { - body.cron_expr = cron - } - if (defaultCommunityId) { - body.default_community_id = defaultCommunityId - } - if (isDomains) { - body.doh_resolver_policy = dohResolverPolicy - body.doh_profile_ids = dohProfileIds - } - - try { - const created = await createMutation.mutateAsync(body) - await navigate({ to: '/modules/$moduleId', params: { moduleId: created.id } }) - } catch { - // toast in mutation - } - } - - const back = ( - }> - - К списку - - ) - - if (sessionQ.isPending) { - return ( - - - - ) - } - - if (!canWrite) { - return ( - - - - - Недостаточно прав - - Нужно право bgp:modules:write, чтобы создавать модули. - - - - ) - } - - return ( - - - - - }> - Отмена - - void submit()} - > - Создать - - > - } - > - - - { - if (value.length > 0) setType(value[0] as ModuleType) - }} - variant="outline" - size="sm" - aria-label="Тип модуля" - className="flex w-full min-w-0 flex-wrap" - > - {MODULE_TYPES.map((value) => ( - - {moduleTypeRu(value)} - - ))} - - - - - setName(e.target.value)} - placeholder="Например: static-ru" - className="w-full min-w-0" - /> - - - - - - - - { - if (typeof next === 'number' && Number.isFinite(next)) { - setPriority(Math.trunc(next)) - } - }} - step={1} - className="w-full max-w-40" - > - - - - - - - - - - setRefreshIntervalSec(e.target.value)} - className="w-full max-w-40" - /> - - - - setCronExpr(e.target.value)} - className="w-full min-w-0" - /> - - - - - - - {isDomains ? ( - <> - - { - if (v) setDohResolverPolicy(v) - }} - /> - - - - {dohProfiles.length === 0 ? ( - Нет профилей в справочнике - ) : ( - - {dohProfiles.map((p) => { - const checked = dohProfileIds.includes(p.id) - return ( - - toggleDohProfile(p.id, v === true)} - className="mt-0.5" - /> - - - {dohProfileShortLabel(p.id, dohProfiles)} - - - {p.url} - - - - ) - })} - - )} - - > - ) : null} - - - - ) -} diff --git a/apps/web/src/components/patterns/projects-empty-state.tsx b/apps/web/src/components/patterns/projects-empty-state.tsx index 9dc8d7a..2acfaa7 100644 --- a/apps/web/src/components/patterns/projects-empty-state.tsx +++ b/apps/web/src/components/patterns/projects-empty-state.tsx @@ -1,19 +1,24 @@ -import { Link } from '@tanstack/react-router' import { Boxes, Plus } from 'lucide-react' import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state' import { Button } from '@evobgp/ui/components/button' /** empty-state-3 pattern for first module. */ -export function ProjectsEmptyState({ canCreate = true }: { canCreate?: boolean }) { +export function ProjectsEmptyState({ + canCreate = true, + onCreate, +}: { + canCreate?: boolean + onCreate?: () => void +}) { return ( }> + canCreate && onCreate ? ( + Новый модуль diff --git a/apps/web/src/routes/_auth/modules/index.tsx b/apps/web/src/routes/_auth/modules/index.tsx index 7e09456..8c5e541 100644 --- a/apps/web/src/routes/_auth/modules/index.tsx +++ b/apps/web/src/routes/_auth/modules/index.tsx @@ -1,4 +1,4 @@ -import { Link, createFileRoute } from '@tanstack/react-router' +import { createFileRoute } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { Plus, RefreshCw } from 'lucide-react' @@ -6,22 +6,45 @@ import { Button } from '@evobgp/ui/components/button' import { FrameDataGrid } from '@/components/reui-kit' import { ProjectsEmptyState } from '@/components/patterns/projects-empty-state' +import { ModuleCreateDialog } from '@/components/modules/module-create-dialog' import { ModulesListGrid } from '@/components/modules/modules-list-grid' import { PageHeader } from '@/components/page-header' import { QueryState } from '@/components/query-state' import { TableSkeleton } from '@/components/skeletons' import { sessionCanWriteModules } from '@/lib/auth' import { authSessionQueryOptions } from '@/queries/auth' +import { + directoriesCommunitiesQueryOptions, + directoriesDohQueryOptions, +} from '@/queries/directories' import { modulesListQueryOptions } from '@/queries/modules' +function parseCreateFlag(value: unknown): boolean { + return value === true || value === '1' || value === 'true' +} + export const Route = createFileRoute('/_auth/modules/')({ component: ModulesListComponent, + validateSearch: (search: Record): { create?: boolean } => { + if (parseCreateFlag(search.create)) return { create: true } + return {} + }, }) function ModulesListComponent() { + const { create } = Route.useSearch() + const navigate = Route.useNavigate() const query = useQuery(modulesListQueryOptions()) const sessionQ = useQuery(authSessionQueryOptions()) const canWrite = sessionCanWriteModules(sessionQ.data) + const communitiesQ = useQuery(directoriesCommunitiesQueryOptions()) + const dohQ = useQuery(directoriesDohQueryOptions()) + + const createOpen = canWrite && create === true + + function setCreateOpen(open: boolean) { + void navigate({ search: open ? { create: true } : {}, replace: true }) + } return ( @@ -47,7 +70,7 @@ function ModulesListComponent() { title="Все модули" actions={ canWrite ? ( - }> + setCreateOpen(true)}> Создать @@ -60,7 +83,12 @@ function ModulesListComponent() { isError={query.isError} error={query.error} empty={query.data?.items?.length === 0} - emptyContent={} + emptyContent={ + setCreateOpen(true)} + /> + } skeleton={} onRetry={() => query.refetch()} > @@ -72,6 +100,16 @@ function ModulesListComponent() { )} + + { + void navigate({ to: '/modules/$moduleId', params: { moduleId: mod.id } }) + }} + /> ) } diff --git a/apps/web/src/routes/_auth/modules/new.tsx b/apps/web/src/routes/_auth/modules/new.tsx index ca700f2..be6e9a1 100644 --- a/apps/web/src/routes/_auth/modules/new.tsx +++ b/apps/web/src/routes/_auth/modules/new.tsx @@ -1,7 +1,7 @@ -import { createFileRoute } from '@tanstack/react-router' - -import { ModuleCreateForm } from '@/components/modules/module-create-form' +import { createFileRoute, redirect } from '@tanstack/react-router' export const Route = createFileRoute('/_auth/modules/new')({ - component: ModuleCreateForm, + beforeLoad: () => { + throw redirect({ to: '/modules', search: { create: true } }) + }, })
+ Выключенный модуль не участвует в обновлении и применении. +
Нет профилей в справочнике