From fda7f353ad28ac6103ef81e635ce670981b30c78 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 15 Oct 2026 21:51:43 +0700 Subject: [PATCH] =?UTF-8?q?fix(map):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=82=D1=8C=20=D1=83=D0=B7=D0=BB=D1=8B=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=B4=D0=B0=D1=82=D0=B0=D1=85=20=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D1=81=D0=B0=20=D0=B8=D0=B7=20=D0=B1=D0=B0=D0=B7?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-progress.md | 6 ++ .../ux-ui/acceptance/prod265-diagnostics.json | 78 +++++++++++++++++++ lib/network-map-response.test.ts | 12 +++ lib/network-map-response.ts | 8 +- tests/e2e/network-map-data.spec.ts | 11 +++ 5 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 docs/ux-ui/acceptance/prod265-diagnostics.json diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 6e68988d..dc4497b8 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -38,6 +38,12 @@ | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Завершён2026-10-15 в границах предоставленного стенда:1396 UI/221 context,120 live states30 экранов, production builds/ReUI checklist и реальные интеграции. Ограничения — в [акте](ux-ui-acceptance-candidate.md); production-выпуск не выполнялся. | +## Production265 — исчезновение узлов карты и первичная диагностика (2026-10-15) + +Через выданный read-only аккаунт подтверждён production v2.0.0. Карта отклоняет все4 устройства: API polledAt имеет штатный PostgreSQL timestamptz string (+00), frontend требует ISO. В локальной ветке исправлена только нормализация явного offset; отсутствующая зона и невозможная дата по-прежнему отклоняются. Сохранённый приватный ответ production теперь проходит:4 servers/25 GRE/0 probes. Регрессия сначала воспроизведена новым unit test; после исправления6 unit и13 map E2E прошли, TypeScript/scoped ESLint passed. Production не обновлялся; push этого исправления не выполнялся. + +SSH только чтением:1 vCPU/1973MiB/no swap, PostgreSQL нагружает CPU/IO. Дважды наблюдался retention DELETE по scheduler_runs.finished_at: около1.06M live rows,853MB, нет индекса по этому столбцу. Исходник выполняет cleanup после каждого задания; EXPLAIN эквивалентного SELECT показывает Parallel Seq Scan. TCP443 из backend до4 устройств сейчас3–50ms; TLS/REST этим не приняты. Причинность всех10s тайм-аутов не установлена; очередь shared maxSockets2 входит в deadline. Трафик: live SSE актуален, REST history/list не обновляются автоматически; вычисления требуют отдельной проверки. [Анонимизированный протокол](ux-ui/acceptance/prod265-diagnostics.json). Приватные сырые ответы и учётные данные в Git не включаются. Следующее действие — подготовить и проверить индекс retention, продолжить трафик; применение DDL и развёртывание production требуют отдельного разрешения владельца, предоставившего доступ для чтения. + ## Подготовка версии 2.0.0 (2026-10-15) По прямому поручению владельца публикация main/2.0 заменяет прежний режим только локальных коммитов. Добавлен major bump для breaking subject со scope/без scope и двух стандартных footer; CI запускает10 integration cases перед вычислением версии. Package.json и теги вручную не меняются. В выпуск включены накопленные изменения ReUI skills/portable MCP config и сохранённые снимки; локальные PID/cache и приватные fixtures не публикуются. Проверена новая история на известные приватные Portal credentials и шаблоны внешних токенов: совпадений0. [Состав и ограничения релиза](releases/2.0.0.md). Отправка main запускает существующую публикацию образов/релиза и настроенный deploy webhook. Фактический результат сборок определяется Gitea Actions. Первый запуск2464 отклонён валидатором Gitea28.1 до jobs: job-level secrets в if больше не принимается. После подтверждения причины read-only ViewPost по официальному handler условие перенесено в step if по job env; side effects webhook не расширены. diff --git a/docs/ux-ui/acceptance/prod265-diagnostics.json b/docs/ux-ui/acceptance/prod265-diagnostics.json new file mode 100644 index 00000000..ca436b61 --- /dev/null +++ b/docs/ux-ui/acceptance/prod265-diagnostics.json @@ -0,0 +1,78 @@ +{ + "date": "2026-10-15", + "scope": "Production diagnosis using a read-only application account and read-only SSH/system/PostgreSQL inspection. No production deployment or configuration changes.", + "productionVersion": "2.0.0", + "map": { + "cause": "PostgreSQL polledAt text with explicit offset is rejected by the ISO-only map validator; the whole topology snapshot is discarded.", + "before": { + "serverValidationErrors": 4, + "field": "polledAt", + "error": "invalid_format" + }, + "localFix": { + "preservesExplicitTimeZone": true, + "rejectsMissingZoneAndInvalidCalendarDate": true, + "exactPrivateResponseAccepted": { + "servers": 4, + "tunnels": 25, + "probes": 0 + } + }, + "checks": { + "unitPassed": 6, + "e2ePassed": 13, + "typescript": "passed", + "scopedEslint": "passed" + }, + "deployed": false + }, + "traffic": { + "confirmed": "REST history/list/detail load once per scope/retry; live SSE keeps updating separately. History can become stale while live rate is current.", + "calculationNotYetFullyAccepted": true, + "rawResponsesPrivate": true + }, + "performance": { + "host": { + "vcpu": 1, + "memoryMiB": 1973, + "swapMiB": 0, + "loadAverage": [ + 4.62, + 4.87, + 5.09 + ], + "rootUsedPercent": 46 + }, + "sample": { + "postgresCpuPercent": 66.51, + "postgresMemory": "642.6MiB", + "backendCpuPercent": 3.26, + "cpuIdlePercent": 0, + "ioWaitPercent": [ + 34, + 29 + ] + }, + "schedulerRuns": { + "estimatedLiveRows": 1064090, + "tableSize": "853 MB", + "indexes": "primary key and (job_key, started_at), no finished_at index", + "observedActiveQuery": "DELETE FROM scheduler_runs WHERE finished_at < $1", + "wait": "IO/AioIoCompletion", + "retention": "30 days after every recorded scheduler job", + "explainEquivalentSelect": "Gather / Parallel Seq Scan" + }, + "tcpConnectMsByAnonymousServerId": { + "1": 36, + "2": 3, + "3": 50, + "4": 6 + }, + "limits": [ + "TCP connect success does not prove TLS/REST authentication or rule out previous failures.", + "The native HTTP client timeout also includes time queued in maxSockets=2; actual queue delay has not been measured.", + "Database temp-byte and deadlock counters are cumulative with an unknown reset epoch; not attributed to this release.", + "No proof that database pressure alone caused every REST timeout." + ] + } +} diff --git a/lib/network-map-response.test.ts b/lib/network-map-response.test.ts index ea7a5743..bf86ffff 100644 --- a/lib/network-map-response.test.ts +++ b/lib/network-map-response.test.ts @@ -2,6 +2,18 @@ import {test} from "node:test" import assert from "node:assert/strict" import {validateNetworkMapSnapshot} from "./network-map-response" const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:null,latency:0} +test("network map retains jump and exit nodes with PostgreSQL timestamps",()=>{ + const timestamps=["2026-10-15 13:46:40.855+00","2026-10-15 20:46:40.855+07","2026-10-15T13:46:40.855Z",null] + const rows=timestamps.map((polledAt,index)=>({...server,id:index+1,type:index===0?"exit-node":"jump-host",polledAt})) + const snapshot=validateNetworkMapSnapshot(rows,{tunnels:[],failures:[]},{probes:[]}) + assert.equal(snapshot.servers.length,4) + assert.equal(snapshot.servers[0].type,"exit-node") + assert.equal(Date.parse(snapshot.servers[0].polledAt!),Date.parse(snapshot.servers[1].polledAt!)) + assert.equal(snapshot.servers[3].polledAt,null) + for(const polledAt of ["2026-10-15 13:46:40.855","2026-02-30 13:46:40+00","not-a-date",42]) { + assert.throws(()=>validateNetworkMapSnapshot([{...server,polledAt}],{tunnels:[],failures:[]},{probes:[]})) + } +}) test("network map retains configured GRE IPsec without key material",()=>{ const tunnel={id:"*1",serverId:"1",name:"own210",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1/30",remoteInnerIp:"10.0.0.2/30",poolId:"",ipsec:{secret:""},mtu:1476,keepaliveInterval:60,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"} const result=validateNetworkMapSnapshot([server],{tunnels:[tunnel],failures:[]},{probes:[]}) diff --git a/lib/network-map-response.ts b/lib/network-map-response.ts index 105db863..d3944821 100644 --- a/lib/network-map-response.ts +++ b/lib/network-map-response.ts @@ -2,12 +2,18 @@ import {z} from "zod" import {optimizerServersSchema,optimizerProbesSchema} from "./route-optimizer-response" import {filtersGreResponseSchema} from "./filters-response" import {greResponseSchema} from "./gre-response" +// Drizzle's string-mode timestamptz is returned as PostgreSQL text (+00), +// while test/other API adapters may already return ISO. Preserve the explicit +// offset and validate the resulting calendar date; never infer a missing zone. +const mapObservedTime=z.preprocess(value=>typeof value==="string" + ?value.replace(/^(\d{4}-\d{2}-\d{2}) /,"$1T").replace(/([+-]\d{2})$/,"$1:00") + :value,z.string().datetime({offset:true})) export const networkMapServersSchema=z.array(optimizerServersSchema.element.extend({ status:z.enum(["online","offline","degraded"]).nullable(), asn:z.string().optional(),comment:z.string().optional(),lanSubnet:z.string().optional(),os:z.string().nullable().optional(), sessions:z.number().int().nonnegative().nullable().optional(),uptime:z.string().nullable().optional(), cpuLoad:z.number().finite().min(0).max(100).nullable().optional(),freeMemory:z.number().int().nonnegative().nullable().optional(),totalMemory:z.number().int().nonnegative().nullable().optional(), - polledAt:z.string().datetime({offset:true}).nullable().optional(), + polledAt:mapObservedTime.nullable().optional(), })).refine(rows=>new Set(rows.map(row=>row.id)).size===rows.length && rows.every(row=>row.freeMemory==null||row.totalMemory==null||row.freeMemory<=row.totalMemory)) export type NetworkMapServerRow=z.infer[number] const mapGreSchema=z.object({tunnels:z.array(filtersGreResponseSchema.shape.tunnels.element).refine(rows=>new Set(rows.map(row=>JSON.stringify([row.serverId,row.id]))).size===rows.length),failures:greResponseSchema.shape.failures}).refine(data=>new Set(data.failures.map(row=>row.serverId)).size===data.failures.length&&!data.tunnels.some(tunnel=>data.failures.some(failure=>failure.serverId===tunnel.serverId))) diff --git a/tests/e2e/network-map-data.spec.ts b/tests/e2e/network-map-data.spec.ts index 6c50bdd0..faa2dcef 100644 --- a/tests/e2e/network-map-data.spec.ts +++ b/tests/e2e/network-map-data.spec.ts @@ -1,6 +1,17 @@ import {test,expect} from "@playwright/test" const paths={servers:"/api/servers",gre:"/api/filters/gre-tunnels",probes:"/api/uptime/speed-probes"} as const const body=(path:string,name="Роутер")=>path===paths.servers?[{id:1,name,host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:0}]:path===paths.gre?{tunnels:[],failures:[]}:path===paths.probes?{probes:[]}:path==="/api/auth/config"?{required:false}:{} +test("Карта: PostgreSQL даты не скрывают JumpHost и Exit Node",async({page})=>{ + await page.route("**/health",r=>r.fulfill({json:{ok:true}})) + const rows=[{id:1,name:"Узел JH",host:"192.0.2.1",type:"jump-host",country:"RU",enabled:true,status:"online",polledAt:"2026-10-15 13:46:40.855+00"},{id:2,name:"Узел EN",host:"192.0.2.2",type:"exit-node",country:"SE",enabled:true,status:"online",polledAt:"2026-10-15 20:46:40.855+07"}] + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p===paths.servers?rows:body(p)})}) + await page.goto("/network-map") + const map=page.getByRole("group",{name:"Интерактивная карта сети"}) + await expect(map.getByText("Узел JH",{exact:true})).toBeVisible() + await expect(map.getByText("Узел EN",{exact:true})).toBeVisible() + await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeEnabled() + await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка карты"})).toHaveCount(0) +}) for(const part of ["servers","gre","probes"] as const)for(const malformed of [false,true])test("Карта: первоначальный отказ и повтор "+part+"/"+malformed,async({page})=>{ let failing=true await page.route("**/health",r=>r.fulfill({json:{ok:true}}))