diff --git a/app/(main)/ospf/page.tsx b/app/(main)/ospf/page.tsx index 6277fb77..39b92565 100644 --- a/app/(main)/ospf/page.tsx +++ b/app/(main)/ospf/page.tsx @@ -660,6 +660,7 @@ function InterfacesTab({ dataReady, dataIncomplete, onOperationBusy, + onOperationConfirmed, }: { items: OspfItem[] isLive: boolean @@ -668,6 +669,7 @@ function InterfacesTab({ onLiveDataRefresh: () => void dataReady: boolean dataIncomplete: boolean + onOperationConfirmed: (id: number) => void onOperationBusy: (busy: boolean) => void }) { const [items, setItems] = useState(initialItems) @@ -750,6 +752,7 @@ function InterfacesTab({ byKey[`${data.serverId}-${row.id}`] = row.optimalCost }) setLiveOptimalCost(byKey) + onOperationConfirmed(data.serverId) toast.success(`API подтвердил оптимизацию OSPF: ${data.optimizedCount} интерфейсов`) onLiveDataRefresh() } catch (err) { @@ -1151,6 +1154,8 @@ function OspfPageContent() { const [fetchedAt, setFetchedAt] = useState(null) const [liveError, setLiveError] = useState(null) const [fetchTick, setFetchTick] = useState(0) + const [readReceipts, setReadReceipts] = useState>({}) + const [writeReceipt, setWriteReceipt] = useState<{ serverId: number; at: string } | null>(null) const operationBusy = useRef(false) const [writeBusy, setWriteBusy] = useState(false) function refresh() { if (operationBusy.current) return; setLoading(true); setFetchTick(t => t + 1) } @@ -1180,7 +1185,9 @@ function OspfPageContent() { const failed = new Set(data.failures.map(f => f.serverId)) setLiveData(previous => ({ ...data, ...Object.fromEntries(sections.map(section => [section, [...data[section], ...(previous?.[section] ?? []).filter(row => enabled.has(row.serverId) && failed.has(row.serverId))]])) })) setLiveServers(inventory.filter(s => s.enabled)) - setLiveError(null); setFetchedAt(new Date()); setLoading(false) + const at = new Date() + setReadReceipts(previous => ({ ...Object.fromEntries(Object.entries(previous).filter(([id]) => enabled.has(Number(id)))), ...Object.fromEntries([...enabled].filter(id => !failed.has(id)).map(id => [String(id), at.toISOString()])) })) + setLiveError(null); setFetchedAt(at); setLoading(false) }) .catch((err: unknown) => { if (cancelled) return @@ -1346,7 +1353,11 @@ function OspfPageContent() { Загрузка OSPF данных… )} - {isLive && fetchedAt &&

Ответ каталога получен . Время получения браузером, не измерения RouterOS.

} + {isLive && fetchedAt &&
+

Ответ каталога получен . Время получения браузером, не измерения RouterOS.

+ {liveServers.filter(s => filterServerId === ALL_SERVERS_ID || String(s.id) === filterServerId).map(s =>

{s.name}: {readReceipts[s.id] ? : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}

)} + {writeReceipt && (filterServerId === ALL_SERVERS_ID || String(writeReceipt.serverId) === filterServerId) &&

API подтвердил оптимизацию устройства {writeReceipt.serverId}:

} +
} {isLive && (liveError || Boolean(liveData?.failures.length)) && {liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}} {incomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) &&

Данные выбранного раздела OSPF пока неизвестны.

} {mode === "mock" && ( @@ -1392,6 +1403,7 @@ function OspfPageContent() { onLiveDataRefresh={() => { setLoading(true); setFetchTick(t => t + 1) }} dataReady={dataReady} dataIncomplete={incomplete} + onOperationConfirmed={serverId => setWriteReceipt({ serverId, at: new Date().toISOString() })} onOperationBusy={busy => { operationBusy.current = busy; setWriteBusy(busy) }} /> )} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index ec6b8170..b4ffee30 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,17 @@ | 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.11, операции OSPF; далее URL/свежесть | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.12, свежесть OSPF; далее поля/URL/layout | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.12 — время чтения и подтверждения OSPF (2026-09-30) + +- Отметки получения каталога, успешного опроса устройства и подтверждения оптимизации API разделены. Partial сохраняет время отказавшего устройства, общий отказ не обновляет успешные отметки. Удалённые/выключенные устройства исключаются из receipts; источник сбрасывает прежние времена. +- Подтверждение записи появляется только после проверки ответа правильного устройства; последующая ошибка чтения не выдаётся за отказ записи и не стирает её receipt. Времена обозначены как получение браузером, не измерение RouterOS. +- Семь E2E прошли: clocks/partial/global/recovery плюс шесть операций/read-права/receipt после ошибки poll. TypeScript чистый, lint — два старых warning. Далее — неизвестные состояния/показатели OSPF, URL и визуальная приёмка. Квота: 90% окна использовано; локальный коммит без push. + ## Блок 10.11 — защита оптимизации OSPF (2026-09-30) - Синхронный lock исключает повторный POST. Во время операции заблокированы Refresh, смена устройства и вкладки; после API-ответа повторное чтение блокирует новую запись до подтверждённого каталога. Право UI — mm:servers:write, соответствующее существующему backend-маршруту /servers/:id/ospf/optimize; политика backend не изменена. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index c0fc05bb..a6c0da75 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -84,3 +84,7 @@ server/session/tab и возврат/reload сохраняют контекст; ## Блок 10.11 — OSPF операции Lock/readiness, существующее mm:servers:write, валидация подтверждения и постоянная ошибка с повторным чтением. Поздний read/write не действует в другом источнике. 21 OSPF E2E/TypeScript/lint прошли (два старых warning). Далее — URL/времена/поля/layout. + +## Блок 10.12 — OSPF времена + +Время успешного опроса устройства сохраняется при отказах; подтверждение оптимизации API имеет отдельную отметку. Семь E2E/TypeScript/lint прошли. Далее — поля/URL/layout. diff --git a/tests/e2e/ospf-freshness.spec.ts b/tests/e2e/ospf-freshness.spec.ts new file mode 100644 index 00000000..039d8c60 --- /dev/null +++ b/tests/e2e/ospf-freshness.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from "@playwright/test" +const servers = [1, 2].map(id => ({ id, name: "Роутер " + id, host: "192.0.2." + id, country: "RU", enabled: true, status: null })) +const host = (serverId: number) => ({ serverId, serverName: "Роутер " + serverId, serverSite: "", serverCountry: "RU" }) +const neighbor = (serverId: number, address = "192.0.2." + serverId) => ({ ...host(serverId), id: "*1", address, routerId: address, instance: "default", area: "backbone", areaId: "0.0.0.0", interface: "ether1", state: "Full", uptime: "1h", stateChanges: 0, priority: 1 }) +const all = { neighbors: [neighbor(1), neighbor(2)], interfaces: [], instances: [], bfdSessions: [], routes: [], failures: [] } + +test("OSPF: время успешного опроса устройства сохраняется при отказе", async ({ page }) => { + await page.clock.install({ time: new Date("2026-09-30T05:00:00Z") }) + let phase = 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 (path === "/api/ospf/catalog" && phase === 2) return r.fulfill({ status: 503, json: { error: "Общий отказ OSPF" } }) + return r.fulfill({ json: path === "/api/servers" ? [...servers] : path === "/api/ospf/catalog" ? { ...all, neighbors: phase === 1 ? [neighbor(1)] : [neighbor(1), neighbor(2)], failures: phase === 1 ? [{ serverId: 2, serverName: "Роутер 2", error: "Таймаут тест" }] : [] } : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/ospf") + const group = page.getByRole("group", { name: "Свежесть OSPF" }) + const first = await group.locator('[data-ospf-server="2"]').getAttribute("datetime") + phase = 1 + await page.clock.fastForward(60_000) + await page.getByRole("button", { name: "Обновить", exact: true }).click() + await expect(page.getByRole("alert").filter({ hasText: "Таймаут тест" })).toBeVisible() + await expect(group.locator('[data-ospf-server="2"]')).toHaveAttribute("datetime", first!) + await expect(group.locator('[data-ospf-server="1"]')).not.toHaveAttribute("datetime", first!) + const after = await group.locator('[data-ospf-server="1"]').getAttribute("datetime") + phase = 2 + await page.clock.fastForward(60_000) + await page.getByRole("button", { name: "Повторить загрузку OSPF" }).click() + await expect(page.getByRole("alert").filter({ hasText: "Общий отказ OSPF" })).toBeVisible() + await expect(group.locator('[data-ospf-server="1"]')).toHaveAttribute("datetime", after!) + await expect(group.locator('[data-ospf-server="2"]')).toHaveAttribute("datetime", first!) + phase = 3 + await page.getByRole("button", { name: "Повторить загрузку OSPF" }).click() + await expect(page.getByRole("alert").filter({ hasText: "Не удалось" })).toHaveCount(0) + await expect(group.locator('[data-ospf-server="2"]')).not.toHaveAttribute("datetime", first!) +}) diff --git a/tests/e2e/ospf-operations.spec.ts b/tests/e2e/ospf-operations.spec.ts index 2dd07507..50f674b2 100644 --- a/tests/e2e/ospf-operations.spec.ts +++ b/tests/e2e/ospf-operations.spec.ts @@ -43,7 +43,10 @@ for (const outcome of ["success", "http", "foreign", "malformed", "poll-failure" if (["http", "foreign", "malformed"].includes(outcome)) { await expect(page.getByRole("alert").filter({ hasText: "Оптимизация OSPF не подтверждена" })).toBeVisible() await expect(page.getByText("API подтвердил оптимизацию OSPF", { exact: false })).toHaveCount(0) - } else await expect(page.getByText("API подтвердил оптимизацию OSPF: 1 интерфейсов", { exact: true })).toBeVisible() + } else { + await expect(page.getByText("API подтвердил оптимизацию OSPF: 1 интерфейсов", { exact: true })).toBeVisible() + await expect(page.getByRole("group", { name: "Свежесть OSPF" }).locator('[data-ospf-time="write"]')).toContainText("API подтвердил оптимизацию устройства 1") + } if (outcome === "poll-failure") { await expect(page.getByRole("alert").filter({ hasText: "Опрос после записи недоступен" })).toBeVisible() await expect(optimize).toBeDisabled()