fix(ui): завершить адаптивность рекурсивных маршрутов
This commit is contained in:
1 parent
54ef752a5b
commit
e58604c3c0
12 files changed
+123
-62
No files matched your search
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 остаются внешней приёмкой. Далее оптимизатор по журналу.
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -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([])
|
||||
})
|
||||
Reference in new issue
Block a user