fix(uptime): отличать ошибки опроса от измеренных потерь Ping
This commit is contained in:
1 parent
ca23284486
commit
8fb46b1552
35 files changed
+1582
-78
No files matched your search
@@ -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}])))
|
||||
})
|
||||
@@ -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&×.length===active.length?new Date(times.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user