fix(uptime): отличать ошибки опроса от измеренных потерь Ping

This commit is contained in:
Denozordec committed 2026-10-12 17:55:16 +07:00
1 parent ca23284486
commit 8fb46b1552
35 files changed
+1582 -78

No files matched your search

+6
View File
@@ -19,3 +19,9 @@ test("ping observed zero and failure retain independent measurement values and s
test("ping invalid time axis, duplicate or out of range samples cannot replace cache",()=>{
for(const raw of [payload([sampled,sampled]),payload([{...sampled,sampledAt:"invalid"}]),payload([{...sampled,sampledAt:"2026-10-02T00:00:00Z"}]),{...payload([sampled]),rangeEndAt:start},{...payload([sampled]),rangeStartAt:undefined},payload([{...sampled,loss:101}])])assert.throws(()=>parseDashboardProbes(raw))
})
test("failed execution is an unknown observation, while measured 100 percent loss remains down",()=>{
const failed={...sampled,sampledAt:"2026-10-03T00:31:00Z",rtt:null,loss:null,status:"unknown",error:"API denied"}
const p=parseDashboardProbes(payload([sampled,failed])).probes[0]
assert.equal(p.status,"unknown");assert.equal(p.rtt,null);assert.equal(p.loss,null);assert.equal(p.observationError,"API denied");assert.equal(p.series[31],null)
assert.throws(()=>parseDashboardProbes(payload([{...failed,loss:100}])))
})
+4 -4
View File
@@ -1,22 +1,22 @@
import {z} from "zod"
import type {PingProbe} from "@/lib/data"
const date=z.iso.datetime({offset:true})
const sample=z.object({sampledAt:date,rtt:z.number().finite().nonnegative().nullable(),loss:z.number().finite().min(0).max(100),status:z.enum(["up","warn","down"])})
const probe=z.object({id:z.string().trim().min(1),srcServerId:z.string().trim().min(1),srcInterface:z.string().optional(),name:z.string(),target:z.string(),filter:z.string(),rtt:z.number().finite().nonnegative().nullable(),loss:z.number().finite().min(0).max(100),status:z.enum(["up","warn","down"]),series:z.array(z.number().finite().nonnegative()),enabled:z.boolean(),showOnDashboard:z.boolean(),observations:z.object({samples:z.array(sample)}).optional()})
const sample=z.object({sampledAt:date,rtt:z.number().finite().nonnegative().nullable(),loss:z.number().finite().min(0).max(100).nullable(),status:z.enum(["up","warn","down","unknown"]),error:z.string().optional()}).refine(s=>s.status==="unknown"?s.rtt===null&&s.loss===null:s.loss!==null)
const probe=z.object({id:z.string().trim().min(1),srcServerId:z.string().trim().min(1),srcInterface:z.string().optional(),name:z.string(),target:z.string(),filter:z.string(),rtt:z.number().finite().nonnegative().nullable(),loss:z.number().finite().min(0).max(100).nullable(),status:z.enum(["up","warn","down","unknown"]),series:z.array(z.number().finite().nonnegative().nullable()),enabled:z.boolean(),showOnDashboard:z.boolean(),observations:z.object({samples:z.array(sample)}).optional()})
const overview=z.object({probes:z.array(probe).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length),rangeStartAt:date.optional(),rangeEndAt:date.optional()}).refine(raw=>{
if(!raw.probes.some(p=>p.observations))return true
if(!raw.rangeStartAt||!raw.rangeEndAt)return false
const start=Date.parse(raw.rangeStartAt),end=Date.parse(raw.rangeEndAt)
return end>start&&raw.probes.every(p=>{const samples=p.observations?.samples??[];return new Set(samples.map(s=>Date.parse(s.sampledAt))).size===samples.length&&samples.every((s,i)=>Date.parse(s.sampledAt)>=start&&Date.parse(s.sampledAt)<=end&&(i===0||Date.parse(s.sampledAt)>Date.parse(samples[i-1].sampledAt)))})
})
export type DashboardProbe=Omit<PingProbe,"status"|"loss"|"series">&{status:PingProbe["status"]|"unknown";loss:number|null;series:(number|null)[];sampledAt?:string|null;historyObserved?:boolean}
export type DashboardProbe=Omit<PingProbe,"status"|"loss"|"series">&{status:PingProbe["status"]|"unknown";loss:number|null;series:(number|null)[];sampledAt?:string|null;historyObserved?:boolean;observationError?:string|null}
function meanRtt(values:Array<number|null>){const mean=values.reduce<number>((sum,v)=>sum+(v??0),0)/values.length;if(!Number.isFinite(mean))throw Error("Средний RTT превышает допустимый диапазон");return Math.round(mean)}
export function parseDashboardProbes(raw:unknown):{probes:DashboardProbe[];sampledAt:string|null}{
const parsed=overview.safeParse(raw);if(!parsed.success)throw Error("Некорректные данные проб")
const data=parsed.data,start=data.rangeStartAt?Date.parse(data.rangeStartAt):0,end=data.rangeEndAt?Date.parse(data.rangeEndAt):0
const probes:DashboardProbe[]=data.probes.map(p=>{const samples=p.observations?.samples??[],last=samples.at(-1);const buckets:Array<Array<number|null>>=Array.from({length:60},()=>[])
for(const s of samples){const i=Math.min(59,Math.floor((Date.parse(s.sampledAt)-start)/(end-start)*60));buckets[i].push(s.rtt)}
return {...p,status:last?.status??"unknown",rtt:last?.rtt??null,loss:last?.loss??null,sampledAt:last?.sampledAt??null,historyObserved:!!p.observations,series:buckets.map(values=>!values.length||values.some(v=>v===null)?null:meanRtt(values))}
return {...p,status:last?.status??"unknown",rtt:last?.rtt??null,loss:last?.loss??null,sampledAt:last?.sampledAt??null,historyObserved:!!p.observations,observationError:last?.error??null,series:buckets.map(values=>!values.length||values.some(v=>v===null)?null:meanRtt(values))}
})
const active=probes.filter(p=>p.enabled),times=active.map(p=>p.sampledAt).filter((v):v is string=>!!v)
return {probes,sampledAt:active.length&&times.length===active.length?new Date(times.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null}
+3 -3
View File
@@ -1,17 +1,17 @@
import type { Dispatch, SetStateAction } from "react"
export type UptimeTab = "probes" | "resources" | "speed"
export type ProbeStatusFilter = "all" | "up" | "warn" | "down"
export type ProbeStatusFilter = "all" | "up" | "warn" | "down" | "unknown"
export function readUptimeUrl(params: URLSearchParams) {
const rawTab = params.get("tab")
const rawStatus = params.get("status")
const search = params.get("q") ?? ""
const tab: UptimeTab = rawTab === "resources" || rawTab === "speed" ? rawTab : "probes"
const status: ProbeStatusFilter = rawStatus === "up" || rawStatus === "warn" || rawStatus === "down" ? rawStatus : "all"
const status: ProbeStatusFilter = rawStatus === "up" || rawStatus === "warn" || rawStatus === "down" || rawStatus === "unknown" ? rawStatus : "all"
let error: string | null = null
let servers: string[] = []
if ((rawTab !== null && !["probes", "resources", "speed"].includes(rawTab)) ||
(rawStatus !== null && !["up", "warn", "down"].includes(rawStatus)) ||
(rawStatus !== null && !["up", "warn", "down", "unknown"].includes(rawStatus)) ||
["tab", "q", "status", "servers"].some(key => params.getAll(key).length > 1)) error = "В ссылке указаны неверные или повторяющиеся параметры мониторинга."
const rawServers = params.get("servers")
if (rawServers !== null) {
+2 -2
View File
@@ -182,8 +182,8 @@ export interface PingProbeSnapshot {
srcInterface: string
ok: boolean
rttMs: number | null
lossPct: number
status: "up" | "warn" | "down"
lossPct: number | null
status: "up" | "warn" | "down" | "unknown"
error?: string
}