fix(uptime): показывать реальные наблюдения и пропуски RTT на дашборде
This commit is contained in:
1 parent
8f65d1620d
commit
9d3129372b
18 files changed
+169
-203
No files matched your search
@@ -123,8 +123,8 @@ export default function DashboardPage() {
|
|||||||
{
|
{
|
||||||
id: "health",
|
id: "health",
|
||||||
label: "Проверки с проблемами",
|
label: "Проверки с проблемами",
|
||||||
value: dash.availability.probes ? String(health) : "—",
|
value: dash.availability.probes && dash.unknownProbeCount===0 ? String(health) : "—",
|
||||||
hint: dash.availability.probes ? `${dash.probeDown} ошибок · ${dash.probeWarn} предупреждений` : "нет данных",
|
hint: dash.availability.probes ? `${dash.probeDown} ошибок · ${dash.probeWarn} предупреждений${dash.unknownProbeCount>0?` · неизвестны: ${dash.unknownProbeCount}`:""}` : "нет данных",
|
||||||
href: "/uptime",
|
href: "/uptime",
|
||||||
icon: <BellIcon />,
|
icon: <BellIcon />,
|
||||||
iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground",
|
iconClassName: health > 0 ? "text-destructive" : "text-muted-foreground",
|
||||||
@@ -218,18 +218,7 @@ export default function DashboardPage() {
|
|||||||
<OpsPanel
|
<OpsPanel
|
||||||
title="Пропускная способность"
|
title="Пропускная способность"
|
||||||
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарная история RX / TX · 1 ч; значения — последний измеренный снимок"}
|
description={dash.traffic?.demo ? "Суммарный RX / TX · демо" : "Суммарная история RX / TX · 1 ч; значения — последний измеренный снимок"}
|
||||||
headerRight={
|
headerRight={<Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>Трафик →</Link>}
|
||||||
dash.traffic ? (
|
|
||||||
<div className="flex items-center gap-3 text-xs">
|
|
||||||
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : !dash.traffic.observationsKnown ? <Badge variant="outline" size="sm">Измерения не подтверждены API</Badge> : null}
|
|
||||||
<span className="tabular-nums">RX {dash.traffic.rxNow===null?"—":fmtRate(dash.traffic.rxNow)}</span>
|
|
||||||
<span className="tabular-nums">TX {dash.traffic.txNow===null?"—":fmtRate(dash.traffic.txNow)}</span>
|
|
||||||
<Link href="/traffic" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "h-7 text-xs")}>
|
|
||||||
Трафик →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
contentClassName="px-3 pb-3"
|
contentClassName="px-3 pb-3"
|
||||||
>
|
>
|
||||||
{dash.loading && !dash.traffic ? (
|
{dash.loading && !dash.traffic ? (
|
||||||
@@ -242,7 +231,14 @@ export default function DashboardPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<TrafficRxTxChart rx={dash.traffic.rx} tx={dash.traffic.tx} range="1h" embedded />
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 px-2 py-3 text-xs">
|
||||||
|
{dash.traffic.demo ? <Badge variant="outline" size="sm">демо</Badge> : !dash.traffic.observationsKnown ? <Badge variant="outline" size="sm">Измерения не подтверждены API</Badge> : null}
|
||||||
|
<span className="tabular-nums">RX {dash.traffic.rxNow===null?"—":fmtRate(dash.traffic.rxNow)}</span>
|
||||||
|
<span className="tabular-nums">TX {dash.traffic.txNow===null?"—":fmtRate(dash.traffic.txNow)}</span>
|
||||||
|
</div>
|
||||||
|
<TrafficRxTxChart rx={dash.traffic.rx} tx={dash.traffic.tx} range="1h" embedded />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</OpsPanel>
|
</OpsPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
updateProbeShowOnDashboard,
|
updateProbeShowOnDashboard,
|
||||||
updateUptimeSettings,
|
updateUptimeSettings,
|
||||||
} from "../services/uptime-collector.js"
|
} from "../services/uptime-collector.js"
|
||||||
|
import {buildUptimeProbeObservations} from "../services/uptime-probe-observations.js"
|
||||||
import { recordSpeedTestFailure, runBandwidthSpeedTest } from "../services/uptime-speed-service.js"
|
import { recordSpeedTestFailure, runBandwidthSpeedTest } from "../services/uptime-speed-service.js"
|
||||||
|
|
||||||
function rangeToMinutes(range: string | undefined): number {
|
function rangeToMinutes(range: string | undefined): number {
|
||||||
@@ -471,8 +472,8 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
app.get("/uptime/overview", async (req, reply) => {
|
app.get("/uptime/overview", async (req, reply) => {
|
||||||
const q = req.query as { range?: string }
|
const q = req.query as { range?: string }
|
||||||
const minutes = rangeToMinutes(q.range)
|
const minutes = rangeToMinutes(q.range)
|
||||||
const sinceIso = new Date(Date.now() - minutes * 60_000).toISOString()
|
const rangeEndAt=new Date().toISOString()
|
||||||
|
const sinceIso = new Date(Date.parse(rangeEndAt) - minutes * 60_000).toISOString()
|
||||||
const allServers = await db.select().from(servers)
|
const allServers = await db.select().from(servers)
|
||||||
const probeRows = await readProbeRows()
|
const probeRows = await readProbeRows()
|
||||||
const probeSamples = await readProbeSamplesSince(sinceIso)
|
const probeSamples = await readProbeSamplesSince(sinceIso)
|
||||||
@@ -498,6 +499,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
series,
|
series,
|
||||||
enabled: p.enabled,
|
enabled: p.enabled,
|
||||||
showOnDashboard: p.showOnDashboard,
|
showOnDashboard: p.showOnDashboard,
|
||||||
|
observations:buildUptimeProbeObservations(rows.filter(r=>Date.parse(r.sampledAt)<=Date.parse(rangeEndAt))),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -527,7 +529,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return reply.send({ probes, resources })
|
return reply.send({ probes, resources,rangeStartAt:sinceIso,rangeEndAt })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {buildUptimeProbeObservations} from "./uptime-probe-observations.js"
|
||||||
|
assert.deepEqual(buildUptimeProbeObservations([]),{samples:[]})
|
||||||
|
const good={sampledAt:"2026-10-03T00:00:00Z",rttMs:0,lossPct:0,status:"up" as const},failed={sampledAt:"2026-10-03T00:01:00Z",rttMs:null,lossPct:100,status:"down" as const}
|
||||||
|
assert.deepEqual(buildUptimeProbeObservations([failed,good]),{samples:[{sampledAt:good.sampledAt,rtt:0,loss:0,status:"up"},{sampledAt:failed.sampledAt,rtt:null,loss:100,status:"down"}]});assert.equal(good.rttMs,0)
|
||||||
|
console.log("uptime probe observations tests ok")
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import type {UptimeProbeSampleRow} from "../db/schema.js"
|
||||||
|
type Sample=Pick<UptimeProbeSampleRow,"sampledAt"|"rttMs"|"lossPct"|"status">
|
||||||
|
/** Additive metadata; no samples means no proof of loss or failure. */
|
||||||
|
export function buildUptimeProbeObservations(rows:readonly Sample[]){return {samples:rows.map(row=>({sampledAt:row.sampledAt,rtt:row.rttMs,loss:row.lossPct,status:row.status})).sort((a,b)=>Date.parse(a.sampledAt)-Date.parse(b.sampledAt))}}
|
||||||
@@ -1,129 +1,27 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import {CartesianGrid,Line,LineChart,XAxis,YAxis} from "recharts"
|
||||||
const PALETTE = [
|
import {ChartContainer,ChartTooltip,ChartTooltipContent,type ChartConfig} from "@/components/ui/chart"
|
||||||
"var(--chart-line-1)",
|
import {formatRangeAgo} from "@/lib/fmt-rate"
|
||||||
"var(--chart-line-2)",
|
const PALETTE=["var(--chart-line-1)","var(--chart-line-2)","var(--chart-line-3)","var(--chart-line-4)","var(--chart-1)","var(--chart-2)","var(--chart-3)","var(--chart-4)"]
|
||||||
"var(--chart-line-3)",
|
const LEGACY_LABELS:Record<string,string>={msk:"MSK",spb:"SPB",fra:"FRA",ams:"AMS"}
|
||||||
"var(--chart-line-4)",
|
interface LatencyChartProps {series:Record<string,(number|null)[]>;labels?:Record<string,string>}
|
||||||
"var(--chart-1)",
|
/** Reuse the installed ChartContainer / multi-line composition of ReUI chart-23. */
|
||||||
"var(--chart-2)",
|
export function LatencyChart({series,labels:labelsProp}:LatencyChartProps){
|
||||||
"var(--chart-3)",
|
const entries=Object.entries(series).filter(([,values])=>values.some(v=>typeof v==="number"&&Number.isFinite(v)))
|
||||||
"var(--chart-4)",
|
const count=Math.max(1,...entries.map(([,values])=>values.length))
|
||||||
]
|
const data=Array.from({length:count},(_,i)=>({time:formatRangeAgo(Math.round((1-i/Math.max(count-1,1))*60)),...Object.fromEntries(entries.map(([key,values])=>[key,typeof values[i]==="number"&&Number.isFinite(values[i])?values[i]:null]))}))
|
||||||
|
const label=(key:string)=>labelsProp?.[key]??LEGACY_LABELS[key]??key.replace(/^src-/,"")
|
||||||
/** Легенда для демо-данных (ключи `msk`, `spb`, …). */
|
const config=Object.fromEntries(entries.map(([key],index)=>[key,{label:label(key),color:PALETTE[index%PALETTE.length]}])) satisfies ChartConfig
|
||||||
const LEGACY_LABELS: Record<string, string> = {
|
const dot=(key:string)=>function IsolatedDot({cx,cy,index}:{cx?:number;cy?:number;index?:number}){const values=series[key];if(index==null||cx==null||cy==null||values[index]==null||values[index-1]!=null||values[index+1]!=null)return <g/>;return <circle cx={cx} cy={cy} r={3} fill={config[key].color} data-latency-isolated="true"/>}
|
||||||
msk: "MSK",
|
if(!entries.length)return <p role="status" className="py-10 text-center text-sm text-muted-foreground">Нет измеренной истории RTT</p>
|
||||||
spb: "SPB",
|
return <div role="region" aria-label="История RTT по источникам" className="flex flex-col gap-3">
|
||||||
fra: "FRA",
|
<ChartContainer config={config} className="aspect-auto h-[220px] w-full"><LineChart data={data} margin={{top:8,right:8,left:0,bottom:0}}>
|
||||||
ams: "AMS",
|
<CartesianGrid vertical={false} strokeDasharray="4 8" stroke="var(--border)"/>
|
||||||
}
|
<XAxis dataKey="time" axisLine={false} tickLine={false} tick={{fontSize:11}} tickMargin={8} minTickGap={16}/>
|
||||||
|
<YAxis axisLine={false} tickLine={false} tick={{fontSize:11}} width={52} tickFormatter={(v:number)=>String(v)+" мс"}/>
|
||||||
interface LatencyChartProps {
|
<ChartTooltip content={<ChartTooltipContent formatter={(value,name)=><div className="flex min-w-0 items-center gap-2 text-xs"><span>{config[String(name)]?.label??name}</span><span className="font-mono">{typeof value==="number"?String(value)+" мс":"—"}</span></div>}/>}/>
|
||||||
series: Record<string, number[]>
|
{entries.map(([key])=><Line key={key} dataKey={key} type="linear" stroke={config[key].color} strokeWidth={1.6} connectNulls={false} dot={dot(key)} isAnimationActive={false}/>)}
|
||||||
/** Подписи линий (ключ → короткое имя). Если нет — берётся legacy или сам ключ. */
|
</LineChart></ChartContainer>
|
||||||
labels?: Record<string, string>
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2">{entries.map(([key])=><span key={key} data-latency-source={key} className="flex items-center gap-1.5 text-xs"><span aria-hidden className="h-0.5 w-3 rounded" style={{background:config[key].color}}/><span className="text-foreground">{label(key)}</span></span>)}</div>
|
||||||
}
|
</div>
|
||||||
|
|
||||||
export function LatencyChart({ series, labels: labelsProp }: LatencyChartProps) {
|
|
||||||
const W = 800
|
|
||||||
const H = 220
|
|
||||||
const pad = { l: 44, r: 12, t: 12, b: 28 }
|
|
||||||
const iw = W - pad.l - pad.r
|
|
||||||
const ih = H - pad.t - pad.b
|
|
||||||
|
|
||||||
const entries = Object.entries(series).filter(([, arr]) => arr.length > 0)
|
|
||||||
const pointCount = entries.length ? Math.max(...entries.map(([, a]) => a.length), 2) : 60
|
|
||||||
|
|
||||||
const allVals = entries.flatMap(([, arr]) => arr)
|
|
||||||
const maxVal = Math.ceil(Math.max(1, ...allVals) / 20) * 20 + 10
|
|
||||||
|
|
||||||
const xAt = (i: number, n: number) => pad.l + (i / Math.max(n - 1, 1)) * iw
|
|
||||||
const yAt = (v: number) => pad.t + (1 - v / maxVal) * ih
|
|
||||||
|
|
||||||
const gridLines = [0, 0.25, 0.5, 0.75, 1]
|
|
||||||
|
|
||||||
const tickIndices = [
|
|
||||||
0,
|
|
||||||
Math.floor((pointCount - 1) * 0.25),
|
|
||||||
Math.floor((pointCount - 1) * 0.5),
|
|
||||||
Math.floor((pointCount - 1) * 0.75),
|
|
||||||
pointCount - 1,
|
|
||||||
]
|
|
||||||
|
|
||||||
const labelForIndex = (i: number) => {
|
|
||||||
const spanMin = 60
|
|
||||||
const m = Math.round(spanMin * (1 - i / Math.max(pointCount - 1, 1)))
|
|
||||||
return `-${m}м`
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height: 220, display: "block" }}>
|
|
||||||
{gridLines.map((p, i) => (
|
|
||||||
<g key={i}>
|
|
||||||
<line
|
|
||||||
x1={pad.l}
|
|
||||||
x2={W - pad.r}
|
|
||||||
y1={pad.t + ih * p}
|
|
||||||
y2={pad.t + ih * p}
|
|
||||||
style={{ stroke: "var(--border)" }}
|
|
||||||
strokeDasharray={p === 1 ? "0" : "2 4"}
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={pad.l - 8}
|
|
||||||
y={pad.t + ih * p + 4}
|
|
||||||
textAnchor="end"
|
|
||||||
fontSize="10"
|
|
||||||
style={{ fill: "var(--muted-foreground)" }}
|
|
||||||
fontFamily="var(--font-mono, monospace)"
|
|
||||||
>
|
|
||||||
{Math.round(maxVal * (1 - p))}мс
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
{tickIndices.map((i) => (
|
|
||||||
<text
|
|
||||||
key={i}
|
|
||||||
x={xAt(i, pointCount)}
|
|
||||||
y={H - 6}
|
|
||||||
textAnchor="middle"
|
|
||||||
fontSize="10"
|
|
||||||
style={{ fill: "var(--muted-foreground)" }}
|
|
||||||
fontFamily="var(--font-mono, monospace)"
|
|
||||||
>
|
|
||||||
{labelForIndex(i)}
|
|
||||||
</text>
|
|
||||||
))}
|
|
||||||
{entries.map(([key, arr], idx) => {
|
|
||||||
const n = arr.length
|
|
||||||
const pts = arr.map((v, i) => `${xAt(i, n).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
|
||||||
const color = PALETTE[idx % PALETTE.length]
|
|
||||||
return (
|
|
||||||
<polyline
|
|
||||||
key={key}
|
|
||||||
points={pts}
|
|
||||||
fill="none"
|
|
||||||
style={{ stroke: color }}
|
|
||||||
strokeWidth="1.6"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
<div className="flex flex-wrap gap-x-4 gap-y-2 pt-2 pl-11">
|
|
||||||
{entries.map(([key], idx) => {
|
|
||||||
const color = PALETTE[idx % PALETTE.length]
|
|
||||||
const leg =
|
|
||||||
labelsProp?.[key] ?? LEGACY_LABELS[key] ?? key.replace(/^src-/, "")
|
|
||||||
return (
|
|
||||||
<div key={key} className="flex items-center gap-1.5 text-xs">
|
|
||||||
<span className="w-3 h-0.5 rounded" style={{ background: color }} />
|
|
||||||
<span className="font-mono text-foreground">{leg}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import type { PingProbe, Server, ServerType } from "@/lib/data"
|
import type { Server, ServerType } from "@/lib/data"
|
||||||
|
import type {DashboardProbe} from "@/lib/dashboard-probes"
|
||||||
import { Flag } from "@/components/flag"
|
import { Flag } from "@/components/flag"
|
||||||
import { StatusBadge } from "@/components/status-badge"
|
import { StatusBadge } from "@/components/status-badge"
|
||||||
import { StatusDot } from "@/components/status-dot"
|
import { StatusDot } from "@/components/status-dot"
|
||||||
@@ -10,8 +11,8 @@ import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-g
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { FilterIcon } from "lucide-react"
|
import { FilterIcon } from "lucide-react"
|
||||||
|
|
||||||
function formatLossPct(loss: number): string {
|
function formatLossPct(loss: number|null): string {
|
||||||
if (!Number.isFinite(loss)) return "—"
|
if (loss===null || !Number.isFinite(loss)) return "—"
|
||||||
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
return Number.isInteger(loss) ? `${loss}%` : `${loss.toFixed(1)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ function TypeChip({ type }: { type: ServerType }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server[] }) {
|
function ProbeSourceCell({ probe, catalog }: { probe: DashboardProbe; catalog: Server[] }) {
|
||||||
const srv = catalog.find((s) => s.id === probe.srcServerId)
|
const srv = catalog.find((s) => s.id === probe.srcServerId)
|
||||||
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
const iface = (probe.srcInterface ?? "").trim() || "auto"
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
<div className="flex items-start gap-2 min-w-0 max-w-[280px]">
|
||||||
<span className="mt-1 shrink-0 inline-flex">
|
<span className="mt-1 shrink-0 inline-flex">
|
||||||
<StatusDot status="offline" />
|
<StatusDot status="unknown" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
<p className="text-[13px] font-medium text-muted-foreground truncate">
|
||||||
@@ -79,7 +80,7 @@ function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DashboardActiveProbesDataGridProps {
|
interface DashboardActiveProbesDataGridProps {
|
||||||
probes: PingProbe[]
|
probes: DashboardProbe[]
|
||||||
catalog: Server[]
|
catalog: Server[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
emptyTitle?: string
|
emptyTitle?: string
|
||||||
@@ -93,7 +94,7 @@ function DashboardActiveProbesDataGrid({
|
|||||||
emptyTitle = "Нет активных проб",
|
emptyTitle = "Нет активных проб",
|
||||||
emptyDescription,
|
emptyDescription,
|
||||||
}: DashboardActiveProbesDataGridProps) {
|
}: DashboardActiveProbesDataGridProps) {
|
||||||
const columns = useMemo<CompactDataGridColumn<PingProbe>[]>(
|
const columns = useMemo<CompactDataGridColumn<DashboardProbe>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: "source",
|
id: "source",
|
||||||
@@ -140,7 +141,7 @@ function DashboardActiveProbesDataGrid({
|
|||||||
cell: (p) => (
|
cell: (p) => (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
p.loss > 5 ? "text-red-500" : p.loss > 0 ? "text-amber-500" : "text-muted-foreground",
|
(p.loss??0) > 5 ? "text-red-500" : (p.loss??0) > 0 ? "text-amber-500" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatLossPct(p.loss)}
|
{formatLossPct(p.loss)}
|
||||||
@@ -149,7 +150,7 @@ function DashboardActiveProbesDataGrid({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "series",
|
id: "series",
|
||||||
header: "60с",
|
header: "RTT · 1 ч",
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
headerClassName: "w-36",
|
headerClassName: "w-36",
|
||||||
cell: (p) => {
|
cell: (p) => {
|
||||||
@@ -168,7 +169,7 @@ function DashboardActiveProbesDataGrid({
|
|||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: (p) => (
|
cell: (p) => (
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
status={p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
status={p.status === "unknown" ? "unknown" : p.status === "up" ? "online" : p.status === "warn" ? "degraded" : "offline"}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## Актуальная точка продолжения — 2026-10-03
|
## Актуальная точка продолжения — 2026-10-03
|
||||||
|
|
||||||
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. Следующий блок12.2.5: наблюдения ping и реальная ось RTT; затем overlay/Internet path, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
|
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. Следующий блок12.2.6: strict GRE/WireGuard/VXLAN и device failures; затем Internet path, производительность и сквозная регрессия. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
|
||||||
|
|
||||||
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,21 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 12.2.5 — реальные ping наблюдения и разрывы RTT (2026-10-03)
|
||||||
|
|
||||||
|
/uptime/overview совместимо добавляет observations.samples и общие rangeStartAt/rangeEndAt. Сохраняет исходные числовые поля legacy клиентов; metadata содержит реальные отсортированные timestamp/RTT/loss/status без искусственных samples и ограничена концом снимка (поступление нового sample во время чтения не нарушает окно). Дашборд предпочитает наблюдения: legacy/empty становятся unknown/null, измеренный0 остаётся0, реальный down/100% остаётся ошибкой. Malformed/duplicate/unordered/out-of-window samples сохраняют cache. Source freshness показывает фактическое время, неизвестные проверки исключают утверждение «без ошибок» и дают warning/— в KPI; отсутствующий каталог источника не рисует offline.
|
||||||
|
|
||||||
|
RTT агрегируется в60 настоящих временных корзин; средний по источнику не дополняется нулями/интерполяцией. Пропуски — null; изолированная точка видима. Нечитаемый на320px SVG заменён установленной композицией ChartContainer/Recharts, как ReUI PRO chart-23 и существующий TrafficRxTxChart; Frame/OpsPanel сохранены. Скорости RX/TX вынесены из заголовка в переносимую строку, перекрытие устранено. Header таблицы «RTT · 1 ч» вместо неверных «60с».
|
||||||
|
|
||||||
|
Проверки:40 dashboard/permissions browser прошли после финального chart; дополнительно6 ping сценариев и снимки1440/320.11 unit dashboard (включая sparse axis/unknown/zero/invalid metadata), чистые backend observations assertions, typecheck/ESLint, frontend production/contracts build(34pages), backend/contracts build прошли. ReUI MCP подтвердил chart-23/Chart API; читались установленные исходники, checklist применён; primitives/theme не изменены. Другие uptime страницы продолжают legacy numeric API и этим блоком не переприняты. Реальные PG/RouterOS measurements не тестировались.
|
||||||
|
|
||||||
|
| Доработка | Референс | Схема / локальные снимки |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Читаемый RTT с пропусками | [Chart23](https://reui.io/preview/base/chart-23?ref=mcp) | Observations → временные корзины → линии/разрывы/точки; [1440](ux-ui/screens/dashboard-observed-ping-1440.png), [320](ux-ui/screens/dashboard-observed-ping-320.png) |
|
||||||
|
| Неизвестное состояние в сводке | [Stats12](https://reui.io/preview/base/stats-12?ref=mcp) | Snapshot → known/unknown → KPI/очередь → объект |
|
||||||
|
|
||||||
|
Далее12.2.6: strict GRE/WireGuard/VXLAN и device failures на dashboard; затем Internet path и сводная приёмка12.2. Внешние ограничения сохраняются.
|
||||||
|
|
||||||
## 12.2.4 — строгие каталоги серверов/проб, unknown серверов (2026-10-03)
|
## 12.2.4 — строгие каталоги серверов/проб, unknown серверов (2026-10-03)
|
||||||
|
|
||||||
Servers использует выбранные поля serverReadSchema и строгие id/latency/sessions; probes — обязательные поля, unique id и диапазоны RTT/loss/series/status. Malformed каталоги отклоняются до публикации, сохраняется cache/время источника. Nullable статус сервера отображается как unknown, warning в очереди; offline KPI считает только явно offline, unknown/degraded перечислены отдельно, недоказанное состояние не называется исправным. Sessions обязательны как в реальном DTO; дополнительная WAN информация остаётся совместимой.
|
Servers использует выбранные поля serverReadSchema и строгие id/latency/sessions; probes — обязательные поля, unique id и диапазоны RTT/loss/series/status. Malformed каталоги отклоняются до публикации, сохраняется cache/время источника. Nullable статус сервера отображается как unknown, warning в очереди; offline KPI считает только явно offline, unknown/degraded перечислены отдельно, недоказанное состояние не называется исправным. Sessions обязательны как в реальном DTO; дополнительная WAN информация остаётся совместимой.
|
||||||
|
|||||||
@@ -59,3 +59,18 @@
|
|||||||
Servers использует выбранные поля serverReadSchema и строгие id/latency/sessions; probes — обязательные поля, unique id и диапазоны RTT/loss/series/status. Malformed каталоги отклоняются до публикации, сохраняется cache/время источника. Nullable статус сервера отображается как unknown, warning в очереди; offline KPI считает только явно offline, unknown/degraded перечислены отдельно, недоказанное состояние не называется исправным. Sessions обязательны как в реальном DTO; дополнительная WAN информация остаётся совместимой.
|
Servers использует выбранные поля serverReadSchema и строгие id/latency/sessions; probes — обязательные поля, unique id и диапазоны RTT/loss/series/status. Malformed каталоги отклоняются до публикации, сохраняется cache/время источника. Nullable статус сервера отображается как unknown, warning в очереди; offline KPI считает только явно offline, unknown/degraded перечислены отдельно, недоказанное состояние не называется исправным. Sessions обязательны как в реальном DTO; дополнительная WAN информация остаётся совместимой.
|
||||||
|
|
||||||
Проверки:34 dashboard/permissions browser и8 dashboard unit, typecheck/ESLint прошли. Initial failing unknown fixture был дополнен обязательным sessions реального DTO; финальный прогон34 прошёл. ReUI KPI/queue композиция сохранена, изменены только data/copy. Далее12.2.5: реальные ping наблюдения/временная ось; backend сейчас подставляет100%/down/нули при отсутствии samples, этот блок ещё не устранил такие backend подстановки. Затем overlay/Internet path.
|
Проверки:34 dashboard/permissions browser и8 dashboard unit, typecheck/ESLint прошли. Initial failing unknown fixture был дополнен обязательным sessions реального DTO; финальный прогон34 прошёл. ReUI KPI/queue композиция сохранена, изменены только data/copy. Далее12.2.5: реальные ping наблюдения/временная ось; backend сейчас подставляет100%/down/нули при отсутствии samples, этот блок ещё не устранил такие backend подстановки. Затем overlay/Internet path.
|
||||||
|
|
||||||
|
## 12.2.5 — реальные ping наблюдения и разрывы RTT (2026-10-03)
|
||||||
|
|
||||||
|
/uptime/overview совместимо добавляет observations.samples и общие rangeStartAt/rangeEndAt. Сохраняет исходные числовые поля legacy клиентов; metadata содержит реальные отсортированные timestamp/RTT/loss/status без искусственных samples и ограничена концом снимка (поступление нового sample во время чтения не нарушает окно). Дашборд предпочитает наблюдения: legacy/empty становятся unknown/null, измеренный0 остаётся0, реальный down/100% остаётся ошибкой. Malformed/duplicate/unordered/out-of-window samples сохраняют cache. Source freshness показывает фактическое время, неизвестные проверки исключают утверждение «без ошибок» и дают warning/— в KPI; отсутствующий каталог источника не рисует offline.
|
||||||
|
|
||||||
|
RTT агрегируется в60 настоящих временных корзин; средний по источнику не дополняется нулями/интерполяцией. Пропуски — null; изолированная точка видима. Нечитаемый на320px SVG заменён установленной композицией ChartContainer/Recharts, как ReUI PRO chart-23 и существующий TrafficRxTxChart; Frame/OpsPanel сохранены. Скорости RX/TX вынесены из заголовка в переносимую строку, перекрытие устранено. Header таблицы «RTT · 1 ч» вместо неверных «60с».
|
||||||
|
|
||||||
|
Проверки:40 dashboard/permissions browser прошли после финального chart; дополнительно6 ping сценариев и снимки1440/320.11 unit dashboard (включая sparse axis/unknown/zero/invalid metadata), чистые backend observations assertions, typecheck/ESLint, frontend production/contracts build(34pages), backend/contracts build прошли. ReUI MCP подтвердил chart-23/Chart API; читались установленные исходники, checklist применён; primitives/theme не изменены. Другие uptime страницы продолжают legacy numeric API и этим блоком не переприняты. Реальные PG/RouterOS measurements не тестировались.
|
||||||
|
|
||||||
|
| Доработка | Референс | Схема / локальные снимки |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Читаемый RTT с пропусками | [Chart23](https://reui.io/preview/base/chart-23?ref=mcp) | Observations → временные корзины → линии/разрывы/точки; [1440](ux-ui/screens/dashboard-observed-ping-1440.png), [320](ux-ui/screens/dashboard-observed-ping-320.png) |
|
||||||
|
| Неизвестное состояние в сводке | [Stats12](https://reui.io/preview/base/stats-12?ref=mcp) | Snapshot → known/unknown → KPI/очередь → объект |
|
||||||
|
|
||||||
|
Далее12.2.6: strict GRE/WireGuard/VXLAN и device failures на dashboard; затем Internet path и сводная приёмка12.2. Внешние ограничения сохраняются.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
+11
-10
@@ -10,7 +10,6 @@ import {
|
|||||||
traffic as mockTraffic,
|
traffic as mockTraffic,
|
||||||
vxlanTunnels as mockVxlan,
|
vxlanTunnels as mockVxlan,
|
||||||
type GreTunnel,
|
type GreTunnel,
|
||||||
type PingProbe,
|
|
||||||
type Server,
|
type Server,
|
||||||
} from "@/lib/data"
|
} from "@/lib/data"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
@@ -30,6 +29,7 @@ import type { FiltersRulesetRow, RouteOptimizerSpeedProbe } from "@/lib/route-op
|
|||||||
import { updateDashboardSource, type DashboardSource, type DashboardSources } from "@/lib/dashboard-source-state"
|
import { updateDashboardSource, type DashboardSource, type DashboardSources } from "@/lib/dashboard-source-state"
|
||||||
|
|
||||||
import {can,ensureAuthConfig} from "@/lib/auth"
|
import {can,ensureAuthConfig} from "@/lib/auth"
|
||||||
|
import type {DashboardProbe} from "@/lib/dashboard-probes"
|
||||||
import {parseDashboardServers,parseDashboardProbes,type DashboardServerRow} from "@/lib/dashboard-readers"
|
import {parseDashboardServers,parseDashboardProbes,type DashboardServerRow} from "@/lib/dashboard-readers"
|
||||||
import {parseDashboardTraffic,type DashboardTraffic} from "@/lib/dashboard-traffic"
|
import {parseDashboardTraffic,type DashboardTraffic} from "@/lib/dashboard-traffic"
|
||||||
import {parseDashboardBgp,type DashboardBgp} from "@/lib/dashboard-bgp"
|
import {parseDashboardBgp,type DashboardBgp} from "@/lib/dashboard-bgp"
|
||||||
@@ -115,8 +115,8 @@ export interface AttentionRow {
|
|||||||
export type LatencyBlock =
|
export type LatencyBlock =
|
||||||
| { kind: "loading" }
|
| { kind: "loading" }
|
||||||
| { kind: "empty"; message: string }
|
| { kind: "empty"; message: string }
|
||||||
| { kind: "mock"; series: Record<string, number[]>; labels?: Record<string, string>; subtitle: string }
|
| { kind: "mock"; series: Record<string, (number|null)[]>; labels?: Record<string, string>; subtitle: string }
|
||||||
| { kind: "live"; series: Record<string, number[]>; labels?: Record<string, string>; subtitle: string }
|
| { kind: "live"; series: Record<string, (number|null)[]>; labels?: Record<string, string>; subtitle: string }
|
||||||
|
|
||||||
function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
|
function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
|
||||||
return {
|
return {
|
||||||
@@ -206,7 +206,7 @@ export function useDashboardLive() {
|
|||||||
const isLive = prefsHydrated && mode === "live"
|
const isLive = prefsHydrated && mode === "live"
|
||||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||||
|
|
||||||
const [liveProbes, setLiveProbes] = useState<PingProbe[] | null>(null)
|
const [liveProbes, setLiveProbes] = useState<DashboardProbe[] | null>(null)
|
||||||
const [liveServers, setLiveServers] = useState<Server[] | null>(null)
|
const [liveServers, setLiveServers] = useState<Server[] | null>(null)
|
||||||
const [overlayItems, setOverlayItems] = useState<OverlayItem[] | null>(null)
|
const [overlayItems, setOverlayItems] = useState<OverlayItem[] | null>(null)
|
||||||
const [bgp, setBgp] = useState<DashboardBgp | null>(null)
|
const [bgp, setBgp] = useState<DashboardBgp | null>(null)
|
||||||
@@ -258,7 +258,7 @@ export function useDashboardLive() {
|
|||||||
const results = { probes: overviewRes, servers: serversRes, bgp: br, gre: greRes, wg: wgRes, traffic: trafficRes, vxlan: vxRes }
|
const results = { probes: overviewRes, servers: serversRes, bgp: br, gre: greRes, wg: wgRes, traffic: trafficRes, vxlan: vxRes }
|
||||||
for (const [key, result] of Object.entries(results)) {
|
for (const [key, result] of Object.entries(results)) {
|
||||||
recordSource(key as DashboardSource, result.status === "rejected"
|
recordSource(key as DashboardSource, result.status === "rejected"
|
||||||
? result.reason instanceof Error ? result.reason.message : "Источник недоступен" : null,key==="traffic"&&trafficRes.status==="fulfilled"?trafficRes.value.sampledAt??undefined:undefined)
|
? result.reason instanceof Error ? result.reason.message : "Источник недоступен" : null,key==="traffic"&&trafficRes.status==="fulfilled"?trafficRes.value.sampledAt??undefined:key==="probes"&&overviewRes.status==="fulfilled"?overviewRes.value.sampledAt??undefined:undefined)
|
||||||
}
|
}
|
||||||
const failures = Object.values(results).filter(result => result.status === "rejected").length
|
const failures = Object.values(results).filter(result => result.status === "rejected").length
|
||||||
setError(failures ? "Не все источники обновлены. Последние полученные данные сохранены." : null)
|
setError(failures ? "Не все источники обновлены. Последние полученные данные сохранены." : null)
|
||||||
@@ -573,7 +573,7 @@ export function useDashboardLive() {
|
|||||||
kind: "live",
|
kind: "live",
|
||||||
series,
|
series,
|
||||||
labels,
|
labels,
|
||||||
subtitle: "Средний RTT · 1 ч · до 8 узлов",
|
subtitle: "Средний измеренный RTT · 1 ч · до 8 узлов",
|
||||||
}
|
}
|
||||||
}, [prefsHydrated, isLive, liveProbes, loading, liveServers])
|
}, [prefsHydrated, isLive, liveProbes, loading, liveServers])
|
||||||
|
|
||||||
@@ -603,13 +603,13 @@ export function useDashboardLive() {
|
|||||||
|
|
||||||
const attentionProbes: AttentionRow[] = useMemo(() => {
|
const attentionProbes: AttentionRow[] = useMemo(() => {
|
||||||
return enabledProbes
|
return enabledProbes
|
||||||
.filter((p) => p.status === "down" || p.status === "warn")
|
.filter((p) => p.status === "down" || p.status === "warn" || p.status === "unknown")
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
title: p.name,
|
title: p.name,
|
||||||
hint: p.status === "warn" ? "Предупреждение" : "Ошибка",
|
hint: p.status === "unknown" ? "Статус неизвестен" : p.status === "warn" ? "Предупреждение" : "Ошибка",
|
||||||
href: "/uptime?probe=" + encodeURIComponent(p.id),
|
href: "/uptime?probe=" + encodeURIComponent(p.id),
|
||||||
tone: p.status === "warn" ? ("warning" as const) : ("destructive" as const),
|
tone: p.status === "warn" || p.status === "unknown" ? ("warning" as const) : ("destructive" as const),
|
||||||
}))
|
}))
|
||||||
}, [enabledProbes])
|
}, [enabledProbes])
|
||||||
|
|
||||||
@@ -645,7 +645,7 @@ export function useDashboardLive() {
|
|||||||
},
|
},
|
||||||
reliable: {
|
reliable: {
|
||||||
servers: !isLive || (liveServers !== null && !sources.servers?.error && liveServers.every(s=>s.status!=="unknown")),
|
servers: !isLive || (liveServers !== null && !sources.servers?.error && liveServers.every(s=>s.status!=="unknown")),
|
||||||
probes: !isLive || (liveProbes !== null && !sources.probes?.error),
|
probes: !isLive || (liveProbes !== null && !sources.probes?.error && liveProbes.filter(p=>p.enabled).every(p=>p.status!=="unknown")),
|
||||||
overlay: !isLive || ["gre", "wg", "vxlan"].every(key => sources[key as DashboardSource]?.receivedAt != null && !sources[key as DashboardSource]?.error),
|
overlay: !isLive || ["gre", "wg", "vxlan"].every(key => sources[key as DashboardSource]?.receivedAt != null && !sources[key as DashboardSource]?.error),
|
||||||
},
|
},
|
||||||
retry: () => {
|
retry: () => {
|
||||||
@@ -665,6 +665,7 @@ export function useDashboardLive() {
|
|||||||
probeDown,
|
probeDown,
|
||||||
probeWarn,
|
probeWarn,
|
||||||
probeCount: enabledProbes.length,
|
probeCount: enabledProbes.length,
|
||||||
|
unknownProbeCount:enabledProbes.filter(p=>p.status==="unknown").length,
|
||||||
latency,
|
latency,
|
||||||
recentEvents,
|
recentEvents,
|
||||||
eventsLoading,
|
eventsLoading,
|
||||||
|
|||||||
+14
-33
@@ -3,44 +3,26 @@ import type { PingProbe, Server } from "@/lib/data"
|
|||||||
/** Длина ряда на графике дашборда (ось времени). */
|
/** Длина ряда на графике дашборда (ось времени). */
|
||||||
export const DASH_LATENCY_POINTS = 60
|
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 {
|
export interface DashboardLatencyChartInput {
|
||||||
series: Record<string, number[]>
|
series: Record<string, (number|null)[]>
|
||||||
labels: Record<string, string>
|
labels: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Средний RTT по всем включённым пробам с одного источника (srcServerId).
|
* Средний RTT по измеренным общим корзинам включённых проб одного источника.
|
||||||
|
* Не интерполируем и не подменяем отсутствующие измерения нулями.
|
||||||
* Ключ серии — стабильный id вида `src-<numericId>`; подпись — площадка или имя сервера.
|
* Ключ серии — стабильный id вида `src-<numericId>`; подпись — площадка или имя сервера.
|
||||||
* Ограничение числа линий — чтобы график оставался читаемым.
|
* Ограничение числа линий — чтобы график оставался читаемым.
|
||||||
*/
|
*/
|
||||||
export function buildLatencySeriesByProbeSource(
|
export function buildLatencySeriesByProbeSource(
|
||||||
probes: PingProbe[],
|
probes: Array<Pick<PingProbe,"enabled"|"srcServerId">&{series:(number|null)[]}>,
|
||||||
servers: Server[],
|
servers: Server[],
|
||||||
opts?: { maxServers?: number; points?: number },
|
opts?: { maxServers?: number; points?: number },
|
||||||
): DashboardLatencyChartInput {
|
): DashboardLatencyChartInput {
|
||||||
const maxServers = opts?.maxServers ?? 8
|
const maxServers = opts?.maxServers ?? 8
|
||||||
const points = opts?.points ?? DASH_LATENCY_POINTS
|
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) {
|
for (const p of probes) {
|
||||||
if (!p.enabled || !p.series?.length) continue
|
if (!p.enabled || !p.series?.length) continue
|
||||||
const sid = p.srcServerId
|
const sid = p.srcServerId
|
||||||
@@ -54,7 +36,7 @@ export function buildLatencySeriesByProbeSource(
|
|||||||
return na.localeCompare(nb, "ru")
|
return na.localeCompare(nb, "ru")
|
||||||
}).slice(0, maxServers)
|
}).slice(0, maxServers)
|
||||||
|
|
||||||
const series: Record<string, number[]> = {}
|
const series: Record<string, (number|null)[]> = {}
|
||||||
const labels: Record<string, string> = {}
|
const labels: Record<string, string> = {}
|
||||||
|
|
||||||
for (const sid of sortedIds) {
|
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)
|
const innerLen = plist.reduce((m, p) => Math.max(m, p.series.length), 0)
|
||||||
if (innerLen === 0) continue
|
if (innerLen === 0) continue
|
||||||
|
|
||||||
const averaged = Array.from({ length: innerLen }, (_, i) => {
|
const averaged = Array.from({ length: Math.min(innerLen,points) }, (_, i) => {
|
||||||
const vals = plist
|
const vals=plist.map(p=>p.series[i])
|
||||||
.map((p) => p.series[i])
|
if(vals.some(v=>typeof v!=="number"||!Number.isFinite(v)))return null
|
||||||
.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
|
const mean=vals.reduce<number>((sum,v)=>sum+(v??0),0)/vals.length
|
||||||
if (!vals.length) return 0
|
return Number.isFinite(mean)?Math.round(mean):null
|
||||||
return Math.round(vals.reduce((x, y) => x + y, 0) / vals.length)
|
|
||||||
})
|
})
|
||||||
|
if(!averaged.some(v=>v!==null))continue
|
||||||
const key = `src-${sid}`
|
const key = "src-"+sid
|
||||||
series[key] = padSeriesToLength(averaged, points)
|
series[key] = averaged
|
||||||
const srv = servers.find((s) => s.id === sid)
|
const srv = servers.find((s) => s.id === sid)
|
||||||
const site = srv?.site?.trim()
|
const site = srv?.site?.trim()
|
||||||
labels[key] =
|
labels[key] =
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
@@ -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&×.length===active.length?new Date(times.reduce((oldest,v)=>Math.min(oldest,Date.parse(v)),Infinity)).toISOString():null}
|
||||||
|
}
|
||||||
@@ -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))
|
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",()=>{
|
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))
|
for(const raw of [{},{probes:[probe,probe]},{probes:[{...probe,loss:101}]},{probes:[{...probe,status:"invalid"}]},{probes:[{...probe,series:[null]}]},{probes:[{}]}])assert.throws(()=>parseDashboardProbes(raw))
|
||||||
})
|
})
|
||||||
@@ -2,8 +2,6 @@ import {z} from "zod"
|
|||||||
import {serverReadSchema} from "@mmapp/contracts/servers"
|
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 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 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()})
|
export {parseDashboardProbes} from "@/lib/dashboard-probes"
|
||||||
const overview=z.object({probes:z.array(probe).refine(rows=>new Set(rows.map(r=>r.id)).size===rows.length)})
|
|
||||||
export type DashboardServerRow=z.infer<typeof server>
|
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 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}
|
|
||||||
@@ -236,3 +236,8 @@ for(const kind of ["servers","probes"])for(const broken of ["missing","duplicate
|
|||||||
test("Dashboard unknown server status is not reported offline or healthy",async({page})=>{
|
test("Dashboard unknown server status is not reported offline or healthy",async({page})=>{
|
||||||
const control=await setup(page);await expect(refresh(page)).toBeEnabled();control.serversPayload=[{id:1,name:"Неопрошенный узел",host:"192.0.2.1",site:"TEST",country:"RU",asn:"64512",type:"exit-node",enabled:true,status:null,latency:null,os:null,model:null,sessions:0}];await refresh(page).click();const tile=page.getByRole("link").filter({hasText:"Серверы онлайн"});await expect(tile).toContainText("Статус неизвестен: 1");await expect(tile).not.toContainText("не в сети");await expect(page.getByRole("region",{name:"Требует внимания",exact:true}).getByRole("link",{name:/Неопрошенный узел/})).toContainText("Статус неизвестен");await expect(page.getByText("Серверы без проблем",{exact:true})).toHaveCount(0);await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");
|
const control=await setup(page);await expect(refresh(page)).toBeEnabled();control.serversPayload=[{id:1,name:"Неопрошенный узел",host:"192.0.2.1",site:"TEST",country:"RU",asn:"64512",type:"exit-node",enabled:true,status:null,latency:null,os:null,model:null,sessions:0}];await refresh(page).click();const tile=page.getByRole("link").filter({hasText:"Серверы онлайн"});await expect(tile).toContainText("Статус неизвестен: 1");await expect(tile).not.toContainText("не в сети");await expect(page.getByRole("region",{name:"Требует внимания",exact:true}).getByRole("link",{name:/Неопрошенный узел/})).toContainText("Статус неизвестен");await expect(page.getByText("Серверы без проблем",{exact:true})).toHaveCount(0);await expect(page.locator('[data-source="servers"]')).not.toContainText("ошибка");
|
||||||
})
|
})
|
||||||
|
|
||||||
|
for(const state of ["legacy","unmeasured","zero","failure","gaps","invalid"])test("Dashboard ping distinguishes observed and missing measurements/"+state,async({page})=>{
|
||||||
|
const control=await setup(page);await expect(refresh(page)).toBeEnabled();const probe={id:"ping-observation",srcServerId:"1",name:"Измерение ping",target:"192.0.2.2",filter:"main",rtt:0,loss:0,status:"up",series:[0],enabled:true,showOnDashboard:true};const sample=(minute:number,rtt:number|null,status="up",loss=0)=>({sampledAt:"2026-10-03T00:"+String(minute).padStart(2,"0")+":00Z",rtt,status,loss});const samples=state==="unmeasured"?[]:state==="failure"?[sample(30,null,"down",100)]:state==="gaps"?[sample(29,5),sample(30,6),sample(32,0)]:state==="invalid"?[sample(30,0),sample(30,0)]:[sample(30,0)];control.probesPayload=state==="legacy"?{probes:[probe]}:{rangeStartAt:"2026-10-03T00:00:00Z",rangeEndAt:"2026-10-03T01:00:00Z",probes:[{...probe,observations:{samples}}]};await refresh(page).click();await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="probes"]');if(state==="invalid"){await expect(source).toContainText("ошибка обновления");return}await expect(source).not.toContainText("ошибка");const row=page.locator('tr[data-row-id]').filter({hasText:"Измерение ping"});await expect(row).toHaveCount(1);
|
||||||
|
if(state==="legacy"||state==="unmeasured"){await expect(row).toContainText("Неизвестно");await expect(page.getByRole("link").filter({hasText:"Проверки с проблемами"})).toContainText("неизвестны: 1");await expect(page.getByText("Проверки без ошибок",{exact:true})).toHaveCount(0);await expect(row).not.toContainText("100%");await expect(page.locator('[data-latency-source]')).toHaveCount(0)}else if(state==="failure"){await expect(row).toContainText("Оффлайн");await expect(row).toContainText("100%");await expect(page.getByRole("link").filter({hasText:"Проверки с проблемами"})).toContainText("1 ошибок")}else{await expect(row).toContainText("Онлайн");await expect(row).toContainText("0%");await expect(page.locator('[data-latency-isolated]')).toHaveCount(1);if(state==="gaps"){await expect(page.locator('.recharts-line-curve')).toHaveAttribute("d",/M.*M/);for(const width of [1440,320]){await page.setViewportSize({width,height:900});await page.getByRole("region",{name:"История RTT по источникам",exact:true}).scrollIntoViewIfNeeded();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true);await page.screenshot({path:"docs/ux-ui/screens/dashboard-observed-ping-"+width+".png",animations:"disabled"})}}else await expect(page.locator('[data-latency-segment]')).toHaveCount(0);await expect(source.locator("time").last()).toHaveAttribute("datetime",new Date(samples.at(-1)!.sampledAt).toISOString())}
|
||||||
|
})
|
||||||
Reference in new issue
Block a user