fix(ui): подтверждать переключение проб через API

This commit is contained in:
Denozordec committed 2026-09-21 21:03:14 +07:00
1 parent 2f5b617e8c
commit 4f235058be
4 files changed
+58 -16

No files matched your search

+13 -14
View File
@@ -1056,6 +1056,7 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId: string | null; allProbesHref: string }) {
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
const draftProbeId = useRef("")
const [probeSaving, setProbeSaving] = useState(false)
const [probeSaveError, setProbeSaveError] = useState<string | null>(null)
const savingRef = useRef(false)
@@ -1579,11 +1580,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
// ── actions ──
const toggleProbe = (id: string, v: boolean) => {
if (!requireWrite()) return
setProbes((prev) => {
const next = prev.map((x) => x.id === id ? { ...x, enabled: v } : x)
void persistProbes(next).catch(error => setOpError(serverOperationError(error)))
return next
})
const next = probes.map(p => p.id === id ? { ...p, enabled: v } : p)
void saveProbeForm(() => persistProbes(next), () => setProbes(next))
}
const deleteProbe = (id: string) => {
@@ -1637,6 +1635,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const openAddSheet = () => {
if (!requireWrite()) return
draftProbeId.current = "p" + Date.now()
setProbeSaveError(null)
setEditingProbeId(null)
setNewSrcId(selectableSources[0]?.id ?? "")
@@ -1649,7 +1648,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
if (!requireWrite()) return
if (!newName.trim() || !newTarget.trim() || !newSrcId) return
const probeBase: PingProbe = {
id: editingProbeId ?? `p${Date.now()}`,
id: editingProbeId ?? draftProbeId.current,
srcServerId: newSrcId,
srcInterface: newSrcInterface,
name: newName.trim(),
@@ -1883,18 +1882,15 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const updateSpeedProbe = (id: string, patch: Partial<SpeedProbeRow>) => {
if (!requireWrite()) return
setSpeedProbes((prev) => {
const nextRows = prev.map((p) => {
const nextRows = speedProbes.map(p => {
if (p.id !== id) return p
const next = { ...p, ...patch }
if (patch.srcServerId && patch.srcServerId === next.dstServerId) {
next.dstServerId = selectableSources.find((s) => s.id !== patch.srcServerId)?.id ?? patch.srcServerId
next.dstServerId = selectableSources.find(s => s.id !== patch.srcServerId)?.id ?? patch.srcServerId
}
return next
})
void persistSpeedProbes(nextRows).catch(error => setSpeedError(serverOperationError(error)))
return nextRows
})
void saveProbeForm(() => persistSpeedProbes(nextRows), () => setSpeedProbes(nextRows))
}
const openAddSpeedSheet = () => {
@@ -1904,7 +1900,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const src = selectableSources[0]?.id ?? ""
const dst = selectableSources.find((s) => s.id !== src)?.id ?? src
setSpeedDraft({
id: "",
id: "sp-" + Date.now(),
srcServerId: src,
dstServerId: dst,
srcInterface: "",
@@ -1937,7 +1933,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
if (!speedDraft.srcServerId || !speedDraft.dstServerId) return
const next = editingSpeedProbeId ? speedProbes.map(p => p.id === editingSpeedProbeId
? { ...p, ...speedDraft, id: editingSpeedProbeId, durationSec: String(speedDraft.durationSec ?? "10") } : p)
: [{ ...speedDraft, id: "sp-" + Date.now() }, ...speedProbes]
: [{ ...speedDraft, id: speedDraft.id }, ...speedProbes]
void saveProbeForm(() => persistSpeedProbes(next), () => {
setSpeedProbes(next)
setEditingSpeedProbeId(null)
@@ -2008,6 +2004,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{!canWrite && <ContextAlert variant="info" role="status"><ContextTitle>Только просмотр</ContextTitle><ContextDescription>Изменение проб и запуск измерений требуют разрешения на запись. Обновление данных загружает сохранённые результаты.</ContextDescription></ContextAlert>}
{permissionError && !sheetOpen && !speedSheetOpen && <ContextAlert variant="destructive" role="alert"><ContextDescription>{permissionError}</ContextDescription></ContextAlert>}
{probeSaveError && !sheetOpen && !speedSheetOpen && <ContextAlert variant="destructive" role="alert"><ContextDescription>{probeSaveError}</ContextDescription></ContextAlert>}
{/* ── Live indicator ── */}
{!isPaused && (
<div className="flex items-center gap-2 px-6 py-1.5 border-b shrink-0 text-[11px]"
@@ -2682,6 +2680,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
onClick={() => {
if (!requireWrite()) return
setProbeSaveError(null)
draftProbeId.current = "p" + Date.now()
setEditingProbeId(null)
setNewSrcId(srv.id)
setNewSrcInterface("")
+9 -1
View File
@@ -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; далее действия строк |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; переключатели подтверждаются API; далее удаление и звезда |
| 8 | Трафик и статистика | Не начат |
| 9 | VPN, Firewall и Filters | Не начат |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
@@ -29,6 +29,14 @@
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 7.4Б1 — подтверждённые переключатели (2026-09-21)
- По прямому поручению пользователя работа продолжена ниже резерва. Ping enabled и Speed update теперь используют общий saveProbeForm: синхронная блокировка, timeout, защита от устаревшего чтения, применение строки только после PUT. Ошибка видна вне Sheet и не сбрасывается фоновым GET.
- ID новой пробы создаётся при открытии формы и сохраняется при повторной отправке. Установленные компоненты ReUI и тема сохранены; checklist проверен.
- 14 профильных E2E прошли суммарно: 12 сценариев форм/прав, затем 2 сценария переключения (ожидание, 403, повтор). Первоначальные два сбоя были неоднозначным селектором alert из-за Next route-announcer; селектор уточнён. ESLint: прежние 1 ошибка/6 предупреждений uptime, тесты чистые. Финальный production build прошёл: 34 страницы, TypeScript без ошибок.
- Осталось: подтверждение удаления, применение удаления только после API, ошибка звезды и конкуренция ранее запущенных PATCH/Speed-test/PUT. Эти действия пока не переведены на общую блокировку. Локальные HTTP-моки не проверяют RouterOS/JWT.
- Квота на завершении блока: 96% пятичасовой и 92% недельной израсходовано. Следующий шаг — 7.4Б2 (удаление и звезда), затем URL мониторинга и приёмка. Коммит локальный, без push.
## Блок 7.3В/7.4А — подтверждённое сохранение форм (2026-09-21)
- Ping/Speed: формы ожидают PUT, обновляют локальный список и закрываются только после успешного ответа. При 400/403/500, сетевой ошибке или таймауте сохраняются поля и показывается ошибка в Sheet. Общий serverOperationError переиспользован для понятного 403 и неопределённого результата timeout.
+1 -1
View File
@@ -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Б — подтверждение удаления и достоверность действий строк Ping/Speed, включая звезду и конкурирующие записи. Ограничения прав и сохранение обеих форм после API выполнены; ошибки 400/403/500 и timeout сохраняют черновик. Оптимистичные row-handlers ещё требуют доработки. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
Следующий исполняемый шаг: блок 7.4Б2 — подтверждение удаления, звезда и конкурирующие записи Ping/Speed. Переключатели (7.4Б1) уже применяются только после API. Ограничения прав и сохранение обеих форм после API выполнены; ошибки 400/403/500 и timeout сохраняют черновик. Оптимистичные row-handlers ещё требуют доработки. Серверная часть 7.3А выполнена: can(mm:servers:write), ограничения кнопок, проверки обработчиков и 5 E2E прав. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
## Решение по синхронизации URL (7.1)
+35
View File
@@ -268,3 +268,38 @@ for (const speed of [false, true]) {
await expect(dialog).toBeVisible()
})
}
for (const speed of [false, true]) {
test(`переключение ${speed ? "Speed" : "Ping"}: ожидание API, отказ, повтор без потери состояния`, async ({ page }) => {
await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write")
await page.getByRole("button", { name: "Пауза", exact: true }).click()
if (speed) await page.getByRole("button", { name: /Скорость/ }).click()
let calls = 0
let fail = true
let release!: () => void
const pending = new Promise<void>(resolve => { release = resolve })
await page.route(speed ? "**/api/uptime/speed-probes" : "**/api/uptime/probes", async route => {
if (route.request().method() !== "PUT") return route.fallback()
calls++
expect(route.request().postDataJSON().probes[0].enabled).toBe(false)
if (fail) await pending
await route.fulfill({ status: fail ? 403 : 200, json: fail ? { error: "Forbidden" } : { ok: true } })
})
const toggle = page.getByRole("switch")
await expect(toggle).toBeChecked()
await toggle.click()
await expect(toggle).toBeDisabled()
await expect(toggle).toBeChecked()
await expect.poll(() => calls).toBe(1)
release()
await expect(page.getByRole("alert").filter({ hasText: "Недостаточно прав" })).toBeVisible()
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
fail = false
await toggle.click()
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
await expect(page.getByRole("alert").filter({ hasText: "Недостаточно прав" })).toHaveCount(0)
expect(calls).toBe(2)
})
}