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

This commit is contained in:
Denozordec committed 2026-09-30 23:23:58 +07:00
1 parent 0ee7f02e87
commit fb70d80220
18 files changed
+173 -122

No files matched your search

+81 -79
View File
@@ -229,7 +229,7 @@ function Chip({ children, color }: { children: React.ReactNode; color?: string }
function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
return (
<Chip color={best
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
? "bg-success/10 text-success border-success/20"
: "bg-muted text-muted-foreground border-border"
}>{prob} б.</Chip>
)
@@ -237,16 +237,16 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
function ConfChip({ conf }: { conf: string }) {
const map: Record<string, string> = {
HIGH: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
MEDIUM: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
LOW: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
HIGH: "bg-info/10 text-info border-info/20",
MEDIUM: "bg-warning/10 text-warning border-warning/20",
LOW: "bg-destructive/10 text-destructive border-destructive/20",
}
return <Chip color={map[conf] ?? map.LOW}>{conf}</Chip>
}
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
function NInput({ value, onChange, min, max, label }: { label: string; value: number; onChange: (v: number) => void; min?: number; max?: number }) {
return (
<Input type="number" value={value} min={min} max={max}
<Input aria-label={label} type="number" value={value} min={min} max={max}
onChange={e => onChange(Math.max(min ?? 0, Math.min(max ?? 9999, parseInt(e.target.value) || 0)))}
className="w-16 h-7 text-xs font-mono text-right" />
)
@@ -287,7 +287,7 @@ function HomeRouterCard({ entry, jumpHosts, pinned, applied, applying, onPin, on
<FramePanel className="p-0 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b flex-wrap">
<MonitorIcon className="size-4 text-muted-foreground shrink-0" />
<MonitorIcon aria-hidden="true" className="size-4 text-muted-foreground shrink-0" />
<div>
<div className="flex items-center gap-2">
<Flag code={home.country} />
@@ -303,17 +303,17 @@ function HomeRouterCard({ entry, jumpHosts, pinned, applied, applying, onPin, on
{/* Best route summary */}
{bestRoute && (
<div className="flex items-center gap-1.5 ml-2 text-xs text-muted-foreground">
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{bestRoute.wan.name}</span>
<ArrowRightIcon className="size-3" />
<span className="font-mono font-semibold text-info ">{bestRoute.wan.name}</span>
<ArrowRightIcon aria-hidden="true" className="size-3" />
<span>{bestRoute.jh.label}</span>
<ArrowRightIcon className="size-3" />
<ArrowRightIcon aria-hidden="true" className="size-3" />
<span className="flex items-center gap-1"><Flag code={bestRoute.exit.country} />{bestRoute.exit.label}</span>
</div>
)}
<div className="ml-auto flex items-center gap-2">
{switchCount > 0 && (
<Chip color="bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
<Chip color="bg-warning/10 text-warning border-warning/20">
{switchCount} переключений
</Chip>
)}
@@ -327,15 +327,15 @@ function HomeRouterCard({ entry, jumpHosts, pinned, applied, applying, onPin, on
</div>
{/* Sub-tabs */}
<div className="flex items-center gap-0 border-b bg-muted/20 px-1">
<div className="flex items-center gap-0 overflow-x-auto border-b bg-muted/20 px-1">
{([
{ id: "wan-matrix", label: `WAN × JH (${home.wans.length}×${jumpHosts.length})` },
{ id: "full-routes", label: `Маршруты (${fullRoutes.length})` },
{ id: "bgp-community", label: `BGP community (${commRecs.length})` },
] as { id: HomeTab; label: string }[]).map(t => (
<button key={t.id} onClick={() => setTab(t.id)}
<button type="button" aria-pressed={tab === t.id} key={t.id} onClick={() => setTab(t.id)}
className={cn(
"px-4 py-2 text-xs font-medium border-b-2 transition-colors -mb-px",
"shrink-0 whitespace-nowrap px-4 py-2 text-xs font-medium border-b-2 transition-colors -mb-px focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
tab === t.id
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
@@ -371,22 +371,22 @@ function TopologyBar() {
return (
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
<div className="flex items-center gap-1.5">
<MonitorIcon className="size-3.5" />
<MonitorIcon aria-hidden="true" className="size-3.5" />
<span>Home Router</span>
</div>
<span className="text-border">→</span>
<div className="flex items-center gap-1.5">
<WifiIcon className="size-3.5 text-sky-500" />
<WifiIcon aria-hidden="true" className="size-3.5 text-info" />
<span>WAN1 / WAN2</span>
</div>
<span className="text-border">→</span>
<div className="flex items-center gap-1.5">
<ServerIcon className="size-3.5 text-violet-500" />
<ServerIcon aria-hidden="true" className="size-3.5 text-focus" />
<span>JumpHost</span>
</div>
<span className="text-border">→</span>
<div className="flex items-center gap-1.5">
<NetworkIcon className="size-3.5 text-emerald-500" />
<NetworkIcon aria-hidden="true" className="size-3.5 text-success" />
<span>Exit Node</span>
</div>
</div>
@@ -577,28 +577,28 @@ function RouteOptimizerContent() {
const exSub = ex.length ? ex.map((e) => e.site).join(" · ") : "—"
return [
{
label: "Home роутеров",
label: "Home",
value: useLiveData && !data ? "—" : homeCount,
sub: useLiveData && !data ? "Количество WAN неизвестно" : `${wanCount} WAN-аплинков`,
icon: <MonitorIcon className="size-4" />,
icon: <MonitorIcon aria-hidden="true" className="size-4" />,
},
{
label: "JumpHost",
value: useLiveData && !data ? "—" : jh.length,
sub: jhSub,
icon: <ServerIcon className="size-4" />,
icon: <ServerIcon aria-hidden="true" className="size-4" />,
},
{
label: "Exit Node",
value: useLiveData && !data ? "—" : ex.length,
sub: exSub,
icon: <NetworkIcon className="size-4" />,
icon: <NetworkIcon aria-hidden="true" className="size-4" />,
},
{
label: "Переключений",
label: "Рекомендации",
value: useLiveData && !data ? "—" : totalSwitches,
sub: useLiveData && !data ? "Расчёт пока неизвестен" : totalSwitches > 0 ? "расчётные рекомендации" : "модель не предлагает переключений",
icon: <ZapIcon className="size-4" />,
icon: <ZapIcon aria-hidden="true" className="size-4" />,
},
]
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
@@ -676,26 +676,27 @@ function RouteOptimizerContent() {
},[apiFetch,ospfServerId,settings.pingWeight,useLiveData,previewKey,readAt,previewTick])
return (
<div className="flex flex-col h-full">
<div className="flex flex-col h-full min-h-0 min-w-0">
<PageHeader
crumbs={[{ label: "Инструменты" }, { label: "Оптимизатор маршрутов" }]}
actions={
<>
{!useLiveData && totalSwitches > 0 && (
<Button size="sm" onClick={applyAll}>
<ZapIcon className="size-4" />Симулировать все ({totalSwitches})
<Button type="button" size="sm" onClick={applyAll}>
<ZapIcon aria-hidden="true" className="size-4" />Симулировать все ({totalSwitches})
</Button>
)}
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading || ospfApplying}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
<Button type="button" variant="outline" size="sm" onClick={() => void load()} disabled={loading || ospfApplying}>
<RefreshCwIcon aria-hidden="true" className={cn("size-4", loading && "animate-spin")} />
{loading ? "Расчёт…" : "Обновить"}
</Button>
</>
}
/>
<div className="flex-1 overflow-y-auto p-6">
<fieldset disabled={ospfApplying} className="min-w-0 flex flex-col gap-4">
<div className="flex-1 min-h-0 min-w-0 overflow-y-auto p-4 md:p-6">
<fieldset disabled={ospfApplying} className="min-w-0">
<div className="flex flex-col gap-4">
{/* Status row */}
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap px-0.5">
@@ -707,16 +708,16 @@ function RouteOptimizerContent() {
<span>Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"}</span>
<Chip>{useLiveData ? "Данные API · расчёт модели" : "Демо · симуляция"}</Chip>
{useLiveData && backendStatus === false && (
<span className="text-amber-600 dark:text-amber-400">
<span className="text-warning ">
бекенд не отвечает на /health — проверьте URL в настройках
</span>
)}
</div>
<p role="status" className="text-xs text-muted-foreground">Рекомендации Community — локальный расчёт. Индекс в баллах оценивает модель; базовая цепочка строится из топологии. Применение Community к устройству недоступно.</p>
<p className="text-xs text-muted-foreground">Ping и скорость могут включать оценки по задержке серверов и лимитам WAN. Настройки ECMP, RPF и VRF служат для локального просмотра. OSPF применяется отдельным действием ниже.</p>
<p role="status" className="text-xs text-muted-foreground">Community — расчёт модели. Применение Community к устройству недоступно.</p>
<details className="text-xs text-muted-foreground"><summary className="cursor-pointer rounded-sm focus-visible:ring-2 focus-visible:ring-ring">Как читать расчёт</summary><p className="mt-2">Индекс в баллах оценивает модель; базовая цепочка строится из топологии. Ping и скорость могут включать оценки по задержке серверов и лимитам WAN. ECMP, RPF и VRF служат локальному просмотру. OSPF применяется отдельным действием ниже.</p></details>
<KpiStatGrid
<KpiStatGrid gridClassName="grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4"
aria-label="Сводка оптимизатора"
items={statsChips.map((s, i) => ({
id: `ro-${i}`,
@@ -724,25 +725,25 @@ function RouteOptimizerContent() {
value: s.value,
hint: s.sub,
icon: s.icon,
iconClassName: s.label === "Переключений" && totalSwitches > 0
iconClassName: s.label === "Рекомендации" && totalSwitches > 0
? "text-warning"
: s.label === "Exit Node"
? "text-success"
: s.label === "JumpHost"
? "text-primary"
: "text-muted-foreground",
variant: s.label === "Переключений" && totalSwitches > 0 ? "warning" as const : "default" as const,
variant: s.label === "Рекомендации" && totalSwitches > 0 ? "warning" as const : "default" as const,
}))}
/>
{error && (
<div role="alert" className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
<AlertCircleIcon className="size-4 shrink-0" />{error}. {data ? "Последний полный снимок; актуальность не подтверждена." : "Данные оптимизатора неизвестны."}
<AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />{error}. {data ? "Последний полный снимок; актуальность не подтверждена." : "Данные оптимизатора неизвестны."}
</div>
)}
{loading && !data && (
<div className="text-muted-foreground text-sm py-8 text-center animate-pulse">
<div role="status" className="text-muted-foreground text-sm py-8 text-center animate-pulse">
Расчёт оптимальных маршрутов…
</div>
)}
@@ -751,15 +752,15 @@ function RouteOptimizerContent() {
<Frame dense className="w-full">
<FramePanel className="p-0">
<button type="button" aria-expanded={showSettings} aria-label="Настройки оптимизатора" disabled={ospfApplying}
className={cn("flex w-full items-center gap-3 px-4 py-3 text-left rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", showSettings && "border-b")}
className={cn("flex flex-wrap w-full items-center gap-3 px-4 py-3 text-left rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", showSettings && "border-b")}
onClick={() => setShowSettings(v => !v)}
>
<SettingsIcon className="size-4 text-muted-foreground shrink-0" />
<SettingsIcon aria-hidden="true" className="size-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium flex-1">Настройки оптимизатора</span>
<span className="text-[11px] text-muted-foreground mr-2">
порог {settings.switchThreshold}% · ping {settings.pingWeight}% · зондирование {settings.probeIntervalMin} мин
</span>
{showSettings ? <ChevronUpIcon className="size-4 text-muted-foreground" /> : <ChevronDownIcon className="size-4 text-muted-foreground" />}
{showSettings ? <ChevronUpIcon aria-hidden="true" className="size-4 text-muted-foreground" /> : <ChevronDownIcon aria-hidden="true" className="size-4 text-muted-foreground" />}
</button>
{showSettings && (
@@ -768,19 +769,19 @@ function RouteOptimizerContent() {
<div className="flex flex-col gap-3">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Пороги переключения</p>
<SettingRow label="Мин. выигрыш" unit="%">
<NInput value={settings.switchThreshold} onChange={v => set("switchThreshold", v)} min={0} max={50} />
<NInput label="Порог переключения" value={settings.switchThreshold} onChange={v => set("switchThreshold", v)} min={0} max={50} />
</SettingRow>
<SettingRow label="Гистерезис" unit="%">
<NInput value={settings.hysteresisThreshold} onChange={v => set("hysteresisThreshold", v)} min={0} max={50} />
<NInput label="Порог гистерезиса" value={settings.hysteresisThreshold} onChange={v => set("hysteresisThreshold", v)} min={0} max={50} />
</SettingRow>
</div>
<div className="flex flex-col gap-3">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Веса метрик</p>
<SettingRow label="Вес задержки" unit="%">
<NInput value={settings.pingWeight} onChange={v => set("pingWeight", Math.min(100, v))} min={0} max={100} />
<NInput label="Вес задержки" value={settings.pingWeight} onChange={v => set("pingWeight", Math.min(100, v))} min={0} max={100} />
</SettingRow>
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
<div className="h-full rounded-full bg-sky-500 transition-all" style={{ width: `${settings.pingWeight}%` }} />
<div className="h-full rounded-full bg-info transition-all" style={{ width: `${settings.pingWeight}%` }} />
</div>
<div className="flex justify-between text-[10px] text-muted-foreground">
<span>Ping {settings.pingWeight}%</span>
@@ -789,7 +790,7 @@ function RouteOptimizerContent() {
<SettingRow label="Интервал зондирования">
<div className="flex rounded-md border border-input overflow-hidden h-7">
{[5, 10, 15, 30].map(v => (
<button key={v} onClick={() => set("probeIntervalMin", v)}
<button type="button" aria-pressed={settings.probeIntervalMin===v} key={v} onClick={() => set("probeIntervalMin", v)}
className={cn("px-2.5 text-xs transition-colors cursor-pointer",
settings.probeIntervalMin === v ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>
{v}м
@@ -804,9 +805,9 @@ function RouteOptimizerContent() {
</div>
</div>
<div className="flex justify-end gap-2 mt-5 pt-4 border-t">
<Button variant="outline" size="sm" onClick={() => setSettings({ ...DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS })}>Сбросить</Button>
<Button size="sm" onClick={() => void load(settings)}>
<CheckCircleIcon className="size-4" />Пересчитать
<Button type="button" variant="outline" size="sm" onClick={() => setSettings({ ...DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS })}>Сбросить</Button>
<Button type="button" size="sm" onClick={() => void load(settings)}>
<CheckCircleIcon aria-hidden="true" className="size-4" />Пересчитать
</Button>
</div>
<p className="text-[10px] text-muted-foreground mt-3">
@@ -837,8 +838,8 @@ function RouteOptimizerContent() {
<>
<div className="rounded-lg border bg-muted/15 overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b bg-background/80 flex-wrap">
<select
className="text-xs bg-background text-foreground border border-input rounded-md px-2 py-1 h-7 min-w-64 focus:outline-none focus:ring-1 focus:ring-ring"
<select aria-label="Устройство OSPF"
className="w-full min-w-0 sm:w-auto sm:min-w-64 text-xs bg-background text-foreground border border-input rounded-md px-2 py-1 h-7 focus:outline-none focus:ring-1 focus:ring-ring"
value={ospfServerId}
onChange={(e) => setOspfServerId(e.target.value)}
>
@@ -852,13 +853,13 @@ function RouteOptimizerContent() {
<span className="text-[11px] text-muted-foreground">
{ospfMetaError ? "Каталог OSPF недостоверен" : ospfMeta ? `${ospfMeta.interfaces} iface · ${ospfMeta.areas} area` : "сбор OSPF-метрик…"}
</span>
<Button
<Button type="button"
size="sm"
className="ml-auto h-7 text-xs"
onClick={() => void applyOspfOptimization()}
disabled={!can("mm:network:write") || loading || Boolean(error) || !data || !ospfServerId || ospfApplying || !previewReady || Boolean(ospfMetaError) || !ospfPreview?.changedCount}
>
<ZapIcon className={cn("size-3.5", ospfApplying && "animate-pulse")} />
<ZapIcon aria-hidden="true" className={cn("size-3.5", ospfApplying && "animate-pulse")} />
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
</Button>
</div>
@@ -873,7 +874,7 @@ function RouteOptimizerContent() {
</div>
<div className="rounded-lg border bg-background/70 overflow-hidden">
<div className="px-3 py-2 border-b text-[11px] text-muted-foreground flex items-center justify-between">
<div className="px-3 py-2 border-b text-[11px] text-muted-foreground flex flex-wrap gap-2 items-center justify-between">
<span>Preview изменений OSPF cost (до применения)</span>
<Button type="button" variant="outline" size="sm" disabled={ospfApplying || ospfPreviewLoading} onClick={()=>setPreviewTick(value=>value+1)}>Обновить preview OSPF</Button>
<span>
@@ -894,7 +895,7 @@ function RouteOptimizerContent() {
{ospfApplying && <p role="status" className="text-xs text-muted-foreground">Ожидание подтверждения OSPF API…</p>}
{!can("mm:network:write") && <p className="text-xs text-muted-foreground">Доступен просмотр OSPF. Для применения требуется право изменения сети.</p>}
{ospfApplyResult && (
<div role="status" className="text-xs rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-600 dark:text-emerald-400">
<div role="status" className="text-xs rounded-md border border-success/30 bg-success/10 px-3 py-2 text-success ">
Последнее подтверждение API: {ospfApplyResult.serverName}, изменено интерфейсов — {ospfApplyResult.optimizedCount}. Получено {new Date(ospfApplyResult.receivedAt).toLocaleTimeString("ru-RU")}.
</div>
)}
@@ -916,11 +917,11 @@ function RouteOptimizerContent() {
title="ECMP"
description="Equal-Cost Multi-Path"
headerRight={
<button type="button" role="switch" aria-checked={ecmpEnabled}
<button type="button" role="switch" aria-label="Черновик ECMP" aria-checked={ecmpEnabled}
onClick={() => setEcmpEnabled(v => !v)}
className={cn("relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent transition-colors",
ecmpEnabled ? "bg-violet-500" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
ecmpEnabled ? "bg-focus" : "bg-input")}>
<span className={cn("pointer-events-none block h-4 w-4 rounded-full bg-primary-foreground shadow-sm transition-transform",
ecmpEnabled ? "translate-x-4" : "translate-x-0")} />
</button>
}
@@ -931,9 +932,9 @@ function RouteOptimizerContent() {
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Макс. путей</span>
<div className="flex items-center gap-2">
<input type="range" min={1} max={64} step={1} value={ecmpMaxPaths}
<input aria-label="Максимальное число путей ECMP" disabled={!ecmpEnabled} type="range" min={1} max={64} step={1} value={ecmpMaxPaths}
onChange={e => setEcmpMaxPaths(Number(e.target.value))}
className="flex-1 accent-violet-500 h-1.5" />
className="flex-1 accent-focus h-1.5" />
<span className="text-sm font-mono w-8 text-right">{ecmpMaxPaths}</span>
</div>
</div>
@@ -942,18 +943,18 @@ function RouteOptimizerContent() {
<span className="text-xs text-muted-foreground">Алгоритм балансировки</span>
<div className="flex rounded-md border border-input overflow-hidden h-7 text-xs">
{([["per-dst","По dst"],["per-conn","По conn"],["per-packet","По пакет"]] as const).map(([v, l]) => (
<button key={v} onClick={() => setEcmpAlgo(v)}
<button type="button" disabled={!ecmpEnabled} aria-pressed={ecmpAlgo===v} key={v} onClick={() => setEcmpAlgo(v)}
className={cn("flex-1 transition-colors cursor-pointer",
ecmpAlgo === v ? "bg-violet-500 text-white" : "text-muted-foreground hover:bg-muted")}>
ecmpAlgo === v ? "bg-focus text-primary-foreground" : "text-muted-foreground hover:bg-muted")}>
{l}
</button>
))}
</div>
</div>
<div className="flex items-start gap-2 rounded-lg border border-violet-500/20 bg-violet-500/5 px-2.5 py-2 text-[11px] text-violet-600 dark:text-violet-400">
<InfoIcon className="size-3 shrink-0 mt-0.5" />
<span>RouterOS 7.x: <code className="font-mono">/routing/rule add ecmp=yes</code></span>
<div className="flex items-start gap-2 rounded-lg border border-focus/20 bg-focus/5 px-2.5 py-2 text-[11px] text-focus ">
<InfoIcon aria-hidden="true" className="size-3 shrink-0 mt-0.5" />
<span>Черновой сценарий ECMP для сравнения настроек.</span>
</div>
</div>
</OpsPanel>
@@ -966,18 +967,18 @@ function RouteOptimizerContent() {
<div className="flex flex-col gap-1.5">
{([
{ v: "disabled", l: "Отключён", d: "Проверка не выполняется", cls: "text-muted-foreground" },
{ v: "loose", l: "Loose", d: "Маршрут к источнику существует", cls: "text-amber-500" },
{ v: "strict", l: "Strict", d: "Пакет пришёл по лучшему маршруту", cls: "text-emerald-500" },
{ v: "loose", l: "Loose", d: "Маршрут к источнику существует", cls: "text-warning" },
{ v: "strict", l: "Strict", d: "Пакет пришёл по лучшему маршруту", cls: "text-success" },
] as const).map(({ v, l, d, cls }) => (
<button key={v} type="button" onClick={() => setRpfMode(v)}
<button key={v} type="button" aria-pressed={rpfMode===v} onClick={() => setRpfMode(v)}
className={cn(
"flex items-start gap-2.5 px-3 py-2 rounded-lg border text-left transition-all text-xs",
rpfMode === v
? "border-amber-500/30 bg-amber-500/5"
? "border-warning/30 bg-warning/5"
: "border-border hover:border-muted-foreground/30 hover:bg-muted/30",
)}>
<div className={cn("size-3.5 rounded-full border-2 shrink-0 mt-0.5 transition-colors",
rpfMode === v ? "border-amber-500 bg-amber-500" : "border-muted-foreground")} />
rpfMode === v ? "border-warning bg-warning" : "border-muted-foreground")} />
<div>
<div className={cn("font-medium", rpfMode === v ? cls : "text-foreground")}>{l}</div>
<div className="text-muted-foreground text-[10px] mt-0.5">{d}</div>
@@ -988,8 +989,8 @@ function RouteOptimizerContent() {
</div>
{rpfMode !== "disabled" && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/20 bg-amber-500/5 px-2.5 py-2 text-[11px] text-amber-600 dark:text-amber-400">
<InfoIcon className="size-3 shrink-0 mt-0.5" />
<div className="flex items-start gap-2 rounded-lg border border-warning/20 bg-warning/5 px-2.5 py-2 text-[11px] text-warning ">
<InfoIcon aria-hidden="true" className="size-3 shrink-0 mt-0.5" />
<span><code className="font-mono">/ip settings set rp-filter={rpfMode}</code></span>
</div>
)}
@@ -1000,20 +1001,20 @@ function RouteOptimizerContent() {
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Контекст оптимизации маршрутов</span>
<div className="flex flex-col gap-1.5 max-h-48 overflow-y-auto pr-0.5">
<div className="flex flex-col gap-1.5">
{VRF_NAMES.map(vrf => {
const isSel = selectedVrf === vrf
const isMain = vrf === "main"
return (
<button key={vrf} type="button" onClick={() => setSelectedVrf(vrf)}
<button key={vrf} type="button" aria-pressed={selectedVrf===vrf} onClick={() => setSelectedVrf(vrf)}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-lg border text-left transition-all text-xs",
isSel
? "border-sky-500/40 bg-sky-500/8 text-sky-400"
? "border-info/40 bg-info/8 text-info"
: "border-border hover:border-muted-foreground/30 hover:bg-muted/30 text-muted-foreground hover:text-foreground",
)}>
<div className={cn("size-2.5 rounded-full shrink-0",
isSel ? "bg-sky-500" : "bg-muted-foreground/30")} />
isSel ? "bg-info" : "bg-muted-foreground/30")} />
<span className="font-mono flex-1">{vrf}</span>
{isMain && (
<span className="text-[10px] border border-current rounded px-1 opacity-60">default</span>
@@ -1024,8 +1025,8 @@ function RouteOptimizerContent() {
</div>
</div>
<div className="flex items-start gap-2 rounded-lg border border-sky-500/20 bg-sky-500/5 px-2.5 py-2 text-[11px] text-sky-600 dark:text-sky-400">
<InfoIcon className="size-3 shrink-0 mt-0.5" />
<div className="flex items-start gap-2 rounded-lg border border-info/20 bg-info/5 px-2.5 py-2 text-[11px] text-info ">
<InfoIcon aria-hidden="true" className="size-3 shrink-0 mt-0.5" />
<span>Выбран черновой контекст VRF <code className="font-mono">{selectedVrf}</code></span>
</div>
</OpsPanel>
@@ -1047,6 +1048,7 @@ function RouteOptimizerContent() {
/>
))}
</div>
</fieldset>
</div>
</div>
@@ -37,7 +37,7 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
<Chip
color={
best
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
? "bg-success/10 text-success border-success/20"
: "bg-muted text-muted-foreground border-border"
}
>
@@ -105,12 +105,12 @@ function RouteOptimizerCommRecsDataGrid({
if (!r.current) return <span className="text-muted-foreground text-xs">—</span>
return (
<div className="text-xs flex items-center gap-1 flex-wrap">
<span className="font-mono font-medium text-sky-600 dark:text-sky-400">
<span className="font-mono font-medium text-info ">
{r.current.wan}
</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
<span>{r.current.jh}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
<span className="text-muted-foreground">{r.current.exit}</span>
<span className="font-mono text-[10px] text-muted-foreground">
({r.current.gateway})
@@ -133,16 +133,16 @@ function RouteOptimizerCommRecsDataGrid({
<div
className={cn(
"text-xs flex items-center gap-1 flex-wrap",
r.shouldSwitch && !isPinned && "text-amber-600 dark:text-amber-400",
r.shouldSwitch && !isPinned && "text-warning ",
)}
>
<span className="font-mono font-medium">{r.recommended.wan}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.jh}</span>
<ArrowRightIcon className="size-3 shrink-0 opacity-60" />
<ArrowRightIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
<span>{r.recommended.exit}</span>
{r.shouldSwitch && !isPinned && (
<span className="ml-1 text-[10px] font-bold bg-amber-500/10 border border-amber-500/20 px-1.5 py-0.5 rounded">
<span className="ml-1 text-[10px] font-bold bg-warning/10 border border-warning/20 px-1.5 py-0.5 rounded">
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)} б.
</span>
)}
@@ -192,32 +192,32 @@ function RouteOptimizerCommRecsDataGrid({
const canApply = simulation && r.shouldSwitch && !isPinned && !isApplied
return (
<div className="flex items-center justify-end gap-1.5" onClick={(e) => e.stopPropagation()}>
{isPinned && <PinIcon className="size-3 text-sky-500 fill-sky-500" />}
<Button
{isPinned && <PinIcon aria-hidden="true" className="size-3 text-info fill-info" />}
<Button type="button"
variant="outline"
size="sm"
className={cn("h-7 text-xs", isPinned && "text-sky-600 dark:text-sky-400 border-sky-500/30")}
className={cn("h-7 text-xs", isPinned && "text-info border-info/30")}
onClick={() => onPin(pinKey)}
>
<PinIcon className={cn("size-3", isPinned && "fill-current")} />
<PinIcon aria-hidden="true" className={cn("size-3", isPinned && "fill-current")} />
{isPinned ? "Открепить" : "Закрепить"}
</Button>
{canApply && (
<Button
<Button type="button"
size="sm"
className="h-7 text-xs"
disabled={isApplying}
onClick={() => onApply(r.community, homeId)}
>
{isApplying ? (
<RefreshCwIcon className="size-3 animate-spin" />
<RefreshCwIcon aria-hidden="true" className="size-3 animate-spin" />
) : (
"Симулировать"
)}
</Button>
)}
{isApplied && (
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
<span className="text-[10px] text-success font-medium">
✓ симуляция завершена
</span>
)}
@@ -249,8 +249,8 @@ function RouteOptimizerCommRecsDataGrid({
headerRow: "border-b bg-muted/20",
bodyRow: cn(
"group/row hover:bg-muted/30",
"[&:has([data-comm-switch=true])]:bg-amber-500/5",
"[&:has([data-comm-applied=true])]:bg-emerald-500/5",
"[&:has([data-comm-switch=true])]:bg-warning/5",
"[&:has([data-comm-applied=true])]:bg-success/5",
),
}}
tableLayout={{ dense: true }}
@@ -37,7 +37,7 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
<Chip
color={
best
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
? "bg-success/10 text-success border-success/20"
: "bg-muted text-muted-foreground border-border"
}
>
@@ -48,9 +48,9 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
function ConfChip({ conf }: { conf: string }) {
const map: Record<string, string> = {
HIGH: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20",
MEDIUM: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
LOW: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
HIGH: "bg-info/10 text-info border-info/20",
MEDIUM: "bg-warning/10 text-warning border-warning/20",
LOW: "bg-destructive/10 text-destructive border-destructive/20",
}
return <Chip color={map[conf] ?? map.LOW}>{conf}</Chip>
}
@@ -86,7 +86,7 @@ function RouteOptimizerFullRoutesDataGrid({
{row.original._index + 1}
</span>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-1.5 py-0.5 rounded">
<span className="text-[9px] font-bold uppercase tracking-wide text-success bg-success/10 border border-success/20 px-1.5 py-0.5 rounded">
Лучший
</span>
)}
@@ -103,8 +103,8 @@ function RouteOptimizerFullRoutesDataGrid({
const r = row.original
return (
<div className="flex items-center gap-1.5 text-xs">
<span className="font-mono font-semibold text-sky-600 dark:text-sky-400">{r.wan.name}</span>
<ArrowRightIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono font-semibold text-info ">{r.wan.name}</span>
<ArrowRightIcon aria-hidden="true" className="size-3 text-muted-foreground shrink-0" />
<div>
<div className="font-medium">{r.jh.label}</div>
<div className="font-mono text-[10px] text-muted-foreground">
@@ -150,10 +150,10 @@ function RouteOptimizerFullRoutesDataGrid({
className={cn(
"font-mono text-xs text-center block",
totalPing < 40
? "text-emerald-600 dark:text-emerald-400"
? "text-success "
: totalPing < 80
? "text-amber-600 dark:text-amber-400"
: "text-red-500",
? "text-warning "
: "text-destructive",
)}
>
{totalPing} мс
@@ -263,7 +263,7 @@ function RouteOptimizerFullRoutesDataGrid({
recordCount={visibleRoutes.length}
tableClassNames={{
headerRow: "border-b bg-muted/20",
bodyRow: cn("group/row hover:bg-muted/30", "has-[[data-route-best=true]]:bg-emerald-500/5"),
bodyRow: cn("group/row hover:bg-muted/30", "has-[[data-route-best=true]]:bg-success/5"),
}}
tableLayout={{ dense: true }}
/>
@@ -275,12 +275,12 @@ function RouteOptimizerFullRoutesDataGrid({
>
{expanded ? (
<>
<ChevronUpIcon className="size-3" />
<ChevronUpIcon aria-hidden="true" className="size-3" />
Свернуть
</>
) : (
<>
<ChevronDownIcon className="size-3" />
<ChevronDownIcon aria-hidden="true" className="size-3" />
Показать все {routes.length} комбинаций
</>
)}
@@ -62,13 +62,13 @@ function RouteOptimizerOspfPreviewDataGrid({
const r = row.original
return (
<span className="font-mono">
<span className="text-sky-600 dark:text-sky-400">{r.currentCost}</span>
<span className="text-info ">{r.currentCost}</span>
{" → "}
<span
className={
r.currentCost === r.optimalCost
? "text-emerald-600 dark:text-emerald-400"
: "text-amber-600 dark:text-amber-400"
? "text-success "
: "text-warning "
}
>
{r.optimalCost}
@@ -127,7 +127,7 @@ function RouteOptimizerOspfPreviewDataGrid({
if (!loading && rows.length === 0) {
return (
<EmptyState
icon={<NetworkIcon className="size-4" />}
icon={<NetworkIcon aria-hidden="true" className="size-4" />}
title="Интерфейсы OSPF не найдены для выбранного сервера"
className="border-0 py-6 text-xs"
/>
@@ -19,9 +19,9 @@ import {
import { WifiIcon } from "lucide-react"
function LossChip({ loss }: { loss: number }) {
if (loss === 0) return <span className="text-emerald-600 dark:text-emerald-400 text-[11px] font-mono">0%</span>
if (loss === 0) return <span className="text-success text-[11px] font-mono">0%</span>
return (
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-red-500" : "text-amber-500")}>
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-destructive" : "text-warning")}>
{loss}%
</span>
)
@@ -50,7 +50,7 @@ function RouteOptimizerWanMatrixDataGrid({
const wan = row.original
return (
<div className="flex items-center gap-2">
<WifiIcon className="size-3.5 text-muted-foreground shrink-0" />
<WifiIcon aria-hidden="true" className="size-3.5 text-muted-foreground shrink-0" />
<div>
<p className="font-mono text-xs font-semibold">{wan.name}</p>
<p className="text-[10px] text-muted-foreground">{wan.iface}</p>
@@ -114,15 +114,15 @@ function RouteOptimizerWanMatrixDataGrid({
if (!leg) return <span className="text-center text-muted-foreground text-xs block">—</span>
const isBest = leg.score === bestScore
return (
<div className={cn("text-center", isBest && "bg-emerald-500/5")}>
<div className={cn("text-center", isBest && "bg-success/5")}>
<div
className={cn(
"flex flex-col items-center gap-0.5 rounded-md px-2 py-1.5 transition-colors",
isBest ? "border border-emerald-500/20 bg-emerald-500/8" : "border border-transparent",
isBest ? "border border-success/20 bg-success/8" : "border border-transparent",
)}
>
{isBest && (
<span className="text-[9px] font-bold uppercase tracking-wide text-emerald-600 dark:text-emerald-400 mb-0.5">
<span className="text-[9px] font-bold uppercase tracking-wide text-success mb-0.5">
★ ЛУЧШИЙ
</span>
)}
@@ -130,10 +130,10 @@ function RouteOptimizerWanMatrixDataGrid({
className={cn(
"font-mono text-xs font-semibold",
leg.pingMs < 10
? "text-emerald-600 dark:text-emerald-400"
? "text-success "
: leg.pingMs < 25
? "text-foreground"
: "text-amber-600 dark:text-amber-400",
: "text-warning ",
)}
>
{leg.pingMs} мс
+10
View File
@@ -171,3 +171,13 @@ HTTP-фикстуры; локальная приёмка: 43 основных +
| [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 остаются внешней приёмкой. Далее оптимизатор по журналу.
## Оптимизатор — визуальная проверка 2026-09-30
| Экран / референс | ПК | Телефон | Схема |
| --- | --- | --- | --- |
| Сводка · [Stats 12](https://reui.io/preview/base/stats-12?ref=mcp) / Frame | [Снимок](ux-ui/screens/route-optimizer-overview-desktop.png) | [390 px](ux-ui/screens/route-optimizer-overview-mobile.png), [320 px](ux-ui/screens/route-optimizer-overview-narrow.png) | Источник → объяснение модели → сводка → OSPF |
| Настройки · [Settings 16](https://reui.io/preview/base/settings-16?ref=mcp) | [Снимок](ux-ui/screens/route-optimizer-settings-desktop.png) | [390 px](ux-ui/screens/route-optimizer-settings-mobile.png), [320 px](ux-ui/screens/route-optimizer-settings-narrow.png) | Пороги → вес → локальный пересчёт |
| Community · [Data Grid Filtering 2](https://reui.io/preview/base/data-grid-filtering-2?ref=mcp) | [Снимок](ux-ui/screens/route-optimizer-community-desktop.png) | [390 px](ux-ui/screens/route-optimizer-community-mobile.png), [320 px](ux-ui/screens/route-optimizer-community-narrow.png) | Устройство → базовый расчёт → модельная рекомендация → закрепление |
35 E2E, TS/lint и production frontend прошли на HTTP-фикстурах. Это проверка layout; whole optimizer acceptance остаётся открытой по model provenance и URL/source-контексту. Точное продолжение — в журнале.
+8 -1
View File
@@ -26,11 +26,18 @@
| 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.33; backend проверяет cost/план OSPF, далее layout/контекст |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.34; адаптивность оптимизатора проверена, далее достоверность индексов/контекст |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.34 — адаптивность и доступность оптимизатора (2026-09-30)
- KPI читаются на 320/390 px: одна колонка на телефоне, две/четыре при достаточной ширине контейнера. Визуальная проверка выявила скрытые KPI внутри native flex-fieldset; внутренний flex-контейнер восстановил layout без изменения общего KpiStatGrid.
- Настройки/вкладки — настоящие кнопки с keyboard/ARIA; числовые поля, устройство и ECMP названы, отключённый ECMP блокирует range и алгоритмы. Панели/toolbar переносятся, таблицы прокручиваются внутри своей оболочки. Убрана неподтверждённая команда ECMP; сценарий явно черновой. Семантические токены и декоративные иконки исправлены в четырёх grid consumers. Vendor/theme не менялись.
- Девять снимков 1440/390/320 px сохранены в плане и просмотрены. ReUI checklist выполнен в границах consumer-композиции. 35 E2E оптимизатора, TypeScript, профильный lint и production frontend прошли. В одном readonly E2E при teardown запрос catalog вышел из HTTP interception и получил ECONNREFUSED локального backend; тесты зелёные, реальный backend не участвовал.
- Оптимизатор целиком ещё не принят: model fallback/clamp и URL/source-контекст требуют отдельного блока; затем карта/терминал. Реальная RouterOS/БД проверка остаётся внешней. Следующий шаг: отсутствующий индекс Community не подменять нулём, затем восстановление контекста из URL.
## Блок 10.33 — достоверность cost и плана OSPF (2026-09-30)
- Оптимизатор использует отдельный observed selector шаблонов: disabled/id/interface/currentCost должны быть достоверны. Cost не подставляется как 10, disabled не превращается в false. Missing/unsupported cost (включая 0 вне текущей write-policy 1–65535), дубликаты id и несколько активных шаблонов одного интерфейса отклоняются. Известные disabled и reference-интерфейсы сохраняют прежнее исключение из оптимизации.
+4
View File
@@ -184,3 +184,7 @@ Strict receipt/server/weight, ref lock, readonly/disabled контекст и re
## 10.33. Backend cost и валидация плана
Observed selector вместо default cost/disabled; неоднозначные шаблоны отклоняются до write. Полный план валидируется, disabled сервер 409. DI HTTP/unit без RouterOS/DB, live-maps regression/backend build прошли; lint два прежних unused warning. Ранжирование прежнее, model fallback/clamp ещё требуют отдельного отображения. Далее layout/контекст.
## 10.34. Адаптивность оптимизатора
1440/390/320 px, keyboard/ARIA и disabled ECMP; native fieldset композиция исправлена после визуального обнаружения невидимых KPI. Семантические токены четырёх таблиц, девять снимков в плане; 35 E2E, TS/lint и production frontend прошли. Teardown catalog отказ backend записан в журнале. URL/source и model provenance ещё открыты.
Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+28
View File
@@ -0,0 +1,28 @@
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 item={id:"i",interface:"ether1",currentCost:10,optimalCost:20,score:50,pingMs:0,dlMbps:0,ulMbps:0}
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"?["home-router","jump-host","exit-node"].map((type,index)=>({id:index+1,name:"Роутер "+(index+1),host:"192.0.2."+(index+1),country:"RU",type,enabled:true,status:null,latency:null,...(index===0?{wanUplinks:[{id:"w",name:"WAN1",iface:"ether1",ip:"198.51.100.1",isp:"ISP",maxDl:100,maxUl:100}]}:{})})):p==="/api/filters/rules"?{rulesets:[{serverId:"1",rules:[{id:"r",community:"65001:1",communityName:"Сервис",gateway:"192.0.2.2",gatewayTunnelId:"",description:""}]}]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:p.endsWith("/preview")?{serverId:1,serverName:"Роутер 1",pingWeight:60,interfacesTotal:1,changedCount:1,unchangedCount:0,interfaces:[item],changes:[item]}:{}})})
await page.goto("/route-optimizer")
await expect(page.getByRole("table").filter({hasText:"Speed (dl/ul)"}).locator("tr[data-row-id]")).toHaveCount(1)
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
await expect(page.getByLabel("Сводка оптимизатора").getByText("Home",{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/route-optimizer-overview-"+device+".png",animations:"disabled"})
const settings=page.getByRole("button",{name:"Настройки оптимизатора",exact:true})
await settings.focus();await page.keyboard.press("Enter");await expect(settings).toHaveAttribute("aria-expanded","true")
await expect(page.getByRole("spinbutton",{name:"Вес задержки",exact:true})).toBeVisible()
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
await page.getByRole("spinbutton",{name:"Вес задержки",exact:true}).scrollIntoViewIfNeeded();
await page.screenshot({path:"docs/ux-ui/screens/route-optimizer-settings-"+device+".png",animations:"disabled"})
await settings.focus();await page.keyboard.press(" ");await expect(settings).toHaveAttribute("aria-expanded","false")
const slider=page.getByRole("slider",{name:"Максимальное число путей ECMP",exact:true});await expect(slider).toBeDisabled()
const toggle=page.getByRole("switch",{name:"Черновик ECMP",exact:true});await toggle.focus();await page.keyboard.press(" ");await expect(toggle).toHaveAttribute("aria-checked","true");await expect(slider).toBeEnabled()
const community=page.getByRole("button",{name:"BGP community (1)",exact:true});await community.focus();await page.keyboard.press("Enter");await expect(community).toHaveAttribute("aria-pressed","true")
const grid=page.getByRole("table").filter({hasText:"Community"});await expect(grid.locator("tr[data-row-id]")).toHaveCount(1)
expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true)
await grid.scrollIntoViewIfNeeded();
await page.screenshot({path:"docs/ux-ui/screens/route-optimizer-community-"+device+".png",animations:"disabled"})
})