fix(ui): ограничить форму маршрута без потери конечных точек
This commit is contained in:
1 parent
9124d451a5
commit
06850169ba
8 files changed
+135
-51
No files matched your search
@@ -8,6 +8,7 @@ import {
|
||||
RecursiveRoutesDataGrid,
|
||||
type RecursiveRouteGroup,
|
||||
} from "@/components/data-grids/recursive-routes-data-grid"
|
||||
import { RecursiveGatewayChoices, type GatewayOption } from "@/components/recursive-gateway-choices"
|
||||
import { FormField, SectionTitle } from "@/components/form-kit"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
@@ -42,13 +43,6 @@ interface BackendServer {
|
||||
latency: number | null
|
||||
}
|
||||
|
||||
interface GatewayOption {
|
||||
id: string
|
||||
name: string
|
||||
ip: string
|
||||
status: "active" | "inactive" | "unknown"
|
||||
}
|
||||
|
||||
const COUNTRY_OPTIONS = [
|
||||
{ code: "RU", label: "Россия" }, { code: "DE", label: "Германия" },
|
||||
{ code: "NL", label: "Нидерланды" }, { code: "SG", label: "Сингапур" },
|
||||
@@ -195,10 +189,15 @@ function RouteSheet({
|
||||
returnFocus: () => HTMLElement | null
|
||||
}) {
|
||||
const [form, setForm] = useState<RouteForm>(initial)
|
||||
const [endpointPage, setEndpointPage] = useState(0)
|
||||
const lastEndpointPage = Math.max(0, Math.ceil(form.endpoints.length / 10) - 1)
|
||||
if (endpointPage > lastEndpointPage) setEndpointPage(lastEndpointPage)
|
||||
const currentEndpointPage = Math.min(endpointPage, lastEndpointPage)
|
||||
const endpointOffset = currentEndpointPage * 10
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { setForm(initial); setError(null) }, [initial, open])
|
||||
useEffect(() => { setForm(initial); setError(null); setEndpointPage(0) }, [initial, open])
|
||||
const set = <K extends keyof RouteForm>(k: K, v: RouteForm[K]) => setForm(f => ({ ...f, [k]: v }))
|
||||
const setEp = <K extends keyof RouteEndpointForm>(id: string, k: K, v: RouteEndpointForm[K]) =>
|
||||
setForm(f => ({ ...f, endpoints: f.endpoints.map(ep => ep.id === id ? { ...ep, [k]: v } : ep) }))
|
||||
@@ -208,9 +207,11 @@ function RouteSheet({
|
||||
if (busy || saveLock.current) return
|
||||
if (!form.dstAddress.trim()) { setError("Dst Address обязателен"); return }
|
||||
if (form.endpoints.length === 0) { setError("Добавь хотя бы одну конечную точку"); return }
|
||||
if (form.endpoints.some(ep => !ep.gateway.trim())) { setError("У каждой конечной точки должен быть Gateway"); return }
|
||||
const emptyGateway = form.endpoints.findIndex(ep => !ep.gateway.trim())
|
||||
if (emptyGateway >= 0) { setEndpointPage(Math.floor(emptyGateway / 10)); setError(`У каждой конечной точки должен быть Gateway (Endpoint ${emptyGateway + 1})`); return }
|
||||
if (!form.routingTable.trim()) { setError("Укажите таблицу маршрутизации"); return }
|
||||
if (form.endpoints.some(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))) { setError("Distance, Scope и T.Scope должны быть целыми числами от 0 до 255"); return }
|
||||
const invalidEndpoint = form.endpoints.findIndex(ep => [ep.distance, ep.scope, ep.targetScope].some(value => value !== null && (!Number.isInteger(value) || value < 0 || value > 255)))
|
||||
if (invalidEndpoint >= 0) { setEndpointPage(Math.floor(invalidEndpoint / 10)); setError(`Distance, Scope и T.Scope должны быть целыми числами от 0 до 255 (Endpoint ${invalidEndpoint + 1})`); return }
|
||||
setError(null)
|
||||
saveLock.current = true
|
||||
try { await onSave(form) } finally { saveLock.current = false }
|
||||
@@ -238,18 +239,31 @@ function RouteSheet({
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<SectionTitle>Конечные точки</SectionTitle>
|
||||
{form.endpoints.length > 10 && <div className="flex flex-wrap items-center gap-2">
|
||||
<Input key={currentEndpointPage} type="number" min={1} max={form.endpoints.length} defaultValue={endpointOffset + 1} aria-label="Перейти к endpoint" aria-describedby="recursive-endpoint-jump-hint" className="w-24 min-h-11" onKeyDown={event => {
|
||||
if (event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
const number = Number(event.currentTarget.value)
|
||||
if (Number.isInteger(number) && number >= 1 && number <= form.endpoints.length) { setEndpointPage(Math.floor((number - 1) / 10)); setError(null) }
|
||||
else setError("Укажите номер endpoint от 1 до " + form.endpoints.length)
|
||||
}} />
|
||||
<span id="recursive-endpoint-jump-hint" className="text-xs text-muted-foreground">Номер endpoint → Enter</span>
|
||||
<span role="status" className="text-xs text-muted-foreground">Endpoints {endpointOffset + 1}–{Math.min(endpointOffset + 10, form.endpoints.length)} из {form.endpoints.length}</span>
|
||||
<Button type="button" variant="outline" className="min-h-11" aria-label="Предыдущие endpoints формы" disabled={currentEndpointPage === 0} onClick={() => setEndpointPage(currentEndpointPage - 1)}>Назад</Button>
|
||||
<Button type="button" variant="outline" className="min-h-11" aria-label="Следующие endpoints формы" disabled={currentEndpointPage === lastEndpointPage} onClick={() => setEndpointPage(currentEndpointPage + 1)}>Далее</Button>
|
||||
</div>}
|
||||
<div className="flex flex-col gap-3">
|
||||
{form.endpoints.map((ep, idx) => (
|
||||
<div key={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
||||
{form.endpoints.slice(endpointOffset, endpointOffset + 10).map((ep, idx) => (
|
||||
<div key={ep.id} data-recursive-endpoint-form={ep.id} className="rounded-lg border border-border bg-muted/20 p-3 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Endpoint {idx + 1}{ep.disabled ? " · отключён" : ""}
|
||||
Endpoint {endpointOffset + idx + 1}{ep.disabled ? " · отключён" : ""}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setForm(f => ({ ...f, endpoints: f.endpoints.filter(x => x.id !== ep.id) }))}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label={`Удалить конечную точку ${idx + 1}`}
|
||||
className="size-11 shrink-0 inline-flex items-center justify-center rounded text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label={`Удалить конечную точку ${endpointOffset + idx + 1}`}
|
||||
disabled={form.endpoints.length <= 1}
|
||||
>
|
||||
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||||
@@ -280,42 +294,10 @@ function RouteSheet({
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{gateways.map((g) => {
|
||||
const value = `${g.ip}%${g.name}`
|
||||
const selected = ep.gateway === value
|
||||
return (
|
||||
<button
|
||||
key={`${ep.id}-${g.id}`}
|
||||
type="button"
|
||||
onClick={() => setEp(ep.id, "gateway", value)}
|
||||
className={cn(
|
||||
"text-left rounded-lg border px-3 py-2.5 transition-all",
|
||||
selected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "active" ? "bg-success" : "bg-muted-foreground")} />
|
||||
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{g.status === "active" ? "Активный маршрут" : g.status === "inactive" ? "Неактивный маршрут" : "Статус неизвестен"}</span>
|
||||
{selected && <CheckIcon aria-hidden="true" className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
|
||||
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
|
||||
<span className="text-muted-foreground/30">→</span>
|
||||
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<RecursiveGatewayChoices gateways={gateways} selectedValue={ep.gateway} endpointNumber={endpointOffset + idx + 1} onChange={value => setEp(ep.id, "gateway", value)} />
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
|
||||
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => { setEndpointPage(Math.floor(form.endpoints.length / 10)); setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] })) }}>
|
||||
<PlusIcon aria-hidden="true" className="size-3.5" />Добавить endpoint
|
||||
</Button>
|
||||
{gateways.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
import { useMemo, useState } from "react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
export interface GatewayOption { id: string; name: string; ip: string; status: "active" | "inactive" | "unknown" }
|
||||
export function RecursiveGatewayChoices({ gateways, selectedValue, endpointNumber, onChange }: { gateways: GatewayOption[]; selectedValue: string; endpointNumber: number; onChange: (value: string) => void }) {
|
||||
const [search, setSearch] = useState("")
|
||||
const matches = useMemo(() => gateways.filter(g => (g.ip + "%" + g.name).toLowerCase().includes(search.toLowerCase())), [gateways, search])
|
||||
const selected = matches.find(g => g.ip + "%" + g.name === selectedValue)
|
||||
const visible = (selected ? [selected, ...matches.filter(g => g.id !== selected.id)] : matches).slice(0, 5)
|
||||
return <div className="flex flex-col gap-1.5">
|
||||
{gateways.length > 5 && <Input aria-label={"Поиск шлюза для endpoint " + endpointNumber} placeholder="IP или имя шлюза…" value={search} onChange={event => setSearch(event.target.value)} />}
|
||||
{gateways.length > 5 && <p role="status" className="text-xs text-muted-foreground">Показано {visible.length} из {matches.length} шлюзов. Поиск охватывает весь список.</p>}
|
||||
{search && matches.length === 0 && <p role="status" className="text-xs text-muted-foreground">Шлюзы не найдены. Измените поиск или введите адрес вручную.</p>}
|
||||
{visible.map((g) => {
|
||||
const value = `${g.ip}%${g.name}`
|
||||
const selected = selectedValue === value
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
type="button"
|
||||
aria-label={`Выбрать шлюз ${value}`}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onChange(value)}
|
||||
className={cn(
|
||||
"text-left rounded-lg border px-3 py-2.5 transition-all",
|
||||
selected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/30"
|
||||
: "border-border hover:border-muted-foreground/40 hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("size-1.5 rounded-full shrink-0", g.status === "active" ? "bg-success" : "bg-muted-foreground")} />
|
||||
<span className="font-mono text-xs font-semibold flex-1 truncate">{g.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{g.status === "active" ? "Активный маршрут" : g.status === "inactive" ? "Неактивный маршрут" : "Статус неизвестен"}</span>
|
||||
{selected && <CheckIcon aria-hidden="true" className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono">
|
||||
<span className="text-muted-foreground/50 break-all">{g.ip}</span>
|
||||
<span className="text-muted-foreground/30">→</span>
|
||||
<span className={cn("min-w-0 break-all whitespace-normal leading-tight", selected ? "text-primary font-medium" : "")}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
| [subusers-data-grid.tsx](../components/data-grids/subusers-data-grid.tsx) | Поиск импортов/JSX в app/components не нашёл потребителя: компонент экспортируется, но не подключён. Не доказывает дефект экрана /users. |
|
||||
| [traffic-flows-data-grid.tsx](../components/data-grids/traffic-flows-data-grid.tsx) | FlowAnalyticsPanel/conversationsList: backend traffic-flow-analytics.ts top=min(50,max(10,settings.topN)), slice(0,top). Это top slice, не полный каталог/серверная pagination. Проверить сообщение об ограничении50 и связывание с графиками. |
|
||||
|
||||
Очередь: форма recursive routes/gateway choices → Filters порядок → Firewall/DnD → OSPF таблица+topology → optimizer preview/комбинаторика. Scheduler11, traffic cap50 и commRecs24 проверять на реальной границе, не искусственно подставлять1000 в недоступный DTO. Demo и неиспользуемый Subusers отделены от live приёмки. Все новые профили должны сохранять search/sort/refresh/clamp, точный target и права,320px и URL scope.
|
||||
Очередь: Filters порядок → Firewall/DnD → OSPF таблица+topology → optimizer preview/комбинаторика. Scheduler11, traffic cap50 и commRecs24 проверять на реальной границе, не искусственно подставлять1000 в недоступный DTO. Demo и неиспользуемый Subusers отделены от live приёмки. Все новые профили должны сохранять search/sort/refresh/clamp, точный target и права,320px и URL scope.
|
||||
|
||||
Принятые после ранней карты: OSPF routes/BFD, WireGuard interfaces/peers/detail, VXLAN/GRE tunnels. Pagination сохраняет полный DTO в памяти и не доказывает серверную производительность. Текущий журнал: [ux-ui-progress.md](ux-ui-progress.md).
|
||||
|
||||
@@ -80,3 +80,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич
|
||||
12.3.38: Communities1000/190573 JSON bytes →50 DOM rows/2216 page nodes/1206ms dev ready.19 browser/type/lint/build прошли.14 manualPagination:true остаются; обновления/selection по ID подтверждены локально.
|
||||
|
||||
12.3.40: recursive1000 групп→50 rows/2118 nodes/1457ms; одна группа1000 endpoints→50 cards/1126 nodes.35 browser/type/lint/build прошли; большой RouteSheet пока вне принятого performance scope.13 manualPagination:true остаются.
|
||||
|
||||
12.3.41: recursive RouteSheet1000 endpoints ×1000 gateways →10 cards/1569 modal nodes, максимум5 gateway choices на карточку; полный PUT1001 сохранён.22+1 browser/type/lint/build. Расчёт/валидация полного draft остаётся на клиенте.
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
## Актуальная точка продолжения — 2026-10-04
|
||||
|
||||
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принLine truncated
|
||||
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принLine truncated
|
||||
|
||||
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||
|
||||
|
||||
@@ -31,6 +31,14 @@
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.25 приняты локально в описанных границах;12.4.4 context213 и12.4.5 main1110 прошли, 12.3.26–12.3.33 приняты локально; 12.3.34 VXLAN и12.3.35 GRE tunnels приняты; остаются network grids/общая регрессия после delta/внешняя приёмка |
|
||||
|
||||
## 12.3.41 — большая форма рекурсивного маршрута (2026-10-04)
|
||||
|
||||
RouteSheet показывает10 endpoints, но хранит весь draft. Предыдущая/следующая страница и прямой переход по номеру endpoint доступны с клавиатуры; add открывает страницу нового элемента, удаление корректирует индекс. Валидация ищет ошибки во всём draft и открывает страницу первого невалидного endpoint. RecursiveGatewayChoices сохраняет прежние карточки/семантику статуса; показывает5 вариантов (выбранный первым) и ищет по всему каталогу IP/name. Ручной gateway ввод сохранён.
|
||||
|
||||
22 профильных browser прошли; финальный large повтор1/1 после прямого перехода/скрытой ошибки.1000 endpoints ×1000 gateways →10 cards/1569 modal nodes,50 gateway choices. Проверены первая/последняя страницы, поиск шлюза1000, скрытый невалидный endpoint1000 без PUT, ошибка503/сохранение draft/повтор,320px; PUT содержит1001 строки, обе скрытые правки и неизменную другую группу. TypeScript без incremental/финальный ESLint и production frontend build прошли. [Форма320px](ux-ui/screens/large-recursive-form-320.png) просмотрена. Общие сетевые правила и права не изменены; реальный RouterOS PUT не проверен.
|
||||
|
||||
Следующий блок12.3.42: Filters1000/порядок через границу страниц/whole-catalog PUT/search/sort/refresh/cache/320px.13 manualPagination:true specialized grids остаются. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
|
||||
|
||||
## 12.3.40 — большие группы рекурсивных маршрутов (2026-10-04)
|
||||
|
||||
ReUI pagination50 групп, global sort, refresh без сброса страницы и clamp. Первичное/новое URL раскрытие находит группу в sorted model и открывает её страницу; обычный переход страниц не навязывает возврат к выбранной группе. Строка показывает3 gateway и точное число остальных. Раскрытие имеет отдельные страницы50 endpoints, сортирует всю группу по distance, сохраняет страницу при refresh и корректирует её при сокращении. На320px route закреплён, edit/delete44px и доступны через горизонтальный scroll; детали ограничены viewport и доступны с клавиатуры.
|
||||
|
||||
@@ -515,3 +515,12 @@ ReUI pagination50 групп, global sort, refresh без сброса стра
|
||||
|
||||
Осталось13 manualPagination:true specialized grids. Большая RouteSheet пока рендерит все endpoints и все варианты gateways для каждого endpoint; performance формы не принят. Следующий блок12.3.41: ограничить DOM формы/вариантов шлюзов без потери скрытых данных/валидации/PUT payload, проверить1000 endpoints и1000 gateways. Затем Filters порядок/Firewall DnD и остальные этапы карты. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
|
||||
|
||||
|
||||
## 12.3.41 — большая форма рекурсивного маршрута (2026-10-04)
|
||||
|
||||
RouteSheet показывает10 endpoints, но хранит весь draft. Предыдущая/следующая страница и прямой переход по номеру endpoint доступны с клавиатуры; add открывает страницу нового элемента, удаление корректирует индекс. Валидация ищет ошибки во всём draft и открывает страницу первого невалидного endpoint. RecursiveGatewayChoices сохраняет прежние карточки/семантику статуса; показывает5 вариантов (выбранный первым) и ищет по всему каталогу IP/name. Ручной gateway ввод сохранён.
|
||||
|
||||
22 профильных browser прошли; финальный large повтор1/1 после прямого перехода/скрытой ошибки.1000 endpoints ×1000 gateways →10 cards/1569 modal nodes,50 gateway choices. Проверены первая/последняя страницы, поиск шлюза1000, скрытый невалидный endpoint1000 без PUT, ошибка503/сохранение draft/повтор,320px; PUT содержит1001 строки, обе скрытые правки и неизменную другую группу. TypeScript без incremental/финальный ESLint и production frontend build прошли. [Форма320px](ux-ui/screens/large-recursive-form-320.png) просмотрена. Общие сетевые правила и права не изменены; реальный RouterOS PUT не проверен.
|
||||
|
||||
Следующий блок12.3.42: Filters1000/порядок через границу страниц/whole-catalog PUT/search/sort/refresh/cache/320px.13 manualPagination:true specialized grids остаются. Общая main/context после delta и внешняя12.5 отдельно. Коммит локальный без push.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,33 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
test("Recursive form1000 endpoints/gateways: full draft/payload/error/retry/320px", async ({ page }, info) => {
|
||||
test.setTimeout(150000)
|
||||
let routes = Array.from({length:1000},(_,i)=>({id:"r"+(i+1),dstAddress:"203.0.113.0/24",gateway:"192.0.2.1%gw-"+(i+1),distance:1,scope:30,targetScope:10,routingTable:"main",checkGateway:"ping",comment:"Большая группа",disabled:false,country:""}))
|
||||
const other = {...routes[0],id:"other",dstAddress:"203.0.114.0/24",gateway:"198.51.100.1%other",routingTable:"other"};routes.push(other)
|
||||
const gateways=Array.from({length:1000},(_,i)=>({id:"g"+(i+1),name:"gw-"+(i+1),ip:"192.0.2.1",status:"unknown"}))
|
||||
let fail=true,attempts=0,submitted:typeof routes=[]
|
||||
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
|
||||
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname
|
||||
if(p==="/api/recursive-routes"&&r.request().method()==="PUT"){attempts++;submitted=r.request().postDataJSON().routes;if(fail)return r.fulfill({status:503,json:{error:"Запись недоступна тест"}});routes=submitted;return r.fulfill({json:{ok:true,serverId:1,routes,pushedRoutes:routes.length}})}
|
||||
return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[{id:1,name:"Роутер",host:"192.0.2.1",country:"RU",enabled:true,status:null}]:p==="/api/recursive-routes/catalog"?{serverId:1,routes,live:true,stale:false}:p==="/api/recursive-routes/gateways/catalog"?{serverId:1,gateways}:{} })
|
||||
})
|
||||
await page.goto("/recursive-routes?server=1")
|
||||
await page.getByRole("button",{name:"Редактировать маршрут 203.0.113.0/24 · main",exact:true}).click()
|
||||
const sheet=page.getByRole("dialog"),cards=sheet.locator('[data-recursive-endpoint-form]')
|
||||
await expect(cards).toHaveCount(10);await expect(sheet.getByRole("button",{name:/^Выбрать шлюз /})).toHaveCount(50)
|
||||
const profile={endpoints:1000,gateways:1000,cards:await cards.count(),nodes:await sheet.locator("*").count()};console.log("Recursive form profile",JSON.stringify(profile));await info.attach("profile",{body:JSON.stringify(profile),contentType:"application/json"})
|
||||
await cards.first().getByRole("textbox",{name:"Шлюз",exact:true}).fill("192.0.2.10%edited-first")
|
||||
const next=sheet.getByRole("button",{name:"Следующие endpoints формы",exact:true});await next.click();await expect(cards.first()).toContainText("Endpoint 11");const jump=sheet.getByRole("spinbutton",{name:"Перейти к endpoint",exact:true});await jump.fill("1000");await jump.press("Enter")
|
||||
await expect(next).toBeDisabled();await expect(cards.last()).toContainText("Endpoint 1000")
|
||||
await cards.first().getByRole("textbox",{name:"Поиск шлюза для endpoint 991",exact:true}).fill("gw-1000")
|
||||
await cards.first().getByRole("button",{name:"Выбрать шлюз 192.0.2.1%gw-1000",exact:true}).click()
|
||||
await expect(cards.first().getByRole("textbox",{name:"Шлюз",exact:true})).toHaveValue("192.0.2.1%gw-1000")
|
||||
await cards.last().getByRole("textbox",{name:"Шлюз",exact:true}).fill("")
|
||||
await sheet.getByRole("button",{name:"Предыдущие endpoints формы",exact:true}).click();await expect(cards.first()).toContainText("Endpoint 981");const save=sheet.getByRole("button",{name:"Сохранить",exact:true});await save.click();await expect(sheet.getByRole("alert")).toContainText("Endpoint 1000");expect(attempts).toBe(0)
|
||||
await cards.last().getByRole("textbox",{name:"Шлюз",exact:true}).fill("192.0.2.99%edited-last")
|
||||
await save.click();await expect(sheet.getByRole("alert")).toContainText("Запись недоступна тест");expect(submitted).toHaveLength(1001)
|
||||
expect(submitted.find(r=>r.gateway==="192.0.2.10%edited-first")).toBeTruthy();expect(submitted.find(r=>r.gateway==="192.0.2.99%edited-last")).toBeTruthy();expect(submitted.find(r=>r.id==="other")).toEqual(other)
|
||||
await expect(cards.last().getByRole("textbox",{name:"Шлюз",exact:true})).toHaveValue("192.0.2.99%edited-last")
|
||||
await page.setViewportSize({width:320,height:1000});await cards.first().scrollIntoViewIfNeeded();expect(await sheet.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
|
||||
await page.addStyleTag({content:"nextjs-portal{display:none!important}"});await page.screenshot({path:info.outputPath("recursive-form-320.png"),animations:"disabled"})
|
||||
fail=false;await save.click();await expect(sheet).toHaveCount(0);expect(attempts).toBe(2);expect(submitted).toHaveLength(1001)
|
||||
})
|
||||
Reference in new issue
Block a user