diff --git a/app/(main)/dashboard/page.tsx b/app/(main)/dashboard/page.tsx
index 2d773064..bc84ab79 100644
--- a/app/(main)/dashboard/page.tsx
+++ b/app/(main)/dashboard/page.tsx
@@ -70,7 +70,7 @@ function AttentionRows({ rows }: { rows: AttentionRow[] }) {
export default function DashboardPage() {
const dash = useDashboardLive()
- const offline = Math.max(0, dash.totalServers - dash.onlineCount)
+ const offline = dash.offlineCount
const health = dash.probeDown + dash.probeWarn
return (
@@ -91,15 +91,15 @@ export default function DashboardPage() {
value: dash.availability.servers ? `${dash.onlineCount} / ${dash.totalServers}` : "—",
hint: !dash.availability.servers ? "нет данных" : !dash.isLive
? "демо"
- : offline > 0
- ? `${offline} не в сети`
+ : offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0
+ ? [offline > 0 ? `${offline} не в сети` : null,dash.unknownServerCount > 0 ? `Статус неизвестен: ${dash.unknownServerCount}` : null,dash.degradedServerCount > 0 ? `Ухудшение: ${dash.degradedServerCount}` : null].filter(Boolean).join(" · ")
: dash.totalServers > 0
? "все в сети"
: "нет серверов",
href: "/servers",
icon: ,
iconClassName: "text-muted-foreground",
- variant: offline > 0 ? "warning" : "default",
+ variant: offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0 ? "warning" : "default",
},
{
id: "overlay",
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index c49f89ee..966905d5 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 status; затем 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 и реальная ось RTT; затем overlay/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 4072078c..04972736 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -36,6 +36,12 @@
+## 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 информация остаётся совместимой.
+
+Проверки: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.3 — наблюдаемость трафика от backend до дашборда (2026-10-03)
/traffic/servers добавляет совместимое observations: независимые признаки RX/TX для последнего снимка и60 временных корзин, реальный sampledAt. Старые числовые поля и default buildTrafficFromSamples сохранены; наблюдаемость opt-in для DTO серверов. Корзина считается измеренной только при валидных дельтах всех включённых интерфейсов; отсутствие, сброс, неверная дата/счётчик и переполнение не подтверждают0. Новые guards не меняют корректные дельты.
diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md
index 7e5d590d..ca6e4a68 100644
--- a/docs/ux-ui-stage-12.md
+++ b/docs/ux-ui-stage-12.md
@@ -53,3 +53,9 @@
Проверки:27 browser dashboard/permissions,12 traffic detail/list (периоды, ошибки, одиночные значения, измеренный0 и пропуски),6 unit BGP/traffic/ACL; backend test:traffic-rate с новыми сценариями empty/single/zero/reset/partial/all interfaces/invalid-time/overflow и совместимостью numeric DTO прошли. Frontend typecheck/lint, backend/contracts build прошли. Не запускалась общая backend suite/реальная БД. ReUI checklist применён к chart/KPI, primitives/theme сохранены.
Далее12.2.4: серверы и пробы дашборда — строгие DTO и unknown статус, затем overlay/Internet path. Реальные RouterOS измерения и внешняя приёмка остаются отдельными.
+
+## 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 информация остаётся совместимой.
+
+Проверки: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.
diff --git a/hooks/use-dashboard-live.ts b/hooks/use-dashboard-live.ts
index 3537f48f..c53ea9c9 100644
--- a/hooks/use-dashboard-live.ts
+++ b/hooks/use-dashboard-live.ts
@@ -12,8 +12,6 @@ import {
type GreTunnel,
type PingProbe,
type Server,
- type ServerStatus,
- type ServerType,
} from "@/lib/data"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
@@ -32,6 +30,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 {parseDashboardServers,parseDashboardProbes,type DashboardServerRow} from "@/lib/dashboard-readers"
import {parseDashboardTraffic,type DashboardTraffic} from "@/lib/dashboard-traffic"
import {parseDashboardBgp,type DashboardBgp} from "@/lib/dashboard-bgp"
import {dashboardRequestPermission} from "@/lib/dashboard-permissions"
@@ -62,30 +61,7 @@ function readMockDashboardStarIds(): Set {
}
}
-interface BackendServerRow {
- id: number
- name: string
- host: string
- site: string
- country: string
- asn: string
- type: ServerType
- enabled: boolean
- status: "online" | "offline" | null
- latency: number | null
- os: string | null
- model: string | null
- sessions?: number
- wanUplinks?: Array<{
- id: string
- name: string
- isp: string
- iface: string
- ip: string
- maxDl: number
- maxUl: number
- }>
-}
+type BackendServerRow=DashboardServerRow
interface ApiGreTunnelRow {
id: string
@@ -190,9 +166,9 @@ function mapBackendToServer(s: BackendServerRow): Server {
asn: s.asn,
type: s.type,
enabled: s.enabled,
- status: (s.status ?? "offline") as ServerStatus,
+ status: s.status ?? "unknown",
latency: s.latency != null ? Math.round(s.latency) : null,
- sessions: s.sessions ?? 0,
+ sessions: s.sessions,
wanUplinks,
}
}
@@ -267,8 +243,8 @@ export function useDashboardLive() {
if(!can("mm:network:read"))setInternetPath(null)
setOverlayItems(previous=>previous?.filter(item=>can(item.kind==="gre"?"mm:filters:read":item.kind==="wg"?"mm:network:read":"mm:dashboard:read"))??null)
const [overviewRes, serversRes, fr, br, ipRes, greRes, wgRes, trafficRes, vxRes] = await Promise.allSettled([
- apiFetch<{ probes: PingProbe[] }>("/api/uptime/overview?range=1h"),
- apiFetch("/api/servers"),
+ apiFetch("/api/uptime/overview?range=1h").then(parseDashboardProbes),
+ apiFetch("/api/servers").then(parseDashboardServers),
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
apiFetch("/api/bgp/catalog").then(parseDashboardBgp),
apiFetch<{ snapshot: InternetPathSnapshotPayload | null }>("/api/internet-path/latest"),
@@ -607,9 +583,9 @@ export function useDashboardLive() {
.map((s) => ({
id: s.id,
title: s.name,
- hint: s.status === "degraded" ? "Ухудшение" : "Не в сети",
+ hint: s.status === "unknown" ? "Статус неизвестен" : s.status === "degraded" ? "Ухудшение" : "Не в сети",
href: "/servers?server=" + encodeURIComponent(s.id),
- tone: s.status === "degraded" ? ("warning" as const) : ("destructive" as const),
+ tone: s.status === "degraded" || s.status === "unknown" ? ("warning" as const) : ("destructive" as const),
}))
}, [servers])
@@ -668,7 +644,7 @@ export function useDashboardLive() {
overlay: !isLive || ["gre", "wg", "vxlan"].every(key => sources[key as DashboardSource]?.receivedAt != null),
},
reliable: {
- servers: !isLive || (liveServers !== null && !sources.servers?.error),
+ servers: !isLive || (liveServers !== null && !sources.servers?.error && liveServers.every(s=>s.status!=="unknown")),
probes: !isLive || (liveProbes !== null && !sources.probes?.error),
overlay: !isLive || ["gre", "wg", "vxlan"].every(key => sources[key as DashboardSource]?.receivedAt != null && !sources[key as DashboardSource]?.error),
},
@@ -682,6 +658,9 @@ export function useDashboardLive() {
bgp: isLive ? bgp : { prefixSum: 8432, establishedCount: 3 },
traffic,
onlineCount,
+ offlineCount:servers.filter(s=>s.status==="offline").length,
+ degradedServerCount:servers.filter(s=>s.status==="degraded").length,
+ unknownServerCount:servers.filter(s=>s.status==="unknown").length,
totalServers: servers.length,
probeDown,
probeWarn,
diff --git a/lib/dashboard-readers.test.ts b/lib/dashboard-readers.test.ts
new file mode 100644
index 00000000..bf29bac4
--- /dev/null
+++ b/lib/dashboard-readers.test.ts
@@ -0,0 +1,13 @@
+import assert from "node:assert/strict"
+import {test} from "node:test"
+import {parseDashboardServers,parseDashboardProbes} from "./dashboard-readers"
+const server={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}
+const probe={id:"p1",srcServerId:"1",name:"Проверка",target:"192.0.2.2",filter:"main",rtt:null,loss:100,status:"down",series:[],enabled:true,showOnDashboard:true}
+test("dashboard server catalog validates identities and keeps unknown status",()=>{
+ assert.equal(parseDashboardServers([server])[0].status,null);assert.deepEqual(parseDashboardServers([]),[])
+ 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:[]})
+ 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
new file mode 100644
index 00000000..101e0262
--- /dev/null
+++ b/lib/dashboard-readers.ts
@@ -0,0 +1,9 @@
+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 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 a0e1017a..f7bfb34c 100644
--- a/tests/e2e/dashboard-data.spec.ts
+++ b/tests/e2e/dashboard-data.spec.ts
@@ -4,7 +4,7 @@ import { serverReadSchema } from "../../packages/contracts/src/servers"
import {bgpSession} from "./fixtures/bgp"
async function setup(page: Page, initiallyFailed = false, serverCount = 1, initialBgpPayload?:unknown) {
- const control = { failed: initiallyFailed, partial: false, prefixes: 321 as number|null, trafficPayload: undefined as unknown, bgpPayload: initialBgpPayload as unknown, hold: false, holdPath: "/api/bgp/catalog", fallback: false, failFallback: false, pending: 0, release: () => {} }
+ const control = { failed: initiallyFailed, partial: false, prefixes: 321 as number|null, serversPayload:undefined as unknown,probesPayload:undefined as unknown, trafficPayload: undefined as unknown, bgpPayload: initialBgpPayload as unknown, hold: false, holdPath: "/api/bgp/catalog", fallback: false, failFallback: false, pending: 0, release: () => {} }
let release!: () => void
const gate = new Promise(resolve => { release = resolve })
control.release = release
@@ -26,8 +26,8 @@ async function setup(page: Page, initiallyFailed = false, serverCount = 1, initi
await gate
}
if (fail) return route.fulfill({ status: 503, json: { error: "Источник временно недоступен" } })
- const json = path === "/api/servers" ? servers
- : path === "/api/uptime/overview" ? { probes: [] }
+ const json = path === "/api/servers" ? control.serversPayload !== undefined ? control.serversPayload : servers
+ : path === "/api/uptime/overview" ? control.probesPayload !== undefined ? control.probesPayload : { probes: [] }
: path === "/api/bgp/catalog" ? control.bgpPayload !== undefined ? control.bgpPayload : {sessions:[bgpSession(1,prefixes)],failures:[]}
: path === "/api/filters/rules" ? { rulesets: [] }
: path === "/api/filters/gre-tunnels" ? { tunnels: [{ id: "g1", name: "GRE сохранён", status: "down", serverId: "1" }] }
@@ -227,3 +227,12 @@ for(const state of ["legacy","unobserved","measured-zero","reset-rx","invalid-ti
await expect(source).not.toContainText("ошибка");if(state==="measured-zero"){await expect(page.getByText("RX 0 Кбит/с",{exact:true})).toBeVisible();await expect(page.getByRole("region",{name:"История RX/TX",exact:true})).toBeVisible();await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toHaveCount(0)}else{await expect(page.getByText("RX —",{exact:true})).toBeVisible();if(state==="reset-rx"){await expect(page.getByText("TX 0 Кбит/с",{exact:true})).toBeVisible();await expect(page.getByText("Нет измерений RX за выбранный период",{exact:true})).toBeVisible()}else{await expect(page.getByText("TX —",{exact:true})).toBeVisible();await expect(page.getByText("Нет исторических измерений за выбранный период",{exact:true})).toBeVisible()}}
if(state==="legacy")await expect(page.getByText("Измерения не подтверждены API",{exact:true})).toBeVisible()
})
+
+for(const kind of ["servers","probes"])for(const broken of ["missing","duplicate","numeric"])test("Dashboard strict catalog keeps source cache/"+kind+"/"+broken,async({page})=>{
+ const control=await setup(page);await expect(refresh(page)).toBeEnabled();const source=page.locator('[data-source="'+kind+'"]');const receivedAt=await source.locator("time").getAttribute("datetime");const server={id:1,name:"Узел",host:"192.0.2.1",site:"TEST",country:"RU",asn:"64512",type:"exit-node",enabled:true,status:"online",latency:1,os:"7",model:"CHR",sessions:0};const probe={id:"p1",srcServerId:"1",name:"Тест",target:"192.0.2.2",filter:"main",rtt:1,loss:0,status:"up",series:[1],enabled:true,showOnDashboard:true};
+ if(kind==="servers")control.serversPayload=broken==="missing"?{}:broken==="duplicate"?[server,server]:[{...server,latency:-1}];else control.probesPayload=broken==="missing"?{}:broken==="duplicate"?{probes:[probe,probe]}:{probes:[{...probe,loss:101}]};await refresh(page).click();await expect(source).toContainText("ошибка обновления");await expect(source.locator("time")).toHaveAttribute("datetime",receivedAt!);if(kind==="servers")await expect(page.getByRole("link").filter({hasText:"Серверы онлайн"})).toContainText("1 / 1");else await expect(page.getByText("Проверки без ошибок",{exact:true})).toHaveCount(0);
+ control.serversPayload=undefined;control.probesPayload=undefined;await refresh(page).click();await expect(source).not.toContainText("ошибка");
+})
+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("ошибка");
+})