fix(ui): сохранить историю скорости при частичном сбое

This commit is contained in:
Denozordec committed 2026-10-04 05:09:20 +07:00
1 parent 611e542ea7
commit e5ff69e243
5 files changed
+102 -38

No files matched your search

+56 -37
View File
@@ -1106,6 +1106,8 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const [speedError, setSpeedError] = useState<string | null>(null)
const [speedRuns, setSpeedRuns] = useState<SpeedTestRun[]>([])
const [speedProbes, setSpeedProbes] = useState<SpeedProbeRow[]>([])
const speedReadRevision = useRef(0)
const [speedReads, setSpeedReads] = useState<Record<"probes" | "history", { receivedAt: number | null; error: string | null }>>({ probes: { receivedAt: null, error: null }, history: { receivedAt: null, error: null } })
const [speedIfaces, setSpeedIfaces] = useState<Record<string, Array<{ name: string; running: boolean; disabled: boolean; addresses: string[] }>>>({})
const [speedCollapsed, setSpeedCollapsed] = useState<Set<string>>(new Set())
const [speedSheetOpen, setSpeedSheetOpen] = useState(false)
@@ -1182,40 +1184,49 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
afterBtPing?: { rttMs: number | null; lossPct: number | null; error: string | null } | null
createdAt: string
}
try {
const [sp, runsRes] = await Promise.all([
apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes"),
apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs"),
])
if (context !== saveContext.current || (savingRef.current && writeContext !== context)) return
setSpeedProbes(sp.probes ?? [])
const rows = (runsRes.runs ?? []).map((r) => ({
id: r.id,
startedAt: Date.parse(r.createdAt),
srcServerId: r.srcServerId,
dstServerId: r.dstServerId,
srcInterface: r.srcInterface || undefined,
dstInterface: r.dstInterface || undefined,
protocol: r.protocol,
direction: r.direction,
durationSec: r.durationSec,
txAvgMbps: Math.round(r.txAvgMbps ?? 0),
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0),
status: (r.status === "error" ? "error" : "done") as "error" | "done",
command: "",
lines: r.error ? [`status: error`, r.error] : [],
afterBtPing: r.afterBtPing ?? null,
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
}))
setSpeedRuns(rows)
} catch {
if (context !== saveContext.current || (savingRef.current && writeContext !== context)) return
setSpeedProbes([])
setSpeedRuns([])
const revision = ++speedReadRevision.current
const current = () => revision === speedReadRevision.current && context === saveContext.current && (!savingRef.current || writeContext === context)
const read = async (resource: "probes" | "history", task: () => Promise<void>) => {
try {
await task()
if (current()) setSpeedReads(previous => ({ ...previous, [resource]: { receivedAt: Date.now(), error: null } }))
} catch (error) {
if (current()) setSpeedReads(previous => ({ ...previous, [resource]: { ...previous[resource], error: serverOperationError(error) } }))
}
}
await Promise.all([
read("probes", async () => {
const sp = await apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes")
if (!Array.isArray(sp.probes)) throw new Error("Некорректный список speed-проб")
if (current()) setSpeedProbes(sp.probes)
}),
read("history", async () => {
const runsRes = await apiFetch<{ runs: RunRow[] }>("/api/uptime/speed-test/runs")
if (!Array.isArray(runsRes.runs)) throw new Error("Некорректная история тестов")
const rows = (runsRes.runs ?? []).map((r) => ({
id: r.id,
startedAt: Date.parse(r.createdAt),
srcServerId: r.srcServerId,
dstServerId: r.dstServerId,
srcInterface: r.srcInterface || undefined,
dstInterface: r.dstInterface || undefined,
protocol: r.protocol,
direction: r.direction,
durationSec: r.durationSec,
txAvgMbps: Math.round(r.txAvgMbps ?? 0),
rxAvgMbps: Math.round(r.rxAvgMbps ?? 0),
status: (r.status === "error" ? "error" : "done") as "error" | "done",
command: "",
lines: r.error ? [`status: error`, r.error] : [],
afterBtPing: r.afterBtPing ?? null,
srcAddress: r.srcAddress ?? null,
dstAddress: r.dstAddress ?? null,
srcInterfaceAddress: r.srcInterfaceAddress ?? null,
dstInterfaceAddress: r.dstInterfaceAddress ?? null,
}))
if (current()) setSpeedRuns(rows)
}),
])
}, [apiFetch, liveApi])
const refreshUptimeLive = useCallback(async (opts?: { showSpinner?: boolean; pollDevices?: boolean }) => {
@@ -2097,7 +2108,15 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{/* ── speed tab ── */}
{tab === "speed" && (
<div className="flex flex-col gap-0">
{liveApi && <div className="px-6 py-3 space-y-2">
{(["probes", "history"] as const).map(resource => {
const state = speedReads[resource], label = resource === "probes" ? "Speed-пробы" : "История тестов"
return <div key={resource}>
<p role="status" className="text-xs text-muted-foreground">{label}: {state.receivedAt ? "получено " + new Date(state.receivedAt).toLocaleString("ru-RU") : "данные пока неизвестны"}</p>
{state.error && <ContextAlert variant="warning" aria-label={"Чтение скорости: " + resource}><ContextTitle>{label}</ContextTitle><ContextDescription>{state.error} {state.receivedAt ? "Последние данные сохранены." : "Данные пока неизвестны."}</ContextDescription></ContextAlert>}
</div>
})}
</div>}
{/* ── KPI strip ── */}
{(() => {
const doneRuns = speedRuns.filter(r => r.status === "done")
@@ -2112,14 +2131,14 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
{
id: "probes",
label: "Speed-пробы",
value: `${speedProbes.length} шт`,
value: liveApi && !speedReads.probes.receivedAt ? "—" : `${speedProbes.length} шт`,
icon: <ArrowUpDownIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "runs",
label: "Тестов выполнено",
value: `${doneRuns.length} run`,
value: liveApi && !speedReads.history.receivedAt ? "—" : `${doneRuns.length} run`,
hint: runningCnt > 0 ? `${runningCnt} выполняется` : undefined,
icon: <PlayIcon className="size-4" />,
iconClassName: runningCnt > 0 ? "text-warning" : "text-muted-foreground",
@@ -2158,7 +2177,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-4">
{/* ── empty state ── */}
{speedGrouped.length === 0 && (
{speedGrouped.length === 0 && (!liveApi || speedReads.probes.receivedAt !== null) && (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<div className="size-12 rounded-full bg-muted flex items-center justify-center">
<ArrowUpDownIcon className="size-5 text-muted-foreground" />
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-04
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. Следующий блок12.3.20:достоверность speed history, затем network grids и финальная матрица12.4;12.5 отдельно. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. Следующий блок12.3.21:strict history DTO/null rates от DB до UI, затем network grids и финальная матрица12.4;12.5 отдельно. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+8
View File
@@ -36,6 +36,14 @@
## 12.3.20 — независимое чтение speed-проб и истории (2026-10-04)
Promise.all с общей очисткой заменён независимыми обработчиками каждого GET: готовый ресурс появляется без ожидания второго, при API/malformed array ошибке прежний список и receivedAt сохраняются. На speed вкладке видны freshness и отдельные ReUI warning alerts. Первоначально неизвестный список не выдаётся за пустой: нет ложного Нет speed-проб, KPI показывают —. Revision/context/write guards исключают поздние ответы прежнего чтения.
58 из59 browser uptime-links/large-history/new-data прошли; первоначальный race fixture задерживал только первый GET, а StrictMode запускает две initial волны. Исправлено задержкой всей старой волны и ожиданием реально полученного позднего ответа; все4 новых browser прошли. Проверены оба initial failures, partial success,503/malformed/cache/freshness/recovery и late response. TypeScript/ESLint без ошибок (9 исходных предупреждений uptime страницы), production contracts/frontend34 страницы прошли. DOM history200 остаётся50 rows/2343 nodes в этом dev профиле.
Это приёмка состояний чтения, а не полного DTO: legacy backend null→0 и fallback status/protocol остаются. Следующий блок12.3.21: общий строгий контракт speed history, сохранение null rates от DB до grid/KPI, pure mapper/unit и browser неизвестных/ошибочных значений. Затем большие network grids и итоговая матрица12.4; внешний12.5 отдельно. Коммит локальный без push.
## 12.3.19 — резервные копии1000 и возврат фокуса (2026-10-04)
GET /backups/listBackups возвращает весь каталог без DB cap. Fixture1000 проходит backupItemDtoSchema,171812 JSON bytes. Baseline1000 rows/43580 DOM nodes/2626ms dev ready; финально50 rows/2462 nodes/898ms. Client pagination50/autoResetPageIndex=false/clamp, полный search/Filters до pagination, штатный ReUI horizontal ScrollArea и pinning filename. Проверены page20/refresh/503 cache/recovery, весь поиск с URL/reload, числовая глобальная сортировка,1000→1, отсутствие запросов записи,320px и tagged axe.
+8
View File
@@ -297,3 +297,11 @@ GET /backups/listBackups возвращает весь каталог без DB
На телефоне действия постоянно видимы и имеют явный минимум44px; rem-based size-11 при root font-size проекта давал около40px. Имя110/actions176 сохраняют видимость всех трёх кнопок в реальном viewport266px внутри Frame. Декоративная иконка хранения удалена из имени: статус остаётся в отдельной колонке. Воспроизведена потеря focus после отмены: cell renderer remount менял элемент. Штатный finalFocus разрешает тот же стабильный row/action, при удалении строки fallback на Обновить; проверены оба диалога и правильный filename после sort.
После финального production кода39 из40 browser прошли, narrow layout прерван Windows UNKNOWN open historical PNG. Backups layout и dialog layout теперь пишут info.outputPath, assertions сохранены; повтор layout+large4 passed. TypeScript/ESLint и production contracts/frontend34 страницы прошли. Мобильный снимок просмотрен. Осталось21 manualPagination:true файла; это не21 доказанный дефект. Следующий блок12.3.20: строгая достоверность speed history и независимые состояния чтения, затем network grids/финальная матрица12.4;12.5 внешняя приёмка отдельно. Коммит локальный, без push.
## 12.3.20 — независимое чтение speed-проб и истории (2026-10-04)
Promise.all с общей очисткой заменён независимыми обработчиками каждого GET: готовый ресурс появляется без ожидания второго, при API/malformed array ошибке прежний список и receivedAt сохраняются. На speed вкладке видны freshness и отдельные ReUI warning alerts. Первоначально неизвестный список не выдаётся за пустой: нет ложного Нет speed-проб, KPI показывают —. Revision/context/write guards исключают поздние ответы прежнего чтения.
58 из59 browser uptime-links/large-history/new-data прошли; первоначальный race fixture задерживал только первый GET, а StrictMode запускает две initial волны. Исправлено задержкой всей старой волны и ожиданием реально полученного позднего ответа; все4 новых browser прошли. Проверены оба initial failures, partial success,503/malformed/cache/freshness/recovery и late response. TypeScript/ESLint без ошибок (9 исходных предупреждений uptime страницы), production contracts/frontend34 страницы прошли. DOM history200 остаётся50 rows/2343 nodes в этом dev профиле.
Это приёмка состояний чтения, а не полного DTO: legacy backend null→0 и fallback status/protocol остаются. Следующий блок12.3.21: общий строгий контракт speed history, сохранение null rates от DB до grid/KPI, pure mapper/unit и browser неизвестных/ошибочных значений. Затем большие network grids и итоговая матрица12.4; внешний12.5 отдельно. Коммит локальный без push.
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from "@playwright/test"
import { server } from "./fixtures/users"
const run = { id:"history-1", srcServerId:"1", dstServerId:"2", srcInterface:"ether1", dstInterface:"ether1", protocol:"tcp", direction:"both", durationSec:10, txAvgMbps:42, rxAvgMbps:0, status:"done", error:null, createdAt:"2026-10-04T00:00:00Z" }
for (const resource of ["probes", "history"] as const) test("Скорость: initial unknown и независимое recovery/" + resource, async ({ page }) => {
let fail = true
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname; const target=resource === "probes" ? "/api/uptime/speed-probes" : "/api/uptime/speed-test/runs";return r.fulfill(p === target && fail ? {status:503,json:{error:"Источник недоступен"}} : {json:p === "/api/auth/config" ? {required:false} : p === "/api/servers" ? [server] : p === "/api/uptime/overview" ? {probes:[],resources:[]} : p === "/api/uptime/speed-probes" ? {probes:[]} : p === "/api/uptime/speed-test/runs" ? {runs:[run]} : {}})})
await page.goto("/uptime?tab=speed"); await page.getByRole("button",{name:"Пауза",exact:true}).click()
const alert=page.getByRole("alert",{name:"Чтение скорости: "+resource,exact:true}); await expect(alert).toContainText("Данные пока неизвестны")
if(resource === "probes") { await expect(page.locator('tr[data-row-id="history-1"]')).toBeVisible(); await expect(page.getByText("Нет speed-проб",{exact:true})).toHaveCount(0) }
else { await expect(page.getByText("Нет speed-проб",{exact:true})).toBeVisible(); await expect(page.getByText("История тестов: данные пока неизвестны",{exact:true})).toBeVisible() }
fail=false; await page.getByRole("button",{name:"Обновить",exact:true}).click(); await expect(alert).toHaveCount(0); await expect(page.locator('tr[data-row-id="history-1"]')).toBeVisible()
})
test("Скорость: background503 и malformed сохраняют последнюю историю и свежесть", async ({page}) => {
let failure="",value=42
await page.route("**/health",r=>r.fulfill({json:{ok:true}})); await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(p === "/api/uptime/speed-test/runs" && failure === "503" ? {status:503,json:{error:"История недоступна"}} : {json:p === "/api/auth/config" ? {required:false} : p === "/api/servers" ? [server] : p === "/api/uptime/overview" ? {probes:[],resources:[]} : p === "/api/uptime/speed-probes" ? {probes:[]} : p === "/api/uptime/speed-test/runs" ? failure === "malformed" ? {} : {runs:[{...run,txAvgMbps:value}]} : {}})})
await page.goto("/uptime?tab=speed"); const row=page.locator('tr[data-row-id="history-1"]'); await expect(row).toContainText("42 Мбит/с"); await page.getByRole("button",{name:"Пауза",exact:true}).click()
const freshness=page.getByRole("status").filter({hasText:/^История тестов: получено/}); const original=await freshness.textContent()
for(const kind of ["503","malformed"]) {failure=kind; await page.getByRole("button",{name:"Обновить",exact:true}).click(); await expect(page.getByRole("alert",{name:"Чтение скорости: history",exact:true})).toContainText("Последние данные сохранены"); await expect(row).toContainText("42 Мбит/с"); await expect(freshness).toHaveText(original!); await expect(page.getByText("Нет speed-проб",{exact:true})).toBeVisible()}
failure="";value=84;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(row).toContainText("84 Мбит/с");await expect(page.getByRole("alert",{name:"Чтение скорости: history",exact:true})).toHaveCount(0)
})
test("Скорость: поздняя история не задерживает пробы и не заменяет новый ответ", async ({page}) => {
let release!:()=>void;const gate=new Promise<void>(r=>release=r);let historyCalls=0,newer=false
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",async r=>{const p=new URL(r.request().url()).pathname;if(p === "/api/uptime/speed-test/runs") {historyCalls++;const old=!newer;if(old) await gate;return r.fulfill({json:{runs:[{...run,id:old ? "stale" : "current"}]}}).catch(()=>{})}return r.fulfill({json:p === "/api/auth/config" ? {required:false} : p === "/api/servers" ? [server] : p === "/api/uptime/overview" ? {probes:[],resources:[]} : p === "/api/uptime/speed-probes" ? {probes:[]} : {}})})
await page.goto("/uptime?tab=speed");await page.getByRole("button",{name:"Пауза",exact:true}).click();await expect(page.getByText("Нет speed-проб",{exact:true})).toBeVisible();await expect(page.getByText("История тестов: данные пока неизвестны",{exact:true})).toBeVisible()
newer=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.locator('tr[data-row-id="current"]')).toBeVisible();const late=page.waitForResponse(async response => response.url().endsWith("/api/uptime/speed-test/runs") && (await response.json()).runs[0].id === "stale");release();await (await late).finished();await page.evaluate(()=>new Promise<void>(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve()))));await expect.poll(()=>historyCalls).toBeGreaterThan(1);await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeEnabled();await expect(page.locator('tr[data-row-id="stale"]')).toHaveCount(0)
})