fix(ui): разделить время опроса и подтверждения OSPF
This commit is contained in:
1 parent
baa43b7ad4
commit
c18c148a23
5 files changed
+65
-4
No files matched your search
@@ -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) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 не изменена.
|
||||
|
||||
@@ -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.
|
||||
@@ -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!)
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user