fix(ui): сериализовать ручной опрос и ограничить ожидание Speed-test
This commit is contained in:
1 parent
cc40a5e33d
commit
f7e0e20673
4 files changed
+121
-29
No files matched your search
+37
-27
@@ -1128,8 +1128,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
/** Сбрасывает ответы устаревших GET /overview (гонка: ответ приходит после клика по ★ и затирает showOnDashboard). */
|
||||
const overviewReqRef = useRef(0)
|
||||
|
||||
const loadLiveOverview = useCallback(async () => {
|
||||
if (!liveApi || savingRef.current) return
|
||||
const loadLiveOverview = useCallback(async (writeContext?: number) => {
|
||||
if (!liveApi || (savingRef.current && writeContext !== saveContext.current)) return
|
||||
const context = saveContext.current
|
||||
const myReq = ++overviewReqRef.current
|
||||
setOpError(null)
|
||||
try {
|
||||
@@ -1137,20 +1138,20 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
apiFetch<BackendServer[]>("/api/servers"),
|
||||
apiFetch<{ probes: PingProbe[]; resources: ServerResource[] }>("/api/uptime/overview?range=1h"),
|
||||
])
|
||||
if (myReq !== overviewReqRef.current || savingRef.current) return
|
||||
if (myReq !== overviewReqRef.current || context !== saveContext.current || (savingRef.current && writeContext !== context)) return
|
||||
setAllServers(mapBackendServersToServers(serverRows))
|
||||
setProbes(data.probes)
|
||||
setResources(data.resources)
|
||||
setOverviewState("ready")
|
||||
} catch (e) {
|
||||
if (myReq !== overviewReqRef.current || savingRef.current) return
|
||||
if (myReq !== overviewReqRef.current || context !== saveContext.current || (savingRef.current && writeContext !== context)) return
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось загрузить uptime")
|
||||
setOverviewState("error")
|
||||
}
|
||||
}, [apiFetch, liveApi])
|
||||
|
||||
const reloadSpeedData = useCallback(async () => {
|
||||
if (!liveApi || savingRef.current) return
|
||||
const reloadSpeedData = useCallback(async (writeContext?: number) => {
|
||||
if (!liveApi || (savingRef.current && writeContext !== saveContext.current)) return
|
||||
const context = saveContext.current
|
||||
type RunRow = {
|
||||
id: string
|
||||
@@ -1177,7 +1178,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes"),
|
||||
apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs"),
|
||||
])
|
||||
if (context !== saveContext.current || savingRef.current) return
|
||||
if (context !== saveContext.current || (savingRef.current && writeContext !== context)) return
|
||||
setSpeedProbes(sp.probes ?? [])
|
||||
const rows = (runsRes.runs ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
@@ -1202,31 +1203,37 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}))
|
||||
setSpeedRuns(rows)
|
||||
} catch {
|
||||
if (context !== saveContext.current || savingRef.current) return
|
||||
if (context !== saveContext.current || (savingRef.current && writeContext !== context)) return
|
||||
setSpeedProbes([])
|
||||
setSpeedRuns([])
|
||||
}
|
||||
}, [apiFetch, liveApi])
|
||||
|
||||
const refreshUptimeLive = useCallback(async (opts?: { showSpinner?: boolean; pollDevices?: boolean }) => {
|
||||
if (!liveApi) return
|
||||
if (opts?.pollDevices && !requireWrite()) return
|
||||
if (!liveApi || savingRef.current) return
|
||||
const collect = opts?.pollDevices === true
|
||||
if (collect && !requireWrite()) return
|
||||
if (collect) {
|
||||
savingRef.current = true; setProbeSaving(true)
|
||||
overviewReqRef.current++; saveContext.current++
|
||||
}
|
||||
const context = saveContext.current
|
||||
if (opts?.showSpinner) setUptimeRefreshBusy(true)
|
||||
let collectErr: string | null = null
|
||||
try {
|
||||
void checkBackend()
|
||||
if (opts?.pollDevices) {
|
||||
if (collect) {
|
||||
try {
|
||||
await apiFetch<{ ok: boolean; lastError?: string | null }>("/api/uptime/collect-now", { method: "POST" })
|
||||
} catch (e) {
|
||||
collectErr = e instanceof Error ? e.message : "Не удалось опросить устройства (collect-now)"
|
||||
collectErr = serverOperationError(e)
|
||||
}
|
||||
}
|
||||
await Promise.all([loadLiveOverview(), reloadSpeedData()])
|
||||
if (collectErr) {
|
||||
setOpError((prev) => (prev ? `${prev} · ${collectErr}` : collectErr))
|
||||
}
|
||||
if (context !== saveContext.current) return
|
||||
await Promise.all([loadLiveOverview(collect ? context : undefined), reloadSpeedData(collect ? context : undefined)])
|
||||
if (context === saveContext.current && collectErr) setOpError(collectErr)
|
||||
} finally {
|
||||
if (collect) { savingRef.current = false; setProbeSaving(false) }
|
||||
if (opts?.showSpinner) setUptimeRefreshBusy(false)
|
||||
}
|
||||
}, [liveApi, loadLiveOverview, reloadSpeedData, checkBackend, apiFetch, requireWrite])
|
||||
@@ -1234,23 +1241,24 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
const refreshProbeGroupPings = useCallback(
|
||||
async (srvId: string, group: { name: string; target: string; probes: PingProbe[] }) => {
|
||||
if (!liveApi || !requireWrite()) return
|
||||
savingRef.current = true; setProbeSaving(true)
|
||||
overviewReqRef.current++
|
||||
const context = ++saveContext.current
|
||||
const key = probeGroupActionKey(srvId, group)
|
||||
setProbeGroupPingBusy((m) => ({ ...m, [key]: true }))
|
||||
setProbeGroupPingBusy(m => ({ ...m, [key]: true }))
|
||||
setOpError(null)
|
||||
try {
|
||||
await apiFetch<{ ok: boolean; polled: number }>("/api/uptime/probes/collect-group", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ probeIds: group.probes.map((p) => p.id) }),
|
||||
body: JSON.stringify({ probeIds: group.probes.map(p => p.id) }),
|
||||
})
|
||||
await loadLiveOverview()
|
||||
if (context !== saveContext.current) return
|
||||
await loadLiveOverview(context)
|
||||
} catch (e) {
|
||||
setOpError(e instanceof Error ? e.message : "Не удалось выполнить ping группы")
|
||||
if (context === saveContext.current) setOpError(serverOperationError(e))
|
||||
} finally {
|
||||
setProbeGroupPingBusy((m) => {
|
||||
const next = { ...m }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
savingRef.current = false; setProbeSaving(false)
|
||||
setProbeGroupPingBusy(m => { const next = { ...m }; delete next[key]; return next })
|
||||
}
|
||||
},
|
||||
[liveApi, apiFetch, loadLiveOverview, requireWrite],
|
||||
@@ -1773,6 +1781,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
}>("/api/uptime/speed-test", {
|
||||
method: "POST",
|
||||
// Includes interface lookups, bandwidth-test, post-test pause and Ping.
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (context !== saveContext.current) return
|
||||
@@ -1850,7 +1860,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
}
|
||||
} catch (e) {
|
||||
if (context !== saveContext.current) return
|
||||
const message = e instanceof Error ? e.message : "Не удалось выполнить speed test"
|
||||
const message = serverOperationError(e)
|
||||
setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({
|
||||
...r,
|
||||
status: "error",
|
||||
@@ -1983,7 +1993,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
: <><PauseIcon className="size-4" />Пауза</>}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" disabled={liveApi && uptimeRefreshBusy} onClick={() => {
|
||||
<Button variant="outline" size="sm" disabled={probeSaving || (liveApi && uptimeRefreshBusy)} onClick={() => {
|
||||
if (liveApi) {
|
||||
void refreshUptimeLive({ showSpinner: true, pollDevices: can("mm:uptime:write") && selectedProbeId === null && (tab === "resources" || tab === "probes") })
|
||||
return
|
||||
|
||||
+10
-1
@@ -21,7 +21,7 @@
|
||||
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже |
|
||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; переключатели подтверждаются API; удаление и звезда подтверждаются API; Speed-test блокирует запись; далее блокировка Ping |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; переключатели подтверждаются API; удаление и звезда подтверждаются API; Speed-test блокирует запись; Ping сериализован с записью; далее URL мониторинга |
|
||||
| 8 | Трафик и статистика | Не начат |
|
||||
| 9 | VPN, Firewall и Filters | Не начат |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
@@ -29,6 +29,15 @@
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 7.4Б3б — ручной Ping и таймаут Speed-test (2026-09-23)
|
||||
|
||||
- collect-now и collect-group занимают общий savingRef до POST и удерживают его до обновления данных. Двойной запуск, CRUD и Speed-test не пересекаются в одном смонтированном экране. GET, запрошенный владельцем операции для обновления результатов, разрешён под блокировкой; фоновые GET пропускаются. saveContext отсекает ответы предыдущей операции/контекста, включая ошибки.
|
||||
- Speed-test: клиентский timeout 120 секунд; ошибка ожидания использует serverOperationError и сообщает неизвестный результат. Проверена цепочка backend: два возможных GET адресов по 10 секунд, bandwidth-test 30 секунд, пауза 3 секунды и Ping 20 секунд, затем БД. 120 секунд — предел ожидания UI с запасом, а не гарантия завершения backend. Отмена fetch не останавливает RouterOS.
|
||||
- Backend bandwidth-test по-прежнему имеет фиксированные 30 секунд при durationSec без верхней границы. Этот существующий конфликт для длительных измерений требует отдельного согласования сетевой операции; здесь backend-политика не менялась. Для общего/группового Ping отдельный клиентский timeout не вводился: длительность зависит от числа проб. Блокировки разных вкладок браузера/пользователей не обеспечиваются клиентским gate.
|
||||
- Проверки: 35 E2E uptime-links прошли, затем 3 проверки Speed/поздней ошибки (2 новые) прошли; всего 37 уникальных сценариев. Проверены 200/403, повторное нажатие, блокировка CRUD/Speed, обновление данных под gate, поздняя ошибка после смены URL и timeout Speed. TypeScript прошёл; lint uptime — прежние 1 ошибка/6 предупреждений, тесты чистые. ReUI checklist: установленные компоненты/композиция не менялись. Финальный production build прошёл: 34 страницы.
|
||||
- Следующий шаг: полный URL-контекст мониторинга (вкладка, поиск, статус, серверы), затем приёмка 7.5. В приёмке отдельно проверить смену backend/mode во время операции и задержку чтения после POST, успешный реальный Speed и его временные ограничения. Реальные JWT/RouterOS не проверялись.
|
||||
- Квота после проверок: 23% пятичасовой / 4% недельной израсходовано. Локальный коммит без push; сторонние изменения сохранены.
|
||||
|
||||
## Блок 7.4Б3а — блокировка записи при Speed-test (2026-09-21)
|
||||
|
||||
- runSpeedTest занимает общий savingRef синхронно перед запросом: повторный запуск и CRUD проб блокируются, фоновые GET не перезаписывают измерение. В finally блокировка снимается при успехе/ошибке. Добавлена проверка saveContext перед применением позднего результата/ошибки после смены backend/mode или размонтирования.
|
||||
|
||||
@@ -61,7 +61,7 @@ URL — источник сохранённого состояния. Back/Forwa
|
||||
3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие.
|
||||
4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении.
|
||||
|
||||
Следующий исполняемый шаг: блок 7.4Б3б — общая блокировка collect-now/collect-group, повторные запуски/поздние ответы и timeout Speed-test. Speed-test (7.4Б3а) уже занимает общий gate и блокирует CRUD до ответа. Переключатели, удаление с подтверждением и звезда (7.4Б1–Б2) уже применяются после API через общий gate. Ограничения прав и сохранение обеих форм после API выполнены; ошибки 400/403/500 и timeout сохраняют черновик. CRUD строк подтверждается API; запуск измерений требует отдельной защиты от конкуренции. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
|
||||
Следующий исполняемый шаг: полный URL-контекст мониторинга (вкладка, поиск, статус, серверы). Блок 7.4Б3б завершён: ручные Ping занимают общий gate с CRUD/Speed, Speed-test ограничен 120 секундами ожидания UI. Затем приёмка 7.5; реальные RouterOS/JWT, длительный Speed и смена backend/mode требуют отдельной проверки. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
|
||||
|
||||
## Решение по синхронизации URL (7.1)
|
||||
|
||||
|
||||
@@ -400,3 +400,76 @@ test("Speed-test: один запуск, запись проб заблокир
|
||||
await expect(run).toBeEnabled()
|
||||
await expect(page.getByText("Измерение отклонено", { exact: true }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
for (const group of [false, true]) {
|
||||
for (const status of [200, 403]) {
|
||||
test(`ручной Ping ${group ? "группы" : "всех"}: один POST, блокировка записи, ответ ${status}`, async ({ page }) => {
|
||||
await setup(page, group ? "/uptime?probe=p1" : "/uptime", 0, false, "mm:uptime:write")
|
||||
await page.getByRole("button", { name: "Пауза", exact: true }).click()
|
||||
let calls = 0
|
||||
let reads = 0
|
||||
let release!: () => void
|
||||
const pending = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route("**/api/uptime/overview?*", route => { reads++; return route.fallback() })
|
||||
await page.route(group ? "**/api/uptime/probes/collect-group" : "**/api/uptime/collect-now", async route => {
|
||||
calls++
|
||||
await pending
|
||||
await route.fulfill({ status, json: status === 200 ? { ok: true, polled: 1 } : { error: "Forbidden" } })
|
||||
})
|
||||
const refresh = page.getByRole("button", { name: "Обновить", exact: true }).first()
|
||||
await refresh.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
|
||||
await expect(refresh).toBeDisabled()
|
||||
await expect(page.getByRole("button", { name: "Новая проба", exact: true })).toBeDisabled()
|
||||
await expect(page.getByRole("switch").first()).toBeDisabled()
|
||||
await expect.poll(() => calls).toBe(1)
|
||||
await page.getByRole("button", { name: /Скорость/ }).click()
|
||||
await expect(page.getByRole("button", { name: "Запустить", exact: true })).toBeDisabled()
|
||||
release()
|
||||
await expect(page.getByRole("button", { name: "Запустить", exact: true })).toBeEnabled()
|
||||
await page.getByRole("button", { name: /Пинг-пробы/ }).click()
|
||||
await expect(refresh).toBeEnabled()
|
||||
if (status === 403) await expect(page.getByRole("alert").filter({ hasText: "Недостаточно прав" })).toBeVisible()
|
||||
if (status === 200 || !group) expect(reads).toBeGreaterThan(0)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("Speed-test: таймаут освобождает действия и сообщает неизвестный результат", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const timeout = AbortSignal.timeout.bind(AbortSignal)
|
||||
AbortSignal.timeout = ms => timeout(ms === 120_000 ? 100 : ms)
|
||||
})
|
||||
await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write")
|
||||
await page.getByRole("button", { name: "Пауза", exact: true }).click()
|
||||
await page.getByRole("button", { name: /Скорость/ }).click()
|
||||
let release!: () => void
|
||||
const pending = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route("**/api/uptime/speed-test", async route => {
|
||||
await pending
|
||||
await route.fulfill({ json: { result: { txAvgMbps: 10, rxAvgMbps: 20 } } }).catch(() => {})
|
||||
})
|
||||
await page.getByRole("button", { name: "Запустить", exact: true }).click()
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Результат операции неизвестен" })).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "Запустить", exact: true })).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "Редактировать speed-пробу", exact: true })).toBeEnabled()
|
||||
release()
|
||||
})
|
||||
|
||||
test("ручной Ping: поздняя ошибка выбранной пробы не попадает в новый список", async ({ page }) => {
|
||||
await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write")
|
||||
let release!: () => void
|
||||
const pending = new Promise<void>(resolve => { release = resolve })
|
||||
await page.route("**/api/uptime/probes/collect-group", async route => {
|
||||
await pending
|
||||
await route.fulfill({ status: 500, json: { error: "Ошибка прежнего контекста" } })
|
||||
})
|
||||
await page.getByRole("button", { name: "Обновить", exact: true }).click()
|
||||
await page.getByRole("link", { name: "Все проверки", exact: true }).click()
|
||||
await expect(page.locator("[data-probe-id]")).toHaveCount(2)
|
||||
const response = page.waitForResponse(r => r.url().endsWith("/collect-group"))
|
||||
release()
|
||||
await response
|
||||
await expect(page.getByText("Ошибка прежнего контекста", { exact: true })).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Новая проба", exact: true })).toBeEnabled()
|
||||
})
|
||||
Reference in new issue
Block a user