From e190785d4f0a9c60d02273b418a5b5c1b3cd8779 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 18 Aug 2026 20:17:51 +0700 Subject: [PATCH] feat(web): add module create form on /modules/new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Форма POST /v1/modules вместо заглушки: тип, название, расписание и DoH для DOMAINS. После 201 переход на карточку; кнопка «Создать» только при bgp:modules:write. Co-authored-by: Cursor --- .../components/modules/module-create-form.tsx | 393 ++++++++++++++++++ .../patterns/projects-empty-state.tsx | 12 +- apps/web/src/queries/modules.ts | 13 + apps/web/src/routes/_auth/modules/index.tsx | 16 +- apps/web/src/routes/_auth/modules/new.tsx | 43 +- 5 files changed, 428 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/components/modules/module-create-form.tsx diff --git a/apps/web/src/components/modules/module-create-form.tsx b/apps/web/src/components/modules/module-create-form.tsx new file mode 100644 index 0000000..0e3aa34 --- /dev/null +++ b/apps/web/src/components/modules/module-create-form.tsx @@ -0,0 +1,393 @@ +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 ( + + ) + })} +
+ )} +
+ + ) : null} +
+
+
+ ) +} diff --git a/apps/web/src/components/patterns/projects-empty-state.tsx b/apps/web/src/components/patterns/projects-empty-state.tsx index 9057096..9dc8d7a 100644 --- a/apps/web/src/components/patterns/projects-empty-state.tsx +++ b/apps/web/src/components/patterns/projects-empty-state.tsx @@ -5,17 +5,19 @@ import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-s import { Button } from '@evobgp/ui/components/button' /** empty-state-3 pattern for first module. */ -export function ProjectsEmptyState() { +export function ProjectsEmptyState({ canCreate = true }: { canCreate?: boolean }) { return ( }> - - Новый модуль - + canCreate ? ( + + ) : undefined } /> ) diff --git a/apps/web/src/queries/modules.ts b/apps/web/src/queries/modules.ts index d0da4d2..a8f9ca8 100644 --- a/apps/web/src/queries/modules.ts +++ b/apps/web/src/queries/modules.ts @@ -4,6 +4,7 @@ import { toast } from 'sonner' import { apiJSON, apiMutate } from '@/lib/api-client' import { overviewKeys } from '@/queries/overview' import type { + ModuleCreate, ModulePatch, ModuleRow, ModulesResponse, @@ -64,6 +65,18 @@ export function moduleEntriesQueryOptions(id: string, type: ModuleRow['type']) { }) } +export function useCreateModuleMutation() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (body: ModuleCreate) => apiMutate('/v1/modules', 'POST', body), + onSuccess: (data) => { + toast.success('Модуль создан') + invalidateModules(qc, data.id) + }, + onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось создать модуль'), + }) +} + export function useUpdateModuleMutation() { const qc = useQueryClient() return useMutation({ diff --git a/apps/web/src/routes/_auth/modules/index.tsx b/apps/web/src/routes/_auth/modules/index.tsx index 78c793e..7e09456 100644 --- a/apps/web/src/routes/_auth/modules/index.tsx +++ b/apps/web/src/routes/_auth/modules/index.tsx @@ -10,6 +10,8 @@ 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 { modulesListQueryOptions } from '@/queries/modules' export const Route = createFileRoute('/_auth/modules/')({ @@ -18,6 +20,8 @@ export const Route = createFileRoute('/_auth/modules/')({ function ModulesListComponent() { const query = useQuery(modulesListQueryOptions()) + const sessionQ = useQuery(authSessionQueryOptions()) + const canWrite = sessionCanWriteModules(sessionQ.data) return (
@@ -42,10 +46,12 @@ function ModulesListComponent() { }> - - Создать - + canWrite ? ( + + ) : undefined } > } + emptyContent={} skeleton={} onRetry={() => query.refetch()} > diff --git a/apps/web/src/routes/_auth/modules/new.tsx b/apps/web/src/routes/_auth/modules/new.tsx index 40a17bb..ca700f2 100644 --- a/apps/web/src/routes/_auth/modules/new.tsx +++ b/apps/web/src/routes/_auth/modules/new.tsx @@ -1,42 +1,7 @@ -import { createFileRoute, Link } from '@tanstack/react-router' -import { Button } from '@evobgp/ui/components/button' -import { PageHeader } from '@/components/page-header' -import { - Frame, - FrameDescription, - FrameHeader, - FramePanel, - FrameTitle, -} from '@/components/reui/frame' +import { createFileRoute } from '@tanstack/react-router' + +import { ModuleCreateForm } from '@/components/modules/module-create-form' export const Route = createFileRoute('/_auth/modules/new')({ - component: NewModuleComponent, + component: ModuleCreateForm, }) - -function NewModuleComponent() { - return ( -
- - - - Создание через API - - Форма в UI появится позже. Сейчас модуль можно создать запросом ниже. - - - -
-{`POST /v1/modules
-{ "type": "DOMAINS", "name": "Мой список" }`}
-          
- -
- -
- ) -}