fix(ui): обозначить расчёт и симуляцию оптимизатора
This commit is contained in:
1 parent
e58604c3c0
commit
59f0f82176
7 files changed
+104
-62
No files matched your search
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
}
|
||||
>
|
||||
{prob}%
|
||||
{prob} б.
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
@@ -53,6 +53,7 @@ interface RouteOptimizerCommRecsDataGridProps {
|
||||
applied: Set<string>
|
||||
applying: Set<string>
|
||||
onPin: (key: string) => void
|
||||
simulation?: boolean
|
||||
onApply: (community: string, homeId: string) => void
|
||||
}
|
||||
|
||||
@@ -64,6 +65,7 @@ function RouteOptimizerCommRecsDataGrid({
|
||||
applying,
|
||||
onPin,
|
||||
onApply,
|
||||
simulation = false,
|
||||
}: RouteOptimizerCommRecsDataGridProps) {
|
||||
const rows = useMemo(
|
||||
() => recs.map((r, idx) => ({ ...r, _rowKey: `${homeId}::${r.community}::${idx}` })),
|
||||
@@ -94,7 +96,7 @@ function RouteOptimizerCommRecsDataGrid({
|
||||
id: "current",
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground">
|
||||
Текущий (WAN → JH → Exit)
|
||||
Базовый расчёт (WAN → JH → Exit)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
@@ -141,7 +143,7 @@ function RouteOptimizerCommRecsDataGrid({
|
||||
<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">
|
||||
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)}%
|
||||
+{(r.recommended.prob ?? 0) - (r.current?.prob ?? 0)} б.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,7 +155,7 @@ function RouteOptimizerCommRecsDataGrid({
|
||||
id: "probability",
|
||||
header: () => (
|
||||
<span className="text-[11px] font-medium text-muted-foreground block text-center">
|
||||
P(тек / рек)
|
||||
Индекс (баз / рек)
|
||||
</span>
|
||||
),
|
||||
enableSorting: false,
|
||||
@@ -185,9 +187,9 @@ function RouteOptimizerCommRecsDataGrid({
|
||||
const r = row.original
|
||||
const pinKey = `${homeId}::${r.community}`
|
||||
const isPinned = pinned.has(pinKey)
|
||||
const isApplied = applied.has(pinKey)
|
||||
const isApplying = applying.has(pinKey)
|
||||
const canApply = r.shouldSwitch && !isPinned && !isApplied
|
||||
const isApplied = simulation && applied.has(pinKey)
|
||||
const isApplying = simulation && applying.has(pinKey)
|
||||
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" />}
|
||||
@@ -210,13 +212,13 @@ function RouteOptimizerCommRecsDataGrid({
|
||||
{isApplying ? (
|
||||
<RefreshCwIcon className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Применить"
|
||||
"Симулировать"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{isApplied && (
|
||||
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
✓ применено
|
||||
✓ симуляция завершена
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -228,7 +230,7 @@ function RouteOptimizerCommRecsDataGrid({
|
||||
},
|
||||
},
|
||||
],
|
||||
[applied, applying, homeId, onApply, onPin, pinned],
|
||||
[applied, applying, homeId, onApply, onPin, pinned, simulation],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
|
||||
@@ -41,7 +41,7 @@ function ProbChip({ prob, best }: { prob: number; best?: boolean }) {
|
||||
: "bg-muted text-muted-foreground border-border"
|
||||
}
|
||||
>
|
||||
{prob}%
|
||||
{prob} б.
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
@@ -211,7 +211,7 @@ function RouteOptimizerFullRoutesDataGrid({
|
||||
id: "prob",
|
||||
accessorKey: "probabilityOptimal",
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="P(opt)" className="mx-auto" />
|
||||
<DataGridSortHeader column={column} title="Индекс" className="mx-auto" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isBest = row.original.id === bestId || row.original._index === 0
|
||||
@@ -222,7 +222,7 @@ function RouteOptimizerFullRoutesDataGrid({
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
headerTitle: "P(opt)",
|
||||
headerTitle: "Индекс",
|
||||
headerClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
cellClassName: cn(DATA_GRID_CELL_PAD, "text-center"),
|
||||
},
|
||||
|
||||
@@ -26,11 +26,19 @@
|
||||
| 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.28; рекурсивные маршруты приняты локально, далее оптимизатор |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.29; Community оптимизатора показывает расчёт, далее strict read/OSPF |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 10.29 — достоверность рекомендаций оптимизатора (2026-09-30)
|
||||
|
||||
- Live Community больше не показывает успех по таймеру: действия симуляции доступны только в демо. Переключение backend/mode размонтирует прежний контекст; таймеры симуляции отменяются, повтор синхронно заблокирован.
|
||||
- Базовая цепочка подписана как расчётная; проценты вероятности заменены баллами индекса. Указано, что метрики могут включать модельные оценки, а ECMP/RPF/VRF служат локальному просмотру. Автоприменение Community отмечено недоступным; пересчёт настроек подписан явно. Алгоритм live ранжирования и сетевые операции не изменены.
|
||||
- Демо-матрицы теперь сопоставлены с текущими mock server IDs: прежние jh/ex ID оставляли их пустыми. Проверена успешная симуляция с отдельной подписью. Настройки раскрываются настоящей кнопкой с aria-expanded.
|
||||
- Проверки: шесть E2E (таблицы/preview, live без фиктивной записи, mock симуляция), TS/lint прошли. Первый прогон выявил div вместо кнопки и несовместимость демо-ID; исправлены.
|
||||
- Далее 10.30: строгие ответы inventory/rules/probes, ошибки частей и последние данные, epoch/freshness. Затем OSPF preview/apply validation/права/контекст и visual acceptance. Раздел целиком ещё не принят; фактическая текущая Community цепочка и вероятности API отсутствуют. Последняя квота: 66% окна, 41% недели использовано. Локальный коммит без push.
|
||||
|
||||
## Блок 10.28 — адаптивность и локальная приёмка маршрутов (2026-09-30)
|
||||
|
||||
- Раскрытие группы — отдельная кнопка с aria-expanded, Enter/Space; URL и клик строки сохранены. Форма имеет доступные имена полей и штатный finalFocus Base UI после Escape.
|
||||
|
||||
@@ -164,3 +164,7 @@ server/q/route/table принадлежат экрану; default replace, вы
|
||||
## 10.28. Локальная приёмка рекурсивных маршрутов
|
||||
|
||||
48 основных + 8 source E2E, backend recursive tests, lint/production frontend прошли. 1440/390/320 px, keyboard раскрытие, trap и возврат фокуса, parent-owned scroll; шесть экранов в плане. Неизвестный тип не подменяется EN. ReUI audit consumer-композиции выполнен без изменения vendor/theme. Далее оптимизатор; RouterOS/DB/JWT остаются внешней приёмкой.
|
||||
|
||||
## 10.29. Community: расчёт и симуляция
|
||||
|
||||
Live таймерный успех убран; demo симуляция явно подписана, cleanup/lock сохранены. Модельная базовая цепочка и индекс в баллах обозначены; автоприменение недоступно, ECMP/RPF/VRF локальные. Исправлены mock-ID матриц. 6 E2E, TS/lint прошли. Далее strict чтение и OSPF операции; раздел ещё не принят.
|
||||
@@ -41,8 +41,8 @@ test("Оптимизатор: 12 WAN, все комбинации и закре
|
||||
await expect(routes.locator("tr[data-row-id]")).toHaveCount(5)
|
||||
await page.getByRole("button", { name: "Показать все 12 комбинаций", exact: true }).click()
|
||||
await expect(routes.locator("tr[data-row-id]")).toHaveCount(12)
|
||||
await routes.getByRole("button", { name: "P(opt)", exact: true }).click()
|
||||
await routes.getByRole("button", { name: "P(opt)", exact: true }).click()
|
||||
await routes.getByRole("button", { name: "Индекс", exact: true }).click()
|
||||
await routes.getByRole("button", { name: "Индекс", exact: true }).click()
|
||||
await expect(routes.locator("tr[data-row-id]").first()).toHaveAttribute("data-row-id", "1-wan12-2-3")
|
||||
await page.getByRole("button", { name: "Свернуть", exact: true }).click()
|
||||
await expect(routes.locator("tr[data-row-id]")).toHaveCount(5)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {test,expect} from "@playwright/test"
|
||||
const hosts=(["home-router","jump-host","exit-node"] as const).map((type,index)=>({id:index+1,name:"Роутер "+(index+1),host:"192.0.2."+(index+1),type,site:"TEST",country:"RU",enabled:true,status:"online",latency:10,model:"CHR",...(index===0?{wanUplinks:[{id:"wan",name:"WAN",isp:"ISP",iface:"ether1",ip:"198.51.100.1",maxDl:100,maxUl:100}]}:{})}))
|
||||
test("Оптимизатор: live рекомендации не подтверждают фиктивную запись",async({page})=>{
|
||||
let writes=0
|
||||
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
|
||||
await page.route("**/api/**",r=>{
|
||||
const p=new URL(r.request().url()).pathname
|
||||
if(r.request().method()!=="GET"&&!p.endsWith("/preview"))writes++
|
||||
return r.fulfill({json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?hosts: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.endsWith("/preview")?{interfaces:[],changes:[],changedCount:0,interfacesTotal:0}:p.endsWith("/ospf")?{interfaces:[],instances:[],neighbors:[],bfdSessions:[]}:{}})
|
||||
})
|
||||
await page.goto("/route-optimizer")
|
||||
await page.getByRole("button",{name:"BGP community (1)",exact:true}).click()
|
||||
const grid=page.getByRole("table").filter({hasText:"Community"})
|
||||
await expect(grid.locator("tr[data-row-id]")).toHaveCount(1)
|
||||
await expect(grid.getByRole("columnheader",{name:"Базовый расчёт (WAN → JH → Exit)",exact:true})).toBeVisible()
|
||||
await expect(grid.getByRole("button",{name:"Применить",exact:true})).toHaveCount(0)
|
||||
await expect(grid.getByRole("button",{name:"Симулировать",exact:true})).toHaveCount(0)
|
||||
await expect(page.getByRole("button",{name:/Симулировать все/})).toHaveCount(0)
|
||||
await expect(page.getByText("✓ применено",{exact:true})).toHaveCount(0)
|
||||
await expect(page.getByText(/Применение Community к устройству недоступно/)).toBeVisible()
|
||||
await page.getByRole("button",{name:/Настройки оптимизатора/}).click()
|
||||
await expect(page.getByText("Автоприменение Community недоступно",{exact:true})).toBeVisible()
|
||||
await expect(page.getByText("Применять автоматически",{exact:true})).toHaveCount(0)
|
||||
expect(writes).toBe(0)
|
||||
})
|
||||
test("Оптимизатор: демо подтверждает только симуляцию",async({page})=>{
|
||||
await page.addInitScript(()=>localStorage.setItem("routerlists:data-source","mock"))
|
||||
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:{}}))
|
||||
await page.goto("/route-optimizer")
|
||||
await page.getByRole("button",{name:/^BGP community/}).last().click()
|
||||
const simulate=page.getByRole("button",{name:"Симулировать",exact:true}).first()
|
||||
await expect(simulate).toBeVisible();await simulate.click()
|
||||
await expect(page.getByText("✓ симуляция завершена",{exact:true}).first()).toBeVisible()
|
||||
await expect(page.getByText("✓ применено",{exact:true})).toHaveCount(0)
|
||||
})
|
||||
Reference in new issue
Block a user