diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index fa1d98e9..c93d1d10 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -1723,6 +1723,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const runSpeedTest = async (probe: SpeedProbeRow) => { if (!requireWrite()) return if (!probe.srcServerId || !probe.dstServerId || probe.srcServerId === probe.dstServerId) return + savingRef.current = true + setProbeSaving(true) + overviewReqRef.current++ + const context = ++saveContext.current setSpeedError(null) setSpeedBusy(true) const durationSec = Math.max(3, Number.parseInt(probe.durationSec, 10) || 10) @@ -1771,6 +1775,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId method: "POST", body: JSON.stringify(payload), }) + if (context !== saveContext.current) return const ap = res.result.afterBtPing const lines = (res.result.raw ?? []).flatMap((row) => Object.entries(row).map(([k, v]) => `${k}: ${String(v)}`), @@ -1844,6 +1849,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId }) : p)) } } catch (e) { + if (context !== saveContext.current) return const message = e instanceof Error ? e.message : "Не удалось выполнить speed test" setSpeedRuns((prev) => prev.map((r) => r.id === runId ? ({ ...r, @@ -1858,6 +1864,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId lastError: message, }) : p)) } finally { + savingRef.current = false + setProbeSaving(false) setSpeedBusy(false) } } diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index cddebc90..1422120b 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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; далее конкуренция измерений | +| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; переключатели подтверждаются API; удаление и звезда подтверждаются API; Speed-test блокирует запись; далее блокировка Ping | | 8 | Трафик и статистика | Не начат | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | @@ -29,6 +29,13 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 7.4Б3а — блокировка записи при Speed-test (2026-09-21) + +- runSpeedTest занимает общий savingRef синхронно перед запросом: повторный запуск и CRUD проб блокируются, фоновые GET не перезаписывают измерение. В finally блокировка снимается при успехе/ошибке. Добавлена проверка saveContext перед применением позднего результата/ошибки после смены backend/mode или размонтирования. +- 6 профильных E2E прошли: блокировки форм, переключатели, звезда и новый сценарий одного Speed-test с запретом CRUD/Ping до ответа и освобождением после 500. Реальная смена backend/mode с отложенным Speed-ответом не проверялась отдельным E2E. Lint тестов чистый; uptime сохраняет 1 исходную ошибку и 6 предупреждений. ReUI checklist: композиция/компоненты не менялись. Production build и TypeScript прошли: 34 страницы. +- Следующий шаг 7.4Б3б: collect-now/collect-group должны занимать общую блокировку, исключать повторные запуски и поздние ответы. До этого уже запущенный Ping может пересекаться с записью/Speed. Также проверить ограничение времени ожидания Speed-test: сейчас общий gate удерживается до завершения fetch, отдельный timeout этой операции ещё не добавлен. Затем URL-контекст мониторинга и приёмка. +- На контрольной точке израсходовано 34% пятичасовой и 97% недельной квоты. Локальные HTTP-моки, без реального RouterOS/JWT. Коммит локальный без push. + ## Блок 7.4Б2 — удаление и звезда (2026-09-21) - После сброса пятичасовой квоты создан ранее отложенный коммит 4f23505. Предыдущий git add не выполнялся: auto-review не смог завершиться из-за лимита; повтор выполнен после восстановления квоты. diff --git a/docs/ux-ui-stage-7.md b/docs/ux-ui-stage-7.md index 6ad0b399..dac66e51 100644 --- a/docs/ux-ui-stage-7.md +++ b/docs/ux-ui-stage-7.md @@ -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 — конкуренция CRUD с измерениями Speed/Ping, повторные запуски и поздние ответы. Переключатели, удаление с подтверждением и звезда (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 выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. +Следующий исполняемый шаг: блок 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.1) diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index c129dfba..bfd4afd2 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -371,3 +371,32 @@ test("звезда дашборда: состояние меняется пос await expect(page.getByRole("button", { name: "Убрать с дашборда", exact: true })).toHaveAttribute("aria-pressed", "true") expect(calls).toBe(2) }) + +test("Speed-test: один запуск, запись проб заблокирована до ответа и освобождается при ошибке", async ({ page }) => { + 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 calls = 0 + let release!: () => void + const pending = new Promise(resolve => { release = resolve }) + await page.route("**/api/uptime/speed-test", async route => { + calls++ + await pending + await route.fulfill({ status: 500, json: { error: "Измерение отклонено" } }) + }) + const run = page.getByRole("button", { name: "Запустить", exact: true }) + await run.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + await expect(run).toBeDisabled() + await expect(page.getByRole("button", { name: "Удалить speed-пробу", exact: true })).toBeDisabled() + await expect(page.getByRole("button", { name: "Редактировать speed-пробу", exact: true })).toBeDisabled() + await expect(page.getByRole("switch")).toBeDisabled() + await expect.poll(() => calls).toBe(1) + await page.getByRole("button", { name: /Пинг-пробы/ }).click() + await expect(page.getByRole("button", { name: "Показать на дашборде", exact: true })).toBeDisabled() + 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(run).toBeEnabled() + await expect(page.getByText("Измерение отклонено", { exact: true }).first()).toBeVisible() +})