From ce14f5de3e9a77e3e2f6e498a207da61bde69951 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 21 Sep 2026 21:08:06 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B0=D1=82=D1=8C=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B1=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B7?= =?UTF-8?q?=D0=B2=D0=B5=D0=B7=D0=B4=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/uptime/page.tsx | 102 ++++++++++++++++++--------------- docs/ux-ui-progress.md | 11 +++- docs/ux-ui-stage-7.md | 2 +- tests/e2e/uptime-links.spec.ts | 68 ++++++++++++++++++++++ 4 files changed, 136 insertions(+), 47 deletions(-) diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 2f0d5b8e..fa1d98e9 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -13,6 +13,7 @@ import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit" import { Frame, FramePanel } from "@/components/reui/frame" import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid" import { Button } from "@/components/ui/button" +import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog" import { Input } from "@/components/ui/input" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Flag } from "@/components/flag" @@ -1056,6 +1057,7 @@ function ResourcesTab({ resources, serversList, liveApi }: { resources: ServerRe function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId: string | null; allProbesHref: string }) { const { mode, backendUrl, backendStatus, checkBackend } = useDataSource() + const [deleteTarget, setDeleteTarget] = useState<{ kind: "ping" | "speed"; id: string; label: string } | null>(null) const draftProbeId = useRef("") const [probeSaving, setProbeSaving] = useState(false) const [probeSaveError, setProbeSaveError] = useState(null) @@ -1498,40 +1500,26 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId }) }, [apiFetch, liveApi]) - const toggleDashboardStar = useCallback((id: string) => { + const toggleDashboardStar = (id: string) => { if (!requireWrite()) return - const cur = probes.find((p) => p.id === id) + const cur = probes.find(p => p.id === id) if (!cur) return const nextVal = !cur.showOnDashboard - if (liveApi) { - overviewReqRef.current += 1 - } - setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: nextVal } : p))) - if (liveApi) { - void apiFetch(`/api/uptime/probes/${encodeURIComponent(id)}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, + void saveProbeForm(async () => { + if (liveApi) await apiFetch("/api/uptime/probes/" + encodeURIComponent(id), { + method: "PATCH", signal: AbortSignal.timeout(30_000), body: JSON.stringify({ showOnDashboard: nextVal }), }) - .then(() => loadLiveOverview()) - .then(() => { - if (typeof window !== "undefined") { - window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED)) - } - }) - .catch(() => { - setProbes((prev) => prev.map((p) => (p.id === id ? { ...p, showOnDashboard: cur.showOnDashboard } : p))) - }) - } else { - const s = readMockDashboardStarIds() - if (nextVal) s.add(id) - else s.delete(id) - writeMockDashboardStarIds(s) - if (typeof window !== "undefined") { - window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED)) + }, () => { + setProbes(prev => prev.map(p => p.id === id ? { ...p, showOnDashboard: nextVal } : p)) + if (!liveApi) { + const ids = readMockDashboardStarIds() + if (nextVal) ids.add(id); else ids.delete(id) + writeMockDashboardStarIds(ids) } - } - }, [probes, liveApi, apiFetch, loadLiveOverview, requireWrite]) + window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED)) + }) + } const persistSpeedProbes = useCallback(async (rows: SpeedProbeRow[]) => { if (!liveApi) return @@ -1584,18 +1572,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId void saveProbeForm(() => persistProbes(next), () => setProbes(next)) } - const deleteProbe = (id: string) => { + const deleteProbe = (id: string) => { if (!requireWrite()) return - if (!liveApi) { - const s = readMockDashboardStarIds() - s.delete(id) - writeMockDashboardStarIds(s) - } - setProbes((prev) => { - const next = prev.filter((x) => x.id !== id) - void persistProbes(next).catch(error => setOpError(serverOperationError(error))) - return next - }) + const probe = probes.find(p => p.id === id) + if (!probe) return + setProbeSaveError(null) + setDeleteTarget({ kind: "ping", id, label: probe.name + " → " + probe.target }) } const toggleCollapse = (serverId: string) => @@ -1943,10 +1925,27 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId const deleteSpeedProbe = (id: string) => { if (!requireWrite()) return - setSpeedProbes((prev) => { - const next = prev.filter((p) => p.id !== id) - void persistSpeedProbes(next).catch(error => setSpeedError(serverOperationError(error))) - return next + const probe = speedProbes.find(p => p.id === id) + if (!probe) return + const name = (id: string) => allServers.find(s => s.id === id)?.name ?? id + setProbeSaveError(null) + setDeleteTarget({ kind: "speed", id, label: name(probe.srcServerId) + " → " + name(probe.dstServerId) }) + } + + const confirmProbeDelete = () => { + if (!deleteTarget || !requireWrite()) return + const { kind, id } = deleteTarget + const nextPing = probes.filter(p => p.id !== id) + const nextSpeed = speedProbes.filter(p => p.id !== id) + void saveProbeForm(() => kind === "ping" ? persistProbes(nextPing) : persistSpeedProbes(nextSpeed), () => { + if (kind === "ping") { + setProbes(nextPing) + if (!liveApi) { + const ids = readMockDashboardStarIds(); ids.delete(id); writeMockDashboardStarIds(ids) + } + window.dispatchEvent(new Event(UPTIME_PROBES_CHANGED)) + } else setSpeedProbes(nextSpeed) + setDeleteTarget(null) }) } @@ -2002,9 +2001,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId /> {!canWrite && Только просмотрИзменение проб и запуск измерений требуют разрешения на запись. Обновление данных загружает сохранённые результаты.} - {permissionError && !sheetOpen && !speedSheetOpen && {permissionError}} + {permissionError && !sheetOpen && !speedSheetOpen && !deleteTarget && {permissionError}} - {probeSaveError && !sheetOpen && !speedSheetOpen && {probeSaveError}} + {probeSaveError && !sheetOpen && !speedSheetOpen && !deleteTarget && {probeSaveError}} {/* ── Live indicator ── */} {!isPaused && ( @@ -2957,6 +2956,19 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId + { if (!open && !savingRef.current) setDeleteTarget(null) }}> + + + Удалить пробу? + Проба «{deleteTarget?.label}» будет удалена из мониторинга. + + {(probeSaveError || permissionError) &&

