Init 2
This commit is contained in:
+222
-171
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState, useMemo } from "react"
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||
import Link from "next/link"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -8,6 +9,25 @@ import { Input } from "@/components/ui/input"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { servers } from "@/lib/data"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import {
|
||||
type HomeRouter,
|
||||
type JumpHost,
|
||||
type ExitNode,
|
||||
type WanJhLeg,
|
||||
type JhExLeg,
|
||||
type FullRoute,
|
||||
type CommRec,
|
||||
type HomeEntry,
|
||||
type OptimizerData,
|
||||
type OptimizerSettings,
|
||||
type OptimizerApiServer,
|
||||
buildLiveOptimizerData,
|
||||
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
||||
mapApiServersToTopology,
|
||||
readStoredRouteOptimizerSettings,
|
||||
ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY,
|
||||
} from "@/lib/route-optimizer-data"
|
||||
import {
|
||||
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
|
||||
SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon,
|
||||
@@ -16,111 +36,29 @@ import {
|
||||
InfoIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface WanUplink {
|
||||
id: string
|
||||
name: string // "WAN1-RT"
|
||||
isp: string // "Rostelecom"
|
||||
iface: string // "ether1"
|
||||
ip: string // external IP
|
||||
maxDl: number // Mbps
|
||||
maxUl: number // Mbps
|
||||
function makeApiFetch(backendUrl: string) {
|
||||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers: HeadersInit = init?.body ? { "Content-Type": "application/json" } : {}
|
||||
const res = await fetch(backendUrl.replace(/\/$/, "") + path, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers ?? {}) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = res.statusText
|
||||
try {
|
||||
const err = await res.json() as { error?: string }
|
||||
message = err.error ?? message
|
||||
} catch {
|
||||
const text = await res.text().catch(() => "")
|
||||
if (text) message = text
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
interface HomeRouter {
|
||||
id: string
|
||||
label: string // "home-msk-01"
|
||||
site: string // "MSK"
|
||||
country: string
|
||||
model: string
|
||||
ip: string // LAN management IP
|
||||
wans: WanUplink[]
|
||||
}
|
||||
|
||||
interface JumpHost {
|
||||
id: string
|
||||
label: string
|
||||
site: string
|
||||
country: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
interface ExitNode {
|
||||
id: string
|
||||
label: string
|
||||
site: string
|
||||
country: string
|
||||
ip: string
|
||||
}
|
||||
|
||||
// WAN→JH measurement (per uplink)
|
||||
interface WanJhLeg {
|
||||
wanId: string
|
||||
jhId: string
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
score: number
|
||||
loss: number
|
||||
}
|
||||
|
||||
// JH→Exit measurement
|
||||
interface JhExLeg {
|
||||
jhId: string
|
||||
exitId: string
|
||||
pingMs: number
|
||||
dlMbps: number
|
||||
ulMbps: number
|
||||
}
|
||||
|
||||
// Full route: HomeRouter + WAN → JH → Exit
|
||||
interface FullRoute {
|
||||
id: string
|
||||
homeId: string
|
||||
wan: WanUplink
|
||||
jh: JumpHost
|
||||
exit: ExitNode
|
||||
hw: WanJhLeg
|
||||
je: JhExLeg
|
||||
score: number
|
||||
confidence: "HIGH" | "MEDIUM" | "LOW"
|
||||
probabilityOptimal: number
|
||||
}
|
||||
|
||||
// BGP community recommendation
|
||||
interface CommRec {
|
||||
community: string
|
||||
communityName: string
|
||||
current: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null
|
||||
recommended: { wan: string; jh: string; exit: string; gateway: string; prob: number } | null
|
||||
shouldSwitch: boolean
|
||||
pinnedBySettings: boolean
|
||||
}
|
||||
|
||||
interface HomeEntry {
|
||||
home: HomeRouter
|
||||
wanJhLegs: WanJhLeg[] // all (WAN × JH) measurements
|
||||
fullRoutes: FullRoute[] // sorted by probabilityOptimal
|
||||
bestRoute: FullRoute | null
|
||||
commRecs: CommRec[]
|
||||
}
|
||||
|
||||
interface OptimizerData {
|
||||
updatedAt: string
|
||||
homes: HomeEntry[]
|
||||
}
|
||||
|
||||
interface OptimizerSettings {
|
||||
switchThreshold: number
|
||||
hysteresisThreshold: number
|
||||
pingWeight: number
|
||||
probeIntervalMin: number
|
||||
autoApply: boolean
|
||||
autoApplyIntervalMin: number
|
||||
}
|
||||
|
||||
// ─── Topology derived from lib/data servers ───────────────────────────────────
|
||||
// ─── Topology derived from lib/data servers (mock) ───────────────────────────
|
||||
|
||||
const HOME_ROUTERS: HomeRouter[] = servers
|
||||
.filter(s => s.type === "home-router" && s.enabled)
|
||||
@@ -361,13 +299,14 @@ function SettingRow({ label, unit, children }: { label: string; unit?: string; c
|
||||
// ─── WAN Matrix table ─────────────────────────────────────────────────────────
|
||||
// Rows = WANs, Columns = JHs, cells show ping / bw / score
|
||||
|
||||
function WanMatrix({ home, legs, pw: _pw }: {
|
||||
function WanMatrix({ home, legs, jumpHosts, pw: _pw }: {
|
||||
home: HomeRouter
|
||||
legs: WanJhLeg[]
|
||||
jumpHosts: JumpHost[]
|
||||
pw: number
|
||||
}) {
|
||||
// find best leg overall
|
||||
const bestScore = Math.max(...legs.map(l => l.score))
|
||||
const bestScore = legs.length ? Math.max(...legs.map(l => l.score)) : 0
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
@@ -377,7 +316,7 @@ function WanMatrix({ home, legs, pw: _pw }: {
|
||||
<th className="text-left font-medium px-4 py-2 w-[160px]">WAN-аплинк</th>
|
||||
<th className="text-left font-medium px-3 py-2">ISP / IP</th>
|
||||
<th className="text-right font-medium px-3 py-2">Макс. полоса</th>
|
||||
{JUMPHOSTS.map(jh => (
|
||||
{jumpHosts.map(jh => (
|
||||
<th key={jh.id} className="text-center font-medium px-3 py-2 min-w-[130px]">
|
||||
<div>{jh.label}</div>
|
||||
<div className="font-mono font-normal text-[10px] opacity-60 flex items-center justify-center gap-1">
|
||||
@@ -412,7 +351,7 @@ function WanMatrix({ home, legs, pw: _pw }: {
|
||||
<p className="font-mono text-[10px] text-muted-foreground">↑{wan.maxUl} Мбит</p>
|
||||
</td>
|
||||
{/* Per-JH cells */}
|
||||
{JUMPHOSTS.map(jh => {
|
||||
{jumpHosts.map(jh => {
|
||||
const leg = legs.find(l => l.wanId === wan.id && l.jhId === jh.id)
|
||||
if (!leg) return <td key={jh.id} className="px-3 py-3 text-center text-muted-foreground text-xs">—</td>
|
||||
const isBest = leg.score === bestScore
|
||||
@@ -682,8 +621,9 @@ function CommRecsTable({ recs, homeId, pinned, applied, applying, onPin, onApply
|
||||
|
||||
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
|
||||
|
||||
function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onApply }: {
|
||||
function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying, onPin, onApply }: {
|
||||
entry: HomeEntry
|
||||
jumpHosts: JumpHost[]
|
||||
settings: OptimizerSettings
|
||||
pinned: Set<string>
|
||||
applied: Set<string>
|
||||
@@ -741,7 +681,7 @@ function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onA
|
||||
{/* Sub-tabs */}
|
||||
<div className="flex items-center gap-0 border-b bg-muted/20 px-1">
|
||||
{([
|
||||
{ id: "wan-matrix", label: `WAN × JH (${home.wans.length}×${JUMPHOSTS.length})` },
|
||||
{ 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 => (
|
||||
@@ -759,7 +699,7 @@ function HomeRouterCard({ entry, settings, pinned, applied, applying, onPin, onA
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === "wan-matrix" && (
|
||||
<WanMatrix home={home} legs={wanJhLegs} pw={settings.pingWeight} />
|
||||
<WanMatrix home={home} legs={wanJhLegs} jumpHosts={jumpHosts} pw={settings.pingWeight} />
|
||||
)}
|
||||
{tab === "full-routes" && (
|
||||
<FullRoutesTable routes={fullRoutes} bestId={bestRoute?.id} />
|
||||
@@ -807,49 +747,111 @@ function TopologyBar() {
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const DEFAULT_SETTINGS: OptimizerSettings = {
|
||||
switchThreshold: 15,
|
||||
hysteresisThreshold: 10,
|
||||
pingWeight: 60,
|
||||
probeIntervalMin: 15,
|
||||
autoApply: false,
|
||||
autoApplyIntervalMin: 60,
|
||||
}
|
||||
|
||||
export default function RouteOptimizerPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<OptimizerData | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [settings, setSettings] = useState<OptimizerSettings>(DEFAULT_SETTINGS)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [pinned, setPinned] = useState<Set<string>>(new Set())
|
||||
const [applied, setApplied] = useState<Set<string>>(new Set())
|
||||
const [applying, setApplying] = useState<Set<string>>(new Set())
|
||||
// ECMP / RPF / VRF
|
||||
const [ecmpEnabled, setEcmpEnabled] = useState(false)
|
||||
const [ecmpMaxPaths, setEcmpMaxPaths] = useState(4)
|
||||
const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst")
|
||||
const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled")
|
||||
const [selectedVrf, setSelectedVrf] = useState("main")
|
||||
const { mode, backendUrl, backendStatus } = useDataSource()
|
||||
/** Данные из API/БД при режиме «Живые»; не ждём /health — иначе до ответа показывались моки. */
|
||||
const useLiveData = mode === "live"
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const load = useCallback((s: OptimizerSettings = settings) => {
|
||||
setLoading(true); setError("")
|
||||
setTimeout(() => {
|
||||
try { setData(buildMockData(s)) }
|
||||
catch { setError("Ошибка расчёта маршрутов") }
|
||||
finally { setLoading(false) }
|
||||
}, 600)
|
||||
const [prefsLoaded, setPrefsLoaded] = useState(false)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<OptimizerData | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [settings, setSettings] = useState<OptimizerSettings>(DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS)
|
||||
const settingsRef = useRef(settings)
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
|
||||
// Initial load + polling every 30 s — load() calls setState internally (standard data-fetching pattern)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
load()
|
||||
const id = setInterval(() => load(), 30_000)
|
||||
return () => clearInterval(id)
|
||||
queueMicrotask(() => {
|
||||
setSettings(readStoredRouteOptimizerSettings())
|
||||
setPrefsLoaded(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsLoaded) return
|
||||
try {
|
||||
localStorage.setItem(ROUTE_OPTIMIZER_SETTINGS_STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch {
|
||||
/* ignore quota */
|
||||
}
|
||||
}, [settings, prefsLoaded])
|
||||
|
||||
const [liveJumpHosts, setLiveJumpHosts] = useState<JumpHost[]>([])
|
||||
const [liveExitNodes, setLiveExitNodes] = useState<ExitNode[]>([])
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [pinned, setPinned] = useState<Set<string>>(new Set())
|
||||
const [applied, setApplied] = useState<Set<string>>(new Set())
|
||||
const [applying, setApplying] = useState<Set<string>>(new Set())
|
||||
const [ecmpEnabled, setEcmpEnabled] = useState(false)
|
||||
const [ecmpMaxPaths, setEcmpMaxPaths] = useState(4)
|
||||
const [ecmpAlgo, setEcmpAlgo] = useState<"per-dst" | "per-conn" | "per-packet">("per-dst")
|
||||
const [rpfMode, setRpfMode] = useState<"disabled" | "loose" | "strict">("disabled")
|
||||
const [selectedVrf, setSelectedVrf] = useState("main")
|
||||
|
||||
const load = useCallback(async (override?: OptimizerSettings) => {
|
||||
const s = override ?? settingsRef.current
|
||||
setLoading(true)
|
||||
setError("")
|
||||
try {
|
||||
if (!useLiveData) {
|
||||
await new Promise((r) => setTimeout(r, 450))
|
||||
setData(buildMockData(s))
|
||||
setLiveJumpHosts([])
|
||||
setLiveExitNodes([])
|
||||
return
|
||||
}
|
||||
const rows = await apiFetch<OptimizerApiServer[]>("/api/servers")
|
||||
const { jumpHosts, exitNodes } = mapApiServersToTopology(rows)
|
||||
setLiveJumpHosts(jumpHosts)
|
||||
setLiveExitNodes(exitNodes)
|
||||
|
||||
let rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> | null = null
|
||||
try {
|
||||
const fr = await apiFetch<{ rulesets: Array<{ serverId: string; rules: import("@/lib/data").FilterRule[] }> }>(
|
||||
"/api/filters/rules",
|
||||
)
|
||||
rulesets = fr.rulesets ?? null
|
||||
} catch {
|
||||
rulesets = null
|
||||
}
|
||||
|
||||
setData(buildLiveOptimizerData(rows, rulesets, s))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||
setData(null)
|
||||
setLiveJumpHosts([])
|
||||
setLiveExitNodes([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [apiFetch, useLiveData])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsLoaded) return
|
||||
queueMicrotask(() => {
|
||||
void load()
|
||||
})
|
||||
}, [load, useLiveData, prefsLoaded])
|
||||
|
||||
const pollMs = useMemo(() => {
|
||||
if (!useLiveData) return 30_000
|
||||
const m = Math.min(Math.max(settings.probeIntervalMin, 1), 30)
|
||||
return m * 60_000
|
||||
}, [useLiveData, settings.probeIntervalMin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefsLoaded) return
|
||||
const id = setInterval(() => {
|
||||
void load()
|
||||
}, pollMs)
|
||||
return () => clearInterval(id)
|
||||
}, [load, pollMs, prefsLoaded])
|
||||
|
||||
function togglePin(key: string) {
|
||||
setPinned(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n })
|
||||
}
|
||||
@@ -863,10 +865,61 @@ export default function RouteOptimizerPage() {
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const totalSwitches = useMemo(() =>
|
||||
data?.homes.flatMap(h => h.commRecs)
|
||||
.filter(r => r.shouldSwitch && !pinned.has(r.community)).length ?? 0
|
||||
, [data, pinned])
|
||||
const totalSwitches = useMemo(
|
||||
() =>
|
||||
data?.homes.flatMap((h) =>
|
||||
h.commRecs.filter(
|
||||
(r) => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`),
|
||||
),
|
||||
).length ?? 0,
|
||||
[data, pinned],
|
||||
)
|
||||
|
||||
const jumpHostsForCards = useLiveData ? liveJumpHosts : JUMPHOSTS
|
||||
|
||||
const statsChips = useMemo(() => {
|
||||
const homeCount = useLiveData ? (data?.homes.length ?? 0) : HOME_ROUTERS.length
|
||||
const wanCount = useLiveData
|
||||
? (data?.homes.reduce((s, h) => s + h.home.wans.length, 0) ?? 0)
|
||||
: HOME_ROUTERS.reduce((s, h) => s + h.wans.length, 0)
|
||||
const jh = useLiveData ? liveJumpHosts : JUMPHOSTS
|
||||
const ex = useLiveData ? liveExitNodes : EXIT_NODES
|
||||
const jhSub = jh.length ? jh.map((j) => j.site).join(" · ") : "—"
|
||||
const exSub = ex.length ? ex.map((e) => e.site).join(" · ") : "—"
|
||||
return [
|
||||
{
|
||||
label: "Home роутеров",
|
||||
value: homeCount,
|
||||
sub: `${wanCount} WAN-аплинков`,
|
||||
icon: <MonitorIcon className="size-4 text-muted-foreground" />,
|
||||
},
|
||||
{
|
||||
label: "JumpHost",
|
||||
value: jh.length,
|
||||
sub: jhSub,
|
||||
icon: <ServerIcon className="size-4 text-violet-400" />,
|
||||
},
|
||||
{
|
||||
label: "Exit Node",
|
||||
value: ex.length,
|
||||
sub: exSub,
|
||||
icon: <NetworkIcon className="size-4 text-emerald-500" />,
|
||||
},
|
||||
{
|
||||
label: "Переключений",
|
||||
value: totalSwitches,
|
||||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||||
icon: (
|
||||
<ZapIcon
|
||||
className={cn(
|
||||
"size-4",
|
||||
totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
|
||||
|
||||
function applyAll() {
|
||||
data?.homes.forEach(h =>
|
||||
@@ -890,7 +943,7 @@ export default function RouteOptimizerPage() {
|
||||
<ZapIcon className="size-4" />Применить все ({totalSwitches})
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => load()} disabled={loading}>
|
||||
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
|
||||
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
|
||||
{loading ? "Расчёт…" : "Обновить"}
|
||||
</Button>
|
||||
@@ -907,26 +960,18 @@ export default function RouteOptimizerPage() {
|
||||
<span className="text-border">·</span>
|
||||
<span>Обновлено: {data?.updatedAt ?? "—"}</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>Авто 30 с</span>
|
||||
<Chip>симуляция</Chip>
|
||||
<span>Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"}</span>
|
||||
<Chip>{useLiveData ? "Живые данные · API" : "Демо · mock"}</Chip>
|
||||
{useLiveData && backendStatus === false && (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
бекенд не отвечает на /health — проверьте URL в настройках
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats chips */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "Home роутеров", value: HOME_ROUTERS.length,
|
||||
sub: `${HOME_ROUTERS.reduce((s, h) => s + h.wans.length, 0)} WAN-аплинков`,
|
||||
icon: <MonitorIcon className="size-4 text-muted-foreground" /> },
|
||||
{ label: "JumpHost", value: JUMPHOSTS.length,
|
||||
sub: JUMPHOSTS.map(j => j.site).join(" · "),
|
||||
icon: <ServerIcon className="size-4 text-violet-400" /> },
|
||||
{ label: "Exit Node", value: EXIT_NODES.length,
|
||||
sub: EXIT_NODES.map(e => e.site).join(" · "),
|
||||
icon: <NetworkIcon className="size-4 text-emerald-500" /> },
|
||||
{ label: "Переключений", value: totalSwitches,
|
||||
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
|
||||
icon: <ZapIcon className={cn("size-4", totalSwitches > 0 ? "text-amber-500" : "text-muted-foreground")} /> },
|
||||
].map(s => (
|
||||
{statsChips.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardContent className="px-4 py-3 flex items-start justify-between">
|
||||
<div>
|
||||
@@ -1022,11 +1067,16 @@ export default function RouteOptimizerPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-5 pt-4 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => setSettings(DEFAULT_SETTINGS)}>Сбросить</Button>
|
||||
<Button size="sm" onClick={() => load(settings)}>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-3">
|
||||
Базовые значения совпадают с разделом{" "}
|
||||
<Link href="/settings#route-ai" className="text-primary underline-offset-2 hover:underline">Настройки → Route AI</Link>
|
||||
.
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
@@ -1173,10 +1223,11 @@ export default function RouteOptimizerPage() {
|
||||
</div>
|
||||
|
||||
{/* Per-home-router cards */}
|
||||
{data?.homes.map(entry => (
|
||||
{data?.homes.map((entry) => (
|
||||
<HomeRouterCard
|
||||
key={entry.home.id}
|
||||
entry={entry}
|
||||
jumpHosts={jumpHostsForCards}
|
||||
settings={settings}
|
||||
pinned={pinned}
|
||||
applied={applied}
|
||||
|
||||
Reference in New Issue
Block a user