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

This commit is contained in:
Denozordec committed 2026-09-30 17:50:47 +07:00
1 parent baa43b7ad4
commit c18c148a23
5 files changed
+65 -4

No files matched your search

+14 -2
View File
@@ -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<OspfItem[]>(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<Date | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [fetchTick, setFetchTick] = useState(0)
const [readReceipts, setReadReceipts] = useState<Record<string, string>>({})
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() {
<RefreshCwIcon className="size-3.5 animate-spin" />Загрузка OSPF данных…
</div>
)}
{isLive && fetchedAt && <p role="group" aria-label="Свежесть OSPF" className="text-xs text-muted-foreground">Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>. Время получения браузером, не измерения RouterOS.</p>}
{isLive && fetchedAt && <div role="group" aria-label="Свежесть OSPF" className="text-xs text-muted-foreground">
<p>Ответ каталога получен <time dateTime={fetchedAt.toISOString()}>{fetchedAt.toLocaleTimeString("ru")}</time>. Время получения браузером, не измерения RouterOS.</p>
{liveServers.filter(s => filterServerId === ALL_SERVERS_ID || String(s.id) === filterServerId).map(s => <p key={s.id}>{s.name}: {readReceipts[s.id] ? <time data-ospf-server={s.id} dateTime={readReceipts[s.id]}>{new Date(readReceipts[s.id]).toLocaleTimeString("ru")}</time> : "успешный опрос не получен"}{unknownIds.has(s.id) ? " · данные устарели" : ""}</p>)}
{writeReceipt && (filterServerId === ALL_SERVERS_ID || String(writeReceipt.serverId) === filterServerId) && <p data-ospf-time="write">API подтвердил оптимизацию устройства {writeReceipt.serverId}: <time dateTime={writeReceipt.at}>{new Date(writeReceipt.at).toLocaleTimeString("ru")}</time></p>}
</div>}
{isLive && (liveError || Boolean(liveData?.failures.length)) && <Alert variant="warning"><AlertCircleIcon /><AlertTitle>{liveError ? "Не удалось загрузить OSPF" : "Не удалось опросить часть роутеров OSPF"}</AlertTitle><AlertDescription>{liveError ?? liveData?.failures.map(f => f.serverName + ": " + f.error).join("; ")}. {liveData ? "Показаны последние полученные данные; состояния отказавших устройств неизвестны." : "Данные пока неизвестны."}<Button variant="outline" size="sm" disabled={loading || writeBusy} onClick={refresh} aria-label="Повторить загрузку OSPF">Повторить</Button></AlertDescription></Alert>}
{incomplete && (activeTab === "interfaces" ? displayItems.length === 0 : activeTab === "neighbors" ? displayNeighbors.length === 0 : activeTab === "bfd" ? displayBfdSessions.length === 0 : displayRoutes.length === 0) && <p role="status">Данные выбранного раздела OSPF пока неизвестны.</p>}
{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) }}
/>
)}
+7 -1
View File
@@ -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 не изменена.
+4
View File
@@ -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.
+36
View File
@@ -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!)
})
+4 -1
View File
@@ -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()