fix(api): показывать подтверждённые метрики OSPF

This commit is contained in:
Denozordec committed 2026-09-30 19:49:47 +07:00
1 parent e3791f3496
commit af05ad5cda
13 files changed
+120 -55

No files matched your search

+20 -40
View File
@@ -46,13 +46,13 @@ interface OspfItem {
serverId: string
interfaceName: string
area: string
cost: number
active: boolean
cost: number | null
active: boolean | null
dataUnknown?: boolean
type?: string
useBfd?: boolean
helloInterval?: number
deadInterval?: number
type?: string | null
useBfd?: boolean | null
helloInterval?: number | null
deadInterval?: number | null
}
interface OspfNeighbor {
@@ -75,7 +75,7 @@ interface OspfRoute {
id: string
destination: string
type: "O" | "O IA" | "O E1" | "O E2"
cost: number
cost: number | null
nextHop: string
via: string
serverId: string
@@ -106,31 +106,10 @@ interface BfdSession {
// ─── backend types ────────────────────────────────────────────────────────────
interface BackendNeighbor {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
address: string; routerId: string; instance: string; area: string; areaId: string
interface: string; state: string; uptime: string | null; stateChanges: number | null; priority: number | null
}
interface BackendInterface {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
instance: string; area: string; areaId: string; interface: string; cost: number
type: string; disabled: boolean; inactive: boolean; priority: number
helloInterval: number; deadInterval: number; useBfd: boolean
}
interface BackendBfdSession {
id: string; serverId: number; serverName: string; serverSite: string; serverCountry: string
localAddr: string; remoteAddr: string; interface: string; state: string; uptime: string | null
multihop: boolean; multiplier: number | null; txInterval: number | null; rxInterval: number | null; holdTime: number | null
packetsRx: number | null; packetsTx: number | null; stateChanges: number | null
}
interface BackendOspfRoute {
id: string; serverId: number; serverName: string; serverSite: string
destination: string; type: OspfRoute["type"]; cost: number; nextHop: string; via: string; area: string
}
type BackendNeighbor = OspfCatalog["neighbors"][number]
type BackendInterface = OspfCatalog["interfaces"][number]
type BackendBfdSession = OspfCatalog["bfdSessions"][number]
type BackendOspfRoute = OspfCatalog["routes"][number]
function isRefInterfaceName(name: string): boolean {
@@ -168,7 +147,7 @@ function backendToItem(b: BackendInterface): OspfItem {
interfaceName: b.interface,
area: b.areaId,
cost: b.cost,
active: !b.disabled && !b.inactive,
active: b.disabled === true || b.inactive === true ? false : b.disabled === false && b.inactive === false ? true : null,
type: b.type,
useBfd: b.useBfd,
helloInterval: b.helloInterval,
@@ -637,7 +616,7 @@ function NodeDetailPanel({
<span className="text-[10px] font-mono text-[#475569] shrink-0">
{iface.area === "0.0.0.0" ? "backbone" : `area ${iface.area}`}
</span>
<span className="text-[10px] font-mono shrink-0" style={{ color: "#0ea5e9" }}>cost {iface.cost}</span>
<span className="text-[10px] font-mono shrink-0" style={{ color: "#0ea5e9" }}>cost {iface.cost ?? "—"}</span>
</div>
))}
</div>
@@ -696,7 +675,7 @@ function InterfacesTab({
return Object.values(byRouter).map(r => ({
...r,
areas: Object.entries(r.areas)
.map(([area, areaItems]) => ({ area, items: [...areaItems].sort((a, b) => a.cost - b.cost) }))
.map(([area, areaItems]) => ({ area, items: [...areaItems].sort((a, b) => (a.cost ?? Infinity) - (b.cost ?? Infinity)) }))
.sort((a, b) => a.area.localeCompare(b.area)),
}))
}, [items])
@@ -765,7 +744,7 @@ function InterfacesTab({
function onDrop(routerKey: string, area: string, targetKey: string) {
if (!dragging || dragging === targetKey) { setDragging(null); setDragOver(null); return }
setItems(prev => {
const areaItems = prev.filter(i => i.routerKey === routerKey && i.area === area).sort((a, b) => a.cost - b.cost)
const areaItems = prev.filter(i => i.routerKey === routerKey && i.area === area).sort((a, b) => (a.cost ?? Infinity) - (b.cost ?? Infinity))
const fromIdx = areaItems.findIndex(i => i.key === dragging)
const toIdx = areaItems.findIndex(i => i.key === targetKey)
if (fromIdx < 0 || toIdx < 0) return prev
@@ -785,6 +764,7 @@ function InterfacesTab({
return (
<div className="flex flex-col gap-4">
{isLive && filterServerId !== ALL_SERVERS_ID && !dataReady && <p role="status" className="text-xs text-muted-foreground">Оптимизация недоступна до полного успешного опроса и подтверждения costs/состояний интерфейсов с правом записи.</p>}
{operationError && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Оптимизация OSPF не подтверждена</AlertTitle><AlertDescription>{operationError}</AlertDescription></Alert>}
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" disabled={optimizing} onClick={() => setItems(initialItems)}>
@@ -856,7 +836,7 @@ function InterfacesTab({
<GripVerticalIcon className={cn("size-3.5 shrink-0", isLive ? "text-muted-foreground/10" : "text-muted-foreground/30")} />
<span className={cn("inline-block size-1.5 rounded-full shrink-0", !item.dataUnknown && item.active ? "bg-[var(--status-online)]" : "bg-muted-foreground/40")} />
<code className="text-xs font-mono flex-1 min-w-0 truncate">{item.interfaceName}</code>
{item.dataUnknown && <span className="text-xs text-muted-foreground">Неизвестно</span>}
{(item.dataUnknown || item.active === null) && <span className="text-xs text-muted-foreground">Неизвестно</span>}
{item.type && item.type !== "broadcast" && (
<Chip color="bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20">{item.type}</Chip>
)}
@@ -869,7 +849,7 @@ function InterfacesTab({
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
}>opt {hint.optimalCost}</Chip>
)}
<Chip color="bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20">cur {item.cost}</Chip>
<Chip color="bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20">cur {item.cost ?? "—"}</Chip>
{liveOptimal !== undefined && !item.dataUnknown && <Chip color={costDiffers
? "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
@@ -1193,7 +1173,7 @@ function OspfPageContent() {
const unknownIds = useMemo(() => new Set(liveError ? liveServers.map(s => s.id) : (liveData?.failures ?? []).map(f => f.serverId)), [liveError, liveServers, liveData])
const incomplete = isLive && (!liveData || Boolean(liveError) || (filterServerId === ALL_SERVERS_ID ? unknownIds.size > 0 : unknownIds.has(Number(filterServerId))))
const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write"))
const dataReady = !isLive || Boolean(liveData && !liveError && !loading && !writeBusy && !liveData.failures.length && liveServers.some(s => String(s.id) === filterServerId) && can("mm:servers:write") && liveData.interfaces.filter(row => String(row.serverId) === filterServerId).every(row => row.cost !== null && row.cost >= 1 && row.disabled !== null && row.inactive !== null))
// Derive frontend types from backend data or use mocks
const { items, neighbors, graphNodes, graphEdges, routerIds, bfdSessions, routes } = useMemo(() => {
@@ -1211,7 +1191,7 @@ function OspfPageContent() {
// Build routerIds from instances
const routerIds: Record<string, string> = {}
for (const inst of liveData.instances) {
if (!routerIds[String(inst.serverId)]) {
if (inst.routerId && !routerIds[String(inst.serverId)]) {
routerIds[String(inst.serverId)] = inst.routerId
}
}
+18 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
import { createOspfRoutes, parseBfdSessions, parseBfdCatalogSessions, parseCatalogNeighbors } from "./ospf.js"
import { createOspfRoutes, parseBfdSessions, parseBfdCatalogSessions, parseCatalogNeighbors, parseCatalogInterfaces, parseCatalogInstances, parseCatalogRoutes } from "./ospf.js"
import { emptyOspfCatalog, pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js"
import { servers } from "../db/schema.js"
const devices = [{ id: 1, name: "Первый" }, { id: 2, name: "Второй" }]
@@ -49,3 +49,20 @@ const neighborZero = parseCatalogNeighbors(bfdServer, [{ ...neighborSource, prio
assert.equal(neighborZero.priority, 0); assert.equal(neighborZero.stateChanges, 0)
for (const priority of ["junk", "1junk", "256", "-1"]) assert.equal(parseCatalogNeighbors(bfdServer, [{ ...neighborSource, priority }], new Map())[0].priority, null)
console.log("OSPF strict values: missing/zero/microseconds/compound, no desired-as-actual and priority bounds passed")
const rawIface = { ".id": "*1", area: "backbone", interfaces: "ether1", disabled: "false", inactive: "false" }
const missingIface = parseCatalogInterfaces(bfdServer, [rawIface], [], [], new Map())[0]
for (const field of ["cost", "priority", "type", "helloInterval", "deadInterval", "useBfd"] as const) assert.equal(missingIface[field], null)
const zeroIface = parseCatalogInterfaces(bfdServer, [{ ...rawIface, cost: "0", priority: "0", type: "ptp", "hello-interval": "0s", "dead-interval": "1s500ms", "use-bfd": "no" }], [], [], new Map())[0]
assert.equal(zeroIface.cost, 0); assert.equal(zeroIface.priority, 0); assert.equal(zeroIface.helloInterval, 0); assert.equal(zeroIface.deadInterval, 1.5); assert.equal(zeroIface.useBfd, false)
assert.equal(parseCatalogInterfaces(bfdServer, [{ ...rawIface, disabled: "junk", cost: "10x" }], [], [], new Map())[0].disabled, null)
const rawInstance = { ".id": "*1", name: "default", disabled: "false", inactive: "false" }
const missingInstance = parseCatalogInstances(bfdServer, [rawInstance])[0]
assert.equal(missingInstance.version, null); assert.equal(missingInstance.routerId, null)
assert.equal(parseCatalogInstances(bfdServer, [{ ...rawInstance, version: "3", "router-id": "main" }])[0].version, 3)
const rawRoute = { ".id": "*1", "dst-address": "192.0.2.0/24", ospf: "true", distance: "110" }
assert.equal(parseCatalogRoutes(bfdServer, [rawRoute])[0].cost, null)
assert.equal(parseCatalogRoutes(bfdServer, [{ ...rawRoute, "ospf-metric": "0" }])[0].cost, 0)
assert.equal(parseCatalogRoutes(bfdServer, [{ ...rawRoute, "ospf-metric": "42" }])[0].cost, 42)
assert.equal(parseCatalogRoutes(bfdServer, [{ ...rawRoute, "ospf-metric": "42x" }])[0].cost, null)
console.log("OSPF strict interfaces/instances/routes: absent/zero/fractional/flags, no distance-as-cost passed")
+19 -2
View File
@@ -11,7 +11,7 @@ import type {
} from "../types/server.js"
import { parseOspfGateway, parseOspfRouteType } from "../services/ospf-route-parse.js"
import { z } from "zod"
import { observedInteger, observedDurationMs } from "../services/ospf-observed-values.js"
import { observedInteger, observedDurationMs, observedBoolean } from "../services/ospf-observed-values.js"
import { pollOspfCatalog, readOspfCatalogRaw, type OspfCatalogCollections } from "../services/ospf-catalog-poll.js"
type ServerRow = typeof servers.$inferSelect
@@ -195,6 +195,23 @@ function parseInterfaces(
})
}
export function parseCatalogInterfaces(server: ServerRow, raw: RosOspfInterfaceTemplate[], areas: RosOspfArea[], instances: RosOspfInstance[], areaMap: Map<string, string>) {
return parseInterfaces(server, raw, areas, instances, areaMap).map((row, index) => {
const source = raw[index]
const helloMs = observedDurationMs(source["hello-interval"]), deadMs = observedDurationMs(source["dead-interval"])
return { ...row, cost: observedInteger(source.cost, 0, 65535), priority: observedInteger(source.priority, 0, 255), type: source.type?.trim() || null, disabled: observedBoolean(source.disabled), inactive: observedBoolean(source.inactive), useBfd: observedBoolean(source["use-bfd"]), helloInterval: helloMs === null ? null : helloMs / 1000, deadInterval: deadMs === null ? null : deadMs / 1000 }
})
}
export function parseCatalogInstances(server: ServerRow, raw: RosOspfInstance[]) {
return parseInstances(server, raw).map((row, index) => ({ ...row, version: ["2", "3"].includes(raw[index].version) ? Number(raw[index].version) : null, routerId: raw[index]["router-id"]?.trim() || null, disabled: observedBoolean(raw[index].disabled), inactive: observedBoolean(raw[index].inactive) }))
}
export function parseCatalogRoutes(server: ServerRow, raw: RosIpRoute[]) {
return raw.flatMap((source, index) => {
const rows = parseOspfRoutes(server, [{ ...source, ".id": source[".id"] ?? String(index) }])
return rows.map(row => ({ ...row, cost: observedInteger(source["ospf-metric"], 0, 4294967295) }))
})
}
function parseInstances(
server: ServerRow,
instances: RosOspfInstance[],
@@ -565,7 +582,7 @@ const catalogDefaults: OspfCatalogDependencies = {
readCatalog: async server => {
const { neighbors, areas, ifaceTemplates, instances, bfdSessions, ipRoutes } = await readOspfCatalogRaw(MikrotikClient.fromServer(server))
const areaMap = buildAreaMap(areas)
return { neighbors: parseCatalogNeighbors(server, neighbors, areaMap), interfaces: parseInterfaces(server, ifaceTemplates, areas, instances, areaMap), instances: parseInstances(server, instances), bfdSessions: parseBfdCatalogSessions(server, bfdSessions), routes: parseOspfRoutes(server, ipRoutes) }
return { neighbors: parseCatalogNeighbors(server, neighbors, areaMap), interfaces: parseCatalogInterfaces(server, ifaceTemplates, areas, instances, areaMap), instances: parseCatalogInstances(server, instances), bfdSessions: parseBfdCatalogSessions(server, bfdSessions), routes: parseCatalogRoutes(server, ipRoutes) }
},
}
export function createOspfRoutes(dependencies: OspfCatalogDependencies = catalogDefaults): FastifyPluginAsyncZod {
+5 -2
View File
@@ -3,9 +3,12 @@ import type { OspfNeighborRead, OspfInterfaceRead, OspfInstanceRead, BfdSessionR
export type NullableFields<T, K extends keyof T> = Omit<T, K> & { [P in K]: T[P] | null }
export type CatalogNeighbor = NullableFields<OspfNeighborRead, "priority" | "stateChanges">
export type CatalogBfdSession = NullableFields<BfdSessionRead, "multiplier" | "txInterval" | "rxInterval" | "holdTime" | "packetsRx" | "packetsTx" | "stateChanges">
export type CatalogInterface = NullableFields<OspfInterfaceRead, "cost" | "type" | "priority" | "helloInterval" | "deadInterval" | "disabled" | "inactive" | "useBfd">
export type CatalogInstance = NullableFields<OspfInstanceRead, "version" | "routerId" | "disabled" | "inactive">
export type CatalogRoute = NullableFields<OspfRouteRead, "cost">
export interface OspfCatalogCollections {
neighbors: CatalogNeighbor[]; interfaces: OspfInterfaceRead[]; instances: OspfInstanceRead[];
bfdSessions: CatalogBfdSession[]; routes: OspfRouteRead[];
neighbors: CatalogNeighbor[]; interfaces: CatalogInterface[]; instances: CatalogInstance[];
bfdSessions: CatalogBfdSession[]; routes: CatalogRoute[];
}
export const emptyOspfCatalog = (): OspfCatalogCollections => ({ neighbors: [], interfaces: [], instances: [], bfdSessions: [], routes: [] })
export async function readOspfCatalogRaw(client: Pick<MikrotikClient, "getOspfNeighbors" | "getOspfAreas" | "getOspfInterfaceTemplates" | "getOspfInstances" | "getBfdSessions" | "getIpRoutes">) {
@@ -1,7 +1,11 @@
import assert from "node:assert/strict"
import { observedInteger, observedDurationMs } from "./ospf-observed-values.js"
import { observedInteger, observedDurationMs, observedBoolean } from "./ospf-observed-values.js"
for (const raw of [undefined, "", " ", "1x", "-1", "1.5", "9007199254740992"]) assert.equal(observedInteger(raw), null)
assert.equal(observedInteger("0"), 0); assert.equal(observedInteger("255", 0, 255), 255); assert.equal(observedInteger("256", 0, 255), null)
for (const raw of [undefined, "", "-1s", "12junk", "200", "1s2s", "60:00", "00:99:00", "1s junk"]) assert.equal(observedDurationMs(raw), null)
for (const [raw, ms] of [["0ms", 0], ["0", 0], ["1us", 0.001], ["1s200ms", 1200], ["1h2m3s", 3723000], ["01:02:03.5", 3723500], ["02:03", 123000]] as const) assert.equal(observedDurationMs(raw), ms)
console.log("OSPF observed values: absent/zero/invalid/unsafe/units/clock passed")
for (const raw of [undefined, "", "junk"]) assert.equal(observedBoolean(raw), null)
for (const raw of ["true", "yes"]) assert.equal(observedBoolean(raw), true)
for (const raw of ["false", "no"]) assert.equal(observedBoolean(raw), false)
@@ -26,3 +26,9 @@ export function observedDurationMs(value: string | undefined): number | null {
}
return cursor === text.length && Number.isFinite(total) && total <= Number.MAX_SAFE_INTEGER ? total : null
}
export function observedBoolean(value: string | undefined): boolean | null {
if (value === "true" || value === "yes") return true
if (value === "false" || value === "no") return false
return null
}
@@ -23,7 +23,7 @@ export interface OspfRouteRow {
dataUnknown?: boolean
destination: string
type: "O" | "O IA" | "O E1" | "O E2"
cost: number
cost: number | null
nextHop: string
via: string
serverId: string
@@ -79,7 +79,7 @@ function OspfRoutesDataGrid({ routes }: OspfRoutesDataGridProps) {
accessorKey: "cost",
header: ({ column }) => <DataGridSortHeader column={column} title="Cost" />,
cell: ({ row }) => (
<span className="font-mono tabular-nums text-center block">{row.original.cost}</span>
<span className="font-mono tabular-nums text-center block">{row.original.cost ?? "—"}</span>
),
meta: {
headerTitle: "Cost",
+8 -1
View File
@@ -26,11 +26,18 @@
| 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.18, nullable BFD/priority соседей; далее интерфейсы/маршруты/URL/layout |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.19, nullable интерфейсы/метрики OSPF; далее URL/layout |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.19 — интерфейсы и метрики строгого OSPF-каталога (2026-09-30)
- Interface cost/priority/timers/type/disabled/inactive/useBfd и instance version/routerId/flags допускают null без legacy defaults. Оптимизация запрещена при неизвестных costs/состояниях выбранного устройства. DTO frontend выводятся из Zod, вместо дублирующих интерфейсов.
- Route cost читается только из ospf-metric; distance больше не выдаётся за cost. Наблюдаемые нули остаются нулями. По документации RouterOS interface cost допускает 0..65535: исправлена граница индекса соседей из 10.16. Чтение cost=0 корректно; существующая политика записи min=1 сохраняется, такая операция не предлагается.
- Проверки: 21 E2E (interface-values/values/states/operations/routing-tables), четыре cost/state unit, test:live-maps и обе production-сборки прошли. TypeScript чистый, lint без ошибок (три старых warning). Правила сетевых операций/legacy endpoints не изменены.
- Следующий объём: URL устройства/вкладки/поиска с back/reload и late-write, desktop/mobile приёмка. При аудите дополнительных метаданных не считать multihop=false/тип O маршрута доказанными только по прежним parser defaults. Использовано 6% окна; локальный коммит без push.
## Блок 10.18 — неизвестные показатели BFD и приоритет соседей (2026-09-30)
- Строгие DTO каталога отделены от legacy: BFD multiplier/tx/rx/hold/counters и neighbor priority/stateChanges допускают null. Отсутствующие/некорректные значения не получают defaults; priority=0 и наблюдаемые нули сохраняются. Actual TX не подменяется desired TX; единицы us/ms/s и составные/clock durations проверяются без частичного parseInt.
+4
View File
@@ -124,3 +124,7 @@ Lock/readiness, существующее mm:servers:write, валидация п
## 10.18. Nullable BFD и соседство
Строгий каталог сохраняет null/0 отдельно для BFD timers/counters/multiplier и priority/stateChanges соседа. Actual TX не подменяется desired TX; units/clock duration проверяются строго. 15 HTTP E2E, raw backend fixtures, TS/backend build прошли. Далее interface/instance/route nullable contract и readiness. [RouterOS BFD](https://help.mikrotik.com/docs/spaces/ROS/pages/191299691/BFD) и [OSPF](https://help.mikrotik.com/docs/spaces/ROS/pages/331612216/routing%2Bospf) — источники семантики.
## 10.19. Nullable интерфейсы и метрики
Строгий catalog DTO сохраняет неизвестные поля interface/instance; route metric не подменяется distance. Наблюдаемый interface cost=0 сохранён согласно [RouterOS OSPF](https://help.mikrotik.com/docs/spaces/ROS/pages/331612216/routing%2Bospf); политика записи min=1 прежняя. При unknown costs/flags операция не предлагается. 21 E2E, 4 unit, backend regression и обе production-сборки прошли. Далее URL/layout и дополнительная проверка метаданных.
+2 -1
View File
@@ -12,6 +12,7 @@ test("neighbor cost is scoped to device, interface and area", () => {
test("conflicting or invalid template costs do not choose a fabricated value", () => {
const key = ospfCostKey(1,"ether1","0.0.0.0")
assert.equal(buildOspfCostIndex([iface, iface]).get(key), 7)
assert.equal(buildOspfCostIndex([{ ...iface, cost: 0 }]).get(key), 0)
assert.equal(buildOspfCostIndex([iface, { ...iface, cost: 42 }, iface]).get(key), null)
for (const cost of [0, -1, 65536, 1.5]) assert.equal(buildOspfCostIndex([{ ...iface, cost }]).get(key), null)
for (const cost of [null, -1, 65536, 1.5]) assert.equal(buildOspfCostIndex([{ ...iface, cost }]).get(key), null)
})
+2 -2
View File
@@ -1,10 +1,10 @@
type InterfaceCost = { serverId: number; interface: string; areaId: string; cost: number }
type InterfaceCost = { serverId: number; interface: string; areaId: string; cost: number | null }
export const ospfCostKey = (serverId: number, iface: string, areaId: string) => JSON.stringify([serverId, iface, areaId])
export function buildOspfCostIndex(interfaces: InterfaceCost[]): Map<string, number | null> {
const result = new Map<string, number | null>()
for (const iface of interfaces) {
const key = ospfCostKey(iface.serverId, iface.interface, iface.areaId)
const cost = Number.isInteger(iface.cost) && iface.cost >= 1 && iface.cost <= 65535 ? iface.cost : null
const cost = iface.cost !== null && Number.isInteger(iface.cost) && iface.cost >= 0 && iface.cost <= 65535 ? iface.cost : null
result.set(key, result.has(key) && result.get(key) !== cost ? null : cost)
}
return result
+3 -3
View File
@@ -4,10 +4,10 @@ const counter = z.number().int().nonnegative()
const base = z.object({ id: z.string().trim().min(1), serverId: z.number().int().positive(), serverName: z.string(), serverSite: z.string(), serverCountry: z.string().default("UN") })
export const ospfCatalogSchema = z.object({
neighbors: z.array(base.extend({ address: z.string(), routerId: z.string(), instance: z.string(), area: z.string(), areaId: z.string(), interface: z.string(), state: z.string(), uptime: z.string().nullable(), stateChanges: counter.nullable(), priority: counter.max(255).nullable() })),
interfaces: z.array(base.extend({ instance: z.string(), area: z.string(), areaId: z.string(), interface: z.string(), cost: counter, type: z.string(), disabled: z.boolean(), inactive: z.boolean(), priority: counter, helloInterval: counter, deadInterval: counter, useBfd: z.boolean() })),
instances: z.array(base.extend({ name: z.string(), routerId: z.string(), version: counter, disabled: z.boolean(), inactive: z.boolean(), redistribute: z.string() })),
interfaces: z.array(base.extend({ instance: z.string(), area: z.string(), areaId: z.string(), interface: z.string(), cost: counter.max(65535).nullable(), type: z.string().nullable(), disabled: z.boolean().nullable(), inactive: z.boolean().nullable(), priority: counter.max(255).nullable(), helloInterval: z.number().finite().nonnegative().nullable(), deadInterval: z.number().finite().nonnegative().nullable(), useBfd: z.boolean().nullable() })),
instances: z.array(base.extend({ name: z.string(), routerId: z.string().nullable(), version: z.union([z.literal(2), z.literal(3)]).nullable(), disabled: z.boolean().nullable(), inactive: z.boolean().nullable(), redistribute: z.string() })),
bfdSessions: z.array(base.extend({ localAddr: z.string(), remoteAddr: z.string(), interface: z.string(), state: z.string(), uptime: z.string().nullable(), multihop: z.boolean(), multiplier: counter.max(255).nullable(), txInterval: z.number().finite().nonnegative().nullable(), rxInterval: z.number().finite().nonnegative().nullable(), holdTime: z.number().finite().nonnegative().nullable(), packetsRx: counter.nullable(), packetsTx: counter.nullable(), stateChanges: counter.nullable() })),
routes: z.array(base.extend({ destination: z.string(), type: z.enum(["O", "O IA", "O E1", "O E2"]), cost: counter, nextHop: z.string(), via: z.string(), area: z.string() })),
routes: z.array(base.extend({ destination: z.string(), type: z.enum(["O", "O IA", "O E1", "O E2"]), cost: counter.max(4294967295).nullable(), nextHop: z.string(), via: z.string(), area: z.string() })),
failures: z.array(z.object({ serverId: z.number().int().positive(), serverName: z.string(), error: z.string() })),
}).refine(data => {
const failed = new Set(data.failures.map(f => f.serverId))
+26
View File
@@ -0,0 +1,26 @@
import { test, expect } from "@playwright/test"
for (const missing of ["cost", "disabled", "inactive"]) test("OSPF: запись недоступна при неизвестном " + missing, async ({ page }) => {
const host={ serverId:1, serverName:"Роутер", serverSite:"", serverCountry:"RU" }
const iface={ ...host, id:"*1", instance:"default", area:"backbone", areaId:"0.0.0.0", interface:"ether1", cost:20, type:null, disabled:false, inactive:false, priority:null, helloInterval:null, deadInterval:null, useBfd:null }
let phase=0, writes=0
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;if(r.request().method()==="POST")writes++;return r.fulfill({json:path==="/api/servers"?[{id:1,name:"Роутер",host:"192.0.2.1",enabled:true,country:"RU",status:null}]:path==="/api/ospf/catalog"?{neighbors:[],interfaces:[{...iface,...(phase===0?{[missing]:null}:phase===2?{cost:0}:{})}],instances:[],bfdSessions:[],routes:[{...host,id:"r",destination:"192.0.2.0/24",type:"O",cost:phase===0?null:0,nextHop:"192.0.2.2",via:"ether1",area:"backbone"}],failures:[]}:path==="/api/auth/config"?{required:false}:{}})})
await page.goto("/ospf")
await page.getByRole("option").filter({hasText:"Роутер"}).click()
const optimize=page.getByRole("button",{name:"Оптимизировать OSPF",exact:true})
await expect(optimize).toBeDisabled()
await expect(page.getByRole("status").filter({hasText:"Оптимизация недоступна"})).toBeVisible()
if(missing==="cost")await expect(page.getByText("cur —",{exact:true})).toBeVisible()
await page.getByRole("button",{name:"Маршруты",exact:true}).click()
await expect(page.locator("tr[data-row-id]").locator("td").nth(2)).toHaveText("—")
phase=1
await page.getByRole("button",{name:"Обновить",exact:true}).click()
await expect(page.locator("tr[data-row-id]").locator("td").nth(2)).toHaveText("0")
await page.getByRole("button",{name:"Интерфейсы",exact:true}).click()
await expect(optimize).toBeEnabled()
phase=2
await page.getByRole("button",{name:"Обновить",exact:true}).click()
await expect(page.getByText("cur 0",{exact:true})).toBeVisible()
await expect(optimize).toBeDisabled()
expect(writes).toBe(0)
})