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>