fix(ui): обозначить расчёт и симуляцию оптимизатора

This commit is contained in:
Denozordec committed 2026-09-30 22:29:46 +07:00
1 parent e58604c3c0
commit 59f0f82176
7 files changed
+104 -62

No files matched your search

+39 -46
View File
@@ -3,12 +3,10 @@
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
import Link from "next/link"
import { PageHeader } from "@/components/page-header"
import { DataPageCard } from "@/components/data-page-card"
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid"
import { RouteOptimizerCommRecsDataGrid } from "@/components/data-grids/route-optimizer-comm-recs-data-grid"
import { RouteOptimizerOspfPreviewDataGrid } from "@/components/data-grids/route-optimizer-ospf-preview-data-grid"
import { FormToggle } from "@/components/form-kit"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { OpsPanel } from "@/components/ops-panel"
@@ -41,8 +39,8 @@ import {
} from "@/lib/route-optimizer-data"
import {
RefreshCwIcon, AlertCircleIcon, ArrowRightIcon,
SettingsIcon, ChevronDownIcon, ChevronUpIcon, PinIcon,
ZapIcon, PlayIcon, CheckCircleIcon, NetworkIcon, WifiIcon,
SettingsIcon, ChevronDownIcon, ChevronUpIcon,
ZapIcon, CheckCircleIcon, NetworkIcon, WifiIcon,
MonitorIcon, ServerIcon,
InfoIcon,
} from "lucide-react"
@@ -123,7 +121,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData {
const jhExLegs: JhExLeg[] = []
for (const jh of JUMPHOSTS) {
for (const ex of EXIT_NODES) {
const base = JE_BASE[`${jh.id}-${ex.id}`]
const base = JE_BASE[`jh${JUMPHOSTS.indexOf(jh) + 1}-ex${EXIT_NODES.indexOf(ex) + 1}`]
if (!base) continue
jhExLegs.push({
jhId: jh.id, exitId: ex.id,
@@ -141,7 +139,7 @@ function buildMockData(settings: OptimizerSettings): OptimizerData {
const wanJhLegs: WanJhLeg[] = []
for (const wan of home.wans) {
for (const jh of JUMPHOSTS) {
const base = HW_BASE[`${wan.id}-${jh.id}`]
const base = HW_BASE[`${wan.id}-jh${JUMPHOSTS.indexOf(jh) + 1}`]
if (!base) continue
const ping = jitter(base.ping, 3)
const dl = Math.min(wan.maxDl, jitter(base.dl, 20))
@@ -231,7 +229,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-muted text-muted-foreground border-border"
}>{prob}%</Chip>
}>{prob} б.</Chip>
)
}
@@ -244,15 +242,6 @@ function ConfChip({ conf }: { conf: string }) {
return <Chip color={map[conf] ?? map.LOW}>{conf}</Chip>
}
function LossChip({ loss }: { loss: number }) {
if (loss === 0) return <span className="text-emerald-600 dark:text-emerald-400 text-[11px] font-mono">0%</span>
return (
<span className={cn("text-[11px] font-mono", loss > 1 ? "text-red-500" : "text-amber-500")}>
{loss}%
</span>
)
}
function NInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
return (
<Input type="number" value={value} min={min} max={max}
@@ -277,15 +266,15 @@ function SettingRow({ label, unit, children }: { label: string; unit?: string; c
type HomeTab = "wan-matrix" | "full-routes" | "bgp-community"
function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying, onPin, onApply }: {
function HomeRouterCard({ entry, jumpHosts, pinned, applied, applying, onPin, onApply, simulation }: {
entry: HomeEntry
jumpHosts: JumpHost[]
settings: OptimizerSettings
pinned: Set<string>
applied: Set<string>
applying: Set<string>
onPin: (k: string) => void
onApply: (comm: string, homeId: string) => void
simulation: boolean
}) {
const [tab, setTab] = useState<HomeTab>("wan-matrix")
const { home, wanJhLegs, fullRoutes, bestRoute, commRecs } = entry
@@ -366,7 +355,7 @@ function HomeRouterCard({ entry, jumpHosts, settings, pinned, applied, applying,
recs={commRecs}
homeId={home.id}
pinned={pinned} applied={applied} applying={applying}
onPin={onPin} onApply={onApply}
onPin={onPin} onApply={onApply} simulation={simulation}
/>
)}
</FramePanel>
@@ -405,6 +394,11 @@ function TopologyBar() {
// ════════════════════════════════════════════════════════════════════════════
export default function RouteOptimizerPage() {
const { mode, backendUrl } = useDataSource()
return <RouteOptimizerContent key={JSON.stringify([mode, backendUrl])} />
}
function RouteOptimizerContent() {
const { mode, backendUrl, backendStatus } = useDataSource()
/** Данные из API/БД при режиме «Живые»; не ждём /health — иначе до ответа показывались моки. */
const useLiveData = mode === "live"
@@ -554,13 +548,22 @@ export default function RouteOptimizerPage() {
setPinned(prev => { const n = new Set(prev); if (n.has(key)) n.delete(key); else n.add(key); return n })
}
const simulationTimers = useRef(new Set<ReturnType<typeof setTimeout>>())
const pendingSimulations = useRef(new Set<string>())
useEffect(() => () => { for (const timer of simulationTimers.current) clearTimeout(timer) }, [])
function applyRec(community: string, homeId: string) {
if (useLiveData) return
const key = `${homeId}::${community}`
if (pendingSimulations.current.has(key) || applied.has(key)) return
pendingSimulations.current.add(key)
setApplying(prev => new Set(prev).add(key))
setTimeout(() => {
const timer = setTimeout(() => {
pendingSimulations.current.delete(key)
simulationTimers.current.delete(timer)
setApplied(prev => new Set(prev).add(key))
setApplying(prev => { const n = new Set(prev); n.delete(key); return n })
}, 1200)
simulationTimers.current.add(timer)
}
const totalSwitches = useMemo(
@@ -606,13 +609,14 @@ export default function RouteOptimizerPage() {
{
label: "Переключений",
value: totalSwitches,
sub: totalSwitches > 0 ? "требуют применения" : "всё оптимально",
sub: totalSwitches > 0 ? "расчётные рекомендации" : "модель не предлагает переключений",
icon: <ZapIcon className="size-4" />,
},
]
}, [useLiveData, data, liveJumpHosts, liveExitNodes, totalSwitches])
function applyAll() {
if (useLiveData) return
data?.homes.forEach(h =>
h.commRecs
.filter(r => r.shouldSwitch && !pinned.has(`${h.home.id}::${r.community}`))
@@ -724,9 +728,9 @@ export default function RouteOptimizerPage() {
crumbs={[{ label: "Инструменты" }, { label: "Оптимизатор маршрутов" }]}
actions={
<>
{totalSwitches > 0 && (
{!useLiveData && totalSwitches > 0 && (
<Button size="sm" onClick={applyAll}>
<ZapIcon className="size-4" />Применить все ({totalSwitches})
<ZapIcon className="size-4" />Симулировать все ({totalSwitches})
</Button>
)}
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
@@ -747,7 +751,7 @@ export default function RouteOptimizerPage() {
<span>Обновлено: {data?.updatedAt ?? "—"}</span>
<span className="text-border">·</span>
<span>Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"}</span>
<Chip>{useLiveData ? "Живые данные · API" : "Демо · mock"}</Chip>
<Chip>{useLiveData ? "Данные API · расчёт модели" : "Демо · симуляция"}</Chip>
{useLiveData && backendStatus === false && (
<span className="text-amber-600 dark:text-amber-400">
бекенд не отвечает на /health — проверьте URL в настройках
@@ -755,6 +759,9 @@ export default function RouteOptimizerPage() {
)}
</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>
<KpiStatGrid
aria-label="Сводка оптимизатора"
items={statsChips.map((s, i) => ({
@@ -789,8 +796,8 @@ export default function RouteOptimizerPage() {
{/* Settings */}
<Frame dense className="w-full">
<FramePanel className="p-0">
<div
className={cn("flex items-center gap-3 px-4 py-3 cursor-pointer select-none", showSettings && "border-b")}
<button type="button" aria-expanded={showSettings} aria-label="Настройки оптимизатора"
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")}
onClick={() => setShowSettings(v => !v)}
>
<SettingsIcon className="size-4 text-muted-foreground shrink-0" />
@@ -799,7 +806,7 @@ export default function RouteOptimizerPage() {
порог {settings.switchThreshold}% · ping {settings.pingWeight}% · зондирование {settings.probeIntervalMin} мин
</span>
{showSettings ? <ChevronUpIcon className="size-4 text-muted-foreground" /> : <ChevronDownIcon className="size-4 text-muted-foreground" />}
</div>
</button>
{showSettings && (
<div className="py-4 px-5">
@@ -838,28 +845,14 @@ export default function RouteOptimizerPage() {
</SettingRow>
</div>
<div className="flex flex-col gap-3">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">Автоприменение</p>
<div className="flex items-center justify-between">
<span className="text-sm">Применять автоматически</span>
<FormToggle checked={settings.autoApply} onChange={v => set("autoApply", v)} />
</div>
{settings.autoApply && (
<>
<SettingRow label="Интервал (мин)">
<NInput value={settings.autoApplyIntervalMin} onChange={v => set("autoApplyIntervalMin", v)} min={5} max={1440} />
</SettingRow>
<div className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-600 dark:text-amber-400">
<AlertCircleIcon className="size-3.5 shrink-0" />
Изменения каждые {settings.autoApplyIntervalMin} мин
</div>
</>
)}
<p className="text-sm font-medium">Автоприменение Community недоступно</p>
<p className="text-xs text-muted-foreground">Настройки расчёта сохраняются в этом браузере. Для сетевых изменений используйте профильные разделы.</p>
</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" />Применить
<CheckCircleIcon className="size-4" />Пересчитать
</Button>
</div>
<p className="text-[10px] text-muted-foreground mt-3">
@@ -1078,7 +1071,7 @@ export default function RouteOptimizerPage() {
<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" />
<span>Оптимизатор работает в VRF <code className="font-mono">{selectedVrf}</code></span>
<span>Выбран черновой контекст VRF <code className="font-mono">{selectedVrf}</code></span>
</div>
</OpsPanel>
@@ -1090,12 +1083,12 @@ export default function RouteOptimizerPage() {
key={entry.home.id}
entry={entry}
jumpHosts={jumpHostsForCards}
settings={settings}
pinned={pinned}
applied={applied}
applying={applying}
onPin={togglePin}
onApply={applyRec}
simulation={!useLiveData}
/>
))}