fix(probes): проверять расписание и сохранять подтверждённые данные
This commit is contained in:
1 parent
1d27127b52
commit
271291a910
10 files changed
+108
-98
No files matched your search
@@ -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"
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
|
||||
@@ -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. Версии вручную не менялись.
|
||||
|
||||
@@ -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 без доказуемой остановки всех отправителей остаётся недоступен.
|
||||
@@ -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))})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()})
|
||||
Reference in new issue
Block a user