fix(ui): сохранять согласованные данные оптимизатора
This commit is contained in:
1 parent
59f0f82176
commit
f93dfe34c2
7 files changed
+139
-53
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
|
||||
import Link from "next/link"
|
||||
import {optimizerServersSchema,optimizerRulesSchema,optimizerProbesSchema} from "@/lib/route-optimizer-response"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
|
||||
import { RouteOptimizerFullRoutesDataGrid } from "@/components/data-grids/route-optimizer-full-routes-data-grid"
|
||||
@@ -29,7 +30,6 @@ import {
|
||||
type OptimizerData,
|
||||
type OptimizerSettings,
|
||||
type OptimizerApiServer,
|
||||
type RouteOptimizerSpeedProbe,
|
||||
buildLiveOptimizerData,
|
||||
DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS,
|
||||
mapApiServersToTopology,
|
||||
@@ -409,6 +409,9 @@ function RouteOptimizerContent() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [data, setData] = useState<OptimizerData | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
const [readAt, setReadAt] = useState<string | null>(null)
|
||||
const reader = useRef({ alive: true, epoch: 0 })
|
||||
useEffect(() => { const state = reader.current; state.alive = true; return () => { state.alive = false; state.epoch++ } }, [])
|
||||
const [settings, setSettings] = useState<OptimizerSettings>(DEFAULT_ROUTE_AI_OPTIMIZER_SETTINGS)
|
||||
const settingsRef = useRef(settings)
|
||||
useEffect(() => {
|
||||
@@ -474,52 +477,45 @@ function RouteOptimizerContent() {
|
||||
} | null>(null)
|
||||
|
||||
const load = useCallback(async (override?: OptimizerSettings) => {
|
||||
const s = override ?? settingsRef.current
|
||||
const epoch = ++reader.current.epoch
|
||||
const current = () => reader.current.alive && reader.current.epoch === epoch
|
||||
const settings = override ?? settingsRef.current
|
||||
if (!current()) return
|
||||
setLoading(true)
|
||||
setError("")
|
||||
try {
|
||||
if (!useLiveData) {
|
||||
await new Promise((r) => setTimeout(r, 450))
|
||||
setData(buildMockData(s))
|
||||
setLiveJumpHosts([])
|
||||
setLiveExitNodes([])
|
||||
setLiveServers([])
|
||||
await new Promise(resolve => setTimeout(resolve, 450))
|
||||
if (!current()) return
|
||||
setData(buildMockData(settings)); setError(""); setReadAt(null)
|
||||
return
|
||||
}
|
||||
const rows = await apiFetch<OptimizerApiServer[]>("/api/servers")
|
||||
const enabledRows = rows.filter((r) => r.enabled)
|
||||
setLiveServers(enabledRows)
|
||||
setOspfServerId((prev) => (prev && enabledRows.some((r) => String(r.id) === prev) ? prev : String(enabledRows[0]?.id ?? "")))
|
||||
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
|
||||
async function readPart<T>(path:string, parse:(raw:unknown)=>T, label:string) {
|
||||
let raw:unknown
|
||||
try { raw = await apiFetch<unknown>(path) }
|
||||
catch (error) { throw new Error(label + ": " + (error instanceof Error ? error.message : "ошибка API")) }
|
||||
try { return parse(raw) } catch { throw new Error(label + ": недостоверный ответ API") }
|
||||
}
|
||||
let speedProbes: RouteOptimizerSpeedProbe[] = []
|
||||
try {
|
||||
const sp = await apiFetch<{ probes: RouteOptimizerSpeedProbe[] }>("/api/uptime/speed-probes")
|
||||
speedProbes = sp.probes ?? []
|
||||
} catch {
|
||||
speedProbes = []
|
||||
}
|
||||
|
||||
setData(buildLiveOptimizerData(rows, rulesets, s, speedProbes))
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Ошибка загрузки данных")
|
||||
setData(null)
|
||||
setLiveJumpHosts([])
|
||||
setLiveExitNodes([])
|
||||
setLiveServers([])
|
||||
const [inventory,rules,probes] = await Promise.allSettled([
|
||||
readPart("/api/servers",raw=>optimizerServersSchema.parse(raw),"Каталог устройств"),
|
||||
readPart("/api/filters/rules",raw=>optimizerRulesSchema.parse(raw),"Правила Community"),
|
||||
readPart("/api/uptime/speed-probes",raw=>optimizerProbesSchema.parse(raw),"Измерения speed-probe"),
|
||||
])
|
||||
if (!current()) return
|
||||
const failures = [inventory,rules,probes].flatMap(result=>result.status==="rejected"?[result.reason instanceof Error?result.reason.message:"Ошибка данных"]:[])
|
||||
if (inventory.status!=="fulfilled" || rules.status!=="fulfilled" || probes.status!=="fulfilled") throw new Error(failures.join("; "))
|
||||
const rows = inventory.value
|
||||
const ids = new Set(rows.map(row=>String(row.id)))
|
||||
if (rules.value.rulesets.some(set=>!ids.has(set.serverId)) || probes.value.probes.some(probe=>!ids.has(probe.srcServerId)||!ids.has(probe.dstServerId))) throw new Error("Правила или измерения относятся к неизвестному устройству")
|
||||
const next = buildLiveOptimizerData(rows, rules.value.rulesets, settings, probes.value.probes)
|
||||
const {jumpHosts,exitNodes} = mapApiServersToTopology(rows)
|
||||
const enabledRows=rows.filter(row=>row.enabled)
|
||||
setLiveServers(enabledRows);setLiveJumpHosts(jumpHosts);setLiveExitNodes(exitNodes)
|
||||
setOspfServerId(previous=>previous && enabledRows.some(row=>String(row.id)===previous)?previous:String(enabledRows[0]?.id??""))
|
||||
setData(next);setError("");setReadAt(new Date().toISOString())
|
||||
} catch (error) {
|
||||
if (current()) setError(error instanceof Error ? error.message : "Ошибка загрузки данных")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (current()) setLoading(false)
|
||||
}
|
||||
}, [apiFetch, useLiveData])
|
||||
|
||||
@@ -590,26 +586,26 @@ function RouteOptimizerContent() {
|
||||
return [
|
||||
{
|
||||
label: "Home роутеров",
|
||||
value: homeCount,
|
||||
sub: `${wanCount} WAN-аплинков`,
|
||||
value: useLiveData && !data ? "—" : homeCount,
|
||||
sub: useLiveData && !data ? "Количество WAN неизвестно" : `${wanCount} WAN-аплинков`,
|
||||
icon: <MonitorIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "JumpHost",
|
||||
value: jh.length,
|
||||
value: useLiveData && !data ? "—" : jh.length,
|
||||
sub: jhSub,
|
||||
icon: <ServerIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Exit Node",
|
||||
value: ex.length,
|
||||
value: useLiveData && !data ? "—" : ex.length,
|
||||
sub: exSub,
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
},
|
||||
{
|
||||
label: "Переключений",
|
||||
value: totalSwitches,
|
||||
sub: totalSwitches > 0 ? "расчётные рекомендации" : "модель не предлагает переключений",
|
||||
value: useLiveData && !data ? "—" : totalSwitches,
|
||||
sub: useLiveData && !data ? "Расчёт пока неизвестен" : totalSwitches > 0 ? "расчётные рекомендации" : "модель не предлагает переключений",
|
||||
icon: <ZapIcon className="size-4" />,
|
||||
},
|
||||
]
|
||||
@@ -625,7 +621,7 @@ function RouteOptimizerContent() {
|
||||
}
|
||||
|
||||
async function applyOspfOptimization() {
|
||||
if (!ospfServerId) return
|
||||
if (!ospfServerId || loading || error || !data) return
|
||||
setOspfApplying(true)
|
||||
setOspfApplyError("")
|
||||
setOspfApplyResult(null)
|
||||
@@ -748,7 +744,8 @@ function RouteOptimizerContent() {
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap px-0.5">
|
||||
<TopologyBar />
|
||||
<span className="text-border">·</span>
|
||||
<span>Обновлено: {data?.updatedAt ?? "—"}</span>
|
||||
<span>Расчёт: {data?.updatedAt ?? "—"}</span>
|
||||
{readAt && <span role="status">Ответ получен {new Date(readAt).toLocaleTimeString("ru-RU")}. Время браузера, не измерения.</span>}
|
||||
<span className="text-border">·</span>
|
||||
<span>Авто {useLiveData ? `${settings.probeIntervalMin} мин` : "30 с"}</span>
|
||||
<Chip>{useLiveData ? "Данные API · расчёт модели" : "Демо · симуляция"}</Chip>
|
||||
@@ -782,8 +779,8 @@ function RouteOptimizerContent() {
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div 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}
|
||||
<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 ? "Последний полный снимок; актуальность не подтверждена." : "Данные оптимизатора неизвестны."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -901,7 +898,7 @@ function RouteOptimizerContent() {
|
||||
size="sm"
|
||||
className="ml-auto h-7 text-xs"
|
||||
onClick={() => void applyOspfOptimization()}
|
||||
disabled={!ospfServerId || ospfApplying}
|
||||
disabled={loading || Boolean(error) || !data || !ospfServerId || ospfApplying}
|
||||
>
|
||||
<ZapIcon className={cn("size-3.5", ospfApplying && "animate-pulse")} />
|
||||
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
|
||||
|
||||
Reference in new issue
Block a user