fix(ui): проверять каталоги дашборда и сохранять неизвестный статус серверов
This commit is contained in:
1 parent
9e0f695684
commit
8f65d1620d
8 files changed
+63
-41
No files matched your search
@@ -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: <ServerIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
variant: offline > 0 ? "warning" : "default",
|
||||
variant: offline > 0 || dash.unknownServerCount > 0 || dash.degradedServerCount > 0 ? "warning" : "default",
|
||||
},
|
||||
{
|
||||
id: "overlay",
|
||||
|
||||
+1
-1
@@ -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).
|
||||
|
||||
|
||||
@@ -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 не меняют корректные дельты.
|
||||
|
||||
@@ -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.
|
||||
+12
-33
@@ -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<string> {
|
||||
}
|
||||
}
|
||||
|
||||
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<BackendServerRow[]>("/api/servers"),
|
||||
apiFetch<unknown>("/api/uptime/overview?range=1h").then(parseDashboardProbes),
|
||||
apiFetch<unknown>("/api/servers").then(parseDashboardServers),
|
||||
apiFetch<{ rulesets: Array<{ rules?: unknown[] }> }>("/api/filters/rules"),
|
||||
apiFetch<unknown>("/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,
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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<typeof server>
|
||||
export function parseDashboardServers(raw:unknown){const parsed=servers.safeParse(raw);if(!parsed.success)throw Error("Некорректный каталог серверов");return parsed.data}
|
||||
export function parseDashboardProbes(raw:unknown){const parsed=overview.safeParse(raw);if(!parsed.success)throw Error("Некорректные данные проб");return parsed.data}
|
||||
@@ -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<void>(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("ошибка");
|
||||
})
|
||||
Reference in new issue
Block a user