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 id: number
name: string name: string
host: string host: string
type: "jump-host" | "exit-node" | "home-router" type?: "jump-host" | "exit-node" | "home-router"
site: string site: string
country: string country: string
asn: 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 { return {
id: String(s.id), id: String(s.id),
name: s.name || s.host, name: s.name || s.host,
@@ -138,7 +138,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
<label className="text-sm font-medium">Страна</label> <label className="text-sm font-medium">Страна</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input placeholder="Поиск или код (RU, DE…)" value={query} <Input aria-label="Код страны" placeholder="Поиск или код (RU, DE…)" value={query}
onChange={e => { onChange={e => {
const v = e.target.value.toUpperCase().slice(0, 3) const v = e.target.value.toUpperCase().slice(0, 3)
setQuery(v) setQuery(v)
@@ -161,7 +161,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
</span> </span>
)} )}
</div> </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 => ( {visible.map(c => (
<button key={c.code} type="button" <button key={c.code} type="button"
onClick={() => { onChange(c.code); setQuery("") }} onClick={() => { onChange(c.code); setQuery("") }}
@@ -182,7 +182,7 @@ function EndpointCountryField({ value, onChange }: { value: string; onChange: (v
} }
function RouteSheet({ function RouteSheet({
open, mode, initial, onSave, onClose, gateways, busy, saveError, open, mode, initial, onSave, onClose, gateways, busy, saveError, returnFocus,
}: { }: {
open: boolean open: boolean
mode: "create" | "edit" mode: "create" | "edit"
@@ -192,6 +192,7 @@ function RouteSheet({
gateways: GatewayOption[] gateways: GatewayOption[]
busy: boolean busy: boolean
saveError: string | null saveError: string | null
returnFocus: () => HTMLElement | null
}) { }) {
const [form, setForm] = useState<RouteForm>(initial) const [form, setForm] = useState<RouteForm>(initial)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -217,21 +218,21 @@ function RouteSheet({
return ( return (
<Sheet open={open} onOpenChange={v => { if (!v && !busy && !saveLock.current) onClose() }}> <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"> <SheetHeader className="px-5 pt-5 pb-4 border-b shrink-0">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<div> <div>
<SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle> <SheetTitle className="text-base">{mode === "create" ? "Новый маршрут" : "Редактировать маршрут"}</SheetTitle>
<SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription> <SheetDescription className="text-xs mt-0.5">Рекурсивный статический маршрут</SheetDescription>
</div> </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> </div>
</SheetHeader> </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"> <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"> <div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle> <SectionTitle>Основные</SectionTitle>
<FormField label="Dst Address" required hint="Например 8.8.8.8/32 или 1.1.1.0/24"> <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> </FormField>
</div> </div>
@@ -251,35 +252,35 @@ function RouteSheet({
aria-label={`Удалить конечную точку ${idx + 1}`} aria-label={`Удалить конечную точку ${idx + 1}`}
disabled={form.endpoints.length <= 1} disabled={form.endpoints.length <= 1}
> >
<TrashIcon className="size-3.5" /> <TrashIcon aria-hidden="true" className="size-3.5" />
</button> </button>
</div> </div>
<EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} /> <EndpointCountryField value={ep.country} onChange={(v) => setEp(ep.id, "country", v)} />
<FormField label="Gateway" required hint="Можно выбрать карточкой ниже или ввести вручную в формате ip%gateway"> <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> </FormField>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<FormField label="Distance (приоритет)"> <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>
<FormField label="Check Gateway"> <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> </FormField>
</div> </div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<FormField label="Scope"> <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>
<FormField label="T.Scope"> <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> </FormField>
</div> </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) => { {gateways.map((g) => {
const value = `${g.ip}%${g.name}` const value = `${g.ip}%${g.name}`
const selected = ep.gateway === value 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={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="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> <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>
<div className="mt-1.5 grid grid-cols-[auto_auto_1fr] items-start gap-2 text-[11px] text-muted-foreground font-mono"> <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/50 break-all">{g.ip}</span>
@@ -315,7 +316,7 @@ function RouteSheet({
</div> </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={() => setForm(f => ({ ...f, endpoints: [...f.endpoints, newEndpoint()] }))}>
<PlusIcon className="size-3.5" />Добавить endpoint <PlusIcon aria-hidden="true" className="size-3.5" />Добавить endpoint
</Button> </Button>
{gateways.length === 0 && ( {gateways.length === 0 && (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground"> <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"> <div className="flex flex-col gap-4">
<SectionTitle>Параметры</SectionTitle> <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="Комментарий"> <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> </FormField>
</div> </div>
{saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>} {saveError && <p role="alert" className="text-sm text-destructive">{saveError}</p>}
{busy && <p role="status" className="text-sm text-muted-foreground">Ожидание подтверждения роутера…</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> </fieldset>
<SheetFooter className="px-6 py-4 border-t shrink-0 gap-2"> <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" variant="outline" disabled={busy} onClick={onClose} className="h-8 flex-none">Отмена</Button>
<Button type="button" disabled={busy} onClick={() => void handleSave()} className="flex-1"><CheckIcon className="size-4" />{mode === "create" ? "Добавить" : "Сохранить"}</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> </SheetFooter>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
@@ -349,10 +350,10 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" })
return ( return (
<span className={cn( <span className={cn(
"inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold border shrink-0", "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" type === "jump-host"
? "bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20" ? "bg-focus/10 text-focus border-focus/20"
: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20", : "bg-info/10 text-info border-info/20",
)}> )}>
{type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"} {type === "jump-host" ? "JH" : type === "home-router" ? "HR" : "EN"}
</span> </span>
@@ -393,7 +394,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
const isLive = mode === "live" const isLive = mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) 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 [selectedServerId, setSelectedServerId] = useState<string>(context.serverId ?? "")
const [rows, setRows] = useState<RecursiveRouteRow[]>([]) const [rows, setRows] = useState<RecursiveRouteRow[]>([])
const [busy, setBusy] = useState<"load" | "apply" | null>(null) const [busy, setBusy] = useState<"load" | "apply" | null>(null)
@@ -444,7 +445,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
if (!active) return if (!active) return
const parsed = recursiveServersSchema.safeParse(data) const parsed = recursiveServersSchema.safeParse(data)
if (!parsed.success) throw new Error("Некорректный список устройств в ответе API") 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) setServers(mapped)
setSelectedServerId(context.serverId ?? mapped[0]?.id ?? "") setSelectedServerId(context.serverId ?? mapped[0]?.id ?? "")
if (!context.serverId && !context.error && mapped[0]) patchUrl({ serverId: mapped[0].id }, true) 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 { function groupKeyOf(row: RecursiveRouteRow): string {
return JSON.stringify([row.dstAddress.trim().toLowerCase(), row.routingTable.trim()]) 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() { function openCreate() {
if (mutationsLocked) return if (mutationsLocked) return
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setSaveError(null) setSaveError(null)
setSheetMode("create") setSheetMode("create")
setEditingGroupKey(null) setEditingGroupKey(null)
@@ -586,6 +595,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
} }
function openEdit(group: RouteGroup) { function openEdit(group: RouteGroup) {
if (mutationsLocked) return if (mutationsLocked) return
sheetTrigger.current = document.activeElement instanceof HTMLElement ? document.activeElement : null
setSaveError(null) setSaveError(null)
const endpoints = rows.filter(row => groupKeyOf(row) === group.key) const endpoints = rows.filter(row => groupKeyOf(row) === group.key)
setSheetMode("edit") setSheetMode("edit")
@@ -711,17 +721,17 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
{isLive && ( {isLive && (
<> <>
<Button <Button
variant="outline" type="button" variant="outline"
size="sm" size="sm"
onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }} onClick={() => { if (!servers.length) setInventoryTick(t => t + 1); else { void loadRoutes(); void loadGateways() } }}
disabled={busy !== null} disabled={busy !== null}
title="Прочитать маршруты с роутера" title="Прочитать маршруты с роутера"
> >
<RefreshCwIcon className={cn("size-4", busy === "load" && "animate-spin")} /> <RefreshCwIcon aria-hidden="true" className={cn("size-4", busy === "load" && "animate-spin")} />
Обновить Обновить
</Button> </Button>
<Button <Button
variant="outline" type="button" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
setHistoryOpen(true) setHistoryOpen(true)
@@ -729,13 +739,13 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
}} }}
disabled={busy !== null} disabled={busy !== null}
> >
<HistoryIcon className="size-4" /> <HistoryIcon aria-hidden="true" className="size-4" />
История История
</Button> </Button>
</> </>
)} )}
<Button size="sm" onClick={openCreate} disabled={mutationsLocked}> <Button type="button" size="sm" onClick={openCreate} disabled={mutationsLocked}>
<PlusIcon className="size-4" />Добавить <PlusIcon aria-hidden="true" className="size-4" />Добавить
</Button> </Button>
</> </>
} }
@@ -744,13 +754,13 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
banner={ banner={
<div className="border-b px-4 py-3 flex items-center gap-3 flex-wrap shrink-0 md:px-6"> <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"> <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…" <Input aria-label="Поиск маршрутов" className="pl-8 h-8 text-sm" placeholder="Dst, gateway, table, comment…"
value={search} onChange={(e) => setSearch(e.target.value)} /> value={search} onChange={(e) => setSearch(e.target.value)} />
{search && ( {search && (
<button type="button" aria-label="Сбросить поиск маршрутов" onClick={() => setSearch("")} <button type="button" aria-label="Сбросить поиск маршрутов" onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"> 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> </button>
)} )}
</div> </div>
@@ -761,6 +771,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
<p className="text-xs text-muted-foreground ml-auto"> <p className="text-xs text-muted-foreground ml-auto">
{!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`} {!hasRouteSnapshot ? "Количество неизвестно" : filteredRows.length !== rows.length ? `${filteredRows.length} из ${rows.length} маршрутов` : `${rows.length} маршрутов`}
</p> </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>} {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>} {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>} {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 && 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 && gatewayError && <p role="status" className="w-full text-xs text-warning">Шлюзы не загружены: {gatewayError}. Адрес можно ввести вручную.</p>}
{isLive && liveStale && ( {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"> <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 className="size-3.5 shrink-0" /> <AlertTriangleIcon aria-hidden="true" className="size-3.5 shrink-0" />
{routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы. {routeOwner === selectedServerId ? "Последний снимок; актуальное состояние не подтверждено." : "Данные маршрутов пока неизвестны."} Изменения заблокированы.
</div> </div>
)} )}
@@ -790,17 +801,17 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
) : ( ) : (
<DataPageCard> <DataPageCard>
{currentServer && ( {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>} {currentServer.status ? <StatusDot status={currentServer.status} pulse={currentServer.status === "online"} /> : <span className="text-xs text-muted-foreground">Состояние неизвестно</span>}
<Flag code={currentServer.country} size={16} /> <Flag code={currentServer.country} size={16} />
<span className="font-mono text-sm font-semibold">{currentServer.name}</span> <span className="min-w-0 break-all font-mono text-sm font-semibold">{currentServer.name}</span>
<TypeChip type={currentServer.type} /> {currentServer.type && <TypeChip type={currentServer.type} />}
<code className="text-[11px] font-mono text-muted-foreground">{currentServer.host}</code> <code className="break-all text-[11px] font-mono text-muted-foreground">{currentServer.host}</code>
<span className="text-xs text-muted-foreground">{currentServer.asn}</span> <span className="text-xs text-muted-foreground">{currentServer.asn}</span>
{currentServer.latency !== null && ( {currentServer.latency !== null && (
<span className={cn( <span className={cn(
"text-xs font-mono", "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> )}>{currentServer.latency}мс</span>
)} )}
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted-foreground"> <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)) 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"> 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> </button>
</DataPageCard> </DataPageCard>
@@ -834,6 +845,7 @@ function RecursiveRoutesContent({ mode, backendUrl, context, patchUrl, beginWrit
</ServerRailLayout> </ServerRailLayout>
<RouteSheet <RouteSheet
returnFocus={returnSheetFocus}
busy={busy !== null} busy={busy !== null}
saveError={saveError} saveError={saveError}
open={sheetOpen} open={sheetOpen}
@@ -95,7 +95,7 @@ function RouteGroupExpandedDetail({ group }: { group: RecursiveRouteGroup }) {
<span className="text-[11px] font-mono">distance: {ep.distance}</span> <span className="text-[11px] font-mono">distance: {ep.distance}</span>
</div> </div>
<p className="mt-1.5 font-mono text-sm break-all leading-tight">{ep.gateway}</p> <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>scope: {ep.scope ?? "—"}</span>
<span>t.scope: {ep.targetScope ?? "—"}</span> <span>t.scope: {ep.targetScope ?? "—"}</span>
<span>check: {ep.checkGateway || "—"}</span> <span>check: {ep.checkGateway || "—"}</span>
@@ -147,17 +147,17 @@ function RecursiveRoutesDataGrid({
const g = row.original const g = row.original
const isExpanded = expandedKey === g.key const isExpanded = expandedKey === g.key
return ( 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 ? ( {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"> <div className="min-w-0">
<p className="font-medium truncate">{g.dstAddress}</p> <p className="font-medium truncate">{g.dstAddress}</p>
<p className="text-xs font-mono text-muted-foreground">{g.comment || "—"}</p> <p className="text-xs font-mono text-muted-foreground">{g.comment || "—"}</p>
</div> </div>
</div> </button>
) )
}, },
meta: { meta: {
@@ -194,7 +194,7 @@ function RecursiveRoutesDataGrid({
? ?
</span> </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} {ep.gateway}
</span> </span>
</div> </div>
@@ -251,19 +251,16 @@ function RecursiveRoutesDataGrid({
className="flex items-center gap-0.5 justify-end" className="flex items-center gap-0.5 justify-end"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<Button <Button type="button" size="sm"
size="sm"
variant="ghost" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground" className="size-7 p-0 text-muted-foreground hover:text-foreground"
type="button"
aria-label={`Редактировать маршрут ${g.dstAddress} · ${g.routingTable}`} aria-label={`Редактировать маршрут ${g.dstAddress} · ${g.routingTable}`}
disabled={mutationsDisabled} disabled={mutationsDisabled}
onClick={() => onEdit(g)} onClick={() => onEdit(g)}
> >
<PencilIcon className="size-3.5" /> <PencilIcon aria-hidden="true" className="size-3.5" />
</Button> </Button>
<Button <Button type="button" size="sm"
size="sm"
variant="ghost" variant="ghost"
className={cn( className={cn(
"size-7 p-0 transition-colors", "size-7 p-0 transition-colors",
@@ -271,7 +268,6 @@ function RecursiveRoutesDataGrid({
? "text-destructive bg-destructive/10 hover:bg-destructive/20" ? "text-destructive bg-destructive/10 hover:bg-destructive/20"
: "text-muted-foreground hover:text-destructive", : "text-muted-foreground hover:text-destructive",
)} )}
type="button"
aria-label={`${confirmDel ? "Подтвердить удаление" : "Удалить маршрут"} ${g.dstAddress} · ${g.routingTable}`} aria-label={`${confirmDel ? "Подтвердить удаление" : "Удалить маршрут"} ${g.dstAddress} · ${g.routingTable}`}
disabled={mutationsDisabled} disabled={mutationsDisabled}
onClick={() => { onClick={() => {
@@ -285,9 +281,9 @@ function RecursiveRoutesDataGrid({
onBlur={() => setConfirmDeleteKey(null)} onBlur={() => setConfirmDeleteKey(null)}
> >
{confirmDel ? ( {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> </Button>
</div> </div>
@@ -297,7 +293,7 @@ function RecursiveRoutesDataGrid({
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST }, 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({ const table = useTable({
@@ -313,7 +309,7 @@ function RecursiveRoutesDataGrid({
if (groups.length === 0) { if (groups.length === 0) {
return ( return (
<EmptyState <EmptyState
icon={<RouteIcon className="size-4" />} icon={<RouteIcon aria-hidden="true" className="size-4" />}
title={emptyTitle} title={emptyTitle}
className="border-0 py-10" 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) | Срез → состояния → наблюдаемые таймеры/счётчики | | 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/БД не проверен. Далее рекурсивные маршруты; точное продолжение в журнале. 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; внешние ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 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А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 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) ## Блок 10.27 — строгий каталог рекурсивных маршрутов (2026-09-30)
- Новый /recursive-routes/catalog читает RouterOS напрямую и принимает только полный валидный managed-снимок. Неизвестные обязательные параметры или неподдерживаемая принадлежащая приложению запись дают 503, а не пустой список, который мог бы удалить маршруты при полном применении. Legacy GET сохранён для существующих потребителей Filters. - Новый /recursive-routes/catalog читает RouterOS напрямую и принимает только полный валидный managed-снимок. Неизвестные обязательные параметры или неподдерживаемая принадлежащая приложению запись дают 503, а не пустой список, который мог бы удалить маршруты при полном применении. Legacy GET сохранён для существующих потребителей Filters.
+4
View File
@@ -160,3 +160,7 @@ server/q/route/table принадлежат экрану; default replace, вы
## 10.27. Строгий наблюдаемый каталог ## 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 требует отдельной проверки. Отдельные 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([])
})