fix(probes): проверять расписание и сохранять подтверждённые данные

This commit is contained in:
Denozordec committed 2026-10-03 22:12:08 +07:00
1 parent 1d27127b52
commit 271291a910
10 files changed
+108 -98

No files matched your search

+7 -77
View File
@@ -15,10 +15,7 @@ import {
type SchedRule,
type SchedType,
} from "@/components/data-grids/probes-schedule-data-grid"
import {
ProbesSpeedProbesDataGrid,
type SpeedProbeApiRow,
} from "@/components/data-grids/probes-speed-probes-data-grid"
import {ProbesSpeedSchedule} from "@/components/probes-speed-schedule"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
@@ -32,7 +29,7 @@ import {
PlayIcon, SquareIcon, CopyIcon, Trash2Icon, PlusIcon,
ActivityIcon, RouteIcon, SearchIcon, NetworkIcon, RulerIcon,
ZapIcon, CheckIcon, TerminalIcon,
LoaderCircleIcon, AlertCircleIcon,
LoaderCircleIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { requestJson } from "@/shared/api/http-client"
@@ -382,65 +379,6 @@ function TerminalOutput({ test }: { test: DiagTest }) {
}
// ─── uptime speed probes (live) ───────────────────────────────────────────────
function ScheduleSpeedProbesLive({
apiFetch,
serversForName,
}: {
apiFetch: ReturnType<typeof makeApiFetch>
serversForName: Server[]
}) {
const [rows, setRows] = useState<SpeedProbeApiRow[]>([])
const [loading, setLoading] = useState(true)
const [err, setErr] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setErr(null)
void apiFetch<{ probes: SpeedProbeApiRow[] }>("/api/uptime/speed-probes")
.then(d => {
if (!cancelled) setRows(d.probes ?? [])
})
.catch(e => {
if (!cancelled) setErr(e instanceof Error ? e.message : String(e))
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [apiFetch])
const name = (id: string) => serversForName.find(s => s.id === id)?.name ?? id
if (loading) {
return (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<LoaderCircleIcon aria-hidden="true" className="size-5 motion-safe:animate-spin" />
Загрузка расписания из БД…
</div>
)
}
if (err) {
return (
<div className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertCircleIcon aria-hidden="true" className="size-4 shrink-0" />
{err}
</div>
)
}
return (
<div className="flex flex-col gap-3">
<DataPageCard>
<ProbesSpeedProbesDataGrid rows={rows} serverName={name} />
</DataPageCard>
<p className="text-[11px] text-muted-foreground">
Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API.
</p>
</div>
)
}
// ─── schedule tab ─────────────────────────────────────────────────────────────
const INIT_RULES: SchedRule[] = [
@@ -464,21 +402,12 @@ function ScheduleTab({
}) {
const [showAdd, setShowAdd] = useState(false)
const [addSrc, setAddSrc] = useState(defaultSrc ?? serverOptions[0]?.id ?? "srv1")
const [addTun, setAddTun] = useState("")
const [selectedTunnel, setAddTun] = useState("")
const [addType, setAddType] = useState<SchedType>("ping")
const [addMin, setAddMin] = useState(10)
const addTunnels = useMemo(() => tunnelsForServer(addSrc), [addSrc, tunnelsForServer])
useEffect(() => {
if (defaultSrc) setAddSrc(defaultSrc)
}, [defaultSrc])
useEffect(() => {
const list = tunnelsForServer(addSrc)
if (list.length && !list.some(t => t.id === addTun)) {
setAddTun(list[0]?.id ?? "")
}
}, [addSrc, addTun, tunnelsForServer])
const addTun = addTunnels.some(t=>t.id===selectedTunnel) ? selectedTunnel : addTunnels[0]?.id ?? ""
const tunnelName = (srcId: string, tunnelId: string) =>
tunnelsForServer(srcId).find((t) => t.id === tunnelId)?.name
@@ -504,7 +433,7 @@ function ScheduleTab({
</div>
<div className="px-4 py-4 flex flex-wrap items-end gap-3">
<div><OptionLabel>Сервер</OptionLabel>
<NativeSelect ariaLabel="Устройство расписания" value={addSrc} onChange={setAddSrc} className="min-w-[160px]">
<NativeSelect ariaLabel="Устройство расписания" value={addSrc} onChange={value=>{setAddSrc(value);setAddTun("")}} className="min-w-[160px]">
{serverOptions.filter(s => s.enabled).map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
</NativeSelect>
</div>
@@ -1195,9 +1124,10 @@ function ProbesContent() {
{/* schedule */}
{tab === "schedule" && (
isLive
? <ScheduleSpeedProbesLive apiFetch={apiFetch} serversForName={allServers} />
? <ProbesSpeedSchedule base={backendUrl} serversForName={allServers} />
: (
<ScheduleTab
key={srcId}
rules={rules}
setRules={setRules}
serverOptions={allServers}
@@ -18,22 +18,8 @@ import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sor
import { EmptyState } from "@/components/empty-state"
import { ClockIcon } from "lucide-react"
export interface SpeedProbeApiRow {
id: string
srcServerId: string
dstServerId: string
srcInterface: string
dstInterface: string
protocol: string
direction: string
durationSec: string
enabled: boolean
lastRunAt: string | null
lastTxAvgMbps: number | null
lastRxAvgMbps: number | null
lastStatus: string | null
lastError: string | null
}
import type {SpeedProbeApiRow} from "@/lib/probe-schedule-response"
export type {SpeedProbeApiRow} from "@/lib/probe-schedule-response"
interface ProbesSpeedProbesDataGridProps {
rows: SpeedProbeApiRow[]
@@ -123,9 +109,9 @@ function ProbesSpeedProbesDataGrid({ rows, serverName }: ProbesSpeedProbesDataGr
return (
<span className="text-xs text-muted-foreground truncate block">
{r.lastRunAt ?? "—"}
{r.lastStatus === "done" && r.lastTxAvgMbps != null && (
{r.lastStatus === "done" && (
<span className="text-emerald-600 dark:text-emerald-400 ml-1">
TX≈{r.lastTxAvgMbps.toFixed(1)} RX≈{(r.lastRxAvgMbps ?? 0).toFixed(1)} Mb/s
TX: {r.lastTxAvgMbps==null?"неизвестно":r.lastTxAvgMbps.toFixed(1)+" Мбит/с"} · RX: {r.lastRxAvgMbps==null?"неизвестно":r.lastRxAvgMbps.toFixed(1)+" Мбит/с"}
</span>
)}
{r.lastStatus === "error" && r.lastError && (
@@ -157,7 +143,7 @@ function ProbesSpeedProbesDataGrid({ rows, serverName }: ProbesSpeedProbesDataGr
<EmptyState
icon={<ClockIcon className="size-4" />}
title="Нет записей speed-test"
description="Настраиваются через API PUT /api/uptime/speed-probes"
description="Добавьте пробы в разделе мониторинга."
className="border-0 py-10"
/>
)
+41
View File
@@ -0,0 +1,41 @@
"use client"
import Link from "next/link"
import {useCallback,useEffect,useRef,useState} from "react"
import {Button} from "@/components/ui/button"
import {DataPageCard} from "@/components/data-page-card"
import {ProbesSpeedProbesDataGrid} from "@/components/data-grids/probes-speed-probes-data-grid"
import {can,ensureAuthConfig} from "@/lib/auth"
import {readProbeSchedule,type SpeedProbeApiRow} from "@/lib/probe-schedule-response"
import type {Server} from "@/lib/data"
import {requestJson} from "@/shared/api/http-client"
type Snapshot={rows:SpeedProbeApiRow[]|undefined;loading:boolean;error:string|null;receivedAt:string|null}
export function ProbesSpeedSchedule({base,serversForName}:{base:string;serversForName:Server[]}){
const [state,setState]=useState<Snapshot>({rows:undefined,loading:true,error:null,receivedAt:null})
const active=useRef(false),revision=useRef(0),request=useRef<AbortController|null>(null),timer=useRef<ReturnType<typeof setTimeout>|null>(null)
const refresh=useCallback(async()=>{
if(!active.current||request.current)return
const id=++revision.current,controller=new AbortController();request.current=controller
const current=()=>active.current&&revision.current===id&&!controller.signal.aborted
setState(previous=>({...previous,loading:true,error:null}))
timer.current=setTimeout(()=>{if(current()){revision.current++;controller.abort();request.current=null;setState(previous=>({...previous,loading:false,error:"Время ожидания проб скорости истекло. Повторите обновление."}))}},30000)
try{
await ensureAuthConfig();if(!current())return
if(!can("mm:uptime:read")){setState({rows:undefined,loading:false,error:"Нет права читать пробы скорости.",receivedAt:null});return}
const rows=readProbeSchedule(await requestJson<unknown>(base,"/api/uptime/speed-probes",{signal:controller.signal,cache:"no-store"}))
if(current()){
if(!can("mm:uptime:read")){setState({rows:undefined,loading:false,error:"Нет права читать пробы скорости.",receivedAt:null});return}
setState({rows,loading:false,error:null,receivedAt:new Date().toISOString()})
}
}catch(error){if(current())setState(previous=>({...previous,loading:false,error:error instanceof Error?error.message:"Не удалось прочитать пробы скорости."}))}
finally{if(revision.current===id){if(timer.current)clearTimeout(timer.current);timer.current=null;request.current=null}}
},[base])
useEffect(()=>{const generation=revision;active.current=true;let pending=true;queueMicrotask(()=>{if(pending)void refresh()});return()=>{pending=false;active.current=false;generation.current++;request.current?.abort();request.current=null;if(timer.current)clearTimeout(timer.current)}},[refresh])
return <section aria-label="Пробы скорости" className="min-w-0 space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"><p role="status" className="text-xs text-muted-foreground">{state.loading?"Обновление проб скорости…":state.receivedAt?"Последние данные получены: "+new Date(state.receivedAt).toLocaleString("ru-RU"):"Данные проб скорости ещё не получены."}</p><Button type="button" size="sm" variant="outline" disabled={state.loading} onClick={()=>void refresh()}>Обновить пробы скорости</Button></div>
{state.error?<p role="alert" className="text-sm text-destructive">{state.error}{state.rows!==undefined?" Показан последний успешный список.":""}</p>:null}
{state.rows!==undefined?<DataPageCard><ProbesSpeedProbesDataGrid rows={state.rows} serverName={id=>serversForName.find(s=>s.id===id)?.name??id}/></DataPageCard>:null}
<p className="text-xs text-muted-foreground">Показаны пробы скорости коллектора. Периодичность запуска задаётся в сборе данных; отдельного расписания диагностических GRE-тестов в живом режиме нет.</p>
<div className="flex flex-wrap gap-3 text-sm"><Link className="underline underline-offset-4" href="/uptime">Настроить пробы в мониторинге</Link><Link className="underline underline-offset-4" href="/data-collection">Открыть сбор данных</Link></div>
</section>
}
+1 -1
View File
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 2026-10-03
Этап 11Б в работе; последний проверенный блок 11Б.5; Alerts и Releases приняты в границах локальной проверки. Следующий блок 11Б.6: достоверное чтение live speed-test в расписании диагностики, права uptime:read, повтор/последние данные и неизвестный RX без нуля; затем source/адаптивная приёмка и сводная приёмка 11Б/12. Processing recovery требует отдельного доказательства остановки всех отправителей.
Этап 11Б в работе; последний проверенный блок 11Б.6; Releases/probe schedule приняты локально. Следующий блок 11Б.7: сводная локальная регрессия оповещений, scheduler/integrations/settings/DB/releases/probe schedule, итоговый список внешних ограничений и production build; затем начать этап 12. Processing recovery без доказуемой остановки всех отправителей остаётся недоступен.
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
+9 -1
View File
@@ -28,7 +28,7 @@
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.5; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.6; scheduler и GeoIP приняты локально; NetFlow принят локально; Evolution принят локально; далее общие Settings |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 11Б.6 — достоверное расписание проб скорости (2026-10-03)
- Live schedule reader выделен из диагностики, ждёт auth config и проверяет uptime:read до запроса/публикации. Strict required DTO/unique IDs/nullable finite metrics, no-store, 30s deadline, abort/lifetime/revision и source-key parent. Первоначальный сбой остаётся unknown, повтор явный; фоновая ошибка сохраняет подтверждённый список/время.
- Таблица не превращает null RX/TX в0, каждый показатель подписан отдельно. Пояснение различает пробы коллектора и отсутствующее live GRE расписание; реальные ссылки ведут к мониторингу и сбору данных. API URL удалён из пользовательского empty state. ReUI Data Grid v9 API/checklist прочитаны, composition/primitives не менялись.
- Demo picker больше не хранит туннель другого устройства: выбор выводится из текущего каталога, context key сбрасывает форму для нового устройства. Удалены исходные synchronous set-state effects, выявленные профильным lint.
- 2 unit, browser36 диагностики/schedule и повтор8 включая320/1440/demo, context8 включая4 ABA schedule, typecheck/lint прошли; screenshot320 просмотрен. Первый запуск теста остановился на ошибке escaping regex в fixture; исправлена. Нет утверждения о реальном запуске или доставке; backend legacy GET normalization и реальный uptime worker не переприняты.
- Следующий блок 11Б.7: сводная локальная регрессия оповещений, scheduler/integrations/settings/DB/releases/probe schedule, итоговый список внешних ограничений и production build; затем начать этап 12. Processing recovery без доказуемой остановки всех отправителей остаётся недоступен.
## Блок 11Б.5 — достоверность метаданных релиза (2026-10-03)
- Runtime parser проверяет version/tag/date/commit fields, уникальные SHA и short SHA; malformed/missing JSON даёт unknown вместо фиктивного fallback v1.0.0. Подтверждённый пустой манифест остаётся пустым. Ссылки допускают только абсолютные HTTP(S) без credentials; rel=noopener noreferrer. Версии вручную не менялись.
+4
View File
@@ -328,3 +328,7 @@ Live BGP-prefix честно помечен unsupported, legacy записи н
### Продолжение 2026-10-03 — 11Б.5
Releases strict metadata/unknown/local/безопасные ссылки и current-production announcement приняты локально. Unit3/browser3/typecheck/lint/final production build прошли, screenshot320 просмотрен; публикация CI/Gitea не проверена. Следующий блок 11Б.6: достоверное чтение live speed-test в расписании диагностики, права uptime:read, повтор/последние данные и неизвестный RX без нуля; затем source/адаптивная приёмка и сводная приёмка 11Б/12. Processing recovery требует отдельного доказательства остановки всех отправителей.
### Продолжение 2026-10-03 — 11Б.6
Live probe schedule strict read/own permissions/last good/retry/deadline/source и unknown TX/RX принят локально; demo tunnel selection исправлен. Unit2/browser36+8/context8/typecheck/lint/ReUI audit прошли, screenshot320 просмотрен. Реальный collector/legacy backend normalization не переприняты. Следующий блок 11Б.7: сводная локальная регрессия оповещений, scheduler/integrations/settings/DB/releases/probe schedule, итоговый список внешних ограничений и production build; затем начать этап 12. Processing recovery без доказуемой остановки всех отправителей остаётся недоступен.
+6
View File
@@ -0,0 +1,6 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {readProbeSchedule} from "./probe-schedule-response"
export const probe={id:"probe-fixture",srcServerId:"1",dstServerId:"2",srcInterface:"",dstInterface:"",protocol:"tcp",direction:"both",durationSec:"10",enabled:true,lastRunAt:null,lastTxAvgMbps:0,lastRxAvgMbps:null,lastStatus:"done",lastError:null}
test("probe schedule keeps confirmed empty and nullable metrics distinct",()=>{assert.deepEqual(readProbeSchedule({probes:[]}),[]);assert.equal(readProbeSchedule({probes:[probe]})[0].lastRxAvgMbps,null);assert.equal(readProbeSchedule({probes:[probe]})[0].lastTxAvgMbps,0)})
test("probe schedule rejects incomplete/duplicate/malformed rows",()=>{for(const raw of [{},{probes:null},{probes:[{}]},{probes:[probe,probe]},{probes:[{...probe,enabled:"yes"}]},{probes:[{...probe,lastRxAvgMbps:-1}]},{probes:[{...probe,lastStatus:"unknown-status"}]},{probes:[{...probe,lastRunAt:"bad-date"}]}])assert.throws(()=>readProbeSchedule(raw))})
+13
View File
@@ -0,0 +1,13 @@
import {z} from "zod"
const metric=z.number().finite().nonnegative().nullable()
const timestamp=z.string().refine(v=>Number.isFinite(Date.parse(v))).nullable()
export const speedProbeSchema=z.object({
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(),
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(),
lastRunAt:timestamp,lastTxAvgMbps:metric,lastRxAvgMbps:metric,lastStatus:z.enum(["done","error"]).nullable(),lastError:z.string().nullable()
})
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
}
+10
View File
@@ -13,3 +13,13 @@ for(const change of ["backend","mode"])for(const fails of [false,true])test("Д
if(change==="backend")await expect(page.getByText("НОВЫЙ РЕЗУЛЬТАТ",{exact:true})).toBeVisible()
await expect(page.getByText("СТАРЫЙ РЕЗУЛЬТАТ",{exact:true})).toHaveCount(0);await expect(page.getByText("СТАРАЯ ОШИБКА",{exact:true})).toHaveCount(0)
})
for(const change of ["backend","mode"])for(const fails of [false,true])test("Probe schedule ignores late old-source snapshot after ABA/"+change+"/"+fails,async({page})=>{
let started=false,completed=false,first=true,release!:()=>void;const gate=new Promise<void>(r=>release=r)
const row={id:"new-probe",srcServerId:"1",dstServerId:"2",srcInterface:"NEW_SCHEDULE",dstInterface:"",protocol:"tcp",direction:"both",durationSec:"10",enabled:true,lastRunAt:null,lastTxAvgMbps:0,lastRxAvgMbps:null,lastStatus:"done",lastError:null}
await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[{id:1,name:"Fixture",host:"192.0.2.1",type:"exit-node",enabled:true,status:"online"}]:p==="/api/uptime/speed-probes"?{probes:[row]}:{tunnels:[]}})})
await page.route("**/api/uptime/speed-probes",async r=>{if(new URL(r.request().url()).port!=="8000"||!first)return r.fallback();first=false;started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"OLD_SCHEDULE_ERROR"}}:{headers,json:{probes:[{...row,id:"old-probe",srcInterface:"OLD_SCHEDULE"}]}})}catch{}finally{completed=true}})
await page.goto("/probes?server=1");await page.getByRole("button",{name:"Расписание",exact:true}).click();await expect.poll(()=>started).toBe(true)
async function switchSource(back:boolean){await page.getByRole("link",{name:/^Настройки/}).click();if(change==="backend"){const input=page.getByPlaceholder("http://localhost:8000");await input.fill(back?"http://localhost:8000":"http://localhost:8101");await input.press("Enter")}else await page.getByRole("button",{name:back?"Живые":"Моковые",exact:true}).click();await page.goto("/probes?server=1");await page.getByRole("button",{name:"Расписание",exact:true}).click()}
await switchSource(false);await switchSource(true);await expect(page.getByText(/NEW_SCHEDULE/)).toBeVisible();release();await expect.poll(()=>completed).toBe(true);await expect(page.getByText(/OLD_SCHEDULE/)).toHaveCount(0)
})
+12
View File
@@ -0,0 +1,12 @@
import {test,expect,type Page} from "@playwright/test"
const probe={id:"probe-fixture",srcServerId:"1",dstServerId:"2",srcInterface:"",dstInterface:"",protocol:"tcp",direction:"both",durationSec:"10",enabled:true,lastRunAt:null,lastTxAvgMbps:0,lastRxAvgMbps:null,lastStatus:"done",lastError:null}
async function setup(page:Page){await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[{id:1,name:"Fixture server",host:"192.0.2.1",enabled:true,type:"exit-node",status:"online"}]:{tunnels:[]}}))}
async function open(page:Page){await page.goto("/probes?server=1");await page.getByRole("button",{name:"Расписание",exact:true}).click();return page.getByRole("region",{name:"Пробы скорости",exact:true})}
for(const malformed of [false,true])test("Probe schedule initial unknown and explicit retry/"+malformed,async({page})=>{await setup(page);let fail=true;await page.route("**/api/uptime/speed-probes",r=>r.fulfill(fail?(malformed?{json:{}}:{status:503,json:{error:"Schedule unavailable"}}):{json:{probes:[]}}));const panel=await open(page);await expect(panel.getByRole("alert")).toBeVisible();await expect(panel.getByText("Нет записей speed-test",{exact:true})).toHaveCount(0);fail=false;await panel.getByRole("button",{name:"Обновить пробы скорости"}).click();await expect(panel.getByText("Нет записей speed-test",{exact:true})).toBeVisible()})
test("Probe schedule refresh preserves confirmed rows and missing RX does not become zero",async({page})=>{await setup(page);let fail=false;await page.route("**/api/uptime/speed-probes",r=>r.fulfill(fail?{status:503,json:{error:"Schedule unavailable"}}:{json:{probes:[probe]}}));const panel=await open(page);await expect(panel.getByText(/TX: 0.0 .*RX: неизвестно/)).toBeVisible();fail=true;await panel.getByRole("button",{name:"Обновить пробы скорости"}).click();await expect(panel.getByRole("alert")).toContainText("Показан последний успешный список");await expect(panel.getByText(/RX: неизвестно/)).toBeVisible();await expect(panel.getByText(/Последние данные получены:/)).toBeVisible()})
test("Probe schedule timeout is retryable without automatic request",async({page})=>{await page.clock.install();await setup(page);let calls=0;await page.route("**/api/uptime/speed-probes",()=>{calls++});const panel=await open(page);await expect.poll(()=>calls).toBe(1);await page.clock.fastForward(30100);await expect(panel.getByRole("alert")).toContainText("Время ожидания");await expect(panel.getByRole("button",{name:"Обновить пробы скорости"})).toBeEnabled();expect(calls).toBe(1)})
test("Probe schedule respects its own uptime permission",async({page})=>{await setup(page);const claims={sub:"fixture",apps:["mm"],permissions:["mm:servers:read"],exp:Math.floor(Date.now()/1000)+3600};const token="e30."+Buffer.from(JSON.stringify(claims)).toString("base64url")+".fixture";await page.addInitScript(value=>localStorage.setItem("mmapp_token",value),token);await page.route("**/api/auth/config",r=>r.fulfill({json:{required:true}}));let calls=0;await page.route("**/api/uptime/speed-probes",r=>{calls++;return r.fulfill({json:{probes:[]}})});const panel=await open(page);await expect(panel.getByRole("alert")).toContainText("Нет права читать пробы скорости");expect(calls).toBe(0)})
for(const width of [320,1440])test("Probe schedule known rows fit/"+width,async({page})=>{await setup(page);await page.setViewportSize({width,height:900});await page.route("**/api/uptime/speed-probes",r=>r.fulfill({json:{probes:[{...probe,lastRunAt:"2026-10-03T00:00:00Z"}]}}));const panel=await open(page);await expect(panel.getByText(/RX: неизвестно/)).toBeVisible();await panel.scrollIntoViewIfNeeded();await expect(panel.getByRole("button",{name:"Обновить пробы скорости"})).toBeInViewport();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true);await panel.screenshot({path:"test-results/probes-schedule-"+width+".png"})})
test("Demo schedule clears a tunnel from another device and selects a valid one",async({page})=>{await setup(page);await page.addInitScript(()=>localStorage.setItem("routerlists:data-source","mock"));await page.goto("/probes?server=srv1");await page.getByRole("button",{name:"Расписание",exact:true}).click();await page.getByRole("button",{name:"Добавить правило",exact:true}).click();const server=page.getByRole("combobox",{name:"Устройство расписания"}),tunnel=page.getByRole("combobox",{name:"Туннель расписания"});await expect(tunnel).toHaveValue("gre1");await server.selectOption("srv2");await expect(tunnel).toHaveValue("");await expect(page.getByRole("button",{name:"Добавить",exact:true})).toBeDisabled();await server.selectOption("srv7");await expect(tunnel).toHaveValue("gre5");await expect(page.getByRole("button",{name:"Добавить",exact:true})).toBeEnabled()})