fix(api): проверить каталог speed-проб и изолировать источник

This commit is contained in:
Denozordec committed 2026-10-04 10:31:02 +07:00
1 parent f30319ac55
commit d4f2961f76
13 files changed
+123 -51

No files matched your search

+17 -14
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { speedHistoryResponseSchema } from "@mmapp/contracts" import { speedHistoryResponseSchema, speedProbesResponseSchema } from "@mmapp/contracts"
import probeStyles from "./probe-rows.module.css" import probeStyles from "./probe-rows.module.css"
@@ -1110,6 +1110,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const [speedProbes, setSpeedProbes] = useState<SpeedProbeRow[]>([]) const [speedProbes, setSpeedProbes] = useState<SpeedProbeRow[]>([])
const speedReadRevision = useRef(0) 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 [speedReads, setSpeedReads] = useState<Record<"probes" | "history", { receivedAt: number | null; error: string | null }>>({ probes: { receivedAt: null, error: null }, history: { receivedAt: null, error: null } })
const speedCatalogReady = !liveApi || (speedReads.probes.receivedAt !== null && speedReads.probes.error === null)
const [speedIfaces, setSpeedIfaces] = useState<Record<string, Array<{ name: string; running: boolean; disabled: boolean; addresses: string[] }>>>({}) const [speedIfaces, setSpeedIfaces] = useState<Record<string, Array<{ name: string; running: boolean; disabled: boolean; addresses: string[] }>>>({})
const [speedCollapsed, setSpeedCollapsed] = useState<Set<string>>(new Set()) const [speedCollapsed, setSpeedCollapsed] = useState<Set<string>>(new Set())
const [speedSheetOpen, setSpeedSheetOpen] = useState(false) const [speedSheetOpen, setSpeedSheetOpen] = useState(false)
@@ -1178,9 +1179,9 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
} }
await Promise.all([ await Promise.all([
read("probes", async () => { read("probes", async () => {
const sp = await apiFetch<{ probes: SpeedProbeRow[] }>("/api/uptime/speed-probes") const result = speedProbesResponseSchema.safeParse(await apiFetch<unknown>("/api/uptime/speed-probes"))
if (!Array.isArray(sp.probes)) throw new Error("Некорректный список speed-проб") if (!result.success) throw new Error("Данные проб скорости не подтверждены сервером.")
if (current()) setSpeedProbes(sp.probes) if (current()) setSpeedProbes(result.data.probes)
}), }),
read("history", async () => { read("history", async () => {
const result = speedHistoryResponseSchema.safeParse(await apiFetch<unknown>("/api/uptime/speed-test/runs")) const result = speedHistoryResponseSchema.safeParse(await apiFetch<unknown>("/api/uptime/speed-test/runs"))
@@ -1522,6 +1523,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const persistSpeedProbes = useCallback(async (rows: SpeedProbeRow[]) => { const persistSpeedProbes = useCallback(async (rows: SpeedProbeRow[]) => {
if (!liveApi) return if (!liveApi) return
if (!speedCatalogReady) throw new Error("Каталог speed-проб не подтверждён. Обновите данные перед сохранением.")
await apiFetch("/api/uptime/speed-probes", { await apiFetch("/api/uptime/speed-probes", {
method: "PUT", method: "PUT",
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
@@ -1544,7 +1546,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
})), })),
}), }),
}) })
}, [apiFetch, liveApi]) }, [apiFetch, liveApi, speedCatalogReady])
async function saveProbeForm(save: () => Promise<void>, confirm: () => void) { async function saveProbeForm(save: () => Promise<void>, confirm: () => void) {
if (!requireWrite()) return if (!requireWrite()) return
@@ -1885,7 +1887,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
} }
const openAddSpeedSheet = () => { const openAddSpeedSheet = () => {
if (!requireWrite()) return if (!requireWrite() || !speedCatalogReady) return
setProbeSaveError(null) setProbeSaveError(null)
setEditingSpeedProbeId(null) setEditingSpeedProbeId(null)
const src = selectableSources[0]?.id ?? "" const src = selectableSources[0]?.id ?? ""
@@ -2001,7 +2003,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
</Button> </Button>
)} )}
{tab === "speed" && ( {tab === "speed" && (
<Button size="sm" disabled={probeSaving || !canWrite} onClick={openAddSpeedSheet}> <Button size="sm" disabled={probeSaving || !canWrite || !speedCatalogReady} onClick={openAddSpeedSheet}>
<PlusIcon className="size-4" />Новая speed-проба <PlusIcon className="size-4" />Новая speed-проба
</Button> </Button>
)} )}
@@ -2077,7 +2079,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<ArrowUpDownIcon className="size-3.5" /> <ArrowUpDownIcon className="size-3.5" />
<span>Скорость</span> <span>Скорость</span>
<span className="text-[10px] font-mono opacity-50">{speedProbes.length}</span> <span className="text-[10px] font-mono opacity-50">{liveApi && speedReads.probes.receivedAt === null ? "—" : speedProbes.length}</span>
</span> </span>
)} )}
</button> </button>
@@ -2096,7 +2098,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
const state = speedReads[resource], label = resource === "probes" ? "Speed-пробы" : "История тестов" const state = speedReads[resource], label = resource === "probes" ? "Speed-пробы" : "История тестов"
return <div key={resource}> return <div key={resource}>
<p role="status" className="text-xs text-muted-foreground">{label}: {state.receivedAt ? "получено " + new Date(state.receivedAt).toLocaleString("ru-RU") : "данные пока неизвестны"}</p> <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>} {state.error && <ContextAlert variant="warning" aria-label={"Чтение скорости: " + resource}><ContextTitle>{label}</ContextTitle><ContextDescription>{state.error} {state.receivedAt ? "Последние данные сохранены." : "Данные пока неизвестны."}{resource === "probes" ? " Изменение каталога недоступно до успешного обновления." : ""}</ContextDescription></ContextAlert>}
</div> </div>
})} })}
</div>} </div>}
@@ -2171,7 +2173,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
<p className="text-xs text-muted-foreground max-w-xs"> <p className="text-xs text-muted-foreground max-w-xs">
Создайте пробу через кнопку «Новая speed-проба» в шапке страницы Создайте пробу через кнопку «Новая speed-проба» в шапке страницы
</p> </p>
<Button size="sm" variant="outline" className="mt-1" disabled={probeSaving || !canWrite} onClick={openAddSpeedSheet}> <Button size="sm" variant="outline" className="mt-1" disabled={probeSaving || !canWrite || !speedCatalogReady} onClick={openAddSpeedSheet}>
<PlusIcon className="size-4" />Новая speed-проба <PlusIcon className="size-4" />Новая speed-проба
</Button> </Button>
</div> </div>
@@ -2229,7 +2231,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}> className={cn("px-4 py-3 hover:bg-muted/20 transition-colors", !probe.enabled && "opacity-50")}>
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
{/* enable toggle */} {/* enable toggle */}
<FormToggle disabled={probeSaving || !canWrite} checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} /> <FormToggle ariaLabel={"Включить speed-пробу " + server.name + " → " + (dst?.name ?? probe.dstServerId)} disabled={probeSaving || !canWrite || !speedCatalogReady} checked={probe.enabled} onChange={(v) => updateSpeedProbe(probe.id, { enabled: v })} />
{/* route: src → dst */} {/* route: src → dst */}
<div className="flex items-center gap-1.5 min-w-0 flex-1"> <div className="flex items-center gap-1.5 min-w-0 flex-1">
@@ -2266,12 +2268,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
Запустить Запустить
</Button> </Button>
<Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-foreground" <Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-foreground"
disabled={probeSaving || !canWrite} aria-label="Редактировать speed-пробу" onClick={() => openEditSpeedSheet(probe)} disabled={probeSaving || !canWrite || !speedCatalogReady} aria-label="Редактировать speed-пробу" onClick={() => openEditSpeedSheet(probe)}
title="Редактировать speed-пробу"> title="Редактировать speed-пробу">
<PencilIcon className="size-3.5" /> <PencilIcon className="size-3.5" />
</Button> </Button>
<Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-destructive" <Button size="sm" variant="ghost" className="h-7 text-muted-foreground hover:text-destructive"
disabled={probeSaving || !canWrite} aria-label="Удалить speed-пробу" onClick={() => deleteSpeedProbe(probe.id)}> disabled={probeSaving || !canWrite || !speedCatalogReady} aria-label="Удалить speed-пробу" onClick={() => deleteSpeedProbe(probe.id)}>
<TrashIcon className="size-3.5" /> <TrashIcon className="size-3.5" />
</Button> </Button>
</div> </div>
@@ -2996,11 +2998,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
} }
function UptimeRoute() { function UptimeRoute() {
const { mode, backendUrl } = useDataSource()
const params = useSearchParams() const params = useSearchParams()
const id = params.get("probe") const id = params.get("probe")
const rest = new URLSearchParams(params.toString()) const rest = new URLSearchParams(params.toString())
rest.delete("probe") rest.delete("probe")
return <UptimePageContent key={id === null ? "list" : "probe:" + id} selectedProbeId={id} allProbesHref={"/uptime" + (rest.size ? "?" + rest : "")} /> return <UptimePageContent key={JSON.stringify([id, mode, backendUrl])} selectedProbeId={id} allProbesHref={"/uptime" + (rest.size ? "?" + rest : "")} />
} }
export default function UptimePage() { export default function UptimePage() {
+6 -22
View File
@@ -1,3 +1,4 @@
import { mapSpeedProbeRead } from "../services/speed-probe-dto.js"
import { mapSpeedHistoryRun } from "../services/speed-history-dto.js" import { mapSpeedHistoryRun } from "../services/speed-history-dto.js"
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { asc, desc, eq } from "drizzle-orm" import { asc, desc, eq } from "drizzle-orm"
@@ -181,28 +182,11 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/uptime/speed-probes", async (_req, reply) => { app.get("/uptime/speed-probes", async (_req, reply) => {
const rows = await db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder)) const rows = await db.select().from(uptimeSpeedProbes).orderBy(asc(uptimeSpeedProbes.sortOrder))
return reply.send({ try {
probes: rows.map((r) => ({ return reply.send({ probes: rows.map(mapSpeedProbeRead) })
id: r.id, } catch {
srcServerId: String(r.srcServerId), return reply.code(502).send({ error: "Каталог speed-проб содержит некорректную запись. Данные не подтверждены." })
dstServerId: String(r.dstServerId), }
srcInterface: r.srcInterface || "",
dstInterface: r.dstInterface || "",
protocol: r.protocol === "udp" ? "udp" : "tcp",
direction: r.direction === "transmit" || r.direction === "receive" ? r.direction : "both",
durationSec: String(Math.max(3, r.durationSec || 10)),
enabled: r.enabled !== false,
lastRunAt: r.lastRunAt ?? null,
lastTxAvgMbps: r.lastTxAvgMbps ?? null,
lastRxAvgMbps: r.lastRxAvgMbps ?? null,
lastStatus: r.lastStatus ?? null,
lastError: r.lastError ?? null,
lastPingRttMs: r.lastPingRttMs ?? null,
lastPingLossPct: r.lastPingLossPct ?? null,
lastPingAt: r.lastPingAt ?? null,
lastPingError: r.lastPingError ?? null,
})),
})
}) })
app.get("/uptime/speed-test/runs", async (_req, reply) => { app.get("/uptime/speed-test/runs", async (_req, reply) => {
@@ -0,0 +1,8 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { mapSpeedProbeRead } from "./speed-probe-dto.js"
import type { UptimeSpeedProbeRow } from "../db/schema.js"
const row:UptimeSpeedProbeRow={id:"probe-1",srcServerId:1,dstServerId:2,srcInterface:"",dstInterface:"",protocol:"tcp",direction:"both",durationSec:10,enabled:false,lastRunAt:null,lastTxAvgMbps:null,lastRxAvgMbps:0,lastStatus:null,lastError:null,lastPingRttMs:null,lastPingLossPct:0,lastPingAt:null,lastPingError:null,sortOrder:0,createdAt:"2026-10-04T00:00:00Z",updatedAt:"2026-10-04T00:00:00Z"}
test("Speed probe: null/zero/disabled и неизвестный status не подменяются",()=>{const dto=mapSpeedProbeRead(row);assert.equal(dto.enabled,false);assert.equal(dto.lastTxAvgMbps,null);assert.equal(dto.lastRxAvgMbps,0);assert.equal(dto.lastStatus,null);assert.equal(dto.lastPingRttMs,null);assert.equal(dto.lastPingLossPct,0)})
test("Speed probe: timestamp PostgreSQL сохраняет instant",()=>{assert.equal(mapSpeedProbeRead({...row,lastRunAt:"2026-10-04 07:00:00+07"}).lastRunAt,"2026-10-04T00:00:00.000Z")})
test("Speed probe: некорректная DB запись отклоняется без fallback",()=>{for(const raw of [{...row,protocol:"bad"},{...row,direction:"bad"},{...row,durationSec:0},{...row,enabled:null},{...row,lastStatus:"bad"},{...row,lastTxAvgMbps:-1},{...row,lastRunAt:"bad"},{...row,srcServerId:0}])assert.throws(()=>mapSpeedProbeRead(raw as UptimeSpeedProbeRow))})
+13
View File
@@ -0,0 +1,13 @@
import { speedProbeReadSchema } from "@mmapp/contracts"
import type { UptimeSpeedProbeRow } from "../db/schema.js"
const timestamp = (value: string | null) => value === null ? null : new Date(value).toISOString()
export function mapSpeedProbeRead(row: UptimeSpeedProbeRow) {
return speedProbeReadSchema.parse({
id: row.id, srcServerId: String(row.srcServerId), dstServerId: String(row.dstServerId),
srcInterface: row.srcInterface, dstInterface: row.dstInterface,
protocol: row.protocol, direction: row.direction, durationSec: String(row.durationSec), enabled: row.enabled,
lastRunAt: timestamp(row.lastRunAt), lastTxAvgMbps: row.lastTxAvgMbps, lastRxAvgMbps: row.lastRxAvgMbps,
lastStatus: row.lastStatus, lastError: row.lastError,
lastPingRttMs: row.lastPingRttMs, lastPingLossPct: row.lastPingLossPct, lastPingAt: timestamp(row.lastPingAt), lastPingError: row.lastPingError,
})
}
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-04 ## Актуальная точка продолжения — 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 GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. Следующий блок12.3.22:strict speed-probes DTO/source context/запись при неизвестном каталоге; далее network grids и полная регрессия main1102/context.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/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. Следующий блок12.3.23:строгая транзакционная запись speed-probes; далее network grids и полная main/context регрессия.12.5 отдельно. См. docs/ux-ui-stage-12.md. Внешние оLine truncated
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md). Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+10
View File
@@ -36,6 +36,16 @@
## 12.3.22 — общий каталог speed-проб и изоляция источника (2026-10-04)
Схема из probe-schedule-response перенесена в contracts: shared speedProbeReadSchema/speedProbesResponseSchema проверяют enums/duration/boolean/server IDs/уникальность ID и nullable observed values. Uptime и live schedule используют один контракт; ping-after-BT поля сохраняются, optional legacy отсутствие остаётся неизвестным. Pure backend mapSpeedProbeRead сохраняет null/0/false, нормализует DB timestamps; недостоверная запись возвращает502 вместо protocol/direction/duration/enabled fallback.
UptimeRoute keyed по probe ID/mode/backendUrl: каталог, история, freshness, интерфейсы и формы относятся к своему источнику. UI create/toggle/edit/delete/start и bulk writer защищены speedCatalogReady; первоначальная неизвестность и malformed refresh не допускают полной замены неподтверждённого списка. Cached строки и receivedAt сохранены; warning объясняет, что нужно успешно обновить данные. Навигационный счётчик unknown показывает —, переключатель получил доступное имя.
60 browser uptime и6 после финальной copy/a11y правки прошли;8 новых context scenarios backend/mode × probes/history × success/error прошли. Второй consumer/diagnostics/schedule/history47 browser прошли, включая1000 probes и1001 rail. Unit mapper3 + shared reader2 прошли без PG. Typecheck/ESLint без ошибок (9 прежних uptime warnings), backend build и frontend/contracts production34 страницы прошли; ReUI checklist и scoped axe error alert пройдены,320px снимок просмотрен. Context файл включён в агрегированную отдельную конфигурацию. Коммит локальный без push.
Следующий блок12.3.23: строгий PUT speed-probes до DB mutation, отказ без удаления malformed строк и транзакционная полная замена. Текущий backend PUT всё ещё имеет permissive normalize/filter и последовательные mutations; защита UI не заменяет серверную проверку. Затем network grids/полная main/context матрица12.4;12.5 реальная PG/RouterOS/integration приёмка отдельно.
## 12.4.3 — context delta последних admin изменений (2026-10-04) ## 12.4.3 — context delta последних admin изменений (2026-10-04)
На a558412 выполнены backups-context и users-context через общий playwright.ux-context.config.ts:36 passed/0 failed/skipped/flaky, Edge/3101/workers1/retries0, наблюдаемые1.4m. Проверены поздние reads при backend/mode switch, schedule/storage writes, create/job poll, delete/restore/download, создание/удаление пользователя. HTTP ответы подставные, реальных внешних операций не было. Отчёт: docs/ux-ui/acceptance/context-delta-2026-10-04.json. На a558412 выполнены backups-context и users-context через общий playwright.ux-context.config.ts:36 passed/0 failed/skipped/flaky, Edge/3101/workers1/retries0, наблюдаемые1.4m. Проверены поздние reads при backend/mode switch, schedule/storage writes, create/job poll, delete/restore/download, создание/удаление пользователя. HTTP ответы подставные, реальных внешних операций не было. Отчёт: docs/ux-ui/acceptance/context-delta-2026-10-04.json.
+10
View File
@@ -323,3 +323,13 @@ Frontend safeParse отклоняет invalid/duplicate данные и сохр
Текущий main inventory по --list:1102 теста/169 файлов. Это перечень, не1102 passed. Ранее main1091 дал1088 passed и3 устаревших fixtures, после исправлений6 профильных прошли; полный context205 прошёл на прежнем HEAD. Последние production delta приняты профильными проверками и builds, полная повторная main/context регрессия остаётся в12.4. Stage12 не закрыт. Текущий main inventory по --list:1102 теста/169 файлов. Это перечень, не1102 passed. Ранее main1091 дал1088 passed и3 устаревших fixtures, после исправлений6 профильных прошли; полный context205 прошёл на прежнем HEAD. Последние production delta приняты профильными проверками и builds, полная повторная main/context регрессия остаётся в12.4. Stage12 не закрыт.
Следующий шаг12.3.22: strict speed-probes DTO и speed state при смене backend/mode, включая неизвестный каталог перед записью. Затем оставшиеся большие network grids, общая матрица и12.5 внешняя PG/RouterOS/integration приёмка. Текущий блок записан локальным коммитом без push; посторонние изменения skills/AGENTS/tsbuildinfo сохранены. Остаток квоты около3% перед записью журнала. Следующий шаг12.3.22: strict speed-probes DTO и speed state при смене backend/mode, включая неизвестный каталог перед записью. Затем оставшиеся большие network grids, общая матрица и12.5 внешняя PG/RouterOS/integration приёмка. Текущий блок записан локальным коммитом без push; посторонние изменения skills/AGENTS/tsbuildinfo сохранены. Остаток квоты около3% перед записью журнала.
## 12.3.22 — общий каталог speed-проб и изоляция источника (2026-10-04)
Схема из probe-schedule-response перенесена в contracts: shared speedProbeReadSchema/speedProbesResponseSchema проверяют enums/duration/boolean/server IDs/уникальность ID и nullable observed values. Uptime и live schedule используют один контракт; ping-after-BT поля сохраняются, optional legacy отсутствие остаётся неизвестным. Pure backend mapSpeedProbeRead сохраняет null/0/false, нормализует DB timestamps; недостоверная запись возвращает502 вместо protocol/direction/duration/enabled fallback.
UptimeRoute keyed по probe ID/mode/backendUrl: каталог, история, freshness, интерфейсы и формы относятся к своему источнику. UI create/toggle/edit/delete/start и bulk writer защищены speedCatalogReady; первоначальная неизвестность и malformed refresh не допускают полной замены неподтверждённого списка. Cached строки и receivedAt сохранены; warning объясняет, что нужно успешно обновить данные. Навигационный счётчик unknown показывает —, переключатель получил доступное имя.
60 browser uptime и6 после финальной copy/a11y правки прошли;8 новых context scenarios backend/mode × probes/history × success/error прошли. Второй consumer/diagnostics/schedule/history47 browser прошли, включая1000 probes и1001 rail. Unit mapper3 + shared reader2 прошли без PG. Typecheck/ESLint без ошибок (9 прежних uptime warnings), backend build и frontend/contracts production34 страницы прошли; ReUI checklist и scoped axe error alert пройдены,320px снимок просмотрен. Context файл включён в агрегированную отдельную конфигурацию. Коммит локальный без push.
Следующий блок12.3.23: строгий PUT speed-probes до DB mutation, отказ без удаления malformed строк и транзакционная полная замена. Текущий backend PUT всё ещё имеет permissive normalize/filter и последовательные mutations; защита UI не заменяет серверную проверку. Затем network grids/полная main/context матрица12.4;12.5 реальная PG/RouterOS/integration приёмка отдельно.
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+7 -12
View File
@@ -1,13 +1,8 @@
import {z} from "zod" import { speedProbesResponseSchema, type SpeedProbeRead } from "@mmapp/contracts"
const metric=z.number().finite().nonnegative().nullable() export { speedProbeReadSchema as speedProbeSchema } from "@mmapp/contracts"
const timestamp=z.string().refine(v=>Number.isFinite(Date.parse(v))).nullable() export type SpeedProbeApiRow = SpeedProbeRead
export const speedProbeSchema=z.object({ export function readProbeSchedule(raw: unknown): SpeedProbeApiRow[] {
id:z.string().min(1),srcServerId:z.string().regex(/^[1-9]\d*$/),dstServerId:z.string().regex(/^[1-9]\d*$/),srcInterface:z.string(),dstInterface:z.string(), const parsed = speedProbesResponseSchema.safeParse(raw)
protocol:z.enum(["tcp","udp"]),direction:z.enum(["both","transmit","receive"]),durationSec:z.string().regex(/^\d+$/).refine(v=>Number.isSafeInteger(Number(v))&&Number(v)>=3),enabled:z.boolean(), if (!parsed.success) throw Error("Данные проб скорости не подтверждены сервером.")
lastRunAt:timestamp,lastTxAvgMbps:metric,lastRxAvgMbps:metric,lastStatus:z.enum(["done","error"]).nullable(),lastError:z.string().nullable() return parsed.data.probes
})
const response=z.object({probes:z.array(speedProbeSchema).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)})
export type SpeedProbeApiRow=z.infer<typeof speedProbeSchema>
export function readProbeSchedule(raw:unknown):SpeedProbeApiRow[]{
const parsed=response.safeParse(raw);if(!parsed.success)throw Error("Данные проб скорости не подтверждены сервером.");return parsed.data.probes
} }
+12
View File
@@ -15,3 +15,15 @@ export const speedHistoryResponseSchema=z.object({runs:z.array(speedHistoryRunSc
const ids=new Set<string>();for(const row of rows){if(ids.has(row.id))ctx.addIssue({code:"custom",message:"Повторный ID истории скорости"});ids.add(row.id)} const ids=new Set<string>();for(const row of rows){if(ids.has(row.id))ctx.addIssue({code:"custom",message:"Повторный ID истории скорости"});ids.add(row.id)}
})}) })})
export type SpeedHistoryRun=z.infer<typeof speedHistoryRunSchema> export type SpeedHistoryRun=z.infer<typeof speedHistoryRunSchema>
const probeTimestamp = z.string().datetime({ offset: true }).nullable()
export const speedProbeReadSchema = z.object({
id: z.string().min(1), srcServerId: z.string().regex(/^[1-9][0-9]*$/), dstServerId: z.string().regex(/^[1-9][0-9]*$/),
srcInterface: z.string(), dstInterface: z.string(), protocol: z.enum(["tcp", "udp"]), direction: z.enum(["both", "transmit", "receive"]),
durationSec: z.string().regex(/^[0-9]+$/).refine(value => Number.isSafeInteger(Number(value)) && Number(value) >= 3), enabled: z.boolean(),
lastRunAt: probeTimestamp, lastTxAvgMbps: rate, lastRxAvgMbps: rate, lastStatus: z.enum(["done", "error"]).nullable(), lastError: z.string().nullable(),
lastPingRttMs: rate.optional(), lastPingLossPct: z.number().finite().min(0).max(100).nullable().optional(),
lastPingAt: probeTimestamp.optional(), lastPingError: z.string().nullable().optional(),
})
export const speedProbesResponseSchema = z.object({ probes: z.array(speedProbeReadSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID speed-пробы") })
export type SpeedProbeRead = z.infer<typeof speedProbeReadSchema>
+1 -1
View File
@@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"
export default defineConfig({ export default defineConfig({
testDir: "./tests/e2e", testDir: "./tests/e2e",
testIgnore: ["**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"], testIgnore: ["**/uptime-context.spec.ts", "**/alerts-context.spec.ts", "**/system-database-context.spec.ts", "**/settings-context.spec.ts", "**/evobgp-context.spec.ts", "**/netflow-context.spec.ts", "**/geoip-context.spec.ts", "**/data-collection-context.spec.ts", "**/backups-context.spec.ts", "**/certificates-context.spec.ts", "**/containers-context.spec.ts", "**/users-context.spec.ts", "**/probes-context.spec.ts", "**/terminal-context.spec.ts", "**/network-map-context.spec.ts", "**/route-optimizer-context.spec.ts", "**/recursive-routes-context.spec.ts", "**/ospf-context.spec.ts", "**/bgp-context.spec.ts", "**/firewall-context.spec.ts", "**/filters-context.spec.ts", "**/ipsec-context.spec.ts", "**/gre-context.spec.ts", "**/vxlan-context.spec.ts"],
fullyParallel: false, fullyParallel: false,
workers: 1, workers: 1,
retries: 0, retries: 0,
+23
View File
@@ -0,0 +1,23 @@
import { test, expect } from "@playwright/test"
import { server } from "./fixtures/users"
const headers = { "access-control-allow-origin":"*", "access-control-allow-methods":"GET,PUT,POST,OPTIONS", "access-control-allow-headers":"Content-Type,Authorization" }
const probe = { id:"probe-1", srcServerId:"1", dstServerId:"2", srcInterface:"ether1", dstInterface:"ether1", protocol:"tcp", direction:"both", durationSec:"10", enabled:true,lastRunAt:null,lastTxAvgMbps:null,lastRxAvgMbps:0,lastStatus:null,lastError:null }
const run = {id:"run-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 change of ["backend","mode"] as const) for (const resource of ["probes","history"] as const) for (const failure of [false,true]) test("Uptime speed context/"+change+"/"+resource+"/"+failure,async({page})=>{
let hold=false,started=false,completed=false;let release!:()=>void;const gate=new Promise<void>(resolve=>release=resolve)
const target=resource === "probes" ? "/api/uptime/speed-probes" : "/api/uptime/speed-test/runs"
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}))
await page.route("**/api/**",async r=>{
const url=new URL(r.request().url()),path=url.pathname,old=url.port === "8000"
const body=path === "/api/auth/config" ? {required:false} : path === "/api/servers" ? [{...server,name:old ? "Старый источник" : "Новый источник"}] : path === "/api/uptime/overview" ? {probes:[],resources:[]} : path === "/api/uptime/speed-probes" ? {probes:[{...probe,id:old ? "old-probe" : "new-probe"}]} : path === "/api/uptime/speed-test/runs" ? {runs:[{...run,id:old ? "old-run" : "new-run"}]} : {}
if(path === target && old && hold){started=true;await gate;try{await r.fulfill(failure ? {headers,status:503,json:{error:"СТАРАЯ ОШИБКА СКОРОСТИ"}} : {headers,json:body})}catch{}finally{completed=true};return}
return r.fulfill({headers,json:body})
})
await page.goto("/uptime?tab=speed");await expect(page.locator('tr[data-row-id="old-run"]')).toBeVisible();await page.getByRole("button",{name:"Пауза",exact:true}).click();hold=true;await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect.poll(()=>started).toBe(true)
await page.goto("/settings")
if(change === "backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill("http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:"Моковые",exact:true}).click()
await page.goto("/uptime?tab=speed")
if(change === "backend"){await expect(page.locator('tr[data-row-id="new-run"]')).toBeVisible();await expect(page.getByText("Новый источник",{exact:true}).first()).toBeVisible()}else await expect(page.getByRole("button",{name:"Сбросить",exact:true})).toBeVisible()
release();await expect.poll(()=>completed).toBe(true);await expect(page.locator('tr[data-row-id="old-run"]')).toHaveCount(0);await expect(page.getByText("СТАРАЯ ОШИБКА СКОРОСТИ",{exact:true})).toHaveCount(0)
if(change === "mode"){await expect(page.getByText(/^Speed-пробы: получено/)).toHaveCount(0);await expect(page.getByRole("alert",{name:"Чтение скорости: probes",exact:true})).toHaveCount(0)}
})
+15 -1
View File
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test" import { test, expect } from "@playwright/test"
import axe from "axe-core"
import { server } from "./fixtures/users" 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" } 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 }) => { for (const resource of ["probes", "history"] as const) test("Скорость: initial unknown и независимое recovery/" + resource, async ({ page }) => {
@@ -30,7 +31,7 @@ test("Скорость: поздняя история не задерживае
test("Скорость: null не становится нулём, malformed DTO сохраняет последний результат", async ({page}) => { test("Скорость: null не становится нулём, malformed DTO сохраняет последний результат", async ({page}) => {
let entries:unknown[]=[{...run,txAvgMbps:null,rxAvgMbps:0}] let entries:unknown[]=[{...run,txAvgMbps:null,rxAvgMbps:0}]
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({json:p === "/api/auth/config" ? {required:false} : p === "/api/servers" ? [server] : p === "/api/uptime/overview" ? {probes:[],resources:[]} : p === "/api/uptime/speed-probes" ? {probes:[{id:"probe-1",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"ether1",protocol:"tcp",direction:"both",durationSec:"10",enabled:true,lastStatus:"done",lastRunAt:"2026-10-03T00:00:00Z",lastTxAvgMbps:999,lastRxAvgMbps:0}]} : p === "/api/uptime/speed-test/runs" ? {runs:entries} : {}})}) 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({json:p === "/api/auth/config" ? {required:false} : p === "/api/servers" ? [server] : p === "/api/uptime/overview" ? {probes:[],resources:[]} : p === "/api/uptime/speed-probes" ? {probes:[{id:"probe-1",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"ether1",protocol:"tcp",direction:"both",durationSec:"10",enabled:true,lastStatus:"done",lastRunAt:"2026-10-03T00:00:00Z",lastTxAvgMbps:999,lastRxAvgMbps:0,lastError:null}]} : p === "/api/uptime/speed-test/runs" ? {runs:entries} : {}})})
await page.goto("/uptime?tab=speed");await page.getByRole("button",{name:"Пауза",exact:true}).click();const row=page.locator('tr[data-row-id="history-1"]');await expect(row).toContainText("Неизвестно");await expect(row).toContainText("0 Мбит/с");await expect(page.getByText("999 Мбит/с",{exact:true})).toHaveCount(0) await page.goto("/uptime?tab=speed");await page.getByRole("button",{name:"Пауза",exact:true}).click();const row=page.locator('tr[data-row-id="history-1"]');await expect(row).toContainText("Неизвестно");await expect(row).toContainText("0 Мбит/с");await expect(page.getByText("999 Мбит/с",{exact:true})).toHaveCount(0)
await page.setViewportSize({width:320,height:1000});expect(await page.evaluate(()=>document.documentElement.scrollWidth <= innerWidth)).toBe(true) await page.setViewportSize({width:320,height:1000});expect(await page.evaluate(()=>document.documentElement.scrollWidth <= innerWidth)).toBe(true)
await page.addStyleTag({content:"nextjs-portal{display:none!important}"});await page.screenshot({path:"docs/ux-ui/screens/speed-history-null-320.png",fullPage:true,animations:"disabled"}) await page.addStyleTag({content:"nextjs-portal{display:none!important}"});await page.screenshot({path:"docs/ux-ui/screens/speed-history-null-320.png",fullPage:true,animations:"disabled"})
@@ -40,3 +41,16 @@ test("Скорость: null не становится нулём, malformed DTO
} }
entries=[{...run,txAvgMbps:0}];await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByRole("alert",{name:"Чтение скорости: history",exact:true})).toHaveCount(0);await expect(row).not.toContainText("Неизвестно");await expect(row).toContainText("0 Мбит/с") entries=[{...run,txAvgMbps:0}];await page.getByRole("button",{name:"Обновить",exact:true}).click();await expect(page.getByRole("alert",{name:"Чтение скорости: history",exact:true})).toHaveCount(0);await expect(row).not.toContainText("Неизвестно");await expect(row).toContainText("0 Мбит/с")
}) })
test("Speed-пробы: malformed каталог, cache/freshness и блокировка замены до recovery", async ({page}) => {
const probe={id:"probe-1",srcServerId:"1",dstServerId:"2",srcInterface:"ether1",dstInterface:"ether1",protocol:"tcp",direction:"both",durationSec:"10",enabled:true,lastRunAt:null,lastTxAvgMbps:null,lastRxAvgMbps:0,lastStatus:null,lastError:null}
let raw:unknown={probes:[{...probe,enabled:"yes"}]};const writes:string[]=[]
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;if(r.request().method() !== "GET")writes.push(p);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" ? raw : p === "/api/uptime/speed-test/runs" ? {runs:[]} : {}})})
await page.goto("/uptime?tab=speed");await page.getByRole("button",{name:"Пауза",exact:true}).click();const alert=page.getByRole("alert",{name:"Чтение скорости: probes",exact:true});await expect(alert).toContainText("Данные пока неизвестны");await expect(page.getByText("Нет speed-проб",{exact:true})).toHaveCount(0);await expect(page.getByRole("button",{name:"Новая speed-проба",exact:true})).toBeDisabled()
const refresh=page.getByRole("button",{name:"Обновить",exact:true});raw={probes:[probe]};await refresh.click();await expect(alert).toHaveCount(0);const toggle=page.getByRole("switch");await expect(toggle).toBeChecked();await expect(toggle).toBeEnabled();const freshness=page.getByRole("status").filter({hasText:/^Speed-пробы: получено/});const before=await freshness.textContent()
for(const invalid of [{probes:[probe,probe]},{probes:[{...probe,lastRxAvgMbps:-1}]},{probes:[{...probe,lastStatus:"other"}]},{probes:[{...probe,lastRunAt:"bad"}]}]) {raw=invalid;await refresh.click();await expect(alert).toContainText("Последние данные сохранены");await expect(freshness).toHaveText(before!);await expect(toggle).toBeChecked();await expect(toggle).toBeDisabled();await expect(page.getByRole("button",{name:"Новая speed-проба",exact:true})).toBeDisabled()}
await page.setViewportSize({width:320,height:1000});expect(await page.evaluate(()=>document.documentElement.scrollWidth <= innerWidth)).toBe(true);await expect(toggle).toHaveAccessibleName(/Включить speed-пробу/)
await page.addScriptTag({content:axe.source});const audit=await page.evaluate(async()=> (window as unknown as {axe:typeof axe}).axe.run(document.querySelector('[aria-label="Чтение скорости: probes"]')!,{runOnly:{type:"tag",values:["wcag2a","wcag2aa","wcag21a","wcag21aa"]}}));expect(audit.violations).toEqual([])
await page.addStyleTag({content:"nextjs-portal{display:none!important}"});await page.screenshot({path:"docs/ux-ui/screens/speed-probes-error-320.png",fullPage:true,animations:"disabled"})
raw={probes:[probe]};await refresh.click();await expect(alert).toHaveCount(0);await expect(toggle).toBeEnabled();expect(writes).toEqual([])
})