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("ошибка"); +})