From 271291a910bfe58ab54dd26a1d00146820176864 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 22:12:08 +0700 Subject: [PATCH] =?UTF-8?q?fix(probes):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D1=82=D1=8C=20=D1=80=D0=B0=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=8F=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B6=D0=B4=D1=91=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/probes/page.tsx | 84 ++----------------- .../probes-speed-probes-data-grid.tsx | 24 ++---- components/probes-speed-schedule.tsx | 41 +++++++++ docs/ux-ui-plan.md | 2 +- docs/ux-ui-progress.md | 10 ++- docs/ux-ui-stage-11b.md | 4 + lib/probe-schedule-response.test.ts | 6 ++ lib/probe-schedule-response.ts | 13 +++ tests/e2e/probes-context.spec.ts | 10 +++ tests/e2e/probes-schedule.spec.ts | 12 +++ 10 files changed, 108 insertions(+), 98 deletions(-) create mode 100644 components/probes-speed-schedule.tsx create mode 100644 lib/probe-schedule-response.test.ts create mode 100644 lib/probe-schedule-response.ts create mode 100644 tests/e2e/probes-schedule.spec.ts diff --git a/app/(main)/probes/page.tsx b/app/(main)/probes/page.tsx index 3a2101cb..a5f05539 100644 --- a/app/(main)/probes/page.tsx +++ b/app/(main)/probes/page.tsx @@ -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 - serversForName: Server[] -}) { - const [rows, setRows] = useState([]) - const [loading, setLoading] = useState(true) - const [err, setErr] = useState(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 ( -
-
- ) - } - if (err) { - return ( -
-
- ) - } - - return ( -
- - - -

- Данные из коллектора uptime (та же БД, что и дашборд). Редактирование — через настройки мониторинга / API. -

-
- ) -} - // ─── 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("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({
Сервер - + {setAddSrc(value);setAddTun("")}} className="min-w-[160px]"> {serverOptions.filter(s => s.enabled).map(s => )}
@@ -1195,9 +1124,10 @@ function ProbesContent() { {/* schedule */} {tab === "schedule" && ( isLive - ? + ? : ( {r.lastRunAt ?? "—"} - {r.lastStatus === "done" && r.lastTxAvgMbps != null && ( + {r.lastStatus === "done" && ( - 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)+" Мбит/с"} )} {r.lastStatus === "error" && r.lastError && ( @@ -157,7 +143,7 @@ function ProbesSpeedProbesDataGrid({ rows, serverName }: ProbesSpeedProbesDataGr } title="Нет записей speed-test" - description="Настраиваются через API PUT /api/uptime/speed-probes" + description="Добавьте пробы в разделе мониторинга." className="border-0 py-10" /> ) diff --git a/components/probes-speed-schedule.tsx b/components/probes-speed-schedule.tsx new file mode 100644 index 00000000..15530a5e --- /dev/null +++ b/components/probes-speed-schedule.tsx @@ -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({rows:undefined,loading:true,error:null,receivedAt:null}) + const active=useRef(false),revision=useRef(0),request=useRef(null),timer=useRef|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(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
+

{state.loading?"Обновление проб скорости…":state.receivedAt?"Последние данные получены: "+new Date(state.receivedAt).toLocaleString("ru-RU"):"Данные проб скорости ещё не получены."}

+ {state.error?

{state.error}{state.rows!==undefined?" Показан последний успешный список.":""}

:null} + {state.rows!==undefined?serversForName.find(s=>s.id===id)?.name??id}/>:null} +

Показаны пробы скорости коллектора. Периодичность запуска задаётся в сборе данных; отдельного расписания диагностических GRE-тестов в живом режиме нет.

+
Настроить пробы в мониторингеОткрыть сбор данных
+
+} diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 58e28d1d..a3a9e503 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -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). diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 1b95af9d..d1cb90e0 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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. Версии вручную не менялись. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 0e5e684b..cb8c6e54 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -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 без доказуемой остановки всех отправителей остаётся недоступен. diff --git a/lib/probe-schedule-response.test.ts b/lib/probe-schedule-response.test.ts new file mode 100644 index 00000000..aa65f237 --- /dev/null +++ b/lib/probe-schedule-response.test.ts @@ -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))}) diff --git a/lib/probe-schedule-response.ts b/lib/probe-schedule-response.ts new file mode 100644 index 00000000..574b9521 --- /dev/null +++ b/lib/probe-schedule-response.ts @@ -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 +export function readProbeSchedule(raw:unknown):SpeedProbeApiRow[]{ + const parsed=response.safeParse(raw);if(!parsed.success)throw Error("Данные проб скорости не подтверждены сервером.");return parsed.data.probes +} diff --git a/tests/e2e/probes-context.spec.ts b/tests/e2e/probes-context.spec.ts index df40105c..076d8979 100644 --- a/tests/e2e/probes-context.spec.ts +++ b/tests/e2e/probes-context.spec.ts @@ -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(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) +}) diff --git a/tests/e2e/probes-schedule.spec.ts b/tests/e2e/probes-schedule.spec.ts new file mode 100644 index 00000000..3a166a1c --- /dev/null +++ b/tests/e2e/probes-schedule.spec.ts @@ -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()})