fix(ui): подтверждать удаление проб и изменение звезды
This commit is contained in:
1 parent
4f235058be
commit
ce14f5de3e
4 files changed
+136
-47
No files matched your search
+57
-45
@@ -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<string | null>(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 && <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 && !deleteTarget && <ContextAlert variant="destructive" role="alert"><ContextDescription>{permissionError}</ContextDescription></ContextAlert>}
|
||||
|
||||
{probeSaveError && !sheetOpen && !speedSheetOpen && <ContextAlert variant="destructive" role="alert"><ContextDescription>{probeSaveError}</ContextDescription></ContextAlert>}
|
||||
{probeSaveError && !sheetOpen && !speedSheetOpen && !deleteTarget && <ContextAlert variant="destructive" role="alert"><ContextDescription>{probeSaveError}</ContextDescription></ContextAlert>}
|
||||
|
||||
{/* ── Live indicator ── */}
|
||||
{!isPaused && (
|
||||
@@ -2957,6 +2956,19 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={open => { if (!open && !savingRef.current) setDeleteTarget(null) }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить пробу?</AlertDialogTitle>
|
||||
<AlertDialogDescription>Проба «{deleteTarget?.label}» будет удалена из мониторинга.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{(probeSaveError || permissionError) && <p role="alert" className="text-sm text-destructive">{probeSaveError || permissionError}</p>}
|
||||
<AlertDialogFooter>
|
||||
<Button type="button" variant="outline" disabled={probeSaving} onClick={() => setDeleteTarget(null)}>Отмена</Button>
|
||||
<Button type="button" variant="destructive" disabled={probeSaving || !canWrite} onClick={confirmProbeDelete}>{probeSaving ? "Удаление…" : "Удалить"}</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+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; далее удаление и звезда |
|
||||
| 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<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++
|
||||
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<void>(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)
|
||||
})
|
||||
Reference in new issue
Block a user