diff --git a/app/(main)/uptime/page.tsx b/app/(main)/uptime/page.tsx index 40d336e4..0658782f 100644 --- a/app/(main)/uptime/page.tsx +++ b/app/(main)/uptime/page.tsx @@ -1,6 +1,8 @@ "use client" -import { speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema } from "@mmapp/contracts" +import { dashboardProbeOverviewSchema } from "@/lib/dashboard-probes" + +import { speedHistoryResponseSchema, speedProbesResponseSchema, speedProbesWriteSchema, speedProbesSavedSchema, pingProbesWriteSchema, probeCatalogSavedSchema } from "@mmapp/contracts" import probeStyles from "./probe-rows.module.css" @@ -250,7 +252,7 @@ function TypeChip({ type }: { type: "jump-host" | "exit-node" | "home-router" }) function StatChip({ label, value, color, active, onClick, }: { - label: string; value: number; color?: "emerald" | "amber" | "red"; active?: boolean; onClick?: () => void + label: string; value: number | string; color?: "emerald" | "amber" | "red"; active?: boolean; onClick?: () => void }) { return ( {tab === "probes" && ( - )} @@ -2052,7 +2056,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {stats.down} )} - {probes.length} + {pingCatalogKnown ? probes.length : "—"} ) : t === "resources" ? ( @@ -2365,8 +2369,19 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {/* ── probes tab ── */} {tab === "probes" && <> + {liveApi && !pingCatalogReady &&
+ + +
} - {opError && ( + {opError && pingCatalogReady && (
@@ -2392,10 +2407,10 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {/* ── summary + server filter ── */}
- - cycleStatusFilter("up")} /> - cycleStatusFilter("warn")} /> - cycleStatusFilter("down")} /> + + cycleStatusFilter("up")} /> + cycleStatusFilter("warn")} /> + cycleStatusFilter("down")} />
@@ -2453,7 +2468,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId

- {filtered.length} из {probes.length} проб + {pingCatalogKnown ? filtered.length + " из " + probes.length + " проб" : "Каталог проб пока неизвестен"} · звезда — блок «Активные пробы» @@ -2464,7 +2479,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId {/* ── probe groups ── */}

