fix(ui): завершить адаптивность оптимизатора маршрутов
No files matched your search
@@ -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} мс
|
||||
|
||||
@@ -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-контексту. Точное продолжение — в журнале.
|
||||
@@ -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-интерфейсы сохраняют прежнее исключение из оптимизации.
|
||||
|
||||
@@ -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 ещё открыты.
|
||||
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -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"})
|
||||
})
|
||||