fix(ui): подтверждать применение OSPF и согласовать права

This commit is contained in:
Denozordec committed 2026-09-30 22:56:31 +07:00
1 parent bf2815b5a4
commit c88e1400bf
8 files changed
+147 -35

No files matched your search

+37 -33
View File
@@ -2,7 +2,8 @@
import { useCallback, useEffect, useState, useMemo, useRef } from "react"
import Link from "next/link"
import {ospfCatalogSchema} from "@/lib/ospf-response"
import {can} from "@/lib/auth"
import {ospfCatalogSchema,ospfOptimizeSchema} from "@/lib/ospf-response"
import {optimizerServersSchema,optimizerRulesSchema,optimizerProbesSchema,optimizerOspfPreviewSchema,type OptimizerOspfPreview} from "@/lib/route-optimizer-response"
import { PageHeader } from "@/components/page-header"
import { RouteOptimizerWanMatrixDataGrid } from "@/components/data-grids/route-optimizer-wan-matrix-data-grid"
@@ -450,8 +451,9 @@ function RouteOptimizerContent() {
const [selectedVrf, setSelectedVrf] = useState("main")
const [ospfServerId, setOspfServerId] = useState("")
const [ospfApplying, setOspfApplying] = useState(false)
const [ospfApplyResult, setOspfApplyResult] = useState<{ serverName: string; optimizedCount: number } | null>(null)
const [ospfApplyError, setOspfApplyError] = useState("")
const [applyResult, setApplyResult] = useState<{owner:string;serverName:string;optimizedCount:number;receivedAt:string}|null>(null)
const [applyError,setApplyError]=useState({owner:"",message:""})
const writeLock=useRef(false),activeContext=useRef("")
const [metaState,setMetaState]=useState<{owner:string;data:{interfaces:number;areas:number;serverName:string}|null;error:string}>({owner:"",data:null,error:""})
const [previewState,setPreviewState]=useState<{owner:string;data:OptimizerOspfPreview|null;error:string;pending:boolean}>({owner:"",data:null,error:"",pending:false})
const [previewTick,setPreviewTick]=useState(0)
@@ -461,6 +463,9 @@ function RouteOptimizerContent() {
const ospfPreview=previewState.owner===previewKey?previewState.data:null
const ospfPreviewError=previewState.owner===previewKey?previewState.error:""
const ospfPreviewLoading=useLiveData && Boolean(ospfServerId) && (previewState.owner!==previewKey || previewState.pending)
useEffect(()=>{activeContext.current=previewKey},[previewKey])
const ospfApplyResult=applyResult?.owner===previewKey?applyResult:null
const ospfApplyError=applyError.owner===previewKey?applyError.message:""
const previewReady=Boolean(ospfPreview) && !ospfPreviewLoading && !ospfPreviewError
const load = useCallback(async (override?: OptimizerSettings) => {
@@ -608,28 +613,25 @@ function RouteOptimizerContent() {
}
async function applyOspfOptimization() {
if (!ospfServerId || loading || error || !data || !previewReady) return
setOspfApplying(true)
setOspfApplyError("")
setOspfApplyResult(null)
if(writeLock.current || !can("mm:network:write") || !useLiveData || !ospfServerId || loading || error || !data || !previewReady || ospfMetaError || !ospfPreview?.changedCount || !liveServers.some(server=>String(server.id)===ospfServerId&&server.enabled))return
const owner=previewKey,target=ospfServerId,weight=settings.pingWeight
const current=()=>reader.current.alive && activeContext.current===owner
writeLock.current=true;setOspfApplying(true);setApplyError({owner,message:""})
try {
const res = await apiFetch<{ serverName: string; optimizedCount: number }>(
`/api/servers/${ospfServerId}/ospf/optimize`,
{
method: "POST",
body: JSON.stringify({ pingWeight: settings.pingWeight }),
},
)
setOspfApplyResult({
serverName: res.serverName,
optimizedCount: res.optimizedCount,
})
await load()
setPreviewTick(value=>value+1)
} catch (e) {
setOspfApplyError(e instanceof Error ? e.message : "Ошибка применения OSPF-оптимизации")
} finally {
setOspfApplying(false)
const raw=await apiFetch<unknown>("/api/servers/"+target+"/ospf/optimize",{method:"POST",body:JSON.stringify({pingWeight:weight})})
const parsed=ospfOptimizeSchema.safeParse(raw)
if(!parsed.success || String(parsed.data.serverId)!==target || parsed.data.pingWeight!==weight)throw new Error("Применение OSPF не подтверждено: недостоверный ответ или другой контекст")
if(current())setApplyResult({owner,serverName:parsed.data.serverName,optimizedCount:parsed.data.optimizedCount,receivedAt:new Date().toISOString()})
}catch(error){
if(current())setApplyError({owner,message:error instanceof Error?error.message:"Ошибка применения OSPF"})
}finally{
writeLock.current=false
if(reader.current.alive){
setOspfApplying(false)
setPreviewState(previous=>({...previous,pending:true}))
setPreviewTick(value=>value+1)
void load()
}
}
}
@@ -684,7 +686,7 @@ function RouteOptimizerContent() {
<ZapIcon className="size-4" />Симулировать все ({totalSwitches})
</Button>
)}
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading}>
<Button variant="outline" size="sm" onClick={() => void load()} disabled={loading || ospfApplying}>
<RefreshCwIcon className={cn("size-4", loading && "animate-spin")} />
{loading ? "Расчёт…" : "Обновить"}
</Button>
@@ -693,7 +695,7 @@ function RouteOptimizerContent() {
/>
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-4">
<fieldset disabled={ospfApplying} className="min-w-0 flex flex-col gap-4">
{/* Status row */}
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap px-0.5">
@@ -748,7 +750,7 @@ function RouteOptimizerContent() {
{/* Settings */}
<Frame dense className="w-full">
<FramePanel className="p-0">
<button type="button" aria-expanded={showSettings} aria-label="Настройки оптимизатора"
<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")}
onClick={() => setShowSettings(v => !v)}
>
@@ -854,7 +856,7 @@ function RouteOptimizerContent() {
size="sm"
className="ml-auto h-7 text-xs"
onClick={() => void applyOspfOptimization()}
disabled={loading || Boolean(error) || !data || !ospfServerId || ospfApplying || !previewReady || Boolean(ospfMetaError)}
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")} />
{ospfApplying ? "Оптимизация…" : "Оптимизировать OSPF"}
@@ -865,7 +867,7 @@ function RouteOptimizerContent() {
<div className="text-muted-foreground">Router</div>
<div className="md:col-span-2 font-mono truncate">{ospfMeta?.serverName ?? "—"}</div>
<div className="text-right text-muted-foreground">
apply: {ospfApplyResult?.optimizedCount ?? 0}
apply: {ospfApplyResult?.optimizedCount ?? "—"}
</div>
</div>
</div>
@@ -889,14 +891,16 @@ function RouteOptimizerContent() {
/>
</div>
{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 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">
Применено на {ospfApplyResult.serverName}: изменено интерфейсов — {ospfApplyResult.optimizedCount}.
<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">
Последнее подтверждение API: {ospfApplyResult.serverName}, изменено интерфейсов — {ospfApplyResult.optimizedCount}. Получено {new Date(ospfApplyResult.receivedAt).toLocaleTimeString("ru-RU")}.
</div>
)}
{ospfApplyError && (
<div className="text-xs rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
<div role="alert" className="text-xs rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-destructive">
Ошибка OSPF-оптимизации: {ospfApplyError}
</div>
)}
@@ -1043,7 +1047,7 @@ function RouteOptimizerContent() {
/>
))}
</div>
</fieldset>
</div>
</div>
)
+1 -1
View File
@@ -13,7 +13,7 @@
"db:studio": "drizzle-kit studio",
"db:migrate-from-sqlite": "tsx src/scripts/migrate-sqlite-to-pg.ts",
"facts:rebuild": "tsx src/scripts/rebuild-flow-facts.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts && tsx src/plugins/auth.ospf-optimizer.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts",
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts",
+5
View File
@@ -61,4 +61,9 @@ assert.equal(hasPermission(["mm:settings:admin"], "mm:users:read"), true)
assert.equal(hasPermission(["mm:settings:admin"], "mm:users:write"), true)
assert.equal(hasPermission(["mm:dashboard:read"], "mm:users:write"), false)
assert.equal(permissionForRequest("GET","/api/servers/1/ospf"),"mm:network:read")
assert.equal(permissionForRequest("POST","/api/servers/1/ospf/optimize/preview?x=1"),"mm:network:read")
assert.equal(permissionForRequest("POST","/api/servers/1/ospf/optimize"),"mm:network:write")
assert.equal(permissionForRequest("POST","/api/servers/1/ospf/optimize/preview/extra"),"mm:network:write")
assert.equal(permissionForRequest("PUT","/api/servers/1"),"mm:servers:write")
console.log("permissions.test.ts: ok")
+16
View File
@@ -42,6 +42,22 @@ type Rule = {
}
const RULES: Rule[] = [
{
methods: ["GET"],
match: p => /^\/api\/servers\/\d+\/ospf(?:\/|$)/.test(p),
permission: "mm:network:read",
},
{
methods: ["POST"],
match: p => /^\/api\/servers\/\d+\/ospf\/optimize\/preview$/.test(p),
permission: "mm:network:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: p => /^\/api\/servers\/\d+\/ospf(?:\/|$)/.test(p),
permission: "mm:network:write",
},
{
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
@@ -0,0 +1,27 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import {SignJWT} from "jose"
process.env.AUTH_REQUIRED="true"
process.env.AUTH_JWT_SECRET="optimizer-test-secret"
process.env.AUTH_ISSUER="https://auth.test.local"
process.env.NODE_ENV="test"
const {default:authPlugin,requireAuth}=await import("./auth.js")
const app=Fastify()
await app.register(authPlugin)
app.addHook("preHandler",requireAuth)
let reads=0,writes=0
app.post("/api/servers/:id/ospf/optimize/preview",async()=>{reads++;return {ok:true}})
app.post("/api/servers/:id/ospf/optimize",async()=>{writes++;return {ok:true}})
const secret=new TextEncoder().encode("optimizer-test-secret")
for(const permission of ["mm:network:read","mm:network:write","mm:servers:write"]){
const token=await new SignJWT({sub:"reader",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(secret)
const headers={authorization:"Bearer "+token}
const preview=await app.inject({method:"POST",url:"/api/servers/1/ospf/optimize/preview",headers})
const apply=await app.inject({method:"POST",url:"/api/servers/1/ospf/optimize",headers})
assert.equal(preview.statusCode,permission==="mm:servers:write"?403:200)
assert.equal(apply.statusCode,permission==="mm:network:write"?200:403)
}
assert.equal(reads,2);assert.equal(writes,1)
assert.equal((await app.inject({method:"POST",url:"/api/servers/1/ospf/optimize"})).statusCode,401)
await app.close()
console.log("OSPF optimizer RBAC: signed JWT read/write/servers-only, no DB/RouterOS")
+9 -1
View File
@@ -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.31; строгий OSPF preview оптимизатора, далее подтверждение apply |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.32; подтверждённое OSPF apply и RBAC, далее backend provenance |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.32 — подтверждение применения OSPF и права (2026-09-30)
- Apply валидирует существующий ospfOptimizeSchema, serverId и вес запроса. Успех по count/name без списка applied больше не принимается. Последний receipt привязан к контексту и времени получения браузером; отсутствие применения отображается «—».
- Синхронный ref lock и disabled fieldset блокируют повтор/смену устройства/параметров до ответа. Проверяются network:write, enabled inventory и готовность preview. После любого результата перечитываются preview/catalog и исходный расчёт: ошибка может сопровождаться частичным применением RouterOS. Late unmounted контекст не публикует результат.
- RBAC конкретных server OSPF endpoints поставлен перед общим server matcher: GET/POST preview требуют network:read, изменения network:write. Общие server rights прежние. Изолированный HTTP тест с настоящими signed JWT проверяет reader/writer/servers-only/401 без БД и RouterOS.
- Проверки: 16 E2E apply/preview (success/HTTP/foreign/weight/count/missing/repeat/readonly), readonly повторно с завершением metadata, TS/lint, frontend/backend production build и backend auth regression прошли. Первичный readonly teardown оставлял metadata запрос; ожидание добавлено, повтор прошёл без proxy ошибки.
- Далее 10.33: происхождение cost и метрик backend OSPF планировщика; legacy defaults ещё не позволяют считать preview полностью наблюдаемым. Затем source/URL и visual acceptance. Реальный RouterOS/DB/JWT integration не проверен; signed JWT тест проверяет middleware со stub handlers. Последняя квота: 76% окна, 43% недели использовано. Локальный коммит без push.
## Блок 10.31 — OSPF preview в оптимизаторе (2026-09-30)
- Preview требует точных serverId/pingWeight, идентификаторов интерфейсов, уникальности и согласованных interfaces/changes/counts. Старый или чужой контекст не публикуется и блокирует apply.
+4
View File
@@ -176,3 +176,7 @@ Live таймерный успех убран; demo симуляция явно
## 10.31. Строгий preview OSPF
Контекст serverId/веса, полный список изменений/counts/ids; metadata strict catalog, повтор/last preview/late context. 12 профильных + ранее 16 существующих (24 разных), TS/lint прошли. Apply receipt и происхождение backend модельных значений ещё требуют проверки; далее 10.32.
## 10.32. Подтверждённое OSPF применение и RBAC
Strict receipt/server/weight, ref lock, readonly/disabled контекст и reread любого результата. 16 E2E, readonly повтор, TS/lint, обе production сборки и auth tests прошли. Specific OSPF rights согласованы: preview read, apply write; signed JWT со stub handlers без RouterOS/DB. Далее backend provenance: legacy cost/metric defaults ещё не приняты как observed данные.
@@ -0,0 +1,48 @@
import {test,expect,type Page} from "@playwright/test"
const row=(changed=true)=>({id:"i",interface:"ether1",currentCost:changed?10:20,optimalCost:20,score:50,pingMs:10,dlMbps:100,ulMbps:50})
async function setup(page:Page,mode:string,delayed=false){
let writes=0,previews=0,applied=false,started=false,release!:()=>void
const gate=new Promise<void>(resolve=>{release=resolve})
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",async r=>{
const p=new URL(r.request().url()).pathname
if(p.endsWith("/ospf/optimize")){
writes++;started=true;if(delayed)await gate
const receipt={serverId:1,serverName:"Роутер 1",pingWeight:60,optimizedCount:1,applied:[{interface:"ether1",from:10,to:20}],interfaces:[row()]}
if(mode==="success")applied=true
return r.fulfill(mode==="http"?{status:503,json:{error:"Ошибка записи"}}:{json:mode==="foreign"?{...receipt,serverId:2}:mode==="weight"?{...receipt,pingWeight:20}:mode==="count"?{...receipt,applied:[]}:mode==="missing"?{}:receipt})
}
if(p.endsWith("/preview")){previews++;const item=row(!applied);return r.fulfill({json:{serverId:1,serverName:"Роутер 1",pingWeight:60,interfacesTotal:1,changedCount:applied?0:1,unchangedCount:applied?1:0,interfaces:[item],changes:applied?[]:[item]}})}
return r.fulfill({json:p==="/api/auth/config"?{required:mode==="readonly"}:p==="/api/servers"?[1,2].map(id=>({id,name:"Роутер "+id,host:"192.0.2."+id,type:"home-router",country:"RU",enabled:true,status:null})):p==="/api/filters/rules"?{rulesets:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/ospf/catalog"?{interfaces:[],instances:[],neighbors:[],bfdSessions:[],routes:[],failures:[]}:{}})
})
await page.goto("/route-optimizer")
const button=page.getByRole("button",{name:"Оптимизировать OSPF",exact:true})
await expect(page.getByRole("table").filter({hasText:"Speed (dl/ul)"}).locator("tr[data-row-id]")).toHaveCount(1)
return {button,writes:()=>writes,previews:()=>previews,started:()=>started,release:()=>release()}
}
for(const mode of ["success","http","foreign","weight","count","missing"])test("Оптимизатор: подтверждение OSPF "+mode,async({page})=>{
const state=await setup(page,mode),before=state.previews()
await expect(state.button).toBeEnabled();await state.button.click()
if(mode==="success")await expect(page.getByRole("status").filter({hasText:"Последнее подтверждение API"})).toBeVisible()
else{await expect(page.getByRole("alert").filter({hasText:mode==="http"?"Ошибка записи":"Применение OSPF не подтверждено"})).toBeVisible();await expect(page.getByText(/Последнее подтверждение API/)).toHaveCount(0)}
await expect.poll(state.previews).toBeGreaterThan(before)
if(mode==="success"){await expect(page.getByText("0 из 1 изменятся",{exact:true})).toBeVisible();await expect(state.button).toBeDisabled()}
else await expect(state.button).toBeEnabled()
expect(state.writes()).toBe(1)
})
test("Оптимизатор: повтор и смена устройства заблокированы до receipt",async({page})=>{
const state=await setup(page,"success",true)
await state.button.evaluate(button=>{(button as HTMLButtonElement).click();(button as HTMLButtonElement).click()})
await expect.poll(state.started).toBe(true);await expect(page.locator("select")).toBeDisabled()
await expect(page.getByRole("button",{name:"Настройки оптимизатора",exact:true})).toBeDisabled()
expect(state.writes()).toBe(1);state.release()
await expect(page.getByRole("status").filter({hasText:"Последнее подтверждение API"})).toBeVisible()
})
test("Оптимизатор: readonly preview доступен без применения",async({page})=>{
const claims={sub:"test",email:"reader@example.test",name:"Reader",apps:["mm"],permissions:["mm:network:read"],exp:4102444800}
await page.addInitScript(value=>localStorage.setItem("mmapp_token","test."+btoa(JSON.stringify(value))+".test"),claims)
const state=await setup(page,"readonly");await expect(state.button).toBeDisabled()
await expect(page.getByText("Доступен просмотр OSPF. Для применения требуется право изменения сети.",{exact:true})).toBeVisible()
await expect(page.getByText("0 iface · 0 area",{exact:true})).toBeVisible()
expect(state.writes()).toBe(0)
})