{probeSaveError || permissionError}

} + + + + +
+
) } diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 528105dd..cddebc90 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; далее удаление и звезда | +| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: 7.1–7.2, права серверов и ограничения действий мониторинга выполнены; формы проб сохраняются после API; переключатели подтверждаются API; удаление и звезда подтверждаются API; далее конкуренция измерений | | 8 | Трафик и статистика | Не начат | | 9 | VPN, Firewall и Filters | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат | @@ -29,6 +29,15 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 7.4Б2 — удаление и звезда (2026-09-21) + +- После сброса пятичасовой квоты создан ранее отложенный коммит 4f23505. Предыдущий git add не выполнялся: auto-review не смог завершиться из-за лимита; повтор выполнен после восстановления квоты. +- Ping/Speed удаляются через существующий AlertDialog с названием/целью. Отмена не пишет данные; подтверждение выполняет PUT через общий gate. Во время запроса закрытие/повтор заблокированы; 403 оставляет диалог и строку, успех удаляет строку. Повторная проверка прав выполняется в обработчике. +- Звезда дашборда теперь применяет состояние после PATCH и сообщает ошибку, сохраняя прежнее значение. PATCH имеет timeout 30 секунд и использует тот же gate, что формы/переключатели/удаление; обновление mock-звёзд и событие дашборда отправляются после успеха. +- Проверки: все 30 E2E uptime-links прошли, включая 3 новых сценария удаления/звезды, прежние формы/права/mobile/ссылки. TypeScript прошёл; lint теста чистый, uptime — прежние 1 set-state-in-effect и 6 unused warnings. ReUI audit: переиспользован AlertDialog из серверного сценария, структура установленных компонентов и тема не изменены. Production build прошёл: 34 статические страницы. +- Осталось 7.4Б3: общий порядок записи и запущенных измерений (runSpeedTest, collect-now, collect-group), блокировка повторного запуска, поздние ответы после смены контекста. PUT/PATCH CRUD теперь сериализованы между собой, но запуск измерения пока не занимает общий gate. Далее URL-контекст мониторинга и приёмка 7.5. +- Проверки — HTTP-моки, реальные JWT/RouterOS не проверялись. Контроль квоты: 12% пятичасовой / 94% недельной израсходовано. Коммит локальный без push. + ## Блок 7.4Б1 — подтверждённые переключатели (2026-09-21) - По прямому поручению пользователя работа продолжена ниже резерва. Ping enabled и Speed update теперь используют общий saveProbeForm: синхронная блокировка, timeout, защита от устаревшего чтения, применение строки только после PUT. Ошибка видна вне Sheet и не сбрасывается фоновым GET. diff --git a/docs/ux-ui-stage-7.md b/docs/ux-ui-stage-7.md index 6f46b216..6ad0b399 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Б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 выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. +Следующий исполняемый шаг: блок 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 выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. ## Решение по синхронизации URL (7.1) diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index 811ebbc5..c129dfba 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -303,3 +303,71 @@ for (const speed of [false, true]) { expect(calls).toBe(2) }) } + +for (const speed of [false, true]) { + test(`удаление ${speed ? "Speed" : "Ping"}: подтверждение, отказ и повтор`, 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(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++ + const rows = route.request().postDataJSON().probes + expect(rows.some((p: { id: string }) => p.id === (speed ? "s1" : "p1"))).toBe(false) + if (fail) await pending + await route.fulfill({ status: fail ? 403 : 200, json: fail ? { error: "Forbidden" } : { ok: true } }) + }) + const remove = page.getByRole("button", { name: speed ? "Удалить speed-пробу" : "Удалить пробу", exact: true }) + await remove.click() + const dialog = page.getByRole("alertdialog") + await expect(dialog).toContainText(speed ? "Источник → Назначение" : "Проверка сети → 192.0.2.10") + await dialog.getByRole("button", { name: "Отмена", exact: true }).click() + expect(calls).toBe(0) + await remove.click() + await dialog.getByRole("button", { name: "Удалить", exact: true }).evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) + await expect(dialog.getByRole("button", { name: "Удаление…", exact: true })).toBeDisabled() + await page.keyboard.press("Escape") + await expect(dialog).toBeVisible() + await expect.poll(() => calls).toBe(1) + release() + await expect(dialog.getByRole("alert")).toContainText("Недостаточно прав") + fail = false + await dialog.getByRole("button", { name: "Удалить", exact: true }).click() + await expect(dialog).toBeHidden() + await expect(remove).toHaveCount(0) + expect(calls).toBe(2) + }) +} + +test("звезда дашборда: состояние меняется после PATCH, ошибка сохраняет прежнее", async ({ page }) => { + await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write") + await page.getByRole("button", { name: "Пауза", exact: true }).click() + let calls = 0 + let fail = true + let release!: () => void + const pending = new Promise(resolve => { release = resolve }) + await page.route("**/api/uptime/probes/p1", async route => { + if (route.request().method() !== "PATCH") return route.fallback() + calls++ + expect(route.request().postDataJSON()).toEqual({ showOnDashboard: true }) + if (fail) await pending + await route.fulfill({ status: fail ? 500 : 200, json: fail ? { error: "Звезда не сохранена" } : { ok: true } }) + }) + const star = page.getByRole("button", { name: "Показать на дашборде", exact: true }) + await star.click() + await expect(star).toBeDisabled() + await expect(star).toHaveAttribute("aria-pressed", "false") + await expect(page.getByRole("switch")).toBeDisabled() + await expect.poll(() => calls).toBe(1) + release() + await expect(page.getByRole("alert").filter({ hasText: "Звезда не сохранена" })).toBeVisible() + await expect(star).toBeEnabled() + fail = false + await star.click() + await expect(page.getByRole("button", { name: "Убрать с дашборда", exact: true })).toHaveAttribute("aria-pressed", "true") + expect(calls).toBe(2) +})