From 8a8ea95da6767bfee855a11d23b7bb7c8884e6e9 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 25 Sep 2026 01:53:07 +0700 Subject: [PATCH] =?UTF-8?q?refactor(web):=20RHF+zod=20=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=B0,=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=BB=D0=B0,=20override,=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20(dirty-state,=20reveal?= =?UTF-8?q?=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0),=20=D0=B6=D0=B8=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=80=D0=B0=D0=B7=D0=B1=D0=BE=D1=80=20=D0=B2?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agents/agent-settings-sheets.tsx | 105 ++++-- apps/web/src/routes/_auth/lists/$id.tsx | 77 ++++- apps/web/src/routes/_auth/lists/index.tsx | 306 ++++++++++-------- apps/web/src/routes/_auth/rules/$setId.tsx | 285 +++++++++++----- apps/web/src/routes/_auth/settings.tsx | 293 ++++++++++++----- 5 files changed, 709 insertions(+), 357 deletions(-) diff --git a/apps/web/src/components/agents/agent-settings-sheets.tsx b/apps/web/src/components/agents/agent-settings-sheets.tsx index f221d70..7aced75 100644 --- a/apps/web/src/components/agents/agent-settings-sheets.tsx +++ b/apps/web/src/components/agents/agent-settings-sheets.tsx @@ -1,6 +1,10 @@ import { useState } from 'react' import { Trash2 } from 'lucide-react' 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 { agentOverridesQueryOptions, @@ -8,8 +12,9 @@ import { } from '@/queries' import { apiFetch } from '@/lib/api' import { Badge } from '@/components/reui/badge' +import { LoadingButton } from '@/components/loading-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 { ScrollArea } from '@evofw/ui/components/scroll-area' import { @@ -40,6 +45,29 @@ type OverrideSheetProps = { 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 + export function AgentOverrideSheet({ agentId, open, @@ -50,18 +78,21 @@ export function AgentOverrideSheet({ ...agentOverridesQueryOptions(agentId), enabled: open, }) - const [cidr, setCidr] = useState('') - const [action, setAction] = useState<'allow' | 'deny'>('deny') + const form = useForm({ + resolver: zodResolver(overrideSchema), + defaultValues: { cidr: '', action: 'deny' }, + mode: 'onBlur', + }) const add = useMutation({ - mutationFn: () => + mutationFn: (values: OverrideValues) => apiFetch(`/api/v1/agents/${agentId}/overrides`, { method: 'POST', - body: JSON.stringify({ cidr, action }), + body: JSON.stringify(values), }), onSuccess: () => { toast.success('Override добавлен — подхватится на следующей итерации sync') - setCidr('') + form.reset({ cidr: '', action: 'deny' }) void qc.invalidateQueries({ queryKey: ['agents', agentId, 'overrides'] }) void qc.invalidateQueries({ queryKey: ['agents', agentId] }) }, @@ -96,42 +127,58 @@ export function AgentOverrideSheet({
-
- +
add.mutateAsync(values))} + className="grid gap-3" + > + CIDR / IP setCidr(e.target.value)} + {...form.register('cidr')} + aria-invalid={Boolean(form.formState.errors.cidr) || undefined} /> + Действие - + ( + + )} + /> - -
+ +

diff --git a/apps/web/src/routes/_auth/lists/$id.tsx b/apps/web/src/routes/_auth/lists/$id.tsx index 8b6e682..c16c99c 100644 --- a/apps/web/src/routes/_auth/lists/$id.tsx +++ b/apps/web/src/routes/_auth/lists/$id.tsx @@ -137,12 +137,14 @@ function ListDetailPage() { }), }) } - const text = addValue.trim() - if (!text) throw new Error('Введите значение') - // Backend auto-classifies each token via parseListEntry (ip / cidr / hostname) + if (!addParse || addParse.valid === 0) { + throw new Error('Нет валидных записей') + } + // Отправляем только валидные уникальные токены; backend классифицирует + // каждый через parseListEntry (ip / cidr / hostname) return apiFetch(`/api/v1/lists/${id}/entries`, { method: 'POST', - body: JSON.stringify({ values: [text] }), + body: JSON.stringify({ values: addParse.values }), }) }, onSuccess: () => { @@ -291,10 +293,35 @@ function ListDetailPage() { return [...kinds] }, [addMode, addValue]) + /** Живой разбор вставки: валидные / не распознанные / дубликаты. */ + const addParse = useMemo(() => { + if (addMode !== 'plaintext') return null + const text = addValue.trim() + if (!text) return null + const seen = new Set() + 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 = addMode === 'nested' ? Boolean(addListRef) - : Boolean(addValue.trim()) + : Boolean(addParse && addParse.valid > 0) if (listQ.isLoading) { return ( @@ -589,18 +616,38 @@ function ListDetailPage() { placeholder={'8.8.8.8\n10.0.0.0/8\nbad.example.com'} onChange={(e) => setAddValue(e.target.value)} /> - {detectedKinds.length > 0 ? ( -

