Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e190785d4f |
@@ -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<ModuleType, string> = {
|
||||
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<ModuleType>('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<string | null>(null)
|
||||
const [dohResolverPolicy, setDohResolverPolicy] = useState<DohResolverPolicy>('primary_only')
|
||||
const [dohProfileIds, setDohProfileIds] = useState<string[]>([])
|
||||
|
||||
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 = (
|
||||
<Button variant="outline" size="sm" render={<Link to="/modules" />}>
|
||||
<ArrowLeft />
|
||||
К списку
|
||||
</Button>
|
||||
)
|
||||
|
||||
if (sessionQ.isPending) {
|
||||
return (
|
||||
<PageShell className="mx-auto w-full max-w-3xl">
|
||||
<PageHeader title="Новый модуль" description="Создание маршрутного списка" actions={back} />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!canWrite) {
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Новый модуль"
|
||||
description="Создание маршрутного списка"
|
||||
actions={back}
|
||||
/>
|
||||
<Alert variant="warning">
|
||||
<ShieldOff aria-hidden />
|
||||
<AlertTitle>Недостаточно прав</AlertTitle>
|
||||
<AlertDescription>
|
||||
Нужно право bgp:modules:write, чтобы создавать модули.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell className="mx-auto w-full max-w-3xl">
|
||||
<PageHeader
|
||||
title="Новый модуль"
|
||||
description="Тип задаётся один раз при создании. Записи (AS, домены, CIDR) добавляются на карточке модуля."
|
||||
actions={back}
|
||||
/>
|
||||
|
||||
<SettingsCard
|
||||
title="Параметры"
|
||||
description="Основные поля. Тип после сохранения изменить нельзя."
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" render={<Link to="/modules" />}>
|
||||
Отмена
|
||||
</Button>
|
||||
<LoadingButton
|
||||
type="button"
|
||||
loading={createMutation.isPending}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
Создать
|
||||
</LoadingButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SettingsFieldGroup
|
||||
legend="Создание модуля"
|
||||
description="Тип, название и расписание нового маршрутного списка."
|
||||
>
|
||||
<SettingRow
|
||||
title="Тип"
|
||||
description={MODULE_TYPE_HINT[type]}
|
||||
stacked
|
||||
labelFor="mod-create-type"
|
||||
>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={[type]}
|
||||
onValueChange={(value) => {
|
||||
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) => (
|
||||
<ToggleGroupItem key={value} value={value} className="gap-1.5">
|
||||
{moduleTypeRu(value)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Название"
|
||||
description="Короткое имя в списках и на карточке."
|
||||
labelFor="mod-create-name"
|
||||
stacked
|
||||
>
|
||||
<Input
|
||||
id="mod-create-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Например: static-ru"
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Включён"
|
||||
description="Выключенный модуль не участвует в обновлении и применении."
|
||||
>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
aria-label="Модуль включён"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Приоритет"
|
||||
description="Меньше значение — выше приоритет при агрегации."
|
||||
labelFor="mod-create-priority"
|
||||
>
|
||||
<NumberField
|
||||
id="mod-create-priority"
|
||||
value={priority}
|
||||
onValueChange={(next) => {
|
||||
if (typeof next === 'number' && Number.isFinite(next)) {
|
||||
setPriority(Math.trunc(next))
|
||||
}
|
||||
}}
|
||||
step={1}
|
||||
className="w-full max-w-40"
|
||||
>
|
||||
<NumberFieldGroup>
|
||||
<NumberFieldDecrement />
|
||||
<NumberFieldInput />
|
||||
<NumberFieldIncrement />
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Интервал обновления"
|
||||
description="Секунды. Пусто — без периодического ingest по интервалу."
|
||||
labelFor="mod-create-interval"
|
||||
>
|
||||
<Input
|
||||
id="mod-create-interval"
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="пусто"
|
||||
value={refreshIntervalSec}
|
||||
onChange={(e) => setRefreshIntervalSec(e.target.value)}
|
||||
className="w-full max-w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Cron"
|
||||
description="Опциональное расписание планировщика, например 0 * * * *."
|
||||
labelFor="mod-create-cron"
|
||||
>
|
||||
<Input
|
||||
id="mod-create-cron"
|
||||
placeholder="0 * * * *"
|
||||
value={cronExpr}
|
||||
onChange={(e) => setCronExpr(e.target.value)}
|
||||
className="w-full min-w-0"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Community по умолчанию"
|
||||
description="Подставляется в записи без своей community."
|
||||
labelFor="mod-create-community"
|
||||
last={!isDomains}
|
||||
>
|
||||
<CommunitySelect
|
||||
id="mod-create-community"
|
||||
value={defaultCommunityId}
|
||||
onValueChange={setDefaultCommunityId}
|
||||
communities={communities}
|
||||
nullable
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{isDomains ? (
|
||||
<>
|
||||
<SettingRow
|
||||
title="Политика DoH"
|
||||
description="Как выбирать профили при нескольких URL."
|
||||
labelFor="mod-create-doh-policy"
|
||||
>
|
||||
<SelectMenu
|
||||
id="mod-create-doh-policy"
|
||||
items={DOH_POLICY_ITEMS}
|
||||
value={dohResolverPolicy}
|
||||
onValueChange={(v) => {
|
||||
if (v) setDohResolverPolicy(v)
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="DoH профили"
|
||||
description="Упорядоченный список. Пусто — резолв без профилей модуля."
|
||||
stacked
|
||||
last
|
||||
>
|
||||
{dohProfiles.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет профилей в справочнике</p>
|
||||
) : (
|
||||
<div className="flex w-full min-w-0 flex-col gap-2">
|
||||
{dohProfiles.map((p) => {
|
||||
const checked = dohProfileIds.includes(p.id)
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
htmlFor={`mod-create-doh-${p.id}`}
|
||||
className="flex cursor-pointer items-start gap-3"
|
||||
>
|
||||
<Checkbox
|
||||
id={`mod-create-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>
|
||||
)}
|
||||
</SettingRow>
|
||||
</>
|
||||
) : null}
|
||||
</SettingsFieldGroup>
|
||||
</SettingsCard>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<IllustratedEmptyState
|
||||
icon={Boxes}
|
||||
title="Создайте первый модуль"
|
||||
description="Модули задают источники префиксов: AS, CDN, домены и IP-диапазоны."
|
||||
action={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
canCreate ? (
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Новый модуль
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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<ModuleRow>('/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({
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -42,10 +46,12 @@ function ModulesListComponent() {
|
||||
<FrameDataGrid
|
||||
title="Все модули"
|
||||
actions={
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
canWrite ? (
|
||||
<Button size="sm" render={<Link to="/modules/new" />}>
|
||||
<Plus />
|
||||
Создать
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<QueryState
|
||||
@@ -54,7 +60,7 @@ function ModulesListComponent() {
|
||||
isError={query.isError}
|
||||
error={query.error}
|
||||
empty={query.data?.items?.length === 0}
|
||||
emptyContent={<ProjectsEmptyState />}
|
||||
emptyContent={<ProjectsEmptyState canCreate={canWrite} />}
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={() => query.refetch()}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Новый модуль"
|
||||
description="Создание модуля — через API или будущая форма"
|
||||
/>
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Создание через API</FrameTitle>
|
||||
<FrameDescription>
|
||||
Форма в UI появится позже. Сейчас модуль можно создать запросом ниже.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3 text-sm text-muted-foreground">
|
||||
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 font-mono text-xs">
|
||||
{`POST /v1/modules
|
||||
{ "type": "DOMAINS", "name": "Мой список" }`}
|
||||
</pre>
|
||||
<Button variant="outline" size="sm" className="self-start" render={<Link to="/modules" />}>
|
||||
Назад к списку
|
||||
</Button>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user