fix(ui): проверять источник IPv4 для диагностики

This commit is contained in:
Denozordec committed 2026-10-01 08:36:31 +07:00
1 parent d0c715e93c
commit b60b7ac8e8
8 files changed
+81 -26

No files matched your search

+28 -22
View File
@@ -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<string | null | undefined>(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<unknown>("/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() {
</div>
)}
{isLive && srcServer?.enabled && sourceRequired && (
<div className="text-xs text-muted-foreground">
{sourceCurrent?.error ? <div role="alert" aria-label="Источник preview"><p>{sourceCurrent.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setSourceRevision(n => n + 1)}>Повторить определение IPv4</Button></div>
: !sourceCurrent || sourceCurrent.loading ? <p role="status">Определяем IPv4 для preview…</p>
: <p role="status">{sourceCurrent.ipv4 === null ? "IPv4 для preview не определён." : "IPv4 для preview: " + sourceCurrent.ipv4}</p>}
<p>Preview справочный. Backend повторно определяет источник при запуске; пустой результат не подтверждает исходный адрес команды.</p>
</div>
)}
{/* ── tool selector + config ── */}
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
+8 -1
View File
@@ -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 удерживает исходный контекст.
+8
View File
@@ -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Б.
+4
View File
@@ -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"))})
+8
View File
@@ -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
}
+1 -1
View File
@@ -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<void>(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()
+2 -2
View File
@@ -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})=>{
+22
View File
@@ -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<void>(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)
})