fix(ui): подтверждать переключение проб через API
This commit is contained in:
1 parent
2f5b617e8c
commit
4f235058be
4 files changed
+63
-21
No files matched your search
+18
-19
@@ -1056,6 +1056,7 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe
|
|||||||
|
|
||||||
function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId: string | null; allProbesHref: string }) {
|
function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId: string | null; allProbesHref: string }) {
|
||||||
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
const { mode, backendUrl, backendStatus, checkBackend } = useDataSource()
|
||||||
|
const draftProbeId = useRef("")
|
||||||
const [probeSaving, setProbeSaving] = useState(false)
|
const [probeSaving, setProbeSaving] = useState(false)
|
||||||
const [probeSaveError, setProbeSaveError] = useState<string | null>(null)
|
const [probeSaveError, setProbeSaveError] = useState<string | null>(null)
|
||||||
const savingRef = useRef(false)
|
const savingRef = useRef(false)
|
||||||
@@ -1579,11 +1580,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
|||||||
// ── actions ──
|
// ── actions ──
|
||||||
const toggleProbe = (id: string, v: boolean) => {
|
const toggleProbe = (id: string, v: boolean) => {
|
||||||
if (!requireWrite()) return
|
if (!requireWrite()) return
|
||||||
setProbes((prev) => {
|
const next = probes.map(p => p.id === id ? { ...p, enabled: v } : p)
|
||||||
const next = prev.map((x) => x.id === id ? { ...x, enabled: v } : x)
|
void saveProbeForm(() => persistProbes(next), () => setProbes(next))
|
||||||
void persistProbes(next).catch(error => setOpError(serverOperationError(error)))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteProbe = (id: string) => {
|
const deleteProbe = (id: string) => {
|
||||||
@@ -1637,6 +1635,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
|||||||
|
|
||||||
const openAddSheet = () => {
|
const openAddSheet = () => {
|
||||||
if (!requireWrite()) return
|
if (!requireWrite()) return
|
||||||
|
draftProbeId.current = "p" + Date.now()
|
||||||
setProbeSaveError(null)
|
setProbeSaveError(null)
|
||||||
setEditingProbeId(null)
|
setEditingProbeId(null)
|
||||||
setNewSrcId(selectableSources[0]?.id ?? "")
|
setNewSrcId(selectableSources[0]?.id ?? "")
|
||||||
@@ -1649,7 +1648,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
|||||||
if (!requireWrite()) return
|
if (!requireWrite()) return
|
||||||
if (!newName.trim() || !newTarget.trim() || !newSrcId) return
|
if (!newName.trim() || !newTarget.trim() || !newSrcId) return
|
||||||
const probeBase: PingProbe = {
|
const probeBase: PingProbe = {
|
||||||
id: editingProbeId ?? `p${Date.now()}`,
|
id: editingProbeId ?? draftProbeId.current,
|
||||||
srcServerId: newSrcId,
|
srcServerId: newSrcId,
|
||||||
srcInterface: newSrcInterface,
|
srcInterface: newSrcInterface,
|
||||||
name: newName.trim(),
|
name: newName.trim(),
|
||||||
@@ -1883,18 +1882,15 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
|||||||
|
|
||||||
const updateSpeedProbe = (id: string, patch: Partial<SpeedProbeRow>) => {
|
const updateSpeedProbe = (id: string, patch: Partial<SpeedProbeRow>) => {
|
||||||
if (!requireWrite()) return
|
if (!requireWrite()) return
|
||||||
setSpeedProbes((prev) => {
|
const nextRows = speedProbes.map(p => {
|
||||||
const nextRows = prev.map((p) => {
|
if (p.id !== id) return p
|
||||||
if (p.id !== id) return p
|
const next = { ...p, ...patch }
|
||||||
const next = { ...p, ...patch }
|
if (patch.srcServerId && patch.srcServerId === next.dstServerId) {
|
||||||
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
|
||||||
return next
|
|
||||||
})
|
|
||||||
void persistSpeedProbes(nextRows).catch(error => setSpeedError(serverOperationError(error)))
|
|
||||||
return nextRows
|
|
||||||
})
|
})
|
||||||
|
void saveProbeForm(() => persistSpeedProbes(nextRows), () => setSpeedProbes(nextRows))
|
||||||
}
|
}
|
||||||
|
|
||||||
const openAddSpeedSheet = () => {
|
const openAddSpeedSheet = () => {
|
||||||
@@ -1904,7 +1900,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
|||||||
const src = selectableSources[0]?.id ?? ""
|
const src = selectableSources[0]?.id ?? ""
|
||||||
const dst = selectableSources.find((s) => s.id !== src)?.id ?? src
|
const dst = selectableSources.find((s) => s.id !== src)?.id ?? src
|
||||||
setSpeedDraft({
|
setSpeedDraft({
|
||||||
id: "",
|
id: "sp-" + Date.now(),
|
||||||
srcServerId: src,
|
srcServerId: src,
|
||||||
dstServerId: dst,
|
dstServerId: dst,
|
||||||
srcInterface: "",
|
srcInterface: "",
|
||||||
@@ -1937,7 +1933,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
|||||||
if (!speedDraft.srcServerId || !speedDraft.dstServerId) return
|
if (!speedDraft.srcServerId || !speedDraft.dstServerId) return
|
||||||
const next = editingSpeedProbeId ? speedProbes.map(p => p.id === editingSpeedProbeId
|
const next = editingSpeedProbeId ? speedProbes.map(p => p.id === editingSpeedProbeId
|
||||||
? { ...p, ...speedDraft, id: editingSpeedProbeId, durationSec: String(speedDraft.durationSec ?? "10") } : p)
|
? { ...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), () => {
|
void saveProbeForm(() => persistSpeedProbes(next), () => {
|
||||||
setSpeedProbes(next)
|
setSpeedProbes(next)
|
||||||
setEditingSpeedProbeId(null)
|
setEditingSpeedProbeId(null)
|
||||||
@@ -2008,6 +2004,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
|||||||
{!canWrite && <ContextAlert variant="info" role="status"><ContextTitle>Только просмотр</ContextTitle><ContextDescription>Изменение проб и запуск измерений требуют разрешения на запись. Обновление данных загружает сохранённые результаты.</ContextDescription></ContextAlert>}
|
{!canWrite && <ContextAlert variant="info" role="status"><ContextTitle>Только просмотр</ContextTitle><ContextDescription>Изменение проб и запуск измерений требуют разрешения на запись. Обновление данных загружает сохранённые результаты.</ContextDescription></ContextAlert>}
|
||||||
{permissionError && !sheetOpen && !speedSheetOpen && <ContextAlert variant="destructive" role="alert"><ContextDescription>{permissionError}</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 ── */}
|
{/* ── Live indicator ── */}
|
||||||
{!isPaused && (
|
{!isPaused && (
|
||||||
<div className="flex items-center gap-2 px-6 py-1.5 border-b shrink-0 text-[11px]"
|
<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={() => {
|
onClick={() => {
|
||||||
if (!requireWrite()) return
|
if (!requireWrite()) return
|
||||||
setProbeSaveError(null)
|
setProbeSaveError(null)
|
||||||
|
draftProbeId.current = "p" + Date.now()
|
||||||
setEditingProbeId(null)
|
setEditingProbeId(null)
|
||||||
setNewSrcId(srv.id)
|
setNewSrcId(srv.id)
|
||||||
setNewSrcInterface("")
|
setNewSrcInterface("")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже |
|
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже |
|
||||||
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
|
||||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; далее действия строк |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; переключатели подтверждаются API; далее удаление и звезда |
|
||||||
| 8 | Трафик и статистика | Не начат |
|
| 8 | Трафик и статистика | Не начат |
|
||||||
| 9 | VPN, Firewall и Filters | Не начат |
|
| 9 | VPN, Firewall и Filters | Не начат |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||||
@@ -29,6 +29,14 @@
|
|||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 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)
|
## Блок 7.3В/7.4А — подтверждённое сохранение форм (2026-09-21)
|
||||||
|
|
||||||
- Ping/Speed: формы ожидают PUT, обновляют локальный список и закрываются только после успешного ответа. При 400/403/500, сетевой ошибке или таймауте сохраняются поля и показывается ошибка в Sheet. Общий serverOperationError переиспользован для понятного 403 и неопределённого результата timeout.
|
- Ping/Speed: формы ожидают PUT, обновляют локальный список и закрываются только после успешного ответа. При 400/403/500, сетевой ошибке или таймауте сохраняются поля и показывается ошибка в Sheet. Общий serverOperationError переиспользован для понятного 403 и неопределённого результата timeout.
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ URL — источник сохранённого состояния. Back/Forwa
|
|||||||
3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие.
|
3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие.
|
||||||
4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении.
|
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)
|
## Решение по синхронизации URL (7.1)
|
||||||
|
|
||||||
|
|||||||
@@ -268,3 +268,38 @@ for (const speed of [false, true]) {
|
|||||||
await expect(dialog).toBeVisible()
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in new issue
Block a user