fix(uptime): показывать реальные наблюдения и пропуски RTT на дашборде

This commit is contained in:
Denozordec committed 2026-10-03 23:42:45 +07:00
1 parent 8f65d1620d
commit 9d3129372b
18 files changed
+169 -203

No files matched your search

+14 -33
View File
@@ -3,44 +3,26 @@ import type { PingProbe, Server } from "@/lib/data"
/** Длина ряда на графике дашборда (ось времени). */
export const DASH_LATENCY_POINTS = 60
/** Растягивает/сэмплирует ряд до `targetLen` (линейная интерполяция по индексам). */
export function padSeriesToLength(values: number[], targetLen: number): number[] {
if (targetLen <= 0) return []
if (values.length === 0) return Array(targetLen).fill(0)
if (values.length === targetLen) return [...values]
if (values.length > targetLen) return values.slice(values.length - targetLen)
if (values.length === 1) return Array(targetLen).fill(values[0] ?? 0)
const out: number[] = []
for (let i = 0; i < targetLen; i++) {
const t = (i / (targetLen - 1)) * (values.length - 1)
const j = Math.floor(t)
const f = t - j
const a = values[j] ?? 0
const b = values[Math.min(j + 1, values.length - 1)] ?? a
out.push(Math.round(a + f * (b - a)))
}
return out
}
export interface DashboardLatencyChartInput {
series: Record<string, number[]>
series: Record<string, (number|null)[]>
labels: Record<string, string>
}
/**
* Средний RTT по всем включённым пробам с одного источника (srcServerId).
* Средний RTT по измеренным общим корзинам включённых проб одного источника.
* Не интерполируем и не подменяем отсутствующие измерения нулями.
* Ключ серии — стабильный id вида `src-<numericId>`; подпись — площадка или имя сервера.
* Ограничение числа линий — чтобы график оставался читаемым.
*/
export function buildLatencySeriesByProbeSource(
probes: PingProbe[],
probes: Array<Pick<PingProbe,"enabled"|"srcServerId">&{series:(number|null)[]}>,
servers: Server[],
opts?: { maxServers?: number; points?: number },
): DashboardLatencyChartInput {
const maxServers = opts?.maxServers ?? 8
const points = opts?.points ?? DASH_LATENCY_POINTS
const bySrv = new Map<string, PingProbe[]>()
const bySrv = new Map<string, Array<Pick<PingProbe,"enabled"|"srcServerId">&{series:(number|null)[]}>>()
for (const p of probes) {
if (!p.enabled || !p.series?.length) continue
const sid = p.srcServerId
@@ -54,7 +36,7 @@ export function buildLatencySeriesByProbeSource(
return na.localeCompare(nb, "ru")
}).slice(0, maxServers)
const series: Record<string, number[]> = {}
const series: Record<string, (number|null)[]> = {}
const labels: Record<string, string> = {}
for (const sid of sortedIds) {
@@ -62,16 +44,15 @@ export function buildLatencySeriesByProbeSource(
const innerLen = plist.reduce((m, p) => Math.max(m, p.series.length), 0)
if (innerLen === 0) continue
const averaged = Array.from({ length: innerLen }, (_, i) => {
const vals = plist
.map((p) => p.series[i])
.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
if (!vals.length) return 0
return Math.round(vals.reduce((x, y) => x + y, 0) / vals.length)
const averaged = Array.from({ length: Math.min(innerLen,points) }, (_, i) => {
const vals=plist.map(p=>p.series[i])
if(vals.some(v=>typeof v!=="number"||!Number.isFinite(v)))return null
const mean=vals.reduce<number>((sum,v)=>sum+(v??0),0)/vals.length
return Number.isFinite(mean)?Math.round(mean):null
})
const key = `src-${sid}`
series[key] = padSeriesToLength(averaged, points)
if(!averaged.some(v=>v!==null))continue
const key = "src-"+sid
series[key] = averaged
const srv = servers.find((s) => s.id === sid)
const site = srv?.site?.trim()
labels[key] =
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {parseDashboardProbes} from "./dashboard-probes"
import {buildLatencySeriesByProbeSource} from "./dashboard-latency"
const start="2026-10-03T00:00:00Z",end="2026-10-03T01:00:00Z"
const base={id:"p1",srcServerId:"1",name:"Проверка",target:"192.0.2.1",filter:"main",rtt:0,loss:0,status:"up",series:[0],enabled:true,showOnDashboard:true}
const sampled={sampledAt:"2026-10-03T00:30:00Z",rtt:0,loss:0,status:"up"}
const payload=(samples:unknown[])=>({rangeStartAt:start,rangeEndAt:end,probes:[{...base,observations:{samples}}]})
test("ping legacy and empty samples do not prove up, down, loss or historical zero",()=>{
for(const raw of [{probes:[base]},payload([])]){const p=parseDashboardProbes(raw).probes[0];assert.equal(p.status,"unknown");assert.equal(p.rtt,null);assert.equal(p.loss,null);assert.ok(p.series.every(v=>v===null))}
assert.deepEqual(parseDashboardProbes({probes:[]}),{probes:[],sampledAt:null})
})
test("ping observed zero and failure retain independent measurement values and sparse time axis",()=>{
const p=parseDashboardProbes(payload([sampled])).probes[0];assert.equal(p.status,"up");assert.equal(p.rtt,0);assert.equal(p.loss,0);assert.equal(p.series[30],0);assert.equal(p.series.filter(v=>v!==null).length,1)
const failed=parseDashboardProbes(payload([sampled,{...sampled,sampledAt:"2026-10-03T00:31:00Z",rtt:null,loss:100,status:"down"}])).probes[0];assert.equal(failed.status,"down");assert.equal(failed.loss,100);assert.equal(failed.series[31],null)
assert.deepEqual(buildLatencySeriesByProbeSource([p],[]).series["src-1"],p.series)
assert.deepEqual(buildLatencySeriesByProbeSource([p,{...p,srcServerId:"1",series:Array(60).fill(null)}],[]).series,{})
})
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))
})
+23
View File
@@ -0,0 +1,23 @@
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 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}
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))}
})
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}
}
+1 -1
View File
@@ -8,6 +8,6 @@ test("dashboard server catalog validates identities and keeps unknown status",()
for(const raw of [{},[server,server],[{...server,status:"invalid"}],[{...server,latency:-1}],[{...server,id:0}],[{id:1}]])assert.throws(()=>parseDashboardServers(raw))
})
test("dashboard probes validate real shape, uniqueness and numeric ranges before publication",()=>{
assert.equal(parseDashboardProbes({probes:[probe]}).probes[0].rtt,null);assert.deepEqual(parseDashboardProbes({probes:[]}),{probes:[]})
assert.equal(parseDashboardProbes({probes:[probe]}).probes[0].rtt,null);assert.deepEqual(parseDashboardProbes({probes:[]}),{probes:[],sampledAt:null})
for(const raw of [{},{probes:[probe,probe]},{probes:[{...probe,loss:101}]},{probes:[{...probe,status:"invalid"}]},{probes:[{...probe,series:[null]}]},{probes:[{}]}])assert.throws(()=>parseDashboardProbes(raw))
})
+1 -3
View File
@@ -2,8 +2,6 @@ import {z} from "zod"
import {serverReadSchema} from "@mmapp/contracts/servers"
const server=serverReadSchema.pick({id:true,name:true,host:true,site:true,country:true,asn:true,type:true,enabled:true,status:true,latency:true,os:true,model:true}).extend({id:z.number().int().positive(),host:z.string().trim().min(1),latency:z.number().finite().nonnegative().nullable(),sessions:z.number().int().nonnegative(),wanUplinks:serverReadSchema.shape.wanUplinks.optional()})
const servers=z.array(server).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)
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()})
const overview=z.object({probes:z.array(probe).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)})
export {parseDashboardProbes} from "@/lib/dashboard-probes"
export type DashboardServerRow=z.infer<typeof server>
export function parseDashboardServers(raw:unknown){const parsed=servers.safeParse(raw);if(!parsed.success)throw Error("Некорректный каталог серверов");return parsed.data}
export function parseDashboardProbes(raw:unknown){const parsed=overview.safeParse(raw);if(!parsed.success)throw Error("Некорректные данные проб");return parsed.data}