fix(ui): завершить адаптивность рекурсивных маршрутов

This commit is contained in:
Denozordec committed 2026-09-30 22:19:21 +07:00
1 parent 54ef752a5b
commit e58604c3c0
12 files changed
+123 -62

No files matched your search

+56 -44
View File
@@ -33,7 +33,7 @@ interface BackendServer {
id: number
name: string
host: string
type: "jump-host" | "exit-node" | "home-router"
type?: "jump-host" | "exit-node" | "home-router"
site: string
country: string
asn: string
@@ -72,7 +72,7 @@ function makeApiFetch(backendUrl: string) {
}
}
function mapBackendServer(s: BackendServer): Omit<Server, "status"> & { status: Server["status"] | null } {
function mapBackendServer(s: BackendServer): Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] } {
return {
id: String(s.id),
name: s.name || s.host,
@@ -138,7 +138,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
<label className="text-sm font-medium">Страна</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input placeholder="Поиск или код (RU, DE…)" value={query}
<Input aria-label="Код страны" placeholder="Поиск или код (RU, DE…)" value={query}
onChange={e => {
const v = e.target.value.toUpperCase().slice(0, 3)
setQuery(v)
@@ -161,7 +161,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
</span>
)}
</div>
<div className="grid grid-cols-5 gap-1.5 max-h-40 overflow-y-auto pr-0.5">
<div className="grid grid-cols-4 sm:grid-cols-5 gap-1.5">
{visible.map(c => (
<button key={c.code} type="button"
onClick={() => { onChange(c.code); setQuery("") }}
@@ -182,7 +182,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
}
function RouteSheet({
open, mode, initial, onSave, onClose, gateways, busy, saveError,
open, mode, initial, onSave, onClose, gateways, busy, saveError, returnFocus,
}: {
open: boolean
mode: "create" | "edit"
@@ -192,6 +192,7 @@ function RouteSheet({
gateways: GatewayOption[]
busy: boolean
saveError: string | null
returnFocus: () => HTMLElement | null
}) {
const [form, setForm] = useState<RouteForm>(initial)
const [error, setError] = useState<string | null>(null)
@@ -217,21 +218,21 @@ function RouteSheet({
return (
<Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
<SheetContent side="right" finalFocus={returnFocus} className="data-[side=right]:w-full sm:max-w-lg flex flex-col gap-0 p-0" showCloseButton={false}>
<SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-2">
<div>
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
</div>
<Button variant="ghost" size="icon-sm" disabled={busy} aria-label="Закрыть форму маршрута" onClick={onClose} className="shrink-0 mt-0.5"><XIcon className="size-4" /></Button>
<Button type="button" variant="ghost" size="icon-sm" disabled={busy} aria-label="Закрыть форму маршрута" onClick={onClose} className="shrink-0 mt-0.5"><XIcon aria-hidden="true" className="size-4" /></Button>
</div>
</SheetHeader>
<fieldset disabled={busy} className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24">
<Input className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
<Input aria-label="Адрес назначения" className="font-mono h-9" placeholder="8.8.8.8/32" value={form.dstAddress} onChange={(e) => set("dstAddress", e.target.value)} />
</FormField>
</div>
@@ -251,35 +252,35 @@ function RouteSheet({
aria-label={`Удалить конечную точку ${idx + 1}`}
disabled={form.endpoints.length <= 1}
>
<TrashIcon className="size-3.5" />
<TrashIcon aria-hidden="true" className="size-3.5" />
</button>
</div>
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway">
<Input className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
<Input aria-label="Шлюз" className="font-mono h-9" placeholder="1.2.3.4%GW-NAME" value={ep.gateway} onChange={(e) => setEp(ep.id, "gateway", e.target.value)} />
</FormField>
<div className="grid grid-cols-2 gap-2">
<FormField label="Distance (приоритет)">
<Input type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value))} />
<Input aria-label="Distance (приоритет)" type="number" className="h-9" value={ep.distance} onChange={(e) => setEp(ep.id, "distance", Number(e.target.value))} />
</FormField>
<FormField label="Check Gateway">
<Input className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
<Input aria-label="Check Gateway" className="h-9 font-mono" placeholder="ping" value={ep.checkGateway} onChange={(e) => setEp(ep.id, "checkGateway", e.target.value)} />
</FormField>
</div>
<div className="grid grid-cols-2 gap-2">
<FormField label="Scope">
<Input type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
<Input aria-label="Scope" type="number" className="h-9" value={ep.scope ?? ""} onChange={(e) => setEp(ep.id, "scope", e.target.value ? Number(e.target.value) : null)} />
</FormField>
<FormField label="T.Scope">
<Input type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
<Input aria-label="T.Scope" type="number" className="h-9" value={ep.targetScope ?? ""} onChange={(e) => setEp(ep.id, "targetScope", e.target.value ? Number(e.target.value) : null)} />
</FormField>
</div>
<div className="flex flex-col gap-1.5 max-h-[180px] overflow-y-auto overflow-x-hidden pr-1">
<div className="flex flex-col gap-1.5">
{gateways.map((g) => {
const value = `${g.ip}%${g.name}`
const selected = ep.gateway === value
@@ -299,7 +300,7 @@ function RouteSheet({
<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 className="size-3.5 text-primary shrink-0" />}
{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>
@@ -315,7 +316,7 @@ function RouteSheet({
</div>
))}
<Button type="button" variant="outline" size="sm" className="w-fit gap-1.5" onClick={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
<PlusIcon className="size-3.5" />Добавить endpoint
<PlusIcon aria-hidden="true" className="size-3.5" />Добавить endpoint
</Button>
{gateways.length === 0 && (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
@@ -327,18 +328,18 @@ function RouteSheet({
<div className="flex flex-col gap-4">
<SectionTitle>Параметры</SectionTitle>
<FormField label="Routing Table"><Input className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
<FormField label="Routing Table"><Input aria-label="Таблица маршрутизации" className="h-9 font-mono" value={form.routingTable} onChange={(e) => set("routingTable", e.target.value)} /></FormField>
<FormField label="Комментарий">
<Input className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
<Input aria-label="Комментарий" className="h-9" value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</FormField>
</div>
{saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения роутера…</p>}
{error && <div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon className="size-4 shrink-0" />{error}</div>}
{error && <div role="alert" className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md"><AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />{error}</div>}
</fieldset>
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={onClose} className="flex-1">Отмена</Button>
<Button type="button" disabled={busy} onClick={() => void handleSave()} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
<Button type="button" variant="outline" disabled={busy} onClick={onClose} className="h-8 flex-none">Отмена</Button>
<Button type="button" disabled={busy} onClick={() => void handleSave()} className="h-8 flex-none"><CheckIcon aria-hidden="true" className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</Button>
</SheetFooter>
</SheetContent>
</Sheet>
@@ -349,10 +350,10 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
return (
<span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0",
type === "home-router" ? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20" :
type === "home-router" ? "bg-success/10 text-success border-success/20" :
type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
? "bg-focus/10 text-focus border-focus/20"
: "bg-info/10 text-info border-info/20",
)}>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span>
@@ -393,7 +394,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [servers, setServers] = useState<Array<Omit<Server, "status"> & { status: Server["status"] | null }>>([])
const [servers, setServers] = useState<Array<Omit<Server, "status" | "type"> & { status: Server["status"] | null; type?: Server["type"] }>>([])
const [selectedServerId, setSelectedServerId] = useState<string>(context.serverId ?? "")
const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "apply" | null>(null)
@@ -444,7 +445,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
if (!active) return
const parsed = recursiveServersSchema.safeParse(data)
if (!parsed.success) throw new Error("Некорректный список устройств в ответе API")
const mapped = parsed.data.map(s => mapBackendServer({ ...s, type: s.type ?? "exit-node", site: s.site ?? "", asn: s.asn ?? "", status: s.status ?? null, latency: s.latency ?? null }))
const mapped = parsed.data.map(s => mapBackendServer({ ...s, type: s.type, site: s.site ?? "", asn: s.asn ?? "", status: s.status ?? null, latency: s.latency ?? null }))
setServers(mapped)
setSelectedServerId(context.serverId ?? mapped[0]?.id ?? "")
if (!context.serverId && !context.error && mapped[0]) patchUrl({ serverId: mapped[0].id }, true)
@@ -576,8 +577,16 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
function groupKeyOf(row: RecursiveRouteRow): string {
return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()])
}
const sheetTrigger = useRef<HTMLElement | null>(null)
function returnSheetFocus() {
const target = sheetTrigger.current
if (target?.isConnected) return target
const label = target?.getAttribute("aria-label")
return label ? document.querySelector<HTMLElement>(`button[aria-label="${CSS.escape(label)}"]`) : null
}
function openCreate() {
if (mutationsLocked) return
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setSaveError(null)
setSheetMode("create")
setEditingGroupKey(null)
@@ -586,6 +595,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
}
function openEdit(group: RouteGroup) {
if (mutationsLocked) return
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setSaveError(null)
const endpoints = rows.filter(row => groupKeyOf(row) === group.key)
setSheetMode("edit")
@@ -711,17 +721,17 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
{isLive && (
<>
<Button
variant="outline"
type="button" variant="outline"
size="sm"
onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
disabled={busy !== null}
title="Прочитать маршруты с роутера"
>
<RefreshCwIcon className={cn("size-4", busy === "load" && "animate-spin")} />
<RefreshCwIcon aria-hidden="true" className={cn("size-4", busy === "load" && "animate-spin")} />
Обновить
</Button>
<Button
variant="outline"
type="button" variant="outline"
size="sm"
onClick={() => {
setHistoryOpen(true)
@@ -729,13 +739,13 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
}}
disabled={busy !== null}
>
<HistoryIcon className="size-4" />
<HistoryIcon aria-hidden="true" className="size-4" />
История
</Button>
</>
)}
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon className="size-4" />Добавить
<Button type="button" size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon aria-hidden="true" className="size-4" />Добавить
</Button>
</>
}
@@ -744,13 +754,13 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
banner={
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6">
<div className="relative min-w-[200px] max-w-xs flex-1">
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<SearchIcon aria-hidden="true" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
<Input aria-label="Поиск маршрутов" className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} />
{search && (
<button type="button" aria-label="Сбросить поиск маршрутов" onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<XIcon className="size-3.5" />
<XIcon aria-hidden="true" className="size-3.5" />
</button>
)}
</div>
@@ -761,6 +771,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
<p className="text-xs text-muted-foreground ml-auto">
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p>
{busy === "load" && <p role="status" className="w-full text-xs text-muted-foreground">Обновление маршрутов…</p>}
{sharedBusy && busy !== "apply" && <p role="status" className="w-full text-sm text-muted-foreground">Операция предыдущего контекста ещё выполняется. Изменения заблокированы до ответа API.</p>}
{contextError && <p role="alert" className="w-full text-sm text-warning">{contextError}. Выберите устройство или сбросьте ссылку. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ serverId: null, q: null, route: null, table: null })}>Сбросить контекст маршрутов</Button></p>}
{expandedGroupKey && !liveStale && !groupedRoutes.some(group => group.key === expandedGroupKey) && <p role="status" className="w-full text-sm text-muted-foreground">Маршрут из ссылки отсутствует в текущем срезе. <Button type="button" variant="outline" size="sm" onClick={() => patchUrl({ route: null, table: null })}>Закрыть маршрут</Button></p>}
@@ -773,8 +784,8 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
{isLive && writeAt && <p role="status" className="w-full text-xs text-muted-foreground">Запись подтверждена {new Date(writeAt).toLocaleTimeString("ru-RU")}.</p>}
{isLive && gatewayError && <p role="status" className="w-full text-xs text-warning">Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.</p>}
{isLive && liveStale && (
<div className="w-full text-xs text-amber-700 dark:text-amber-400 bg-amber-500/10 border border-amber-500/20 rounded-md px-3 py-2 flex items-center gap-2">
<AlertTriangleIcon className="size-3.5 shrink-0" />
<div className="w-full text-xs text-warning bg-warning/10 border border-warning/20 rounded-md px-3 py-2 flex items-center gap-2">
<AlertTriangleIcon aria-hidden="true" className="size-3.5 shrink-0" />
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
</div>
)}
@@ -790,17 +801,17 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
) : (
<DataPageCard>
{currentServer && (
<div className="flex items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
<div className="flex flex-wrap items-center gap-2.5 px-5 py-3 border-b bg-muted/10">
{currentServer.status ? <StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> : <span className="text-xs text-muted-foreground">Состояние неизвестно</span>}
<Flag code={currentServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{currentServer.name}</span>
<TypeChip type={currentServer.type} />
<code className="text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
<span className="min-w-0 break-all font-mono text-sm font-semibold">{currentServer.name}</span>
{currentServer.type && <TypeChip type={currentServer.type} />}
<code className="break-all text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
<span className="text-xs text-muted-foreground">{currentServer.asn}</span>
{currentServer.latency !== null && (
<span className={cn(
"text-xs font-mono",
currentServer.latency > 60 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground",
currentServer.latency > 60 ? "text-warning" : "text-muted-foreground",
)}>{currentServer.latency}мс</span>
)}
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground">
@@ -824,9 +835,9 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
void applyRoutes(rows.filter((r) => groupKeyOf(r) !== g.key))
}}
/>
<button onClick={openCreate} disabled={mutationsLocked}
<button type="button" onClick={openCreate} disabled={mutationsLocked}
className="w-full flex items-center gap-2 px-5 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t disabled:opacity-50">
<PlusIcon className="size-3.5" />
<PlusIcon aria-hidden="true" className="size-3.5" />
Добавить маршрут
</button>
</DataPageCard>
@@ -834,6 +845,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
</ServerRailLayout>
<RouteSheet
returnFocus={returnSheetFocus}
busy={busy !== null}
saveError={saveError}
open={sheetOpen}
@@ -95,7 +95,7 @@ function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
<span className="text-[11px] font-mono">distance: {ep.distance}</span>
</div>
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p>
<div className="mt-1.5 text-[11px] text-muted-foreground flex items-center gap-3">
<div className="mt-1.5 text-[11px] text-muted-foreground flex flex-wrap items-center gap-3">
<span>scope: {ep.scope ?? "—"}</span>
<span>t.scope: {ep.targetScope ?? "—"}</span>
<span>check: {ep.checkGateway || "—"}</span>
@@ -147,17 +147,17 @@ function RecursiveRoutesDataGrid({
const g = row.original
const isExpanded = expandedKey === g.key
return (
<div className="flex items-start gap-2 min-w-0">
<button type="button" aria-label={`Раскрыть маршрут ${g.dstAddress} · ${g.routingTable}`} aria-expanded={isExpanded} onClick={event => { event.stopPropagation(); onExpandedChange?.(isExpanded ? null : g.key) }} className="flex items-start gap-2 min-w-0 text-left rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring">
{isExpanded ? (
<ChevronDownIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRightIcon className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/40" />
)}
<div className="min-w-0">
<p className="font-medium truncate">{g.dstAddress}</p>
<p className="text-xs font-mono text-muted-foreground">{g.comment || "—"}</p>
</div>
</div>
</button>
)
},
meta: {
@@ -194,7 +194,7 @@ function RecursiveRoutesDataGrid({
?
</span>
)}
<span className="font-semibold text-sky-600 dark:text-sky-400 truncate min-w-0">
<span className="font-semibold text-info truncate min-w-0">
{ep.gateway}
</span>
</div>
@@ -251,19 +251,16 @@ function RecursiveRoutesDataGrid({
className="flex items-center gap-0.5 justify-end"
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
<Button type="button" size="sm"
variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
type="button"
aria-label={`Редактировать маршрут ${g.dstAddress} · ${g.routingTable}`}
disabled={mutationsDisabled}
onClick={() => onEdit(g)}
>
<PencilIcon className="size-3.5" />
<PencilIcon aria-hidden="true" className="size-3.5" />
</Button>
<Button
size="sm"
<Button type="button" size="sm"
variant="ghost"
className={cn(
"size-7 p-0 transition-colors",
@@ -271,7 +268,6 @@ function RecursiveRoutesDataGrid({
? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive",
)}
type="button"
aria-label={`${confirmDel ? "Подтвердить удаление" : "Удалить маршрут"} ${g.dstAddress} · ${g.routingTable}`}
disabled={mutationsDisabled}
onClick={() => {
@@ -285,9 +281,9 @@ function RecursiveRoutesDataGrid({
onBlur={() => setConfirmDeleteKey(null)}
>
{confirmDel ? (
<AlertCircleIcon className="size-3.5" />
<AlertCircleIcon aria-hidden="true" className="size-3.5" />
) : (
<TrashIcon className="size-3.5" />
<TrashIcon aria-hidden="true" className="size-3.5" />
)}
</Button>
</div>
@@ -297,7 +293,7 @@ function RecursiveRoutesDataGrid({
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
},
],
[confirmDeleteKey, expandedKey, onDelete, onEdit, mutationsDisabled],
[confirmDeleteKey, expandedKey, onDelete, onEdit, onExpandedChange, mutationsDisabled],
)
const table = useTable({
@@ -313,7 +309,7 @@ function RecursiveRoutesDataGrid({
if (groups.length === 0) {
return (
<EmptyState
icon={<RouteIcon className="size-4" />}
icon={<RouteIcon aria-hidden="true" className="size-4" />}
title={emptyTitle}
className="border-0 py-10"
/>
+9
View File
@@ -162,3 +162,12 @@ HTTP-фикстуры; layout/клавиатура проверены. Productio
| BFD · Data Grid / Stats 12 | [Снимок](ux-ui/screens/ospf-bfd-desktop.png) | [Снимок](ux-ui/screens/ospf-bfd-mobile.png) | Срез → состояния → наблюдаемые таймеры/счётчики |
HTTP-фикстуры; локальная приёмка: 43 основных + 8 source E2E, 9 unit, production frontend. Реальный RouterOS/JWT/БД не проверен. Далее рекурсивные маршруты; точное продолжение в журнале.
## Рекурсивные маршруты — локальная визуальная приёмка 2026-09-30
| Экран / референс | ПК | Телефон | Схема |
| --- | --- | --- | --- |
| [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) / [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Детали](ux-ui/screens/recursive-routes-detail-desktop.png) | [390 px](ux-ui/screens/recursive-routes-detail-mobile.png), [320 px](ux-ui/screens/recursive-routes-detail-narrow.png) | Устройство → свежесть → группа/таблица → endpoints |
| [Sheet 8](https://reui.io/preview/base/sheet-8?ref=mcp) | [Форма](ux-ui/screens/recursive-routes-form-desktop.png) | [390 px](ux-ui/screens/recursive-routes-form-mobile.png), [320 px](ux-ui/screens/recursive-routes-form-narrow.png) | Полный снимок → параметры → подтверждение API → возврат фокуса |
48 основных + 8 source E2E, backend recursive regression и production frontend прошли. HTTP-фикстуры; RouterOS/DB/JWT остаются внешней приёмкой. Далее оптимизатор по журналу.
+9 -1
View File
@@ -26,11 +26,19 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.27; строгий каталог маршрутов, далее layout/keyboard |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.28; рекурсивные маршруты приняты локально, далее оптимизатор |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.28 — адаптивность и локальная приёмка маршрутов (2026-09-30)
- Раскрытие группы — отдельная кнопка с aria-expanded, Enter/Space; URL и клик строки сохранены. Форма имеет доступные имена полей и штатный finalFocus Base UI после Escape.
- Мобильный Sheet занимает полную ширину, header/footer сохраняются, fieldset владеет прокруткой. Убраны вложенные max-height списков стран/шлюзов; кнопки footer сохраняют высоту. Шапка устройства переносится, неизвестный тип больше не подменяется exit-node.
- Проверки: 48 основных E2E, восемь source, backend recursive HTTP/parser, lint и production frontend (34 страницы) прошли. Три layout сценария повторно прошли после audit: 1440/390/320 px, keyboard/focus trap/Escape/без page overflow. Первый layout-прогон выявил ширину ¾ и потерю фокуса; оба исправлены. Шесть снимков просмотрены и добавлены в план.
- ReUI checklist применён к consumer-композиции: Frame/DataGrid/Sheet переиспользованы, vendor/theme не изменены; семантические цвета, декоративные иконки и объявления обновления. API finalFocus проверен в установленном Base UI.
- Рекурсивные маршруты завершены в границах локальной приёмки 10.24–10.28. RouterOS/DB/JWT остаются внешними ограничениями; strict catalog может отклонять неполный ответ конкретной версии, это требует проверки на устройстве. Далее 10.29: достоверность результатов и чтения оптимизатора. Последняя квота: 60% окна, 40% недели использовано. Локальный коммит без push.
## Блок 10.27 — строгий каталог рекурсивных маршрутов (2026-09-30)
- Новый /recursive-routes/catalog читает RouterOS напрямую и принимает только полный валидный managed-снимок. Неизвестные обязательные параметры или неподдерживаемая принадлежащая приложению запись дают 503, а не пустой список, который мог бы удалить маршруты при полном применении. Legacy GET сохранён для существующих потребителей Filters.
+4
View File
@@ -160,3 +160,7 @@ server/q/route/table принадлежат экрану; default replace, вы
## 10.27. Строгий наблюдаемый каталог
Отдельные catalog endpoints сохраняют legacy потребителей. Неполный owned snapshot отклоняется целиком до сохранения; нули, disabled и unset scope сохранены. Gateway active означает использование в маршруте, без заявления о reachability; страна только явная. 42 основных + повторные 18 data/values, 8 source, backend parser/HTTP/config-sync/build и TS/lint прошли. Далее визуальная приёмка. Реальная версия RouterOS требует отдельной проверки.
## 10.28. Локальная приёмка рекурсивных маршрутов
48 основных + 8 source E2E, backend recursive tests, lint/production frontend прошли. 1440/390/320 px, keyboard раскрытие, trap и возврат фокуса, parent-owned scroll; шесть экранов в плане. Неизвестный тип не подменяется EN. ReUI audit consumer-композиции выполнен без изменения vendor/theme. Далее оптимизатор; RouterOS/DB/JWT остаются внешней приёмкой.
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+32
View File
@@ -0,0 +1,32 @@
import {test,expect} from "@playwright/test"
import {mkdirSync} from "node:fs"
for(const [device,width,height] of [["desktop",1440,1000],["mobile",390,844],["narrow",320,700]] as const)test("Маршруты: layout/keyboard "+device,async({page})=>{
await page.setViewportSize({width,height});await page.emulateMedia({reducedMotion:"reduce"})
const errors:string[]=[];page.on("pageerror",error=>errors.push(error.message))
const item={id:"r",dstAddress:"10.0.0.0/24",gateway:"192.0.2.1%bridge1",distance:0,scope:0,targetScope:null,routingTable:"main",checkGateway:"none",comment:"Резервный маршрут",disabled:true,country:""}
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;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:[item],live:true,stale:false}:p==="/api/recursive-routes/gateways/catalog"?{serverId:1,gateways:[{id:"g",ip:"192.0.2.1",name:"bridge1",status:"unknown"}]}:{}})})
await page.goto("/recursive-routes?server=1")
const open=page.getByRole("button",{name:"Раскрыть маршрут 10.0.0.0/24 · main",exact:true})
await expect(open).toBeVisible();await expect(page.getByText("EN",{exact:true})).toHaveCount(0)
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
await open.focus();await page.keyboard.press("Enter");await expect(open).toHaveAttribute("aria-expanded","true")
await expect(page.getByText("scope: 0",{exact:true})).toBeVisible()
await page.addStyleTag({content:"nextjs-portal {display:none!important}"});mkdirSync("docs/ux-ui/screens",{recursive:true})
await page.screenshot({path:"docs/ux-ui/screens/recursive-routes-detail-"+device+".png",animations:"disabled"})
await open.focus();await page.keyboard.press(" ");await expect(open).toHaveAttribute("aria-expanded","false")
const edit=page.getByRole("button",{name:"Редактировать маршрут 10.0.0.0/24 · main",exact:true})
await edit.focus();await page.keyboard.press("Enter")
const sheet=page.getByRole("dialog");await expect(sheet).toBeVisible()
await expect(sheet.getByRole("textbox",{name:"Адрес назначения",exact:true})).toHaveValue("10.0.0.0/24")
await expect(sheet.getByRole("textbox",{name:"Шлюз",exact:true})).toHaveValue("192.0.2.1%bridge1")
await expect(sheet.getByRole("spinbutton",{name:"Distance (приоритет)",exact:true})).toHaveValue("0")
await sheet.getByRole("textbox",{name:"Комментарий",exact:true}).scrollIntoViewIfNeeded()
expect(await sheet.evaluate(el=>el.scrollWidth<=el.clientWidth)).toBe(true)
const save=sheet.getByRole("button",{name:"Сохранить",exact:true});await expect(save).toBeInViewport()
await save.focus();await page.keyboard.press("Tab")
expect(await sheet.evaluate(el=>el.contains(document.activeElement))).toBe(true)
await page.screenshot({path:"docs/ux-ui/screens/recursive-routes-form-"+device+".png",animations:"disabled"})
await page.keyboard.press("Escape");await expect(sheet).toHaveCount(0);await expect(edit).toBeFocused()
expect(errors).toEqual([])
})