diff --git a/backend/src/services/wireguard-live.ts b/backend/src/services/wireguard-live.ts
index f0481f54..42d5b06c 100644
--- a/backend/src/services/wireguard-live.ts
+++ b/backend/src/services/wireguard-live.ts
@@ -1,3 +1,4 @@
+import { parseWireGuardBytes as parseBytes } from "./wireguard-observed-values.js"
import {boundedCompleteCount,completeCollectionCount} from "./complete-count.js"
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
@@ -53,12 +54,6 @@ interface RosIpAddress {
disabled?: string
}
-function parseBytes(v: string | undefined): number | undefined {
- if (v == null || v === "") return undefined
- const n = Number.parseInt(v, 10)
- return Number.isFinite(n) ? n : undefined
-}
-
function mapPeer(p: RosWireGuardPeer, idx: number): WgPeerDto {
const rosId = String(p[".id"] ?? `peer-${idx}`)
const allowed = (p["allowed-address"] ?? "")
diff --git a/backend/src/services/wireguard-observed-values.test.ts b/backend/src/services/wireguard-observed-values.test.ts
new file mode 100644
index 00000000..4d267c77
--- /dev/null
+++ b/backend/src/services/wireguard-observed-values.test.ts
@@ -0,0 +1,9 @@
+import { test } from "node:test"
+import assert from "node:assert/strict"
+import { parseWireGuardBytes } from "./wireguard-observed-values.js"
+test("WireGuard byte counters preserve zero and reject partial, negative or unsafe values", () => {
+ assert.equal(parseWireGuardBytes("0"), 0)
+ assert.equal(parseWireGuardBytes(" 1234 "), 1234)
+ assert.equal(parseWireGuardBytes("9007199254740991"), Number.MAX_SAFE_INTEGER)
+ for (const raw of [undefined, null, 0, {}, "", " ", "12junk", "1.5", "-1", "Infinity", "1e3", "9007199254740992"]) assert.equal(parseWireGuardBytes(raw), undefined)
+})
diff --git a/backend/src/services/wireguard-observed-values.ts b/backend/src/services/wireguard-observed-values.ts
new file mode 100644
index 00000000..795b5aba
--- /dev/null
+++ b/backend/src/services/wireguard-observed-values.ts
@@ -0,0 +1,6 @@
+// RouterOS byte counters are whole decimal values; absent or malformed values are unknown.
+export function parseWireGuardBytes(raw: unknown): number | undefined {
+ if (typeof raw !== "string" || !/^[0-9]+$/.test(raw.trim())) return undefined
+ const value = Number(raw.trim())
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined
+}
diff --git a/components/data-grids/wireguard-peers-detail.tsx b/components/data-grids/wireguard-peers-detail.tsx
index 81520862..b55dc691 100644
--- a/components/data-grids/wireguard-peers-detail.tsx
+++ b/components/data-grids/wireguard-peers-detail.tsx
@@ -12,7 +12,7 @@ import {
} from "lucide-react"
function fmtBytes(n: number | undefined): string {
- if (!n) return "—"
+ if (n === undefined || !Number.isFinite(n) || n < 0) return "—"
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)} ГБ`
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
if (n >= 1_000) return `${(n / 1_000).toFixed(0)} КБ`
@@ -80,12 +80,12 @@ function WireGuardPeersDetail({
{peer.latestHandshake ?? "нет рукопожатия"}
-
-
+
+
{fmtBytes(peer.transferRx)}
-
-
+
+
{fmtBytes(peer.transferTx)}
diff --git a/components/data-grids/wireguard-peers-grid.tsx b/components/data-grids/wireguard-peers-grid.tsx
index 7a181086..f744b278 100644
--- a/components/data-grids/wireguard-peers-grid.tsx
+++ b/components/data-grids/wireguard-peers-grid.tsx
@@ -156,12 +156,12 @@ function WireguardPeersGrid({
enableSorting: false,
cell: ({ row }) => (
-
-
+
+
{fmtBytes(row.original.transferRx)}
-
-
+
+
{fmtBytes(row.original.transferTx)}
diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md
index ed1fa96b..d864af10 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/подтверждение каталога и сохранения/история удаления/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Б сохраняются.
+Этап 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 zero/missing counters/UI/backend mapper/29 browser/unit/builds. Следующий блок12.3.28: большой каталог WireGuard interfaces; далее 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 7e1caf10..37058d1c 100644
--- a/docs/ux-ui-progress.md
+++ b/docs/ux-ui-progress.md
@@ -36,6 +36,12 @@
+## 12.3.27 — достоверные счётчики WireGuard (2026-10-04)
+
+Нулевые RX/TX отображаются как 0 Б, отсутствующие значения — как — в таблице пиров и раскрытии интерфейса. Направления подписаны для accessibility. Backend принимает только целые неотрицательные десятичные счётчики в safe integer диапазоне: строки вроде12junk, дроби/минус/unsafe больше не превращаются в показания. Существующие правила сетевых операций не изменены.
+
+Проверки: wireguard-links/tables29/29, включая обе композиции zero/missing и старые сценарии операций/ссылок; pure mapper1/1; TypeScript без incremental, ESLint изменённых файлов и backend/frontend production build прошли. Реальные RouterOS ответы не проверены. Следующий блок12.3.28: большой каталог интерфейсов WireGuard, пагинация50/refresh/search/320px; полный peer grid и раскрытие большого списка пиров остаются отдельным объёмом. Коммит локальный без 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 с предупреждением.
diff --git a/docs/ux-ui-stage-12.md b/docs/ux-ui-stage-12.md
index 19291e2b..ecc9bcf2 100644
--- a/docs/ux-ui-stage-12.md
+++ b/docs/ux-ui-stage-12.md
@@ -391,3 +391,10 @@ UI проверяет Ping-каталог до публикации, сохра
Реальный 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.3.27 — достоверные счётчики WireGuard (2026-10-04)
+
+Нулевые RX/TX отображаются как 0 Б, отсутствующие значения — как — в таблице пиров и раскрытии интерфейса. Направления подписаны для accessibility. Backend принимает только целые неотрицательные десятичные счётчики в safe integer диапазоне: строки вроде12junk, дроби/минус/unsafe больше не превращаются в показания. Существующие правила сетевых операций не изменены.
+
+Проверки: wireguard-links/tables29/29, включая обе композиции zero/missing и старые сценарии операций/ссылок; pure mapper1/1; TypeScript без incremental, ESLint изменённых файлов и backend/frontend production build прошли. Реальные RouterOS ответы не проверены. Следующий блок12.3.28: большой каталог интерфейсов WireGuard, пагинация50/refresh/search/320px; полный peer grid и раскрытие большого списка пиров остаются отдельным объёмом. Коммит локальный без push.
+
diff --git a/tests/e2e/wireguard-tables.spec.ts b/tests/e2e/wireguard-tables.spec.ts
index d8942135..f50fe49d 100644
--- a/tests/e2e/wireguard-tables.spec.ts
+++ b/tests/e2e/wireguard-tables.spec.ts
@@ -45,3 +45,23 @@ test("WireGuard: пустые интерфейсы и пиры", async ({ page }
await page.getByRole("tab", { name: /Пиры/ }).click()
await expect(page.getByText("Нет пиров", { exact: true })).toBeVisible()
})
+
+for (const view of ["details", "peers"] as const) test("WireGuard traffic: observed zero differs from missing/" + view, async ({ page }) => {
+ await setup(page)
+ const interfaces = fixture.interfaces.map((row, i) => ({ ...row, peers: row.peers.map(peer => {
+ const { transferRx, transferTx, ...config } = peer
+ return i === 0 ? { ...config, transferRx: 0, transferTx: 0 } : i === 1 ? config : { ...config, transferRx, transferTx }
+ }) }))
+ await page.route("**/api/wireguard", route => route.fulfill({ json: wgListResponseSchema.parse({ interfaces }) }))
+ await page.reload()
+ const rows = page.locator("tr[data-row-id]")
+ if (view === "peers") await page.getByRole("tab", { name: /Пиры/ }).click()
+ for (const [index, value] of [[0, "0 Б"], [1, "—"], [2, "3 Б"]] as const) {
+ const row = rows.nth(index)
+ if (view === "details") await row.click()
+ const target = view === "details" ? row.locator("xpath=following-sibling::tr[1]") : row
+ await expect(target.getByLabel("RX", { exact: true })).toHaveText(value)
+ await expect(target.getByLabel("TX", { exact: true })).toHaveText(value)
+ if (view === "details") await row.click()
+ }
+})