From b60b7ac8e812d6a3c6795ebc663a603ea571d252 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 1 Oct 2026 08:36:31 +0700 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=20IPv4=20=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=B8=D0=B0=D0=B3?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/probes/page.tsx | 50 ++++++++++++++++++-------------- docs/ux-ui-progress.md | 9 +++++- docs/ux-ui-stage-10.md | 8 +++++ lib/probe-source.test.ts | 4 +++ lib/probe-source.ts | 8 +++++ tests/e2e/probes-context.spec.ts | 2 +- tests/e2e/probes-data.spec.ts | 4 +-- tests/e2e/probes-source.spec.ts | 22 ++++++++++++++ 8 files changed, 81 insertions(+), 26 deletions(-) create mode 100644 lib/probe-source.test.ts create mode 100644 lib/probe-source.ts create mode 100644 tests/e2e/probes-source.spec.ts diff --git a/app/(main)/probes/page.tsx b/app/(main)/probes/page.tsx index 0af63d7c..71192bcd 100644 --- a/app/(main)/probes/page.tsx +++ b/app/(main)/probes/page.tsx @@ -2,6 +2,7 @@ import { Suspense, useEffect, useRef, useState, useMemo, useCallback } from "react" import { useSearchParams } from "next/navigation" +import { readProbeSource } from "@/lib/probe-source" import { readProbeUrl, patchProbeUrl } from "@/lib/probe-url" import { PageHeader } from "@/components/page-header" import { FormToggle } from "@/components/form-kit" @@ -616,7 +617,8 @@ function ProbesContent() { const [bwDuration, setBwDuration] = useState(10) const [runBusy, setRunBusy] = useState(false) /** В live: IPv4 для src-address (DNS A-запись к host API); undefined = грузим, null = не удалось */ - const [rosSrcV4, setRosSrcV4] = useState(undefined) + const [source, setSource] = useState<{ key: string; ipv4?: string | null; error?: string; loading: boolean } | null>(null) + const [sourceRevision, setSourceRevision] = useState(0) useEffect(() => { if (!isLive || !canRead) return @@ -664,27 +666,6 @@ function ProbesContent() { })) ), [allServers]) - useEffect(() => { - if (!isLive || !canRead || liveLoad !== "idle" || !allServers.some(s => s.id === srcId && s.enabled)) { - queueMicrotask(() => setRosSrcV4(undefined)) - return - } - const n = Number.parseInt(srcId, 10) - if (!Number.isFinite(n)) { - setRosSrcV4(undefined) - return - } - let cancelled = false - setRosSrcV4(undefined) - void apiFetch<{ ipv4: string | null }>(`/api/servers/${n}/ros-src-address`) - .then((r) => { - if (!cancelled) setRosSrcV4(r.ipv4) - }) - .catch(() => { - if (!cancelled) setRosSrcV4(null) - }) - return () => { cancelled = true } - }, [isLive, canRead, liveLoad, allServers, srcId, apiFetch]) useEffect(() => { if (!isLive || !canReadGre || liveLoad !== "idle" || !allServers.some(s => s.id === srcId && s.enabled)) return @@ -735,6 +716,22 @@ function ProbesContent() { const bwTunnels = tunnelsForSrc const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId]) + const sourceKey = JSON.stringify([srcId, srcServer?.host]) + const sourceRequired = tool === "ping" || tool === "traceroute" || tool === "mtu" + const sourceCurrent = source?.key === sourceKey ? source : null + const rosSrcV4 = sourceCurrent?.ipv4 + useEffect(() => { + if (!isLive || !canRead || liveLoad !== "idle" || !srcServer?.enabled || !sourceRequired) return + const host = srcServer.host, id = srcServer.id + if (!/^[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(Number(id))) return + let cancelled = false + const ctrl = new AbortController() + queueMicrotask(() => { if (!cancelled) setSource({ key: sourceKey, loading: true }) }) + void apiFetch("/api/servers/" + id + "/ros-src-address", { signal: ctrl.signal }) + .then(raw => { const ipv4 = readProbeSource(raw, host); if (!cancelled) setSource({ key: sourceKey, loading: false, ipv4 }) }) + .catch(error => { if (!cancelled) setSource({ key: sourceKey, loading: false, error: error instanceof Error ? error.message : "Источник preview неизвестен." }) }) + return () => { cancelled = true; ctrl.abort() } + }, [isLive, canRead, liveLoad, srcServer, sourceRequired, sourceKey, apiFetch, sourceRevision]) const greReady = !isLive || (greState?.id === srcId && greState.state === "ready") const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId]) @@ -1000,6 +997,15 @@ function ProbesContent() { )} + {isLive && srcServer?.enabled && sourceRequired && ( +
+ {sourceCurrent?.error ?

{sourceCurrent.error}

+ : !sourceCurrent || sourceCurrent.loading ?

Определяем IPv4 для preview…

+ :

{sourceCurrent.ipv4 === null ? "IPv4 для preview не определён." : "IPv4 для preview: " + sourceCurrent.ipv4}

} +

Preview справочный. Backend повторно определяет источник при запуске; пустой результат не подтверждает исходный адрес команды.

+
+ )} + {/* ── tool selector + config ── */} diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index da313dbc..bc9769ca 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,7 +26,7 @@ | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | -| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.63; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.64; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 10.64 — IPv4 источника preview (2026-10-01) + +- ros-src-address проверяет host текущего устройства и канонический IPv4/null. Preview привязан к id+host; поздний ответ/ошибка отменяются при смене устройства/источника. Запрос идёт только для enabled устройства и инструментов ping/traceroute/mtu; DNS приложения не запрашивает этот источник. +- Error/loading/null отличаются; ошибка имеет ручной повтор. В UI явно указано: preview справочный, backend повторно определяет src-address при запуске. Ошибка preview не меняет сетевые правила и не запрещает разрешённую диагностику; неизвестный FQDN адрес показан ?, а не прежним IP. +- 36 основных E2E (29 данных + 7 IPv4), 4 source E2E, unit decoder, профильный lint, TypeScript и production frontend прошли. Реальный DNS/RouterOS не проверен. +- Далее 10.65: DI backend probes, честные HTTP ошибки вместо legacy error output, сохранение allowlist/методов; затем строгие аргументы и MTU/неизвестные измерения. Доступность, адаптивная приёмка /probes и прежние lint эффекты ещё открыты. Расписание остаётся 11Б. + ## Блок 10.63 — устройство диагностики из URL (2026-10-01) - /probes читает server через useSearchParams/Suspense. Выбор явно сохраняется pushState; остальные query/hash, back и reload сохранены. Нет автоматического выбора первого устройства в live/демо; ServerRail preserveSelection удерживает исходный контекст. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index 5cc88c4f..85d4a06c 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -389,3 +389,11 @@ Shared контракт и backend observed исходные значения/д - 29 основных + 4 source E2E, 2 URL unit, профильный lint, TypeScript и production frontend прошли. Старые тесты выбирают server=1 явно; при переходе в demo выбирают реальное demo устройство вместо прежнего fallback. - Далее 10.64: строгий ros-src-address контракт и привязка preview к id/host с отменой/повтором; затем backend probes DI/аргументы/ошибки/метрики, доступность/адаптивная приёмка. Этап 10 остаётся в работе. Моки не доказывают реальные RouterOS/DNS/БД. + +## Блок 10.64 — IPv4 источника preview (2026-10-01) + +- ros-src-address проверяет host текущего устройства и канонический IPv4/null. Preview привязан к id+host; поздний ответ/ошибка отменяются при смене устройства/источника. Запрос идёт только для enabled устройства и инструментов ping/traceroute/mtu; DNS приложения не запрашивает этот источник. +- Error/loading/null отличаются; ошибка имеет ручной повтор. В UI явно указано: preview справочный, backend повторно определяет src-address при запуске. Ошибка preview не меняет сетевые правила и не запрещает разрешённую диагностику; неизвестный FQDN адрес показан ?, а не прежним IP. +- 36 основных E2E (29 данных + 7 IPv4), 4 source E2E, unit decoder, профильный lint, TypeScript и production frontend прошли. Реальный DNS/RouterOS не проверен. +- Далее 10.65: DI backend probes, честные HTTP ошибки вместо legacy error output, сохранение allowlist/методов; затем строгие аргументы и MTU/неизвестные измерения. Доступность, адаптивная приёмка /probes и прежние lint эффекты ещё открыты. Расписание остаётся 11Б. + diff --git a/lib/probe-source.test.ts b/lib/probe-source.test.ts new file mode 100644 index 00000000..d9f74099 --- /dev/null +++ b/lib/probe-source.test.ts @@ -0,0 +1,4 @@ +import {test} from "node:test" +import assert from "node:assert/strict" +import {readProbeSource} from "./probe-source" +test("Preview принимает только IPv4 текущего host, null остаётся неизвестностью",()=>{assert.equal(readProbeSource({host:"router.test",ipv4:"192.0.2.1"},"router.test"),"192.0.2.1");assert.equal(readProbeSource({host:"router.test",ipv4:null},"router.test"),null);for(const raw of [{ipv4:"192.0.2.1"},{host:"other.test",ipv4:"192.0.2.1"},{host:"router.test",ipv4:"999.0.0.1"},{host:"router.test",ipv4:"192.000.2.1"},{host:"router.test",ipv4:"::1"},{}])assert.throws(()=>readProbeSource(raw,"router.test"))}) diff --git a/lib/probe-source.ts b/lib/probe-source.ts new file mode 100644 index 00000000..1877373c --- /dev/null +++ b/lib/probe-source.ts @@ -0,0 +1,8 @@ +import {z} from "zod" +const ipv4=z.string().refine(value=>/^\d{1,3}(\.\d{1,3}){3}$/.test(value)&&value.split(".").every(part=>Number(part)<=255&&String(Number(part))===part),"Ожидался IPv4") +const response=z.object({host:z.string().min(1),ipv4:ipv4.nullable()}) +export function readProbeSource(raw:unknown,host:string){ + const result=response.safeParse(raw) + if(!result.success||result.data.host!==host)throw new Error("Некорректный ответ API: источник preview не подтверждён.") + return result.data.ipv4 +} diff --git a/tests/e2e/probes-context.spec.ts b/tests/e2e/probes-context.spec.ts index 7bfdc18f..df40105c 100644 --- a/tests/e2e/probes-context.spec.ts +++ b/tests/e2e/probes-context.spec.ts @@ -3,7 +3,7 @@ const headers={"access-control-allow-origin":"*","access-control-allow-methods": for(const change of ["backend","mode"])for(const fails of [false,true])test("Диагностика: поздний запрос/"+change+"/"+fails,async({page})=>{ let started=false,completed=false,release!:()=>void;const gate=new Promise(r=>release=r) await page.route("**/health",r=>r.fulfill({headers,json:{ok:true}})) - await page.route("**/api/**",r=>{const u=new URL(r.request().url()),p=u.pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[{id:1,name:u.port==="8101"?"Новый источник":"Прежний источник",host:"192.0.2.1",type:"exit-node",country:"RU",enabled:true,status:"online"}]:p.endsWith("/ros-src-address")?{ipv4:"192.0.2.1"}:p.endsWith("/probes/run")?{output:"НОВЫЙ РЕЗУЛЬТАТ"}:{tunnels:[]}})}) + await page.route("**/api/**",r=>{const u=new URL(r.request().url()),p=u.pathname;return r.fulfill({headers,json:p==="/api/auth/config"?{required:false}:p==="/api/servers"?[{id:1,name:u.port==="8101"?"Новый источник":"Прежний источник",host:"192.0.2.1",type:"exit-node",country:"RU",enabled:true,status:"online"}]:p.endsWith("/ros-src-address")?{host:"192.0.2.1",ipv4:"192.0.2.1"}:p.endsWith("/probes/run")?{output:"НОВЫЙ РЕЗУЛЬТАТ"}:{tunnels:[]}})}) await page.goto("/probes?server=1");await expect(page.getByRole("button",{name:"Запустить",exact:true})).toBeEnabled() await page.route("**/api/servers/1/probes/run",async r=>{if(new URL(r.request().url()).port!=="8000")return r.fallback();started=true;await gate;try{await r.fulfill(fails?{headers,status:503,json:{error:"СТАРАЯ ОШИБКА"}}:{headers,json:{output:"СТАРЫЙ РЕЗУЛЬТАТ"}})}catch{}finally{completed=true}}) await page.getByRole("button",{name:"Запустить",exact:true}).click();await expect.poll(()=>started).toBe(true);await page.getByRole("link",{name:/^Настройки/}).click() diff --git a/tests/e2e/probes-data.spec.ts b/tests/e2e/probes-data.spec.ts index b0b4e976..aa361c9e 100644 --- a/tests/e2e/probes-data.spec.ts +++ b/tests/e2e/probes-data.spec.ts @@ -2,7 +2,7 @@ import { test, expect, type Page } from "@playwright/test" const devices=[{id:1,name:"Первый",host:"192.0.2.1",site:"Тест",country:"RU",asn:"AS64500",type:"exit-node",enabled:true,status:"online",latency:null}] async function routes(page:Page){ await page.route("**/health",r=>r.fulfill({json:{ok:true}})) - await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?devices:p==="/api/auth/config"?{required:false}:p.endsWith("/ros-src-address")?{ipv4:"192.0.2.1"}:{tunnels:[]}})}) + await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?devices:p==="/api/auth/config"?{required:false}:p.endsWith("/ros-src-address")?{host:"192.0.2.1",ipv4:"192.0.2.1"}:{tunnels:[]}})}) } test.beforeEach(async({page})=>routes(page)) const run=(page:Page)=>page.getByRole("button",{name:"Запустить",exact:true}) @@ -94,7 +94,7 @@ test("Диагностика: DNS приложения, inline ошибка и }) test("Диагностика: без выбора не запрашивает устройство",async({page})=>{ - let requests=0;await page.route("**/api/servers/*/**",r=>{requests++;return r.fulfill({json:{ipv4:"192.0.2.1"}})}) + let requests=0;await page.route("**/api/servers/*/**",r=>{requests++;return r.fulfill({json:{host:"192.0.2.1",ipv4:"192.0.2.1"}})}) await page.goto("/probes");await expect(page.getByRole("status").filter({hasText:"Выберите устройство для диагностики"})).toBeVisible();await expect(run(page)).toBeDisabled();expect(requests).toBe(0) }) for(const query of ["server=1&server=2","server=bad%20id","server="])test("Диагностика: неверная ссылка не читает API "+query,async({page})=>{ diff --git a/tests/e2e/probes-source.spec.ts b/tests/e2e/probes-source.spec.ts new file mode 100644 index 00000000..9b304765 --- /dev/null +++ b/tests/e2e/probes-source.spec.ts @@ -0,0 +1,22 @@ +import {test,expect} from "@playwright/test" +const devices=[{id:1,name:"Первый",host:"first.test",enabled:true,status:null,type:"exit-node"},{id:2,name:"Второй",host:"second.test",enabled:true,status:"online",type:"exit-node"}] +test.beforeEach(async({page})=>{await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/servers"?devices:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:{tunnels:[]}}))}) +for(const kind of ["API","bad-ip","wrong-host"])test("Источник preview: "+kind+" и повтор",async({page})=>{ + let fail=true;await page.route("**/api/servers/1/ros-src-address",r=>r.fulfill(fail?(kind==="API"?{status:503,json:{error:"IPv4 отказ"}}:{json:{host:kind==="wrong-host"?"other.test":"first.test",ipv4:kind==="bad-ip"?"999.0.0.1":"192.0.2.1"}}):{json:{host:"first.test",ipv4:"192.0.2.1"}})) + await page.goto("/probes?server=1");await expect(page.getByRole("alert",{name:"Источник preview"})).toBeVisible();await expect(page.getByRole("button",{name:"Запустить",exact:true})).toBeEnabled() + fail=false;await page.getByRole("button",{name:"Повторить определение IPv4"}).click();await expect(page.getByRole("status").filter({hasText:"IPv4 для preview: 192.0.2.1"})).toBeVisible();await expect(page.getByRole("alert",{name:"Источник preview"})).toHaveCount(0) +}) +test("Источник preview: null не является ошибкой или IP",async({page})=>{ + await page.route("**/api/servers/1/ros-src-address",r=>r.fulfill({json:{host:"first.test",ipv4:null}}));await page.goto("/probes?server=1");await expect(page.getByRole("status").filter({hasText:"IPv4 для preview не определён"})).toBeVisible();await expect(page.getByRole("alert",{name:"Источник preview"})).toHaveCount(0);await expect(page.getByText(/src-address=\?/)).toBeVisible() +}) +for(const fails of [false,true])test("Источник preview: поздний адрес прежнего устройства "+fails,async({page})=>{ + let started=false,completed=false,release!:()=>void;const gate=new Promise(r=>release=r) + await page.route("**/api/servers/1/ros-src-address",async r=>{started=true;await gate;try{await r.fulfill(fails?{status:503,json:{error:"СТАРАЯ ОШИБКА"}}:{json:{host:"first.test",ipv4:"192.0.2.1"}})}catch{}finally{completed=true}}) + await page.route("**/api/servers/2/ros-src-address",r=>r.fulfill({json:{host:"second.test",ipv4:"192.0.2.2"}})) + await page.goto("/probes?server=1");await expect.poll(()=>started).toBe(true);await page.getByRole("option",{name:/Второй/}).click();await expect(page.getByRole("status").filter({hasText:"IPv4 для preview: 192.0.2.2"})).toBeVisible();release();await expect.poll(()=>completed).toBe(true) + await expect(page.getByText(/src-address=192.0.2.2/)).toBeVisible();await expect(page.getByText(/192.0.2.1|СТАРАЯ ОШИБКА/)).toHaveCount(0) +}) +test("Источник preview: DNS не запрашивает RouterOS источник",async({page})=>{ + let reads=0;await page.route("**/api/servers/*/ros-src-address",r=>{reads++;return r.fulfill({json:{host:"first.test",ipv4:null}})}) + await page.goto("/probes");await page.getByRole("button",{name:"DNS",exact:true}).click();await page.getByRole("option",{name:/Первый/}).click();await expect(page.getByRole("textbox",{name:"Домен"})).toBeVisible();expect(reads).toBe(0) +})