- - Определено: - - {detectedKinds.map((kind) => ( - - ))} + {addParse && addParse.valid > 0 ? ( +
+ {detectedKinds.length > 0 ? ( +
+ + Определено: + + {detectedKinds.map((kind) => ( + + ))} +
+ ) : null} +
+ + {addParse.valid} валидно + + {addParse.invalid > 0 ? ( + + {addParse.invalid} не распознано + + ) : null} + {addParse.duplicates > 0 ? ( + + {addParse.duplicates} дубликатов будет пропущено + + ) : null} +
) : addValue.trim() ? ( -

- Не распознано — проверьте формат перед добавлением. +

+ Нет валидных записей — проверьте формат (IP, CIDR или + домен).

) : null} diff --git a/apps/web/src/routes/_auth/lists/index.tsx b/apps/web/src/routes/_auth/lists/index.tsx index 906933e..e486b72 100644 --- a/apps/web/src/routes/_auth/lists/index.tsx +++ b/apps/web/src/routes/_auth/lists/index.tsx @@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' import { Plus, RefreshCwIcon } from 'lucide-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 { PageHeader, PageShell, ResourcePage } from '@/components/reui-kit' import { @@ -31,6 +34,8 @@ import { AutocompleteList, } from '@/components/reui/autocomplete' 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 { Field, FieldLabel } from '@evofw/ui/components/field' import { Input } from '@evofw/ui/components/input' @@ -50,7 +55,6 @@ import { SheetHeader, SheetTitle, } from '@evofw/ui/components/sheet' -import { guessListSourceFromInput } from '@evofw/shared' 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' +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> + const CREATE_SOURCE_ITEMS = [ { value: 'static', label: 'Ручной' }, { value: 'json_url', label: 'JSON по URL' }, @@ -101,13 +132,16 @@ function ListsPage() { const filters = fParam ?? [] const [createOpenState, setCreateOpenState] = useState(false) const createOpen = createOpenState || createParam === true - const [name, setName] = useState('') - const [source, setSource] = useState('static') - const [extra, setExtra] = useState('') - /** Resolved EvoBGP community UUID (Base UI Autocomplete stores label in input). */ - const [communityId, setCommunityId] = useState('') const [deleteListId, setDeleteListId] = useState(null) + const createForm = useForm({ + 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) без перезагрузки. */ const setListSearch = useCallback( (next: { tab?: string; q?: string; f?: Filter[]; create?: boolean }) => { @@ -133,7 +167,7 @@ function ListsPage() { const listsQ = useQuery(listsQueryOptions()) const communitiesQ = useQuery({ ...evobgpCommunitiesQueryOptions(), - enabled: createOpen && source === 'evobgp_community', + enabled: createOpen && createSource === 'evobgp_community', }) const communityItems = useMemo( @@ -161,24 +195,21 @@ function ListsPage() { } const create = useMutation({ - mutationFn: async () => { + mutationFn: async (values: CreateListValues) => { const config: Record = {} - if (source === 'json_url') { - config.url = extra.trim() - } else if (source === 'evobgp_community') { - config.community_id = communityId || resolveCommunityId(extra) + if (values.source === 'json_url') { + config.url = values.extra.trim() + } else if (values.source === 'evobgp_community') { + config.community_id = resolveCommunityId(values.extra) } return apiFetch<{ id: string }>('/api/v1/lists', { method: 'POST', - body: JSON.stringify({ name, type: source, config }), + body: JSON.stringify({ name: values.name, type: values.source, config }), }) }, onSuccess: (row) => { toast.success('Список создан') - setName('') - setExtra('') - setCommunityId('') - setSource('static') + createForm.reset() setCreateOpenState(false) void qc.invalidateQueries({ queryKey: ['lists'] }) 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 ( - { setCreateOpenState(open) @@ -320,123 +344,129 @@ function ListsPage() { setListSearch({ create: false }) } }} - > - - - Новый список - - После создания откроется страница со содержимым списка. - - - -
- - Имя - setName(e.target.value)} - /> - - - Источник - - - {source === 'json_url' ? ( - - URL JSON - { - const v = e.target.value - setExtra(v) - if (guessListSourceFromInput(v) === 'json_url') { - setSource('json_url') - } - }} - /> - - ) : null} - {source === 'evobgp_community' ? ( - - BGP community - { - 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} - > - - - - {communitiesQ.isLoading - ? 'Загрузка…' - : 'Нет совпадений'} - - - {(item) => ( - - {item.label} - - )} - - - - - ) : null} -
-
- - - create.mutate()} - > + Создать - -
-
+ + } + > + + Имя + + + + + Источник + ( + + )} + /> + + {createSource === 'json_url' ? ( + + URL JSON + + + + ) : null} + {createSource === 'evobgp_community' ? ( + + BGP community + { + 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} + > + + + + {communitiesQ.isLoading ? 'Загрузка…' : 'Нет совпадений'} + + + {(item) => ( + + {item.label} + + )} + + + + + + ) : null} +
) } diff --git a/apps/web/src/routes/_auth/rules/$setId.tsx b/apps/web/src/routes/_auth/rules/$setId.tsx index 7a92083..25ebfd3 100644 --- a/apps/web/src/routes/_auth/rules/$setId.tsx +++ b/apps/web/src/routes/_auth/rules/$setId.tsx @@ -1,6 +1,10 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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 { CircleAlertIcon, ListIcon, @@ -19,6 +23,8 @@ import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-colu import { DataGridPrimaryCell } from '@/components/data-grid-cell' import { StatusBadge } from '@/components/status-badge' 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 { agentsQueryOptions, @@ -29,7 +35,7 @@ import { import { apiFetch } from '@/lib/api' import { Button } from '@evofw/ui/components/button' 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 { Switch } from '@evofw/ui/components/switch' import { @@ -72,6 +78,68 @@ export const Route = createFileRoute('/_auth/rules/$setId')({ 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 | 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 + +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. * 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 [ruleOpen, setRuleOpen] = useState(false) - const [action, setAction] = useState<'allow' | 'deny'>('deny') - const [source, setSource] = useState('cidr') - const [listId, setListId] = useState('') - const [cidr, setCidr] = useState('') - const [hostname, setHostname] = useState('') const [selectedAgents, setSelectedAgents] = useState(null) const [deleteRuleId, setDeleteRuleId] = useState(null) + const ruleForm = useForm({ + 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 approvedAgents = useMemo( @@ -150,14 +220,16 @@ function PolicySetDetailPage() { }) const createRule = useMutation({ - mutationFn: () => { + mutationFn: (values: RuleValues) => { const body: Record = { 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', { method: 'POST', body: JSON.stringify(body), @@ -166,9 +238,7 @@ function PolicySetDetailPage() { onSuccess: () => { toast.success('Правило создано') setRuleOpen(false) - setCidr('') - setHostname('') - setListId('') + ruleForm.reset() void qc.invalidateQueries({ queryKey: ['policy-sets', setId] }) }, onError: (e: Error) => toast.error(e.message), @@ -261,13 +331,6 @@ function PolicySetDetailPage() { state: { rowSelection: agentRowSelection }, }) - const canCreate = - source === 'list' - ? Boolean(listId) - : source === 'cidr' - ? Boolean(cidr.trim()) - : Boolean(hostname.trim()) - if (setQ.isLoading) { return ( @@ -442,57 +505,103 @@ function PolicySetDetailPage() { disabled={removeRule.isPending} /> - - - - Новое правило - - Один источник: список, CIDR или DNS-имя (priority — в конец) - - -
- - Action + { + setRuleOpen(open) + if (!open) ruleForm.reset() + }} + title="Новое правило" + description="Один источник: список, CIDR или DNS-имя (priority — в конец)" + form={ruleForm} + onSubmit={async (values) => { + await createRule.mutateAsync(values) + }} + footer={ + <> + + + Создать + + + } + > + + Действие + ( - - - Источник + )} + /> + + + Источник + ( - - {source === 'list' ? ( - - Список + )} + /> + + {ruleSource === 'list' ? ( + + Список + ( - - ) : null} - {source === 'cidr' ? ( - - CIDR - setCidr(e.target.value)} - placeholder="203.0.113.0/24" - /> - - ) : null} - {source === 'hostname' ? ( - - DNS-имя - setHostname(e.target.value)} - placeholder="bad.example.com" - /> - - ) : null} -
- - - - -
-
+ )} + /> + + + ) : null} + {ruleSource === 'cidr' ? ( + + CIDR + + + + ) : null} + {ruleSource === 'hostname' ? ( + + DNS-имя + + + + ) : null} +
) } diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/settings.tsx index 12d898c..d69f9b8 100644 --- a/apps/web/src/routes/_auth/settings.tsx +++ b/apps/web/src/routes/_auth/settings.tsx @@ -1,8 +1,13 @@ import { createFileRoute } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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 { Badge } from '@/components/reui/badge' import { Frame, FrameDescription, @@ -15,11 +20,14 @@ import { SettingRow } from '@/components/setting-row' import { settingsQueryOptions } from '@/queries' import { apiFetch } from '@/lib/api' 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 { Switch } from '@evofw/ui/components/switch' /** * 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 */ @@ -27,26 +35,63 @@ export const Route = createFileRoute('/_auth/settings')({ 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 + +const EMPTY_VALUES: SettingsValues = { + enroll_seed: '', + evobgp_api_url: '', + evobgp_api_token: '', + agent_sync_interval_sec: '60', + show_quick_actions: 'true', +} + function SettingsPage() { const qc = useQueryClient() const settingsQ = useQuery(settingsQueryOptions()) const canSave = useCan()('fw:settings:admin') - const [form, setForm] = useState>({}) - // Seed the form once — a background refetch must not wipe in-progress edits. - const initialized = useRef(false) + const form = useForm({ + resolver: zodResolver(settingsSchema), + defaultValues: EMPTY_VALUES, + mode: 'onBlur', + }) + + // Seed once — background refetch не должен затирать правки в процессе. useEffect(() => { - if (settingsQ.data && !initialized.current) { - initialized.current = true - setForm(settingsQ.data) + if (settingsQ.data) { + form.reset({ ...EMPTY_VALUES, ...settingsQ.data }) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [settingsQ.data]) const save = useMutation({ - mutationFn: () => + mutationFn: (values: SettingsValues) => apiFetch('/api/v1/settings', { method: 'PUT', - body: JSON.stringify(form), + body: JSON.stringify(values), }), onSuccess: () => { toast.success('Сохранено') @@ -56,97 +101,171 @@ function SettingsPage() { onError: (e: Error) => toast.error(e.message), }) - const showQuickActions = form.show_quick_actions !== 'false' - - 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', - }, - ] + const isDirty = form.formState.isDirty return ( + Есть несохранённые изменения + + ) : null + } /> - - -
- Control plane - - Auth-portal app id: fw · JWT через AUTH_* - -
-
- -
- {fields.map((f, i) => ( +
save.mutateAsync(values))} + className="flex flex-col gap-4 md:gap-6" + > + + +
+ Control plane + + Auth-portal app id: fw · JWT через AUTH_* + +
+
+ +
- - setForm((prev) => ({ ...prev, [f.key]: e.target.value })) - } + + + + + + + + + + + + + + + + + + + + + + ( + + field.onChange(checked ? 'true' : 'false') + } + aria-label="Быстрые действия" + /> + )} /> - ))} - + + + {canSave ? ( +
+ - - setForm((prev) => ({ - ...prev, - show_quick_actions: checked ? 'true' : 'false', - })) - } - aria-label="Быстрые действия" - /> - + Сохранить +
- - - {canSave ? ( -
- save.mutate()} - isLoading={save.isPending} - disabled={!initialized.current} - loadingLabel="Сохранение…" - > - Сохранить - -
- ) : null} + ) : null} + ) } + +/** Password-поле с reveal (Eye toggle). */ +function TokenField({ + form, +}: { + form: ReturnType> +}) { + const [visible, setVisible] = useState(false) + const invalid = Boolean(form.formState.errors.evobgp_api_token) + return ( + +
+ + +
+ +
+ ) +} +