@@ -79,7 +80,7 @@ function ProbeSourceCell({ probe, catalog }: { probe: PingProbe; catalog: Server
}
interface DashboardActiveProbesDataGridProps {
- probes: PingProbe[]
+ probes: DashboardProbe[]
catalog: Server[]
isLoading?: boolean
emptyTitle?: string
@@ -93,7 +94,7 @@ function DashboardActiveProbesDataGrid({
emptyTitle = "Нет активных проб",
emptyDescription,
}: DashboardActiveProbesDataGridProps) {
- const columns = useMemo[]>(
+ const columns = useMemo[]>(
() => [
{
id: "source",
@@ -140,7 +141,7 @@ function DashboardActiveProbesDataGrid({
cell: (p) => (
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)}
@@ -149,7 +150,7 @@ function DashboardActiveProbesDataGrid({
},
{
id: "series",
- header: "60с",
+ header: "RTT · 1 ч",
enableSorting: false,
headerClassName: "w-36",
cell: (p) => {
@@ -168,7 +169,7 @@ function DashboardActiveProbesDataGrid({
enableSorting: false,
cell: (p) => (
),
},
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index 966905d5..c3d1079b 100644
--- a/docs/ux-ui-plan.md
+++ b/docs/ux-ui-plan.md
@@ -4,7 +4,7 @@
## Актуальная точка продолжения — 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).
diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md
index 04972736..dab058ea 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/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)
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 информация остаётся совместимой.
diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md
index ca6e4a68..ea4dbe4a 100644
--- a/docs/ux-ui-stage-12.md
+++ b/docs/ux-ui-stage-12.md
@@ -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 информация остаётся совместимой.
Проверки: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. Внешние ограничения сохраняются.
diff --git a/docs/ux-ui/screens/dashboard-observed-ping-1440.png b/docs/ux-ui/screens/dashboard-observed-ping-1440.png
new file mode 100644
index 00000000..e9d82245
Binary files /dev/null and b/docs/ux-ui/screens/dashboard-observed-ping-1440.png differ
diff --git a/docs/ux-ui/screens/dashboard-observed-ping-320.png b/docs/ux-ui/screens/dashboard-observed-ping-320.png
new file mode 100644
index 00000000..14776383
Binary files /dev/null and b/docs/ux-ui/screens/dashboard-observed-ping-320.png differ
diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts
index c53ea9c9..779e3293 100644
--- a/hooks/use-dashboard-live.ts
+++ b/hooks/use-dashboard-live.ts
@@ -10,7 +10,6 @@ import {
traffic as mockTraffic,
vxlanTunnels as mockVxlan,
type GreTunnel,
- type PingProbe,
type Server,
} from "@/lib/data"
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 {can,ensureAuthConfig} from "@/lib/auth"
+import type {DashboardProbe} from "@/lib/dashboard-probes"
import {parseDashboardServers,parseDashboardProbes,type DashboardServerRow} from "@/lib/dashboard-readers"
import {parseDashboardTraffic,type DashboardTraffic} from "@/lib/dashboard-traffic"
import {parseDashboardBgp,type DashboardBgp} from "@/lib/dashboard-bgp"
@@ -115,8 +115,8 @@ export interface AttentionRow {
export type LatencyBlock =
| { kind: "loading" }
| { kind: "empty"; message: string }
- | { kind: "mock"; series: Record; labels?: Record; subtitle: string }
- | { kind: "live"; series: Record; labels?: Record; subtitle: string }
+ | { kind: "mock"; series: Record; labels?: Record; subtitle: string }
+ | { kind: "live"; series: Record; labels?: Record; subtitle: string }
function apiGreToGreTunnel(t: ApiGreTunnelRow): GreTunnel {
return {
@@ -206,7 +206,7 @@ export function useDashboardLive() {
const isLive = prefsHydrated && mode === "live"
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
- const [liveProbes, setLiveProbes] = useState(null)
+ const [liveProbes, setLiveProbes] = useState(null)
const [liveServers, setLiveServers] = useState(null)
const [overlayItems, setOverlayItems] = useState(null)
const [bgp, setBgp] = useState(null)
@@ -258,7 +258,7 @@ export function useDashboardLive() {
const results = { probes: overviewRes, servers: serversRes, bgp: br, gre: greRes, wg: wgRes, traffic: trafficRes, vxlan: vxRes }
for (const [key, result] of Object.entries(results)) {
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
setError(failures ? "Не все источники обновлены. Последние полученные данные сохранены." : null)
@@ -573,7 +573,7 @@ export function useDashboardLive() {
kind: "live",
series,
labels,
- subtitle: "Средний RTT · 1 ч · до 8 узлов",
+ subtitle: "Средний измеренный RTT · 1 ч · до 8 узлов",
}
}, [prefsHydrated, isLive, liveProbes, loading, liveServers])
@@ -603,13 +603,13 @@ export function useDashboardLive() {
const attentionProbes: AttentionRow[] = useMemo(() => {
return enabledProbes
- .filter((p) => p.status === "down" || p.status === "warn")
+ .filter((p) => p.status === "down" || p.status === "warn" || p.status === "unknown")
.map((p) => ({
id: p.id,
title: p.name,
- hint: p.status === "warn" ? "Предупреждение" : "Ошибка",
+ hint: p.status === "unknown" ? "Статус неизвестен" : p.status === "warn" ? "Предупреждение" : "Ошибка",
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])
@@ -645,7 +645,7 @@ export function useDashboardLive() {
},
reliable: {
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),
},
retry: () => {
@@ -665,6 +665,7 @@ export function useDashboardLive() {
probeDown,
probeWarn,
probeCount: enabledProbes.length,
+ unknownProbeCount:enabledProbes.filter(p=>p.status==="unknown").length,
latency,
recentEvents,
eventsLoading,
diff --git a/lib/dashboard-latency.ts b/lib/dashboard-latency.ts
index 98cfc134..5312439b 100644
--- a/lib/dashboard-latency.ts
+++ b/lib/dashboard-latency.ts
@@ -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
+ series: Record
labels: Record
}
/**
- * Средний RTT по всем включённым пробам с одного источника (srcServerId).
+ * Средний RTT по измеренным общим корзинам включённых проб одного источника.
+ * Не интерполируем и не подменяем отсутствующие измерения нулями.
* Ключ серии — стабильный id вида `src-`; подпись — площадка или имя сервера.
* Ограничение числа линий — чтобы график оставался читаемым.
*/
export function buildLatencySeriesByProbeSource(
- probes: PingProbe[],
+ probes: Array&{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()
+ const bySrv = new Map&{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 = {}
+ const series: Record = {}
const labels: Record = {}
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((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] =
diff --git a/lib/dashboard-probes.test.ts b/lib/dashboard-probes.test.ts
new file mode 100644
index 00000000..0769b39e
--- /dev/null
+++ b/lib/dashboard-probes.test.ts
@@ -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))
+})
diff --git a/lib/dashboard-probes.ts b/lib/dashboard-probes.ts
new file mode 100644
index 00000000..a69cd6e4
--- /dev/null
+++ b/lib/dashboard-probes.ts
@@ -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&{status:PingProbe["status"]|"unknown";loss:number|null;series:(number|null)[];sampledAt?:string|null;historyObserved?:boolean}
+function meanRtt(values:Array){const mean=values.reduce((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.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}
+}
diff --git a/lib/dashboard-readers.test.ts b/lib/dashboard-readers.test.ts
index bf29bac4..e014b895 100644
--- a/lib/dashboard-readers.test.ts
+++ b/lib/dashboard-readers.test.ts
@@ -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))
})
diff --git a/lib/dashboard-readers.ts b/lib/dashboard-readers.ts
index 101e0262..f497ed0d 100644
--- a/lib/dashboard-readers.ts
+++ b/lib/dashboard-readers.ts
@@ -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
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}
diff --git a/tests/e2e/dashboard-data.spec.ts b/tests/e2e/dashboard-data.spec.ts
index f7bfb34c..fabd09b5 100644
--- a/tests/e2e/dashboard-data.spec.ts
+++ b/tests/e2e/dashboard-data.spec.ts
@@ -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})=>{
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())}
+})