- {grouped.length === 0 && ( + {pingCatalogKnown && grouped.length === 0 && (

Пробы не найдены

@@ -2560,7 +2575,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId !p.enabled && "opacity-40", )} > - +
toggleDashboardStar(p.id)} + disabled={probeSaving || !canWrite || !pingCatalogReady} onClick={() => toggleDashboardStar(p.id)} title={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"} aria-label={p.showOnDashboard ? "Убрать с дашборда" : "Показать на дашборде"} aria-pressed={p.showOnDashboard === true} @@ -2641,12 +2656,12 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId
@@ -2690,7 +2705,7 @@ function UptimePageContent({ selectedProbeId, allProbesHref }: { selectedProbeId })} - + diff --git a/backend/src/routes/ping-probes-write.test.ts b/backend/src/routes/ping-probes-write.test.ts new file mode 100644 index 00000000..7622f7bc --- /dev/null +++ b/backend/src/routes/ping-probes-write.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { SignJWT } from "jose" +import { createPingProbesWriteRoute } from "./ping-probes-write.js" +import { pingProbesWriteSchema } from "@mmapp/contracts" +import { replacePingProbes, type PingProbePatch, type PingProbeStore } from "../services/ping-probe-replacement.js" +const probe = { id: "p1", srcServerId: "1", name: " Проверка ", target: "192.0.2.2", enabled: false, showOnDashboard: true } +let transactions = 0, refreshes = 0, failWrite = false, failScheduler = false +let rows = new Map([["old", { ...pingProbesWriteSchema.parse({ probes: [probe] }).probes[0], id: "old", probeFilter: "main", sortOrder: 0, updatedAt: "old", intervalSec: 60 }]]) +let history = new Map([["old", [42]]]) +const store: PingProbeStore = { transaction: async run => { + transactions++; const draft = new Map(rows), samples = new Map(history) + await run({ listIds: async () => [...draft.keys()], remove: async id => { draft.delete(id); samples.delete(id) }, upsert: async patch => { + draft.set(patch.id, { ...draft.get(patch.id), ...patch }) + if (failWrite) throw new Error("write failed") + } }) + rows = draft; history = samples +} } +const app = Fastify(); await app.register(createPingProbesWriteRoute({ replace: rows => replacePingProbes(store, rows), refreshScheduler: async () => { refreshes++; if (failScheduler) throw new Error("scheduler failed") } }), { prefix: "/api" }) +const put = (payload: unknown) => app.inject({ method: "PUT", url: "/api/uptime/probes", headers: { "content-type": "application/json" }, payload: JSON.stringify(payload) }) +for (const payload of [null, {}, { probes: null }, { probes: {} }, { probes: [probe, probe] }, ...[ + { id: "" }, { srcServerId: "1junk" }, { srcServerId: 0 }, { srcServerId: 9007199254740992 }, { name: " " }, { target: " " }, + { enabled: undefined }, { enabled: "true" }, { showOnDashboard: "false" }, { filter: false }, { srcInterface: null }, + { intervalSec: -1 }, { intervalSec: "10junk" }, { intervalSec: 3.5 }, { intervalSec: 2147483648 }, +].map(patch => ({ probes: [probe, { ...probe, id: "p2", ...patch }] }))]) assert.equal((await put(payload)).statusCode, 400) +assert.equal(transactions, 0); assert.equal(refreshes, 0); assert.deepEqual(history.get("old"), [42]) +failWrite = true; assert.equal((await put({ probes: [probe] })).statusCode, 500) +assert.ok(rows.has("old")); assert.deepEqual(history.get("old"), [42]); assert.equal(refreshes, 0) +failWrite = false; assert.equal((await put({ probes: [{ ...probe, intervalSec: 60 }] })).statusCode, 200) +assert.equal(rows.get("p1")?.name, "Проверка"); assert.equal(rows.get("p1")?.enabled, false); assert.equal(history.has("old"), false) +history.set("p1", [0, 42]); await put({ probes: [{ ...probe, showOnDashboard: undefined }] }) +assert.equal(rows.get("p1")?.intervalSec, 60); assert.equal(rows.get("p1")?.showOnDashboard, true); assert.deepEqual(history.get("p1"), [0, 42]) +for (const intervalSec of [3, 0, "", null]) { + await put({ probes: [{ ...probe, intervalSec }] }); assert.equal(rows.get("p1")?.intervalSec, intervalSec === 3 ? 5 : 0) +} +failScheduler = true; const saved = await put({ probes: [{ ...probe, name: "Изменена" }] }) +assert.equal(saved.statusCode, 200); assert.equal(saved.json().ok, true); assert.match(saved.json().warning, /Ping-пробы сохранены/); assert.equal(rows.get("p1")?.name, "Изменена") +failScheduler = false; await put({ probes: [probe, { ...probe, id: "p2", name: "Вторая" }] }) +assert.deepEqual([...rows.values()].map(row => [row.id, row.sortOrder]), [["p1", 0], ["p2", 1]]) +assert.equal((await put({ probes: [] })).statusCode, 200); assert.equal(rows.size, 0); assert.equal(history.size, 0) +await app.close(); console.log("Ping probes HTTP/DI: whole request validated before mutation; modeled atomic rollback including history; omitted interval preserved; explicit delete and scheduler warning") +process.env.AUTH_REQUIRED = "true"; process.env.AUTH_JWT_SECRET = "ping-probes-test-secret"; process.env.AUTH_ISSUER = "https://auth.test.local"; process.env.NODE_ENV = "test" +const { default: authPlugin, requireAuth } = await import("../plugins/auth.js") +const secured = Fastify(); await secured.register(authPlugin); secured.addHook("preHandler", requireAuth) +let writes = 0; await secured.register(createPingProbesWriteRoute({ replace: async () => { writes++ }, refreshScheduler: async () => undefined }), { prefix: "/api" }) +const send = (token?: string) => secured.inject({ method: "PUT", url: "/api/uptime/probes", payload: { probes: [] }, headers: token ? { authorization: "Bearer " + token } : {} }) +assert.equal((await send()).statusCode, 401); assert.equal(writes, 0) +for (const permission of ["mm:uptime:read", "mm:servers:write", "mm:uptime:write"]) { + const token = await new SignJWT({ sub: "test", apps: ["mm"], permissions: [permission] }).setProtectedHeader({ alg: "HS256" }).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("ping-probes-test-secret")) + assert.equal((await send(token)).statusCode, permission === "mm:uptime:write" ? 200 : 403) +} +assert.equal(writes, 1); await secured.close(); console.log("Ping probes RBAC: unauthorized requests have no storage side effects") diff --git a/backend/src/routes/ping-probes-write.ts b/backend/src/routes/ping-probes-write.ts new file mode 100644 index 00000000..a2610090 --- /dev/null +++ b/backend/src/routes/ping-probes-write.ts @@ -0,0 +1,16 @@ +import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" +import { pingProbesWriteSchema, type PingProbeWrite } from "@mmapp/contracts" +export function createPingProbesWriteRoute(deps: { replace: (probes: PingProbeWrite[]) => Promise; refreshScheduler: () => Promise }): FastifyPluginAsyncZod { + return async app => { + app.put("/uptime/probes", async (req, reply) => { + const parsed = pingProbesWriteSchema.safeParse(req.body) + if (!parsed.success) return reply.code(400).send({ error: "Список Ping-проб некорректен. Проверьте устройства, имена, цели и уникальность записей. Изменения не сохранены." }) + await deps.replace(parsed.data.probes) + try { await deps.refreshScheduler(); return reply.send({ ok: true }) } + catch (error) { + req.log.error({ err: error }, "Ping probes saved; scheduler refresh failed") + return reply.send({ ok: true, warning: "Ping-пробы сохранены, но расписание не обновлено. Проверьте состояние сборщика данных." }) + } + }) + } +} diff --git a/backend/src/routes/uptime.ts b/backend/src/routes/uptime.ts index 38f2bcc7..00a26cba 100644 --- a/backend/src/routes/uptime.ts +++ b/backend/src/routes/uptime.ts @@ -1,3 +1,4 @@ +import { createPingProbesWriteRoute } from "./ping-probes-write.js" import { createSpeedProbesWriteRoute } from "./speed-probes-write.js" import { replaceSpeedProbes } from "../services/speed-probe-replacement.js" import { mapSpeedProbeRead } from "../services/speed-probe-dto.js" @@ -217,39 +218,7 @@ const uptimeRoutes: FastifyPluginAsyncZod = async (app) => { refreshScheduler, })) - app.put("/uptime/probes", async (req, reply) => { - const body = req.body as { - probes?: Array<{ - id?: string - srcServerId?: string | number - srcInterface?: string - name?: string - target?: string - filter?: string - enabled?: boolean - showOnDashboard?: boolean - intervalSec?: number | string - }> - } - const normalized = (body.probes ?? []) - .map((p, i) => ({ - id: p.id || `p-${Date.now()}-${i}`, - srcServerId: Number.parseInt(String(p.srcServerId ?? ""), 10), - srcInterface: String(p.srcInterface ?? "").trim(), - name: String(p.name ?? "").trim(), - target: String(p.target ?? "").trim(), - probeFilter: String(p.filter ?? "—"), - enabled: p.enabled !== false, - showOnDashboard: p.showOnDashboard === true, - intervalSec: p.intervalSec == null || p.intervalSec === "" - ? undefined - : Number.parseInt(String(p.intervalSec), 10), - })) - .filter((p) => Number.isFinite(p.srcServerId) && p.name && p.target) - await replaceProbes(normalized) - await refreshScheduler() - return reply.send({ ok: true }) - }) + await app.register(createPingProbesWriteRoute({ replace: replaceProbes, refreshScheduler })) app.patch("/uptime/probes/:id", async (req, reply) => { const params = req.params as { id?: string } diff --git a/backend/src/services/ping-probe-replacement.ts b/backend/src/services/ping-probe-replacement.ts new file mode 100644 index 00000000..0ee4df44 --- /dev/null +++ b/backend/src/services/ping-probe-replacement.ts @@ -0,0 +1,21 @@ +import type { PingProbeWrite } from "@mmapp/contracts" +export type PingProbePatch = Omit & { probeFilter: string; sortOrder: number; updatedAt: string } +export type PingProbeTransaction = { + listIds: () => Promise + remove: (id: string) => Promise + upsert: (row: PingProbePatch, createdAt: string) => Promise +} +export type PingProbeStore = { transaction: (run: (tx: PingProbeTransaction) => Promise) => Promise } +export async function replacePingProbes(store: PingProbeStore, probes: PingProbeWrite[], now = new Date().toISOString()): Promise { + await store.transaction(async tx => { + const nextIds = new Set(probes.map(row => row.id)) + for (const id of await tx.listIds()) if (!nextIds.has(id)) await tx.remove(id) + for (const [sortOrder, row] of probes.entries()) { + const { filter, intervalSec, showOnDashboard, ...config } = row + await tx.upsert({ ...config, probeFilter: filter, sortOrder, updatedAt: now, + ...(intervalSec === undefined ? {} : { intervalSec: intervalSec > 0 ? Math.max(5, intervalSec) : 0 }), + ...(showOnDashboard === undefined ? {} : { showOnDashboard }), + }, now) + } + }) +} diff --git a/backend/src/services/uptime-collector.ts b/backend/src/services/uptime-collector.ts index dd511495..0d589c2d 100644 --- a/backend/src/services/uptime-collector.ts +++ b/backend/src/services/uptime-collector.ts @@ -1,4 +1,6 @@ -import { and, asc, eq, gte, inArray, max, or } from "drizzle-orm" +import { replacePingProbes } from "./ping-probe-replacement.js" +import type { PingProbeWrite } from "@mmapp/contracts" +import { and, asc, eq, gte, inArray, max, or, sql } from "drizzle-orm" import { db } from "../db/index.js" import { servers, @@ -467,49 +469,19 @@ export async function updateProbeShowOnDashboard(probeId: string, showOnDashboar return r.length > 0 } -export async function replaceProbes(rows: Array<{ - id: string - srcServerId: number - srcInterface: string - name: string - target: string - probeFilter: string - enabled: boolean - showOnDashboard?: boolean - intervalSec?: number -}>) { - const now = new Date().toISOString() - const existing = await db.select({ id: uptimeProbes.id }).from(uptimeProbes) - const nextIds = new Set(rows.map((r) => r.id)) - - for (const p of existing) { - if (nextIds.has(p.id)) continue - await db.delete(uptimeProbes).where(eq(uptimeProbes.id, p.id)) - } - - for (let i = 0; i < rows.length; i += 1) { - const r = rows[i] - const intervalSec = r.intervalSec != null && r.intervalSec > 0 ? Math.max(5, Math.floor(r.intervalSec)) : 0 - const patch = { - srcServerId: r.srcServerId, - srcInterface: r.srcInterface || "", - name: r.name, - target: r.target, - probeFilter: r.probeFilter || "—", - enabled: r.enabled, - showOnDashboard: r.showOnDashboard ?? false, - intervalSec, - sortOrder: i, - updatedAt: now, - } - const updated = await db.update(uptimeProbes).set(patch).where(eq(uptimeProbes.id, r.id)).returning({ id: uptimeProbes.id }) - if (updated.length > 0) continue - await db.insert(uptimeProbes).values({ - id: r.id, - ...patch, - createdAt: now, +export async function replaceProbes(rows: PingProbeWrite[]) { + await replacePingProbes({ transaction: run => db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtext('uptime-ping-probes'))`) + await run({ + listIds: async () => (await tx.select({ id: uptimeProbes.id }).from(uptimeProbes)).map(row => row.id), + remove: async id => { await tx.delete(uptimeProbes).where(eq(uptimeProbes.id, id)) }, + upsert: async (row, createdAt) => { + const { id, ...patch } = row + await tx.insert(uptimeProbes).values({ id, ...patch, createdAt }) + .onConflictDoUpdate({ target: uptimeProbes.id, set: patch }) + }, }) - } + }) }, rows) } export async function readProbeSamplesSince(sinceIso: string) { diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index cf30fa3a..ed1fa96b 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -4,7 +4,7 @@ ## Актуальная точка продолжения — 2026-10-04 -Этап 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. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принят локально:BFD1000/50 DOM/cache/320px/12 browser/build. 12.4.4 принят локально:context213/25 файлов и unit36 прошли на5b2a291. 12.4.5 принят локально:main1110/171 прошли на21859be. Следующий блок12.3.26:strict atomic Ping write и copy об удалении истории; затем12.3.27 WireGuard missing/zero bytes и network grids; далее network grids и полная main/context регрессия.12.5 отдельно. См. 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. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. 12.3.19 принят локально:backups1000/все мобильные действия/возврат focus/39+4 browser/build. 12.3.20 принят локально:независимые speed GET/cache/freshness/58+4 browser/build. 12.3.21 принят локально:strict history DTO/null rates от DB до UI/60+5 browser/unit4/builds. 12.4.3 принят локально:users/backups delta context36 на a558412. 12.3.22 принят локально:shared speed-probes DTO/context8/60+6+47 browser/unit5/builds. 12.3.23 принят локально:strict write DTO/atomic replacement/confirmed scheduler warning/65 browser/HTTP DI JWT/builds. 12.3.24 принят локально:OSPF routes1000/50 DOM/page20/cache/320px/22+1 browser/build. 12.3.25 принят локально:BFD1000/50 DOM/cache/320px/12 browser/build. 12.4.4 принят локально:context213/25 файлов и unit36 прошли на5b2a291. 12.4.5 принят локально:main1110/171 прошли на21859be. 12.3.26 принят локально:strict atomic Ping write/подтверждение каталога и сохранения/история удаления/62 browser/context11/DI6/builds. Следующий блок12.3.27: WireGuard missing/zero bytes и network grids; далее network grids и полная main/context регрессия.12.5 отдельно. См. 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 bbe6b3b4..7e1caf10 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -29,13 +29,23 @@ | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | -| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.25 приняты локально в описанных границах;12.4.4 context213 и12.4.5 main1110 прошли, остаются Ping write/network grids/внешняя приёмка | +| 12 | Итоговая доступность, адаптивность, производительность и регрессия | В работе;12.1/12.2.1–12.2.10 и12.3.1–12.3.5 и12.3.7–12.3.12 приняты в описанных локальных границах;12.4.1 main регрессия и профильный повтор записаны;12.4.2 context205 и unit28 прошли;12.3.13–12.3.25 приняты локально в описанных границах;12.4.4 context213 и12.4.5 main1110 прошли, 12.3.26 Ping write принят локально; остаются WireGuard counters/network grids/внешняя приёмка | +## 12.3.26 — атомарная запись каталога Ping-проб (2026-10-04) + +Shared write DTO проверяет весь список до DB: обязательный массив, уникальные ID, безопасные ID устройств, непустые имена/цели, boolean и целые интервалы. Некорректные строки не отбрасываются с последующим удалением других проб. Замена выполняется в PostgreSQL transaction с advisory lock; конфигурация обновляется отдельно от измерений. Оmitted interval/dashboard сохраняются для существующих строк, явный пустой список означает удаление. Ошибка refreshScheduler после commit возвращает подтверждённое ok:true с предупреждением. + +UI проверяет Ping-каталог до публикации, сохраняет последний успешный список при ошибке и запрещает замену неподтверждённого каталога. Первоначально неизвестные количества показаны как —. PUT требует подтверждённый ответ; malformed ack сохраняет черновик. Один ReUI Alert показывает причину и время последнего успешного списка. Диалог удаления точно описывает каскадное удаление Ping history; speed history сохраняется. Снимок320px: [Ping catalog error](ux-ui/screens/ping-catalog-error-320.png). + +Проверки: uptime-links62/62; context uptime/backend-switch11/11; pure/HTTP DI6/6 (включая JWT401/403, отсутствие mutations до validation и моделированный rollback с историей). TypeScript без incremental и ESLint прошли (9 исходных warnings Uptime); backend и frontend production build прошли. ReUI Alert API/usage проверены, primitives/base-nova не изменены. Fixture backend-switch дополнен реальным backend полем showOnDashboard. Общая регрессия1110/213 относится к предыдущим HEAD, после delta целиком ещё не повторена. + +Реальный PG rollback/advisory lock/RouterOS не проверены; DI не заменяет внешнюю12.5. Whole-catalog PUT остаётся last writer wins без revision/ETag. Resources entries остаются legacy DTO (проверена только array оболочка). Следующий блок12.3.27: WireGuard missing/zero RX/TX в деталях и таблице пиров; далее network grids. Коммит локальный, без push. + ## 12.4.5 — повтор основной регрессии (2026-10-04) На HEAD21859be полный playwright.config.ts дал1110 из1110 passed/171 файл за26.6min;0 unexpected/0 skipped/0 flaky, Edge/workers1/retries0/maxFailures12. Прежние3 устаревшие fixtures из12.4.1 исправлены и теперь прошли в общей матрице. Включены последние speed read/write и большие OSPF/BFD профили, двухтемная доступность оболочки, права, responsive и сквозные API scenarios. Доказательство: docs/ux-ui/acceptance/main-repeat-2026-10-04.json с точным HEAD и результатами по файлам. Context213/25 и unit36 приняты отдельно в12.4.4. Исторические PNG, пересозданные этим прогоном, восстановлены по явному проверенному списку; новые принятые OSPF/BFD снимки сохранены. diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md index 6120cacf..19291e2b 100644 --- a/docs/ux-ui-stage-12.md +++ b/docs/ux-ui-stage-12.md @@ -380,3 +380,14 @@ Backend заменяет каталог в одной Drizzle transaction с pg_ Следующий блок12.3.26: строгий Ping write DTO до DB, атомарная замена, защита записи неизвестного каталога, подтверждение scheduler warning и точная copy о каскадном удалении истории. Затем12.3.27 WireGuard missing/zero counters и оставшиеся network grids. Внешняя12.5 отдельно; коммит локальный без push. + +## 12.3.26 — атомарная запись каталога Ping-проб (2026-10-04) + +Shared write DTO проверяет весь список до DB: обязательный массив, уникальные ID, безопасные ID устройств, непустые имена/цели, boolean и целые интервалы. Некорректные строки не отбрасываются с последующим удалением других проб. Замена выполняется в PostgreSQL transaction с advisory lock; конфигурация обновляется отдельно от измерений. Оmitted interval/dashboard сохраняются для существующих строк, явный пустой список означает удаление. Ошибка refreshScheduler после commit возвращает подтверждённое ok:true с предупреждением. + +UI проверяет Ping-каталог до публикации, сохраняет последний успешный список при ошибке и запрещает замену неподтверждённого каталога. Первоначально неизвестные количества показаны как —. PUT требует подтверждённый ответ; malformed ack сохраняет черновик. Один ReUI Alert показывает причину и время последнего успешного списка. Диалог удаления точно описывает каскадное удаление Ping history; speed history сохраняется. Снимок320px: [Ping catalog error](ux-ui/screens/ping-catalog-error-320.png). + +Проверки: uptime-links62/62; context uptime/backend-switch11/11; pure/HTTP DI6/6 (включая JWT401/403, отсутствие mutations до validation и моделированный rollback с историей). TypeScript без incremental и ESLint прошли (9 исходных warnings Uptime); backend и frontend production build прошли. ReUI Alert API/usage проверены, primitives/base-nova не изменены. Fixture backend-switch дополнен реальным backend полем showOnDashboard. Общая регрессия1110/213 относится к предыдущим HEAD, после delta целиком ещё не повторена. + +Реальный PG rollback/advisory lock/RouterOS не проверены; DI не заменяет внешнюю12.5. Whole-catalog PUT остаётся last writer wins без revision/ETag. Resources entries остаются legacy DTO (проверена только array оболочка). Следующий блок12.3.27: WireGuard missing/zero RX/TX в деталях и таблице пиров; далее network grids. Коммит локальный, без push. + diff --git a/docs/ux-ui/screens/ping-catalog-error-320.png b/docs/ux-ui/screens/ping-catalog-error-320.png new file mode 100644 index 00000000..511824b5 Binary files /dev/null and b/docs/ux-ui/screens/ping-catalog-error-320.png differ diff --git a/lib/dashboard-probes.ts b/lib/dashboard-probes.ts index a69cd6e4..e540726f 100644 --- a/lib/dashboard-probes.ts +++ b/lib/dashboard-probes.ts @@ -21,3 +21,5 @@ export function parseDashboardProbes(raw:unknown):{probes:DashboardProbe[];sampl 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} } + +export { overview as dashboardProbeOverviewSchema } diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 4deabc0f..9177d031 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -15,3 +15,5 @@ export * from "./ospf-optimizer.js" export * from "./evobgp.js" export * from "./uptime-speed.js" + +export * from "./uptime-probes.js" diff --git a/packages/contracts/src/uptime-probes.ts b/packages/contracts/src/uptime-probes.ts new file mode 100644 index 00000000..a0578b38 --- /dev/null +++ b/packages/contracts/src/uptime-probes.ts @@ -0,0 +1,12 @@ +import { z } from "zod" +const serverId = z.union([z.number(), z.string().regex(/^[1-9][0-9]*$/).transform(Number)]).pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)) +const interval = z.union([z.number(), z.string().regex(/^[0-9]+$/).transform(Number), z.literal("").transform(() => 0), z.null().transform(() => 0)]).pipe(z.number().int().min(0).max(2147483647)) +export const pingProbeWriteSchema = z.object({ + id: z.string().trim().min(1), srcServerId: serverId, + srcInterface: z.string().trim().default(""), name: z.string().trim().min(1), target: z.string().trim().min(1), + filter: z.string().trim().transform(value => value || "—").default("—"), enabled: z.boolean(), + showOnDashboard: z.boolean().optional(), intervalSec: interval.optional(), +}) +export const pingProbesWriteSchema = z.object({ probes: z.array(pingProbeWriteSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторный ID Ping-пробы") }) +export const probeCatalogSavedSchema = z.object({ ok: z.literal(true), warning: z.string().min(1).optional() }) +export type PingProbeWrite = z.infer diff --git a/tests/e2e/backend-switch.e2e.ts b/tests/e2e/backend-switch.e2e.ts index 0e2069b0..9af23934 100644 --- a/tests/e2e/backend-switch.e2e.ts +++ b/tests/e2e/backend-switch.e2e.ts @@ -19,7 +19,7 @@ for (const status of [200, 500]) { } } const servers = [{ id: 1, name: isNew ? 'Новый источник' : 'Старый источник', host: '192.0.2.1', type: 'exit-node', enabled: true, country: 'RU', site: 'TEST', status: 'online', wanUplinks: [] }] - const probes = [{ id: 'p1', srcServerId: '1', name: isNew ? 'Новая проверка' : 'Старая проверка', target: '192.0.2.10', filter: 'main', rtt: 10, loss: 0, status: 'up', series: [10], enabled: true }] + const probes = [{ id: 'p1', srcServerId: '1', name: isNew ? 'Новая проверка' : 'Старая проверка', target: '192.0.2.10', filter: 'main', rtt: 10, loss: 0, status: 'up', series: [10], enabled: true, showOnDashboard: false }] const json = url.pathname === '/api/auth/config' ? { required: false } : url.pathname === '/api/servers' ? servers : url.pathname === '/api/uptime/overview' ? { probes, resources: [] } diff --git a/tests/e2e/uptime-links.spec.ts b/tests/e2e/uptime-links.spec.ts index f02cd5b5..e109ed1f 100644 --- a/tests/e2e/uptime-links.spec.ts +++ b/tests/e2e/uptime-links.spec.ts @@ -724,3 +724,42 @@ for (const malformed of [false, true]) test("Speed save requires confirmation an } expect(calls).toBe(1) }) + +for (const malformed of [false, true]) test("Ping save requires confirmation and separates scheduler failure/" + malformed, async ({ page }) => { + await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write") + await page.getByRole("button", { name: "Пауза", exact: true }).click() + let calls = 0 + await page.route("**/api/uptime/probes", route => { if (route.request().method() !== "PUT") return route.fallback(); calls++; return route.fulfill({ json: malformed ? {} : { ok: true, warning: "Ping-пробы сохранены, но расписание не обновлено. Проверьте состояние сборщика данных." } }) }) + await page.getByRole("button", { name: "Редактировать пробу", exact: true }).click() + const dialog = page.getByRole("dialog"), input = dialog.getByPlaceholder("youtube.com") + await input.fill("Черновик") + await dialog.getByRole("button", { name: "Сохранить", exact: true }).click() + if (malformed) { await expect(dialog.getByRole("alert")).toContainText("Ответ о сохранении Ping-проб не подтверждён"); await expect(input).toHaveValue("Черновик") } + else { await expect(dialog).toBeHidden(); await expect(page.getByRole("status").filter({ hasText: "Ping-пробы сохранены, но расписание" })).toBeVisible() } + expect(calls).toBe(1) +}) +test("Ping catalog: malformed initial/refresh disables replacement, recovery and history deletion copy", async ({ page }, info) => { + const control = await setup(page, "/uptime?probe=p1", 0, false, "mm:uptime:write") + await page.getByRole("button", { name: "Пауза", exact: true }).click() + let malformed = true + await page.route("**/api/uptime/overview*", route => malformed ? route.fulfill({ json: { probes: [{ id: "broken" }], resources: [] } }) : route.fallback()) + await page.reload(); await page.getByRole("button", { name: "Пауза", exact: true }).click() + const warning = page.getByRole("alert", { name: "Чтение Ping-проб", exact: true }) + await expect(warning).toContainText("Данные пока неизвестны") + await expect(page.getByRole("button", { name: "Новая проба", exact: true }).first()).toBeDisabled() + await expect(page.getByText("Пробы не найдены", { exact: true })).toHaveCount(0) + malformed = false; await page.getByRole("button", { name: "Обновить данные", exact: true }).click() + await expect(warning).toHaveCount(0); await expect(page.getByRole("button", { name: "Редактировать пробу", exact: true })).toHaveCount(1) + malformed = true; await page.getByRole("button", { name: "Обновить данные", exact: true }).click() + await expect(warning).toContainText("Показан последний успешный список") + await expect(page.getByRole("button", { name: "Редактировать пробу", exact: true }).first()).toBeDisabled() + await page.setViewportSize({ width: 320, height: 1000 }) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true) + await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }) + await page.screenshot({ path: info.outputPath("ping-catalog-error-320.png"), animations: "disabled" }) + malformed = false; await page.getByRole("button", { name: "Обновить данные", exact: true }).click() + await page.getByRole("button", { name: "Удалить пробу", exact: true }).first().click() + await expect(page.getByRole("alertdialog")).toContainText("Вместе с ней будет удалена история её измерений") + await page.getByRole("alertdialog").getByRole("button", { name: "Отмена", exact: true }).click() + expect(control.writes).toEqual([]) +})