refactor(web): RHF+zod валидация форм — создание списка, правила, override, настройки (dirty-state, reveal токена), живой разбор вставок
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Trash2 } from 'lucide-react'
|
import { Trash2 } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { parseListEntry } from '@evofw/shared'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
agentOverridesQueryOptions,
|
agentOverridesQueryOptions,
|
||||||
@@ -8,8 +12,9 @@ import {
|
|||||||
} from '@/queries'
|
} from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +45,29 @@ type OverrideSheetProps = {
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OVERRIDE_ACTION_ITEMS = [
|
||||||
|
{ value: 'deny', label: 'deny — блокировать' },
|
||||||
|
{ value: 'allow', label: 'allow — пропускать' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const overrideSchema = z.object({
|
||||||
|
cidr: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'Укажите CIDR или IP')
|
||||||
|
.refine((v) => {
|
||||||
|
try {
|
||||||
|
const kind = parseListEntry(v).kind
|
||||||
|
return kind === 'cidr' || kind === 'ip'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, 'Ожидается IP или CIDR, например 1.2.3.4/32'),
|
||||||
|
action: z.enum(['deny', 'allow']),
|
||||||
|
})
|
||||||
|
|
||||||
|
type OverrideValues = z.infer<typeof overrideSchema>
|
||||||
|
|
||||||
export function AgentOverrideSheet({
|
export function AgentOverrideSheet({
|
||||||
agentId,
|
agentId,
|
||||||
open,
|
open,
|
||||||
@@ -50,18 +78,21 @@ export function AgentOverrideSheet({
|
|||||||
...agentOverridesQueryOptions(agentId),
|
...agentOverridesQueryOptions(agentId),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
})
|
})
|
||||||
const [cidr, setCidr] = useState('')
|
const form = useForm<OverrideValues>({
|
||||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
resolver: zodResolver(overrideSchema),
|
||||||
|
defaultValues: { cidr: '', action: 'deny' },
|
||||||
|
mode: 'onBlur',
|
||||||
|
})
|
||||||
|
|
||||||
const add = useMutation({
|
const add = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (values: OverrideValues) =>
|
||||||
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
|
apiFetch(`/api/v1/agents/${agentId}/overrides`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ cidr, action }),
|
body: JSON.stringify(values),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Override добавлен — подхватится на следующей итерации sync')
|
toast.success('Override добавлен — подхватится на следующей итерации sync')
|
||||||
setCidr('')
|
form.reset({ cidr: '', action: 'deny' })
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] })
|
||||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||||
},
|
},
|
||||||
@@ -96,42 +127,58 @@ export function AgentOverrideSheet({
|
|||||||
|
|
||||||
<ScrollArea className="flex-1 px-4">
|
<ScrollArea className="flex-1 px-4">
|
||||||
<div className="flex flex-col gap-4 py-2 pb-4">
|
<div className="flex flex-col gap-4 py-2 pb-4">
|
||||||
<div className="grid gap-3">
|
<form
|
||||||
<Field>
|
onSubmit={form.handleSubmit((values) => add.mutateAsync(values))}
|
||||||
|
className="grid gap-3"
|
||||||
|
>
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(form.formState.errors.cidr) || undefined}
|
||||||
|
>
|
||||||
<FieldLabel htmlFor="ov-cidr">CIDR / IP</FieldLabel>
|
<FieldLabel htmlFor="ov-cidr">CIDR / IP</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
id="ov-cidr"
|
id="ov-cidr"
|
||||||
placeholder="1.2.3.4/32"
|
placeholder="1.2.3.4/32"
|
||||||
value={cidr}
|
{...form.register('cidr')}
|
||||||
onChange={(e) => setCidr(e.target.value)}
|
aria-invalid={Boolean(form.formState.errors.cidr) || undefined}
|
||||||
/>
|
/>
|
||||||
|
<FieldError errors={[form.formState.errors.cidr]} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Действие</FieldLabel>
|
<FieldLabel>Действие</FieldLabel>
|
||||||
<Select
|
<Controller
|
||||||
value={action}
|
control={form.control}
|
||||||
onValueChange={(v) => {
|
name="action"
|
||||||
if (v) setAction(v as 'allow' | 'deny')
|
render={({ field }) => (
|
||||||
}}
|
<Select
|
||||||
>
|
items={[...OVERRIDE_ACTION_ITEMS]}
|
||||||
<SelectTrigger className="w-full">
|
value={field.value}
|
||||||
<SelectValue />
|
onValueChange={(v) => {
|
||||||
</SelectTrigger>
|
if (v === 'allow' || v === 'deny') field.onChange(v)
|
||||||
<SelectContent>
|
}}
|
||||||
<SelectItem value="deny">deny</SelectItem>
|
>
|
||||||
<SelectItem value="allow">allow</SelectItem>
|
<SelectTrigger className="w-full">
|
||||||
</SelectContent>
|
<SelectValue />
|
||||||
</Select>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OVERRIDE_ACTION_ITEMS.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Button
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
isLoading={add.isPending}
|
||||||
className="self-start"
|
className="self-start"
|
||||||
disabled={!cidr.trim() || add.isPending}
|
|
||||||
onClick={() => add.mutate()}
|
|
||||||
>
|
>
|
||||||
Добавить
|
Добавить
|
||||||
</Button>
|
</LoadingButton>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
|
|||||||
@@ -137,12 +137,14 @@ function ListDetailPage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const text = addValue.trim()
|
if (!addParse || addParse.valid === 0) {
|
||||||
if (!text) throw new Error('Введите значение')
|
throw new Error('Нет валидных записей')
|
||||||
// Backend auto-classifies each token via parseListEntry (ip / cidr / hostname)
|
}
|
||||||
|
// Отправляем только валидные уникальные токены; backend классифицирует
|
||||||
|
// каждый через parseListEntry (ip / cidr / hostname)
|
||||||
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
return apiFetch(`/api/v1/lists/${id}/entries`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ values: [text] }),
|
body: JSON.stringify({ values: addParse.values }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -291,10 +293,35 @@ function ListDetailPage() {
|
|||||||
return [...kinds]
|
return [...kinds]
|
||||||
}, [addMode, addValue])
|
}, [addMode, addValue])
|
||||||
|
|
||||||
|
/** Живой разбор вставки: валидные / не распознанные / дубликаты. */
|
||||||
|
const addParse = useMemo(() => {
|
||||||
|
if (addMode !== 'plaintext') return null
|
||||||
|
const text = addValue.trim()
|
||||||
|
if (!text) return null
|
||||||
|
const seen = new Set<string>()
|
||||||
|
let valid = 0
|
||||||
|
let invalid = 0
|
||||||
|
let duplicates = 0
|
||||||
|
for (const token of splitListPlaintext(text)) {
|
||||||
|
try {
|
||||||
|
const parsed = parseListEntry(token)
|
||||||
|
if (seen.has(parsed.value)) {
|
||||||
|
duplicates++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.add(parsed.value)
|
||||||
|
valid++
|
||||||
|
} catch {
|
||||||
|
invalid++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid, invalid, duplicates, values: [...seen] }
|
||||||
|
}, [addMode, addValue])
|
||||||
|
|
||||||
const canAdd =
|
const canAdd =
|
||||||
addMode === 'nested'
|
addMode === 'nested'
|
||||||
? Boolean(addListRef)
|
? Boolean(addListRef)
|
||||||
: Boolean(addValue.trim())
|
: Boolean(addParse && addParse.valid > 0)
|
||||||
|
|
||||||
if (listQ.isLoading) {
|
if (listQ.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -589,18 +616,38 @@ function ListDetailPage() {
|
|||||||
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
|
placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'}
|
||||||
onChange={(e) => setAddValue(e.target.value)}
|
onChange={(e) => setAddValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{detectedKinds.length > 0 ? (
|
{addParse && addParse.valid > 0 ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="text-muted-foreground text-xs">
|
{detectedKinds.length > 0 ? (
|
||||||
Определено:
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
</span>
|
<span className="text-muted-foreground text-xs">
|
||||||
{detectedKinds.map((kind) => (
|
Определено:
|
||||||
<StatusBadge key={kind} status={kind} />
|
</span>
|
||||||
))}
|
{detectedKinds.map((kind) => (
|
||||||
|
<StatusBadge key={kind} status={kind} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span className="text-success">
|
||||||
|
{addParse.valid} валидно
|
||||||
|
</span>
|
||||||
|
{addParse.invalid > 0 ? (
|
||||||
|
<span className="text-destructive">
|
||||||
|
{addParse.invalid} не распознано
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{addParse.duplicates > 0 ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{addParse.duplicates} дубликатов будет пропущено
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : addValue.trim() ? (
|
) : addValue.trim() ? (
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-destructive text-xs">
|
||||||
Не распознано — проверьте формат перед добавлением.
|
Нет валидных записей — проверьте формат (IP, CIDR или
|
||||||
|
домен).
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Plus, RefreshCwIcon } from 'lucide-react'
|
import { Plus, RefreshCwIcon } from 'lucide-react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
import type { Filter } from '@/components/reui/filters'
|
import type { Filter } from '@/components/reui/filters'
|
||||||
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
import { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit'
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +34,8 @@ import {
|
|||||||
AutocompleteList,
|
AutocompleteList,
|
||||||
} from '@/components/reui/autocomplete'
|
} from '@/components/reui/autocomplete'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
|
import { FieldError } from '@evofw/ui/components/field'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
@@ -50,7 +55,6 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@evofw/ui/components/sheet'
|
} from '@evofw/ui/components/sheet'
|
||||||
import { guessListSourceFromInput } from '@evofw/shared'
|
|
||||||
|
|
||||||
const LIST_TAB_IDS = LIST_TABS.map((t) => t.id) as string[]
|
const LIST_TAB_IDS = LIST_TABS.map((t) => t.id) as string[]
|
||||||
|
|
||||||
@@ -78,6 +82,33 @@ export const Route = createFileRoute('/_auth/lists/')({
|
|||||||
|
|
||||||
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
|
type CreateSource = 'static' | 'json_url' | 'evobgp_community'
|
||||||
|
|
||||||
|
function buildCreateListSchema(resolveCommunity: (input: string) => string) {
|
||||||
|
return z
|
||||||
|
.object({
|
||||||
|
name: z.string().trim().min(2, 'Минимум 2 символа'),
|
||||||
|
source: z.enum(['static', 'json_url', 'evobgp_community']),
|
||||||
|
extra: z.string(),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
if (v.source === 'json_url' && !/^https?:\/\//i.test(v.extra.trim())) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['extra'],
|
||||||
|
message: 'Укажите полный URL, начиная с http:// или https://',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (v.source === 'evobgp_community' && !resolveCommunity(v.extra)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['extra'],
|
||||||
|
message: 'Выберите community из списка или укажите ID',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateListValues = z.infer<ReturnType<typeof buildCreateListSchema>>
|
||||||
|
|
||||||
const CREATE_SOURCE_ITEMS = [
|
const CREATE_SOURCE_ITEMS = [
|
||||||
{ value: 'static', label: 'Ручной' },
|
{ value: 'static', label: 'Ручной' },
|
||||||
{ value: 'json_url', label: 'JSON по URL' },
|
{ value: 'json_url', label: 'JSON по URL' },
|
||||||
@@ -101,13 +132,16 @@ function ListsPage() {
|
|||||||
const filters = fParam ?? []
|
const filters = fParam ?? []
|
||||||
const [createOpenState, setCreateOpenState] = useState(false)
|
const [createOpenState, setCreateOpenState] = useState(false)
|
||||||
const createOpen = createOpenState || createParam === true
|
const createOpen = createOpenState || createParam === true
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [source, setSource] = useState<CreateSource>('static')
|
|
||||||
const [extra, setExtra] = useState('')
|
|
||||||
/** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */
|
|
||||||
const [communityId, setCommunityId] = useState('')
|
|
||||||
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
const [deleteListId, setDeleteListId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const createForm = useForm<CreateListValues>({
|
||||||
|
resolver: zodResolver(buildCreateListSchema((input) => resolveCommunityId(input))),
|
||||||
|
defaultValues: { name: '', source: 'static', extra: '' },
|
||||||
|
mode: 'onBlur',
|
||||||
|
})
|
||||||
|
const createSource = createForm.watch('source') as CreateSource
|
||||||
|
const createExtra = createForm.watch('extra')
|
||||||
|
|
||||||
/** Обновление URL-состояния (tab/q/f) без перезагрузки. */
|
/** Обновление URL-состояния (tab/q/f) без перезагрузки. */
|
||||||
const setListSearch = useCallback(
|
const setListSearch = useCallback(
|
||||||
(next: { tab?: string; q?: string; f?: Filter[]; create?: boolean }) => {
|
(next: { tab?: string; q?: string; f?: Filter[]; create?: boolean }) => {
|
||||||
@@ -133,7 +167,7 @@ function ListsPage() {
|
|||||||
const listsQ = useQuery(listsQueryOptions())
|
const listsQ = useQuery(listsQueryOptions())
|
||||||
const communitiesQ = useQuery({
|
const communitiesQ = useQuery({
|
||||||
...evobgpCommunitiesQueryOptions(),
|
...evobgpCommunitiesQueryOptions(),
|
||||||
enabled: createOpen && source === 'evobgp_community',
|
enabled: createOpen && createSource === 'evobgp_community',
|
||||||
})
|
})
|
||||||
|
|
||||||
const communityItems = useMemo(
|
const communityItems = useMemo(
|
||||||
@@ -161,24 +195,21 @@ function ListsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async (values: CreateListValues) => {
|
||||||
const config: Record<string, unknown> = {}
|
const config: Record<string, unknown> = {}
|
||||||
if (source === 'json_url') {
|
if (values.source === 'json_url') {
|
||||||
config.url = extra.trim()
|
config.url = values.extra.trim()
|
||||||
} else if (source === 'evobgp_community') {
|
} else if (values.source === 'evobgp_community') {
|
||||||
config.community_id = communityId || resolveCommunityId(extra)
|
config.community_id = resolveCommunityId(values.extra)
|
||||||
}
|
}
|
||||||
return apiFetch<{ id: string }>('/api/v1/lists', {
|
return apiFetch<{ id: string }>('/api/v1/lists', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ name, type: source, config }),
|
body: JSON.stringify({ name: values.name, type: values.source, config }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
onSuccess: (row) => {
|
onSuccess: (row) => {
|
||||||
toast.success('Список создан')
|
toast.success('Список создан')
|
||||||
setName('')
|
createForm.reset()
|
||||||
setExtra('')
|
|
||||||
setCommunityId('')
|
|
||||||
setSource('static')
|
|
||||||
setCreateOpenState(false)
|
setCreateOpenState(false)
|
||||||
void qc.invalidateQueries({ queryKey: ['lists'] })
|
void qc.invalidateQueries({ queryKey: ['lists'] })
|
||||||
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
void navigate({ to: '/lists/$id', params: { id: row.id } })
|
||||||
@@ -226,13 +257,6 @@ function ListsPage() {
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
const canCreate =
|
|
||||||
Boolean(name.trim()) &&
|
|
||||||
(source === 'static' ||
|
|
||||||
(source === 'json_url' && Boolean(extra.trim())) ||
|
|
||||||
(source === 'evobgp_community' &&
|
|
||||||
Boolean(communityId || resolveCommunityId(extra))))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -312,7 +336,7 @@ function ListsPage() {
|
|||||||
disabled={removeList.isPending}
|
disabled={removeList.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Sheet
|
<FormSheet
|
||||||
open={createOpen}
|
open={createOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setCreateOpenState(open)
|
setCreateOpenState(open)
|
||||||
@@ -320,123 +344,129 @@ function ListsPage() {
|
|||||||
setListSearch({ create: false })
|
setListSearch({ create: false })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
title="Новый список"
|
||||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
description="После создания откроется страница со содержимым списка."
|
||||||
<SheetHeader className="shrink-0">
|
form={createForm}
|
||||||
<SheetTitle>Новый список</SheetTitle>
|
onSubmit={async (values) => {
|
||||||
<SheetDescription>
|
await create.mutateAsync(values)
|
||||||
После создания откроется страница со содержимым списка.
|
}}
|
||||||
</SheetDescription>
|
footer={
|
||||||
</SheetHeader>
|
<>
|
||||||
<ScrollArea className="flex-1 px-4">
|
<Button
|
||||||
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
type="button"
|
||||||
<Field>
|
variant="outline"
|
||||||
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
onClick={() => setCreateOpenState(false)}
|
||||||
<Input
|
>
|
||||||
id="list-name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>Источник</FieldLabel>
|
|
||||||
<Select
|
|
||||||
items={[...CREATE_SOURCE_ITEMS]}
|
|
||||||
value={source}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (!v) return
|
|
||||||
setSource(v as CreateSource)
|
|
||||||
setExtra('')
|
|
||||||
setCommunityId('')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{CREATE_SOURCE_ITEMS.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
{item.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
{source === 'json_url' ? (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
|
||||||
<Input
|
|
||||||
id="list-url"
|
|
||||||
value={extra}
|
|
||||||
placeholder="https://…"
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value
|
|
||||||
setExtra(v)
|
|
||||||
if (guessListSourceFromInput(v) === 'json_url') {
|
|
||||||
setSource('json_url')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
) : null}
|
|
||||||
{source === 'evobgp_community' ? (
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>BGP community</FieldLabel>
|
|
||||||
<Autocomplete
|
|
||||||
items={communityItems}
|
|
||||||
value={extra}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setExtra(v)
|
|
||||||
// Base UI {value,label}: input stores label by default —
|
|
||||||
// keep UUID separately for config.community_id.
|
|
||||||
// Preview: https://reui.io/preview/base/components/c-autocomplete-9
|
|
||||||
// Docs: https://reui.io/docs/components/base/autocomplete
|
|
||||||
// Base UI: https://base-ui.com/react/components/autocomplete
|
|
||||||
setCommunityId(resolveCommunityId(v))
|
|
||||||
}}
|
|
||||||
itemToStringValue={(item) => item.label}
|
|
||||||
>
|
|
||||||
<AutocompleteInput
|
|
||||||
placeholder={
|
|
||||||
communitiesQ.isError
|
|
||||||
? 'ID вручную (EvoBGP недоступен)'
|
|
||||||
: 'Поиск community…'
|
|
||||||
}
|
|
||||||
showClear
|
|
||||||
/>
|
|
||||||
<AutocompleteContent>
|
|
||||||
<AutocompleteEmpty>
|
|
||||||
{communitiesQ.isLoading
|
|
||||||
? 'Загрузка…'
|
|
||||||
: 'Нет совпадений'}
|
|
||||||
</AutocompleteEmpty>
|
|
||||||
<AutocompleteList>
|
|
||||||
{(item) => (
|
|
||||||
<AutocompleteItem key={item.value} value={item}>
|
|
||||||
{item.label}
|
|
||||||
</AutocompleteItem>
|
|
||||||
)}
|
|
||||||
</AutocompleteList>
|
|
||||||
</AutocompleteContent>
|
|
||||||
</Autocomplete>
|
|
||||||
</Field>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
|
||||||
<Button variant="outline" onClick={() => setCreateOpenState(false)}>
|
|
||||||
Отмена
|
Отмена
|
||||||
</Button>
|
</Button>
|
||||||
<LoadingButton
|
<LoadingButton type="submit" isLoading={create.isPending}>
|
||||||
disabled={!canCreate}
|
|
||||||
isLoading={create.isPending}
|
|
||||||
onClick={() => create.mutate()}
|
|
||||||
>
|
|
||||||
Создать
|
Создать
|
||||||
</LoadingButton>
|
</LoadingButton>
|
||||||
</SheetFooter>
|
</>
|
||||||
</SheetContent>
|
}
|
||||||
</Sheet>
|
>
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(createForm.formState.errors.name) || undefined}
|
||||||
|
>
|
||||||
|
<FieldLabel htmlFor="list-name">Имя</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="list-name"
|
||||||
|
placeholder="spamhaus-drop"
|
||||||
|
{...createForm.register('name')}
|
||||||
|
aria-invalid={
|
||||||
|
Boolean(createForm.formState.errors.name) || undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[createForm.formState.errors.name]} />
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Источник</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
control={createForm.control}
|
||||||
|
name="source"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Select
|
||||||
|
items={[...CREATE_SOURCE_ITEMS]}
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (!v) return
|
||||||
|
field.onChange(v)
|
||||||
|
createForm.setValue('extra', '')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{CREATE_SOURCE_ITEMS.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{createSource === 'json_url' ? (
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(createForm.formState.errors.extra) || undefined}
|
||||||
|
>
|
||||||
|
<FieldLabel htmlFor="list-url">URL JSON</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="list-url"
|
||||||
|
placeholder="https://…"
|
||||||
|
{...createForm.register('extra')}
|
||||||
|
aria-invalid={
|
||||||
|
Boolean(createForm.formState.errors.extra) || undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[createForm.formState.errors.extra]} />
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
{createSource === 'evobgp_community' ? (
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(createForm.formState.errors.extra) || undefined}
|
||||||
|
>
|
||||||
|
<FieldLabel>BGP community</FieldLabel>
|
||||||
|
<Autocomplete
|
||||||
|
items={communityItems}
|
||||||
|
value={createExtra}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
createForm.setValue('extra', v)
|
||||||
|
// Base UI {value,label}: input stores label by default —
|
||||||
|
// UUID резолвится на submit через resolveCommunityId.
|
||||||
|
// Preview: https://reui.io/preview/base/components/c-autocomplete-9
|
||||||
|
// Docs: https://reui.io/docs/components/base/autocomplete
|
||||||
|
// Base UI: https://base-ui.com/react/components/autocomplete
|
||||||
|
}}
|
||||||
|
itemToStringValue={(item) => item.label}
|
||||||
|
>
|
||||||
|
<AutocompleteInput
|
||||||
|
placeholder={
|
||||||
|
communitiesQ.isError
|
||||||
|
? 'ID вручную (EvoBGP недоступен)'
|
||||||
|
: 'Поиск community…'
|
||||||
|
}
|
||||||
|
showClear
|
||||||
|
/>
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>
|
||||||
|
{communitiesQ.isLoading ? 'Загрузка…' : 'Нет совпадений'}
|
||||||
|
</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => (
|
||||||
|
<AutocompleteItem key={item.value} value={item}>
|
||||||
|
{item.label}
|
||||||
|
</AutocompleteItem>
|
||||||
|
)}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
|
<FieldError errors={[createForm.formState.errors.extra]} />
|
||||||
|
</Field>
|
||||||
|
) : null}
|
||||||
|
</FormSheet>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { parseListEntry } from '@evofw/shared'
|
||||||
import {
|
import {
|
||||||
CircleAlertIcon,
|
CircleAlertIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
@@ -19,6 +23,8 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu
|
|||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { FormSheet } from '@/components/form-sheet'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
import { PolicyRulesSortable } from '@/components/rules/policy-rules-sortable'
|
||||||
import {
|
import {
|
||||||
agentsQueryOptions,
|
agentsQueryOptions,
|
||||||
@@ -29,7 +35,7 @@ import {
|
|||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Checkbox } from '@evofw/ui/components/checkbox'
|
import { Checkbox } from '@evofw/ui/components/checkbox'
|
||||||
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
import { Field, FieldError, FieldLabel } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
import { Switch } from '@evofw/ui/components/switch'
|
import { Switch } from '@evofw/ui/components/switch'
|
||||||
import {
|
import {
|
||||||
@@ -72,6 +78,68 @@ export const Route = createFileRoute('/_auth/rules/$setId')({
|
|||||||
|
|
||||||
type SourceKind = 'list' | 'cidr' | 'hostname'
|
type SourceKind = 'list' | 'cidr' | 'hostname'
|
||||||
|
|
||||||
|
const ruleSchema = z
|
||||||
|
.object({
|
||||||
|
action: z.enum(['allow', 'deny']),
|
||||||
|
source: z.enum(['list', 'cidr', 'hostname']),
|
||||||
|
listId: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
if (v.source === 'list') {
|
||||||
|
if (!v.listId) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['listId'],
|
||||||
|
message: 'Выберите список',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const raw = v.value.trim()
|
||||||
|
if (!raw) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['value'],
|
||||||
|
message: v.source === 'cidr' ? 'Укажите CIDR' : 'Укажите DNS-имя',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let parsed: ReturnType<typeof parseListEntry> | null = null
|
||||||
|
try {
|
||||||
|
parsed = parseListEntry(raw)
|
||||||
|
} catch {
|
||||||
|
parsed = null
|
||||||
|
}
|
||||||
|
if (v.source === 'cidr' && (!parsed || (parsed.kind !== 'cidr' && parsed.kind !== 'ip'))) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['value'],
|
||||||
|
message: 'Ожидается CIDR, например 203.0.113.0/24',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (v.source === 'hostname' && (!parsed || parsed.kind !== 'hostname')) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['value'],
|
||||||
|
message: 'Ожидается DNS-имя, например bad.example.com',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
type RuleValues = z.infer<typeof ruleSchema>
|
||||||
|
|
||||||
|
const RULE_ACTION_ITEMS = [
|
||||||
|
{ value: 'deny', label: 'deny — блокировать' },
|
||||||
|
{ value: 'allow', label: 'allow — пропускать' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const RULE_SOURCE_ITEMS = [
|
||||||
|
{ value: 'cidr', label: 'CIDR / IP' },
|
||||||
|
{ value: 'hostname', label: 'DNS-имя' },
|
||||||
|
{ value: 'list', label: 'IP-список' },
|
||||||
|
] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Policy set detail — Sortable rules (ReUI PRO) + agents.
|
* Policy set detail — Sortable rules (ReUI PRO) + agents.
|
||||||
* Preview: https://reui.io/preview/base/components/c-sortable-5 · settings-8 · settings-3
|
* Preview: https://reui.io/preview/base/components/c-sortable-5 · settings-8 · settings-3
|
||||||
@@ -86,14 +154,16 @@ function PolicySetDetailPage() {
|
|||||||
const listsQ = useQuery(listsQueryOptions())
|
const listsQ = useQuery(listsQueryOptions())
|
||||||
|
|
||||||
const [ruleOpen, setRuleOpen] = useState(false)
|
const [ruleOpen, setRuleOpen] = useState(false)
|
||||||
const [action, setAction] = useState<'allow' | 'deny'>('deny')
|
|
||||||
const [source, setSource] = useState<SourceKind>('cidr')
|
|
||||||
const [listId, setListId] = useState('')
|
|
||||||
const [cidr, setCidr] = useState('')
|
|
||||||
const [hostname, setHostname] = useState('')
|
|
||||||
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null)
|
const [selectedAgents, setSelectedAgents] = useState<string[] | null>(null)
|
||||||
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null)
|
const [deleteRuleId, setDeleteRuleId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const ruleForm = useForm<RuleValues>({
|
||||||
|
resolver: zodResolver(ruleSchema),
|
||||||
|
defaultValues: { action: 'deny', source: 'cidr', listId: '', value: '' },
|
||||||
|
mode: 'onBlur',
|
||||||
|
})
|
||||||
|
const ruleSource = ruleForm.watch('source')
|
||||||
|
|
||||||
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
|
const assignedIds = selectedAgents ?? setQ.data?.agent_ids ?? []
|
||||||
|
|
||||||
const approvedAgents = useMemo(
|
const approvedAgents = useMemo(
|
||||||
@@ -150,14 +220,16 @@ function PolicySetDetailPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const createRule = useMutation({
|
const createRule = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: (values: RuleValues) => {
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
set_id: setId,
|
set_id: setId,
|
||||||
action,
|
action: values.action,
|
||||||
|
}
|
||||||
|
if (values.source === 'list') body.list_id = values.listId
|
||||||
|
if (values.source === 'cidr') body.cidr = values.value.trim()
|
||||||
|
if (values.source === 'hostname') {
|
||||||
|
body.hostname = values.value.trim().toLowerCase()
|
||||||
}
|
}
|
||||||
if (source === 'list') body.list_id = listId
|
|
||||||
if (source === 'cidr') body.cidr = cidr.trim()
|
|
||||||
if (source === 'hostname') body.hostname = hostname.trim()
|
|
||||||
return apiFetch('/api/v1/rules', {
|
return apiFetch('/api/v1/rules', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -166,9 +238,7 @@ function PolicySetDetailPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Правило создано')
|
toast.success('Правило создано')
|
||||||
setRuleOpen(false)
|
setRuleOpen(false)
|
||||||
setCidr('')
|
ruleForm.reset()
|
||||||
setHostname('')
|
|
||||||
setListId('')
|
|
||||||
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
void qc.invalidateQueries({ queryKey: ['policy-sets', setId] })
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
@@ -261,13 +331,6 @@ function PolicySetDetailPage() {
|
|||||||
state: { rowSelection: agentRowSelection },
|
state: { rowSelection: agentRowSelection },
|
||||||
})
|
})
|
||||||
|
|
||||||
const canCreate =
|
|
||||||
source === 'list'
|
|
||||||
? Boolean(listId)
|
|
||||||
: source === 'cidr'
|
|
||||||
? Boolean(cidr.trim())
|
|
||||||
: Boolean(hostname.trim())
|
|
||||||
|
|
||||||
if (setQ.isLoading) {
|
if (setQ.isLoading) {
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -442,57 +505,103 @@ function PolicySetDetailPage() {
|
|||||||
disabled={removeRule.isPending}
|
disabled={removeRule.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Sheet open={ruleOpen} onOpenChange={setRuleOpen}>
|
<FormSheet
|
||||||
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
open={ruleOpen}
|
||||||
<SheetHeader className="shrink-0">
|
onOpenChange={(open) => {
|
||||||
<SheetTitle>Новое правило</SheetTitle>
|
setRuleOpen(open)
|
||||||
<SheetDescription>
|
if (!open) ruleForm.reset()
|
||||||
Один источник: список, CIDR или DNS-имя (priority — в конец)
|
}}
|
||||||
</SheetDescription>
|
title="Новое правило"
|
||||||
</SheetHeader>
|
description="Один источник: список, CIDR или DNS-имя (priority — в конец)"
|
||||||
<div className="grid flex-1 auto-rows-min gap-4 overflow-y-auto px-4">
|
form={ruleForm}
|
||||||
<Field>
|
onSubmit={async (values) => {
|
||||||
<FieldLabel>Action</FieldLabel>
|
await createRule.mutateAsync(values)
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRuleOpen(false)}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<LoadingButton type="submit" isLoading={createRule.isPending}>
|
||||||
|
Создать
|
||||||
|
</LoadingButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Действие</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
control={ruleForm.control}
|
||||||
|
name="action"
|
||||||
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
value={action}
|
items={RULE_ACTION_ITEMS}
|
||||||
|
value={field.value}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v) setAction(v as 'allow' | 'deny')
|
if (v === 'allow' || v === 'deny') field.onChange(v)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="deny">deny</SelectItem>
|
{RULE_ACTION_ITEMS.map((item) => (
|
||||||
<SelectItem value="allow">allow</SelectItem>
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
)}
|
||||||
<Field>
|
/>
|
||||||
<FieldLabel>Источник</FieldLabel>
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Источник</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
control={ruleForm.control}
|
||||||
|
name="source"
|
||||||
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
value={source}
|
items={[...RULE_SOURCE_ITEMS]}
|
||||||
|
value={field.value}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
if (v) setSource(v as SourceKind)
|
if (v === 'list' || v === 'cidr' || v === 'hostname') {
|
||||||
|
field.onChange(v)
|
||||||
|
ruleForm.setValue('value', '')
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="cidr">CIDR / IP</SelectItem>
|
{RULE_SOURCE_ITEMS.map((item) => (
|
||||||
<SelectItem value="hostname">DNS-имя</SelectItem>
|
<SelectItem key={item.value} value={item.value}>
|
||||||
<SelectItem value="list">IP-список</SelectItem>
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
)}
|
||||||
{source === 'list' ? (
|
/>
|
||||||
<Field>
|
</Field>
|
||||||
<FieldLabel>Список</FieldLabel>
|
{ruleSource === 'list' ? (
|
||||||
|
<Field
|
||||||
|
data-invalid={Boolean(ruleForm.formState.errors.listId) || undefined}
|
||||||
|
>
|
||||||
|
<FieldLabel>Список</FieldLabel>
|
||||||
|
<Controller
|
||||||
|
control={ruleForm.control}
|
||||||
|
name="listId"
|
||||||
|
render={({ field }) => (
|
||||||
<Select
|
<Select
|
||||||
items={listSelectItems}
|
items={listSelectItems}
|
||||||
value={listId || null}
|
value={field.value || null}
|
||||||
onValueChange={(v) => setListId(v ?? '')}
|
onValueChange={(v) => field.onChange(v ?? '')}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="Выберите список" />
|
<SelectValue placeholder="Выберите список" />
|
||||||
@@ -505,44 +614,44 @@ function PolicySetDetailPage() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
)}
|
||||||
) : null}
|
/>
|
||||||
{source === 'cidr' ? (
|
<FieldError errors={[ruleForm.formState.errors.listId]} />
|
||||||
<Field>
|
</Field>
|
||||||
<FieldLabel htmlFor="cidr">CIDR</FieldLabel>
|
) : null}
|
||||||
<Input
|
{ruleSource === 'cidr' ? (
|
||||||
id="cidr"
|
<Field
|
||||||
value={cidr}
|
data-invalid={Boolean(ruleForm.formState.errors.value) || undefined}
|
||||||
onChange={(e) => setCidr(e.target.value)}
|
>
|
||||||
placeholder="203.0.113.0/24"
|
<FieldLabel htmlFor="rule-cidr">CIDR</FieldLabel>
|
||||||
/>
|
<Input
|
||||||
</Field>
|
id="rule-cidr"
|
||||||
) : null}
|
placeholder="203.0.113.0/24"
|
||||||
{source === 'hostname' ? (
|
{...ruleForm.register('value')}
|
||||||
<Field>
|
aria-invalid={
|
||||||
<FieldLabel htmlFor="host">DNS-имя</FieldLabel>
|
Boolean(ruleForm.formState.errors.value) || undefined
|
||||||
<Input
|
}
|
||||||
id="host"
|
/>
|
||||||
value={hostname}
|
<FieldError errors={[ruleForm.formState.errors.value]} />
|
||||||
onChange={(e) => setHostname(e.target.value)}
|
</Field>
|
||||||
placeholder="bad.example.com"
|
) : null}
|
||||||
/>
|
{ruleSource === 'hostname' ? (
|
||||||
</Field>
|
<Field
|
||||||
) : null}
|
data-invalid={Boolean(ruleForm.formState.errors.value) || undefined}
|
||||||
</div>
|
>
|
||||||
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
<FieldLabel htmlFor="rule-host">DNS-имя</FieldLabel>
|
||||||
<Button variant="outline" onClick={() => setRuleOpen(false)}>
|
<Input
|
||||||
Отмена
|
id="rule-host"
|
||||||
</Button>
|
placeholder="bad.example.com"
|
||||||
<Button
|
{...ruleForm.register('value')}
|
||||||
disabled={!canCreate || createRule.isPending}
|
aria-invalid={
|
||||||
onClick={() => createRule.mutate()}
|
Boolean(ruleForm.formState.errors.value) || undefined
|
||||||
>
|
}
|
||||||
Создать
|
/>
|
||||||
</Button>
|
<FieldError errors={[ruleForm.formState.errors.value]} />
|
||||||
</SheetFooter>
|
</Field>
|
||||||
</SheetContent>
|
) : null}
|
||||||
</Sheet>
|
</FormSheet>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { Controller, useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
||||||
import { PageHeader, PageShell } from '@/components/reui-kit'
|
import { PageHeader, PageShell } from '@/components/reui-kit'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
Frame,
|
Frame,
|
||||||
FrameDescription,
|
FrameDescription,
|
||||||
@@ -15,11 +20,14 @@ import { SettingRow } from '@/components/setting-row'
|
|||||||
import { settingsQueryOptions } from '@/queries'
|
import { settingsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { useCan } from '@/lib/permissions'
|
import { useCan } from '@/lib/permissions'
|
||||||
|
import { Button } from '@evofw/ui/components/button'
|
||||||
|
import { Field, FieldError } from '@evofw/ui/components/field'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
import { Input } from '@evofw/ui/components/input'
|
||||||
import { Switch } from '@evofw/ui/components/switch'
|
import { Switch } from '@evofw/ui/components/switch'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Control plane settings — single page: PageShell + Frame + SettingRow.
|
* Control plane settings — single page: PageShell + Frame + SettingRow.
|
||||||
|
* RHF+zod: валидация полей, dirty-state, reveal токена.
|
||||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -27,26 +35,63 @@ export const Route = createFileRoute('/_auth/settings')({
|
|||||||
component: SettingsPage,
|
component: SettingsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const settingsSchema = z.object({
|
||||||
|
enroll_seed: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, 'Обязательное поле — используется в install.sh'),
|
||||||
|
evobgp_api_url: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine(
|
||||||
|
(v) => v === '' || /^https?:\/\//i.test(v),
|
||||||
|
'URL должен начинаться с http:// или https://',
|
||||||
|
),
|
||||||
|
evobgp_api_token: z.string(),
|
||||||
|
agent_sync_interval_sec: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((v) => {
|
||||||
|
const n = Number(v)
|
||||||
|
return Number.isInteger(n) && n >= 30 && n <= 86400
|
||||||
|
}, 'Целое число секунд, 30–86400'),
|
||||||
|
show_quick_actions: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type SettingsValues = z.infer<typeof settingsSchema>
|
||||||
|
|
||||||
|
const EMPTY_VALUES: SettingsValues = {
|
||||||
|
enroll_seed: '',
|
||||||
|
evobgp_api_url: '',
|
||||||
|
evobgp_api_token: '',
|
||||||
|
agent_sync_interval_sec: '60',
|
||||||
|
show_quick_actions: 'true',
|
||||||
|
}
|
||||||
|
|
||||||
function SettingsPage() {
|
function SettingsPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const settingsQ = useQuery(settingsQueryOptions())
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
const canSave = useCan()('fw:settings:admin')
|
const canSave = useCan()('fw:settings:admin')
|
||||||
const [form, setForm] = useState<Record<string, string>>({})
|
|
||||||
|
|
||||||
// Seed the form once — a background refetch must not wipe in-progress edits.
|
const form = useForm<SettingsValues>({
|
||||||
const initialized = useRef(false)
|
resolver: zodResolver(settingsSchema),
|
||||||
|
defaultValues: EMPTY_VALUES,
|
||||||
|
mode: 'onBlur',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Seed once — background refetch не должен затирать правки в процессе.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsQ.data && !initialized.current) {
|
if (settingsQ.data) {
|
||||||
initialized.current = true
|
form.reset({ ...EMPTY_VALUES, ...settingsQ.data })
|
||||||
setForm(settingsQ.data)
|
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [settingsQ.data])
|
}, [settingsQ.data])
|
||||||
|
|
||||||
const save = useMutation({
|
const save = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: (values: SettingsValues) =>
|
||||||
apiFetch('/api/v1/settings', {
|
apiFetch('/api/v1/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(form),
|
body: JSON.stringify(values),
|
||||||
}),
|
}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Сохранено')
|
toast.success('Сохранено')
|
||||||
@@ -56,97 +101,171 @@ function SettingsPage() {
|
|||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const showQuickActions = form.show_quick_actions !== 'false'
|
const isDirty = form.formState.isDirty
|
||||||
|
|
||||||
const fields = [
|
|
||||||
{
|
|
||||||
key: 'enroll_seed',
|
|
||||||
label: 'Enroll seed',
|
|
||||||
hint: 'X-EvoFW-Seed для install.sh',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'evobgp_api_url',
|
|
||||||
label: 'EvoBGP API URL',
|
|
||||||
hint: 'Источник community prefixes',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'evobgp_api_token',
|
|
||||||
label: 'EvoBGP API token',
|
|
||||||
hint: 'Bearer для интеграции',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'agent_sync_interval_sec',
|
|
||||||
label: 'Agent sync interval (sec)',
|
|
||||||
hint: 'Рекомендуется 30–60',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Настройки"
|
title="Настройки"
|
||||||
description="Интеграции и enroll — settings-16"
|
description="Интеграции, enroll и интервал синхронизации"
|
||||||
|
actions={
|
||||||
|
isDirty ? (
|
||||||
|
<Badge variant="warning-light" size="sm">
|
||||||
|
Есть несохранённые изменения
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Frame dense spacing="sm">
|
<form
|
||||||
<FrameHeader>
|
onSubmit={form.handleSubmit((values) => save.mutateAsync(values))}
|
||||||
<div>
|
className="flex flex-col gap-4 md:gap-6"
|
||||||
<FrameTitle>Control plane</FrameTitle>
|
>
|
||||||
<FrameDescription>
|
<Frame dense spacing="sm">
|
||||||
Auth-portal app id: fw · JWT через AUTH_*
|
<FrameHeader>
|
||||||
</FrameDescription>
|
<div>
|
||||||
</div>
|
<FrameTitle>Control plane</FrameTitle>
|
||||||
</FrameHeader>
|
<FrameDescription>
|
||||||
<FramePanel className="p-0">
|
Auth-portal app id: fw · JWT через AUTH_*
|
||||||
<div className="flex flex-col">
|
</FrameDescription>
|
||||||
{fields.map((f, i) => (
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<div className="flex flex-col">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
key={f.key}
|
title="Enroll seed"
|
||||||
title={f.label}
|
description="X-EvoFW-Seed для install.sh"
|
||||||
description={f.hint}
|
labelFor="enroll_seed"
|
||||||
labelFor={f.key}
|
|
||||||
last={false}
|
last={false}
|
||||||
>
|
>
|
||||||
<Input
|
<Field data-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}>
|
||||||
id={f.key}
|
<Input
|
||||||
type={f.key.includes('token') ? 'password' : 'text'}
|
id="enroll_seed"
|
||||||
value={form[f.key] ?? ''}
|
{...form.register('enroll_seed')}
|
||||||
onChange={(e) =>
|
aria-invalid={Boolean(form.formState.errors.enroll_seed) || undefined}
|
||||||
setForm((prev) => ({ ...prev, [f.key]: e.target.value }))
|
/>
|
||||||
}
|
<FieldError errors={[form.formState.errors.enroll_seed]} />
|
||||||
|
</Field>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="EvoBGP API URL"
|
||||||
|
description="Источник community prefixes"
|
||||||
|
labelFor="evobgp_api_url"
|
||||||
|
last={false}
|
||||||
|
>
|
||||||
|
<Field data-invalid={Boolean(form.formState.errors.evobgp_api_url) || undefined}>
|
||||||
|
<Input
|
||||||
|
id="evobgp_api_url"
|
||||||
|
placeholder="https://…"
|
||||||
|
{...form.register('evobgp_api_url')}
|
||||||
|
aria-invalid={Boolean(form.formState.errors.evobgp_api_url) || undefined}
|
||||||
|
/>
|
||||||
|
<FieldError errors={[form.formState.errors.evobgp_api_url]} />
|
||||||
|
</Field>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="EvoBGP API token"
|
||||||
|
description="Bearer для интеграции"
|
||||||
|
labelFor="evobgp_api_token"
|
||||||
|
last={false}
|
||||||
|
>
|
||||||
|
<TokenField form={form} />
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Интервал синхронизации агентов (сек)"
|
||||||
|
description="Как часто агенты забирают политику (30–86400)"
|
||||||
|
labelFor="agent_sync_interval_sec"
|
||||||
|
last={false}
|
||||||
|
>
|
||||||
|
<Field data-invalid={Boolean(form.formState.errors.agent_sync_interval_sec) || undefined}>
|
||||||
|
<Input
|
||||||
|
id="agent_sync_interval_sec"
|
||||||
|
inputMode="numeric"
|
||||||
|
className="max-w-32"
|
||||||
|
{...form.register('agent_sync_interval_sec')}
|
||||||
|
aria-invalid={
|
||||||
|
Boolean(form.formState.errors.agent_sync_interval_sec) ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldError
|
||||||
|
errors={[form.formState.errors.agent_sync_interval_sec]}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
title="Быстрые действия"
|
||||||
|
description="Показывать Quick Actions на панели и у агентов"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="show_quick_actions"
|
||||||
|
render={({ field }) => (
|
||||||
|
<Switch
|
||||||
|
checked={field.value !== 'false'}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
field.onChange(checked ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
aria-label="Быстрые действия"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
))}
|
</div>
|
||||||
<SettingRow
|
</FramePanel>
|
||||||
title="Быстрые действия"
|
</Frame>
|
||||||
description="Показывать Quick Actions на панели и у агентов"
|
{canSave ? (
|
||||||
last
|
<div className="flex justify-end">
|
||||||
|
<LoadingButton
|
||||||
|
type="submit"
|
||||||
|
isLoading={save.isPending}
|
||||||
|
disabled={!settingsQ.isSuccess}
|
||||||
|
loadingLabel="Сохранение…"
|
||||||
>
|
>
|
||||||
<Switch
|
Сохранить
|
||||||
checked={showQuickActions}
|
</LoadingButton>
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setForm((prev) => ({
|
|
||||||
...prev,
|
|
||||||
show_quick_actions: checked ? 'true' : 'false',
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
aria-label="Быстрые действия"
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</div>
|
</div>
|
||||||
</FramePanel>
|
) : null}
|
||||||
</Frame>
|
</form>
|
||||||
{canSave ? (
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<LoadingButton
|
|
||||||
onClick={() => save.mutate()}
|
|
||||||
isLoading={save.isPending}
|
|
||||||
disabled={!initialized.current}
|
|
||||||
loadingLabel="Сохранение…"
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</LoadingButton>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</PageShell>
|
</PageShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Password-поле с reveal (Eye toggle). */
|
||||||
|
function TokenField({
|
||||||
|
form,
|
||||||
|
}: {
|
||||||
|
form: ReturnType<typeof useForm<SettingsValues>>
|
||||||
|
}) {
|
||||||
|
const [visible, setVisible] = useState(false)
|
||||||
|
const invalid = Boolean(form.formState.errors.evobgp_api_token)
|
||||||
|
return (
|
||||||
|
<Field data-invalid={invalid || undefined}>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="evobgp_api_token"
|
||||||
|
type={visible ? 'text' : 'password'}
|
||||||
|
className="pr-10"
|
||||||
|
autoComplete="off"
|
||||||
|
{...form.register('evobgp_api_token')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-1/2 right-1 -translate-y-1/2"
|
||||||
|
aria-label={visible ? 'Скрыть токен' : 'Показать токен'}
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
>
|
||||||
|
{visible ? (
|
||||||
|
<EyeOffIcon className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<EyeIcon className="size-3.5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FieldError errors={[form.formState.errors.evobgp_api_token]} />
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user