fix(ui): адаптировать строки Ping-проб для телефона

This commit is contained in:
Denozordec committed 2026-09-21 09:58:43 +07:00
1 parent dfb993e1a3
commit e9e5030047
5 files changed
+133 -38

No files matched your search

+35 -32
View File
@@ -1,5 +1,7 @@
"use client"
import probeStyles from "./probe-rows.module.css"
import { Suspense, useState, useMemo, useEffect, useRef, useCallback } from "react"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
@@ -2422,7 +2424,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
</div>
{/* ── probe groups ── */}
<div className="p-6 flex flex-col gap-4">
<div className="p-4 md:p-6 flex flex-col gap-4">
{grouped.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground gap-2">
@@ -2439,18 +2441,18 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const warnCount = allServerProbes.filter(p => p.status === "warn").length
return (
<Frame key={srv.id} dense className="w-full overflow-hidden">
<Frame key={srv.id} dense className={cn("w-full overflow-hidden", probeStyles.container)}>
<FramePanel className="p-0 overflow-hidden">
{/* server header */}
<button onClick={() => toggleCollapse(srv.id)}
className="w-full flex items-center gap-2.5 px-4 py-3 hover:bg-muted/30 transition-colors text-left border-b">
<button type="button" aria-expanded={!isCollapsed} onClick={() => toggleCollapse(srv.id)}
className="w-full flex flex-wrap items-center gap-2.5 px-4 py-3 hover:bg-muted/30 transition-colors text-left border-b">
{isCollapsed
? <ChevronRightIcon className="size-4 text-muted-foreground shrink-0" />
: <ChevronDownIcon className="size-4 text-muted-foreground shrink-0" />}
<StatusDot status={srv.status} pulse={srv.status === "online"} />
<Flag code={srv.country} size={16} />
<span className="font-mono text-sm font-semibold">{srv.name}</span>
<span className={cn(probeStyles.serverName, "font-mono text-sm font-semibold min-w-0 wrap-anywhere")}>{srv.name}</span>
<TypeChip type={srv.type} />
<span className="text-xs text-muted-foreground">{srv.site}</span>
<div className="ml-auto flex items-center gap-3 text-xs">
@@ -2475,10 +2477,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const groupBusy = !!probeGroupPingBusy[groupBusyKey]
return (
<div key={`${group.name}|${group.target}`}>
<div className="px-4 py-2 border-b bg-muted/20 flex items-center gap-2 text-xs">
<div className="px-4 py-2 border-b bg-muted/20 flex flex-wrap items-center gap-2 text-xs">
<ArrowRightIcon className="size-3.5 text-muted-foreground/60 shrink-0" />
<span className="font-medium truncate min-w-0">{group.name}</span>
<span className="font-mono text-muted-foreground shrink-0">{group.target}</span>
<span className="font-medium min-w-0 wrap-anywhere">{group.name}</span>
<span className="font-mono text-muted-foreground min-w-0 wrap-anywhere">{group.target}</span>
<div className="ml-auto flex items-center gap-2 shrink-0">
<span className="text-muted-foreground whitespace-nowrap">{group.probes.length} интерф.</span>
<Button
@@ -2496,8 +2498,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
</div>
</div>
<div className="grid items-center gap-3 px-4 py-1.5 bg-muted/30 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
<div className={cn(probeStyles.header, "items-center gap-3 px-4 py-1.5 bg-muted/30 border-b text-[10px] font-semibold uppercase tracking-widest text-muted-foreground")}>
<span />
<span />
<span>Интерфейс</span>
@@ -2517,33 +2518,33 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
<div key={p.id} data-probe-id={p.id} className="border-b border-border/40 last:border-b-0">
<div
className={cn(
"grid items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
probeStyles.row, "items-center gap-3 px-4 py-2.5 hover:bg-muted/20 transition-colors",
!p.enabled && "opacity-40",
)}
style={{ gridTemplateColumns: "36px 16px 130px 120px 140px 70px 44px minmax(132px,1fr) 96px 36px 72px" }}>
<FormToggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} />
<StatusDot
>
<label className={probeStyles.toggle}><span className="sr-only">Включить пробу {p.name}</span><span className={probeStyles.label} aria-hidden="true">Включена</span><FormToggle checked={p.enabled} onChange={v => toggleProbe(p.id, v)} /></label>
<div className={probeStyles.status}><StatusDot
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
pulse={p.status === "up" && p.enabled}
/>
<span className="font-mono text-xs text-muted-foreground truncate">{p.srcInterface || "auto"}</span>
<span className="text-sm font-medium truncate">{p.name}</span>
{p.filter === "—"
/><span className={probeStyles.mobile}>{!p.enabled ? "Отключена" : p.status === "up" ? "Доступна" : p.status === "warn" ? "Предупреждение" : "Недоступна"}</span></div>
<div className={probeStyles.iface}><span className={probeStyles.label}>Интерфейс</span><span className="font-mono text-xs text-muted-foreground">{p.srcInterface || "auto"}</span></div>
<div className={probeStyles.name}><span className="text-sm font-medium">{p.name}</span></div>
<div className={probeStyles.filter}><span className={probeStyles.label}>Фильтр</span>{p.filter === "—"
? <span className="text-muted-foreground/40 text-xs">—</span>
: <span className="text-[11px] font-medium text-sky-600 dark:text-sky-400 truncate">{p.filter}</span>
}
<span className={cn("font-mono text-sm font-semibold tabular-nums", rttColor(p.rtt, p.loss))}>
: <span className="text-[11px] font-medium text-sky-600 dark:text-sky-400">{p.filter}</span>
}</div>
<div className={probeStyles.rtt}><span className={probeStyles.label}>RTT</span><span className={cn("font-mono text-sm font-semibold tabular-nums", rttColor(p.rtt, p.loss))}>
{p.rtt === null ? "—" : `${p.rtt}мс`}
</span>
<span className={cn("font-mono text-xs tabular-nums",
</span></div>
<div className={probeStyles.loss}><span className={probeStyles.label}>Потери</span><span className={cn("font-mono text-xs tabular-nums",
p.loss === 0 ? "text-muted-foreground" :
p.loss >= 100 ? "text-red-600 dark:text-red-400" :
p.loss > 5 ? "text-red-600 dark:text-red-400" :
"text-amber-600 dark:text-amber-400",
)}>
{p.loss}%
</span>
<div className="min-w-0 flex flex-col gap-0.5 justify-center">
</span></div>
<div className={cn(probeStyles.bt, "min-w-0 flex flex-col gap-0.5 justify-center")}><span className={probeStyles.label}>BT · ping</span>
{!linkedSp?.lastRunAt ? (
<span className="text-muted-foreground/35 text-[10px]">—</span>
) : linkedSp.lastStatus === "error" && linkedSp.lastError ? (
@@ -2577,8 +2578,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
</>
)}
</div>
<Sparkline data={p.series} width={96} height={24} color={probeSparkColor(p.status)} filled />
<div className="flex justify-center">
<div className={probeStyles.graph}><span className={probeStyles.label}>График RTT</span><Sparkline data={p.series} width={96} height={24} color={probeSparkColor(p.status)} filled /></div>
<div className={probeStyles.star}><span className={probeStyles.label}>На дашборде</span>
<Button
type="button"
size="sm"
@@ -2586,6 +2587,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
className="size-7 p-0 text-muted-foreground hover:text-amber-500"
onClick={() => toggleDashboardStar(p.id)}
title={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"}
aria-label={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"}
aria-pressed={p.showOnDashboard === true}
>
<StarIcon
@@ -2598,15 +2600,15 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
/>
</Button>
</div>
<div className="flex justify-end gap-0.5">
<div className={cn(probeStyles.actions, "flex justify-end gap-1")}>
<Button size="sm" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => openEditProbe(p)} title="Редактировать пробу">
type="button" aria-label="Редактировать пробу" onClick={() => openEditProbe(p)} title="Редактировать пробу">
<PencilIcon className="size-3.5" />
</Button>
<Button size="sm" variant="ghost"
className="size-7 p-0 text-muted-foreground hover:text-destructive"
onClick={() => deleteProbe(p.id)} title="Удалить пробу">
type="button" aria-label="Удалить пробу" onClick={() => deleteProbe(p.id)} title="Удалить пробу">
<TrashIcon className="size-3.5" />
</Button>
</div>
@@ -2615,7 +2617,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
open={probeRttChartOpen[p.id] ?? false}
onOpenChange={(open) => setProbeRttChartOpen((m) => ({ ...m, [p.id]: open }))}
>
<CollapsibleTrigger
<CollapsibleTrigger type="button"
className={cn(
"flex w-full items-center gap-2 border-t border-border/50 bg-muted/15 px-4 py-1.5 text-left text-xs text-muted-foreground",
"outline-none hover:bg-muted/30 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
@@ -2657,7 +2659,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
setNewName(group.name); setNewTarget(group.target); setNewFilter("—")
setSheetOpen(true)
}}
className="w-full flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
type="button"
className="w-full flex items-center gap-2 px-4 py-2 text-left wrap-anywhere text-xs text-muted-foreground hover:text-foreground hover:bg-muted/20 transition-colors border-t">
<PlusIcon className="size-3.5" />
Добавить интерфейс для {group.name} ({group.target})
</button>
+33
View File
@@ -0,0 +1,33 @@
.container { container-type: inline-size; }
.header { display: none; }
.serverName { flex: 1 1 160px; }
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: "name name" "status toggle" "iface filter" "rtt loss" "bt graph" "star actions";
}
.row > * { min-width: 0; overflow-wrap: anywhere; }
.name { grid-area: name; }
.status { grid-area: status; display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; }
.toggle { grid-area: toggle; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; justify-content: flex-end; }
.iface { grid-area: iface; }
.filter { grid-area: filter; }
.rtt { grid-area: rtt; }
.loss { grid-area: loss; }
.bt { grid-area: bt; }
.graph { grid-area: graph; }
.star { grid-area: star; display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; }
.actions { grid-area: actions; }
.label { display: block; font-size: 0.625rem; color: var(--muted-foreground); }
.actions button, .star button { min-width: 2rem; min-height: 2rem; }
@container (min-width: 1100px) {
.header, .row {
display: grid;
grid-template-columns: 36px 16px 130px 120px 140px 70px 44px minmax(132px, 1fr) 96px 36px 72px;
grid-template-areas: "toggle status iface name filter rtt loss bt graph star actions";
}
.label, .mobile { display: none; }
.toggle, .star { justify-content: center; }
.actions button, .star button { min-width: unset; min-height: unset; }
}
+11 -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 URL-фильтров серверов завершён; далее 7.2 |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: блоки 7.1–7.2 завершены; далее права действий 7.3 |
| 8 | Трафик и статистика | Не начат |
| 9 | VPN, Firewall и Filters | Не начат |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
@@ -487,6 +487,16 @@ Production build успешен: контракты, TypeScript, 34 страни
Квота: использовано 1% на старте, 30% перед заключительной сборкой (недельной 66%). Сохранить локальным коммитом без push. **Следующий шаг — 7.2:** мобильные строки Ping-проб в uptime/page.tsx, доступность имени/цели/состояния и меню без обрезки на 320/390 px с сохранением действий, выбора и раскрытия; затем права по блоку 7.3. Этап 7 в целом остаётся открытым.
## Этап 7 — блок 7.2: мобильные строки Ping-проб (2026-09-21)
Устранена отмеченная на этапе 6 обрезка правой части строк Ping-проб. В uptime/page.tsx сохранён один набор контролов и прежние обработчики. Frame-контейнер и локальный probe-rows.module.css переключают строку между двумя колонками с подписями и широкой сеткой при доступной ширине от 1100 px. Длинные имя, фильтр и цель переносятся, серверный заголовок и действия группы тоже допускают перенос. FormToggle получил label, кнопки звезды/редактирования/удаления — aria-label, заголовок группы сервера — aria-expanded. Вендорные ReUI-компоненты, тема, данные и сетевые правила не менялись.
Проверки: **11 E2E passed** (uptime-links и collector-probes-tables). Новые сценарии 320/390/1440 px проверяют длинные подписи, доступность кнопок в пределах экрана, отсутствие горизонтального переполнения страницы, широкую/узкую композицию, переключатель, раскрытие графика Enter, редактирование/Escape, PATCH звезды и PUT включения. Скриншоты 320/1440 просмотрены после исправлений переноса. TypeScript и production build успешны, 34 страницы. Новый тест проходит lint; у uptime/page.tsx прежние 1 ошибка set-state-in-effect и 6 предупреждений, тот же baseline подтверждён на HEAD. Не заявлять чистый lint всего проекта.
Ограничения: проверены HTTP-моки. Общая адаптивность вкладок Resources/Speed, подтверждение удаления и обработка ошибок существующего persistProbes не входили в этот блок; относятся к дальнейшей работе с формами и операциями. Этап 7 остаётся открытым. Квота: использовано 47% на старте, 81% перед фиксацией (недельной 74%). Новые блоки в этом окне не начинать; сохранить локальным коммитом без push.
**Следующий шаг — 7.3:** подключить разрешения действий серверов и мониторинга по can/hasPermission и действующим backend-маршрутам; проверить read-only, write/admin, auth disabled и серверный 403 без потери формы. Начать с серверов, затем мониторинг; UI-проверки не заменяют backend.
## Карта миграции (исходная инвентаризация этапа 0)
Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация.
+9 -3
View File
@@ -1,6 +1,6 @@
# Этап 7: серверы и мониторинг
Подготовка 2026-09-20 по текущему коду после этапа 6. Блок 7.1 реализован 2026-09-21; остальные блоки ожидают выполнения. Основа: ReUI PRO, base-nova, Frame; переиспользовать установленные Filters/DataGrid, ServerFilters, PageHeader и Sheet. Не менять vendored-компоненты и правила сетевых операций.
Подготовка 2026-09-20 по текущему коду после этапа 6. Блоки 7.1 и 7.2 реализованы 2026-09-21; далее права действий, блок 7.3. Основа: ReUI PRO, base-nova, Frame; переиспользовать установленные Filters/DataGrid, ServerFilters, PageHeader и Sheet. Не менять vendored-компоненты и правила сетевых операций.
## Инвентаризация
@@ -56,13 +56,19 @@ URL — источник сохранённого состояния. Back/Forwa
## Блоки после 7.1
1. **7.2 — мобильные проверки.** Устранить обрезку Ping-проб, сохранив выбор строк, раскрытие, запуск и меню. Проверить 320/390/1440 px, клавиатуру/Escape, отсутствие скрытых обязательных действий и горизонтального скролла всей страницы.
1. **7.2 — мобильные проверки (выполнено).** Устранить обрезку Ping-проб, сохранив выбор строк, раскрытие, запуск и меню. Проверить 320/390/1440 px, клавиатуру/Escape, отсутствие скрытых обязательных действий и горизонтального скролла всей страницы.
2. **7.3 — права действий.** Источник политики — `backend/src/lib/permissions.ts`, применение — `backend/src/plugins/auth.ts`. GET `/api/servers*` требует `mm:servers:read`, запись — `mm:servers:write`; GET `/api/uptime*` — `mm:uptime:read`, запись — `mm:uptime:write`. Учитывать наследование write/admin через существующий hasPermission и режим auth disabled. Проверить конкретные маршруты опроса/теста соединения, не считать их чтением только по названию. UI не заменяет backend-проверку: серверный 403 сохраняет форму и сообщает отказ. Не выдавать доступ только по is_admin без подтверждения текущей политики.
3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие.
4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении.
Следующий исполняемый шаг: блок 7.2 — мобильные строки Ping-проб. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
Следующий исполняемый шаг: блок 7.3 — права действий серверов и мониторинга. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
## Решение по синхронизации URL (7.1)
Поиск использует native replaceState без debounce: установленный Next App Router синхронизирует useSearchParams, не запрашивая серверные данные. Браузерный тест подтвердил быстрый посимвольный ввод и неизменное количество GET /api/servers. Это устраняет отложенную запись, способную затереть последующий выбор type/filters. Дискретные изменения используют pushState, читая актуальный window.location.search. Черновик Filters привязан к сохранённому filters и сбрасывается при popstate. Неверные type/filters блокируют выдачу до исправления/сброса; server и чужие параметры сохраняются.
## Реализация адаптивных строк (7.2)
Один DOM и прежние обработчики Ping-проб сохранены. Frame задаёт контейнер ширины; локальный CSS module перестраивает существующую строку в две колонки с подписями при ширине контейнера менее 1100 px. При достаточной ширине возвращается сетка 11 колонок. Имена, цель, интерфейс и фильтр переносятся; состояние подписано на узком экране. Переключатель получил доступное имя через label, кнопки — aria-label, серверный заголовок — aria-expanded. Используются прежние FormToggle/Button/Collapsible/Sparkline; ReUI и общая тема не изменены.
11 профильных E2E прошли, в том числе длинные подписи на 320/390/1440 px, видимость действий, Enter для раскрытия графика, редактирование/Escape, запрос звезды и включение пробы. Скриншоты 320/1440 просмотрены, обрезка строки устранена. Проверка не означает адаптивность всех вкладок uptime: Resources/Speed и их формы остаются отдельными частями плана.
+45 -2
View File
@@ -1,13 +1,18 @@
import { test, expect, type Page } from "@playwright/test"
async function setup(page: Page, url: string, failed = 0) {
const control = { failed, posts: [] as { path: string; body: unknown }[] }
async function setup(page: Page, url: string, failed = 0, longNames = false) {
const control = { writes: [] as { method: string; path: string; body: unknown }[], failed, posts: [] as { path: string; body: unknown }[] }
const servers = [{ id: 1, name: "Источник", host: "192.0.2.1", type: "exit-node", enabled: true, country: "RU", site: "TEST", asn: "64512", status: "online", latency: 1, os: "7", model: "CHR", wanUplinks: [] }]
const probes = ["p1", "p/2"].map(id => ({ id, srcServerId: "1", name: "Проверка сети", target: "192.0.2.10", filter: "main", rtt: null, loss: 100, status: "down", series: [0], enabled: true, showOnDashboard: false }))
if (longNames) {
servers[0].name = "Источник-с-длинным-названием-площадки-для-проверки-переноса"
probes.forEach(p => { p.name = "Проверка-доступности-длинного-имени-сетевого-узла"; p.target = "router-with-a-long-hostname.example.test"; p.filter = "Фильтр-с-длинным-названием" })
}
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
await page.route("**/api/**", route => {
const request = route.request()
const path = new URL(request.url()).pathname
if (!["GET", "HEAD"].includes(request.method())) control.writes.push({ method: request.method(), path, body: request.postDataJSON() })
if (request.method() === "POST") control.posts.push({ path, body: request.postDataJSON() })
if (path === "/api/uptime/overview" && control.failed) return route.fulfill({ status: control.failed, json: { error: "Мониторинг недоступен" } })
const json = path === "/api/auth/config" ? { required: false }
@@ -83,3 +88,41 @@ for (const failed of [0, 403, 500]) {
expect(control.posts).toEqual([])
})
}
for (const width of [320, 390, 1440]) {
test(`Ping-пробы ${width}px: длинные подписи, доступные действия и раскрытие`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 })
const control = await setup(page, "/uptime?probe=p%2F2", 0, true)
const row = page.locator('[data-probe-id="p/2"]')
await expect(row).toBeVisible()
await expect(row.getByText("График RTT", { exact: true })).toBeVisible({ visible: width < 1440 })
if (width < 1440) expect(await row.getByText("Фильтр-с-длинным-названием", { exact: true }).evaluate(el => el.getBoundingClientRect().right)).toBeLessThanOrEqual(width)
for (const name of ["Редактировать пробу", "Удалить пробу", "Показать на дашборде"]) {
const button = row.getByRole("button", { name, exact: true })
await button.scrollIntoViewIfNeeded()
const bounds = await button.boundingBox()
expect(bounds!.x).toBeGreaterThanOrEqual(0)
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(width)
}
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width)
const toggle = row.getByRole("switch", { name: /Включить пробу/ })
await expect(toggle).toBeChecked()
const chart = row.getByRole("button", { name: /Подробный график RTT/ })
await chart.focus()
await page.keyboard.press("Enter")
await expect(chart).toHaveAttribute("aria-expanded", "true")
await page.keyboard.press("Enter")
await expect(chart).toHaveAttribute("aria-expanded", "false")
await row.scrollIntoViewIfNeeded()
await page.screenshot({ path: `test-results/ping-row-${width}.png`, animations: "disabled" })
expect(control.writes).toEqual([])
await row.getByRole("button", { name: "Редактировать пробу", exact: true }).click()
await expect(page.getByRole("dialog")).toBeVisible()
await page.keyboard.press("Escape")
await expect(page.getByRole("dialog")).toBeHidden()
await row.getByRole("button", { name: "Показать на дашборде", exact: true }).click()
await expect.poll(() => control.writes.some(w => w.method === "PATCH" && w.path === "/api/uptime/probes/p%2F2" && (w.body as { showOnDashboard: boolean }).showOnDashboard === true)).toBe(true)
await toggle.click()
await expect.poll(() => control.writes.some(w => w.method === "PUT" && w.path === "/api/uptime/probes")).toBe(true)
})
}