refactor(web): RHF+zod валидация форм — создание списка, правила, override, настройки (dirty-state, reveal токена), живой разбор вставок

This commit is contained in:
Denozordec
2026-09-25 01:53:07 +07:00
parent 65434857bb
commit 8a8ea95da6
5 changed files with 709 additions and 357 deletions
@@ -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">
+62 -15
View File
@@ -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>
+168 -138
View File
@@ -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>
) )
} }
+197 -88
View File
@@ -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>
) )
} }
+206 -87
View File
@@ -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>
)
}