fix(ui): подтверждать GRE перед тестом пропускной способности

This commit is contained in:
Denozordec committed 2026-10-01 04:20:19 +07:00
1 parent b856215c03
commit 155dfc5716
4 files changed
+75 -18

No files matched your search

+33 -17
View File
@@ -20,6 +20,7 @@ import { Input } from "@/components/ui/input"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { type ServerTileItem } from "@/components/server-tile-rail"
import { servers, greTunnels, type GreTunnel, type Server } from "@/lib/data"
import { filtersGreResponseSchema } from "@/lib/filters-response"
import { can } from "@/lib/auth"
import { readProbeOutput, probeServersSchema } from "@/lib/probe-response"
import { useDataSource } from "@/lib/data-source"
@@ -584,6 +585,8 @@ function ProbesContent() {
const [catalogError, setCatalogError] = useState<string | null>(null)
const [catalogRevision, setCatalogRevision] = useState(0)
const [catalogReadAt, setCatalogReadAt] = useState<number | null>(null)
const [greState, setGreState] = useState<{ id: string; state: "loading" | "ready" | "error"; error?: string } | null>(null)
const [greRevision, setGreRevision] = useState(0)
const [greByServer, setGreByServer] = useState<Record<string, GreTunnel[]>>({})
const [tool, setTool] = useState<DiagTool>("ping")
@@ -671,22 +674,25 @@ function ProbesContent() {
return () => { cancelled = true }
}, [isLive, srcId, apiFetch])
const ensureGre = useCallback(async (serverId: string) => {
if (!isLive || !canReadGre) return
try {
const d = await apiFetch<{ tunnels: GreTunnel[] }>(
`/api/filters/gre-tunnels?serverId=${encodeURIComponent(serverId)}`,
)
setGreByServer(prev => ({ ...prev, [serverId]: d.tunnels }))
} catch {
setGreByServer(prev => ({ ...prev, [serverId]: [] }))
}
}, [isLive, canReadGre, apiFetch])
useEffect(() => {
if (!isLive || !srcId) return
void ensureGre(srcId)
}, [isLive, srcId, ensureGre])
if (!isLive || !canReadGre || liveLoad !== "idle" || !allServers.some(s => s.id === srcId && s.enabled)) return
let cancelled = false
const ctrl = new AbortController()
queueMicrotask(() => { if (!cancelled) setGreState({ id: srcId, state: "loading" }) })
void apiFetch<unknown>("/api/filters/gre-tunnels?serverId=" + encodeURIComponent(srcId), { signal: ctrl.signal })
.then(raw => {
const result = filtersGreResponseSchema.safeParse(raw)
if (!result.success || result.data.tunnels.some(tunnel => tunnel.serverId !== srcId)) throw new Error("Некорректный ответ API: GRE выбранного устройства не подтверждены.")
if (cancelled) return
setGreByServer(prev => ({ ...prev, [srcId]: result.data.tunnels }))
setGreState({ id: srcId, state: "ready" })
})
.catch(error => {
if (cancelled) return
setGreState({ id: srcId, state: "error", error: error instanceof Error ? error.message : "Не удалось прочитать GRE." })
})
return () => { cancelled = true; ctrl.abort() }
}, [isLive, canReadGre, liveLoad, allServers, srcId, apiFetch, greRevision])
const tunnelsForSrc = useMemo(() => {
if (isLive) return greByServer[srcId] ?? []
@@ -722,6 +728,7 @@ function ProbesContent() {
const bwTunnels = tunnelsForSrc
const srcServer = useMemo(() => allServers.find(s => s.id === srcId), [allServers, srcId])
const greReady = !isLive || (greState?.id === srcId && greState.state === "ready")
const bwTun = useMemo(() => bwTunnels.find(t => t.id === bwTunId), [bwTunnels, bwTunId])
// current command preview
@@ -752,7 +759,7 @@ function ProbesContent() {
// ── run test ──
const runTest = useCallback(async () => {
const srv = allServers.find(s => s.id === srcId)
if (!canRun || (tool === "bandwidth" && isLive && !canReadGre) || !srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
if (!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || !srv || !srv.enabled || runLock.current || (isLive && liveLoad !== "idle")) return
const id = String(nextId.current++)
const numericServerId = Number.parseInt(srcId, 10)
@@ -881,6 +888,7 @@ function ProbesContent() {
liveLoad,
canRun,
canReadGre,
greReady,
allServers,
srcId,
tool,
@@ -973,6 +981,14 @@ function ProbesContent() {
{!canRun && <p role="status" className="text-sm text-muted-foreground">Для запуска диагностики требуется mm:servers:write.</p>}
{isLive && tool === "bandwidth" && !canReadGre && <p role="status" className="text-sm text-muted-foreground">Для выбора GRE требуется mm:filters:read.</p>}
{isLive && tool === "bandwidth" && canReadGre && (
<div className="text-sm">
{greState?.id === srcId && greState.state === "error" ? <div role="alert" aria-label="GRE диагностики"><p className="text-destructive">GRE недоступны. Последний список сохранён; BW-тест приостановлен. {greState.error}</p><Button type="button" size="sm" variant="outline" onClick={() => setGreRevision(n => n + 1)}>Повторить GRE</Button></div>
: !greReady ? <p role="status">GRE выбранного устройства загружаются…</p>
: !bwTunnels.length ? <p role="status">У выбранного устройства нет GRE-туннелей.</p> : !bwTun?.enabled || !bwTun.remoteAddress.trim() ? <p role="status">Выберите включённый GRE с адресом назначения.</p> : null}
</div>
)}
{/* ── tool selector + config ── */}
<OpsPanel contentClassName="pt-4 pb-4 px-4 flex flex-col gap-4">
@@ -1089,7 +1105,7 @@ function ProbesContent() {
<Button
onClick={() => void runTest()}
disabled={!canRun || (tool === "bandwidth" && isLive && !canReadGre) || runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
disabled={!canRun || (tool === "bandwidth" && (!bwTun?.enabled || !bwTun.remoteAddress.trim() || !greReady || (isLive && !canReadGre))) || runBusy || !srcServer?.enabled || (isLive && liveLoad !== "idle") || (tool === "bandwidth" && !bwTunnels.length)}
className="h-8 shrink-0 gap-1.5 self-end"
>
{runBusy ? <LoaderCircleIcon className="size-3.5 animate-spin" /> : <PlayIcon className="size-3.5" />}
+9 -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.60; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.61; оптимизатор, карта и терминал приняты локально; диагностика /probes в работе |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
@@ -36,6 +36,14 @@
## Блок 10.61 — достоверный GRE-каталог диагностики (2026-10-01)
- Переиспользован filtersGreResponseSchema; подтверждается форма ответа, уникальность и принадлежность serverId. Только enabled текущий сервер вызывает GET. Abort/cleanup запрещают поздние GRE после смены устройства/источника.
- Ошибка сохраняет прошлый список, отдельно подписана и даёт Повторить GRE. Loading/error не выдаются за пустой каталог. BW требует ready, включённый выбранный туннель и непустой remoteAddress; отказ объяснён. Ping и другие инструменты не блокируются независимым GRE отказом.
- 18 основных E2E + 2 поздних GRE + 2 disabled/no-address BW, 4 source E2E, 3 unit, TypeScript и production frontend прошли. Профильный test lint чистый. В странице остаются 6 прежних эффектных lint ошибок и 2 предупреждения; GRE эффект устранён без suppressions.
- Далее 10.62: проверить точность подписи/preview DNS (сейчас имитирует /resolve server=1.1.1.1, backend использует DNS приложения); затем URL выбор без fallback, ros-src-address контракт, backend probes ошибки/аргументы/метрики, адаптивные снимки и локальная приёмка. /probes пока не принят, этап 10 не завершён.
- Остаток окна при сохранении около 4%; RouterOS/БД/внешний DNS не проверены. История локальная, отмена HTTP не подтверждает остановку устройства. Расписание/формы сбора остаются 11Б.
## Блок 10.60 — права диагностики (2026-10-01)
- permissionForPath(/probes) соответствует каталогу servers:read, вместо прежнего fallback dashboard:read. Запуск и Enter требуют servers:write, как текущий POST /servers/:id/probes/run; серверный RBAC не расширен. Нет servers:read — каталог не запрашивается.
+9
View File
@@ -363,3 +363,12 @@ Shared контракт и backend observed исходные значения/д
- 14 E2E, TypeScript, production frontend, профильный lint и backend test:exec/test:auth прошли. Auth exec тест теперь проверяет также probes HTTP путь подписанными JWT на настоящем requireAuth/permissionForRequest со stub handler: read/network write запрещены, servers write/admin допущены. Это не проверка production probes handler/RouterOS/портала.
- Далее 10.61: GRE каталог — ошибка не равна пустому списку, строгий контракт/cleanup/повтор. Затем URL, backend probes и адаптивная приёмка. Исходные 7 эффектных lint ошибок страницы ещё открыты.
## Блок 10.61 — достоверный GRE-каталог диагностики (2026-10-01)
- Переиспользован filtersGreResponseSchema; подтверждается форма ответа, уникальность и принадлежность serverId. Только enabled текущий сервер вызывает GET. Abort/cleanup запрещают поздние GRE после смены устройства/источника.
- Ошибка сохраняет прошлый список, отдельно подписана и даёт Повторить GRE. Loading/error не выдаются за пустой каталог. BW требует ready, включённый выбранный туннель и непустой remoteAddress; отказ объяснён. Ping и другие инструменты не блокируются независимым GRE отказом.
- 18 основных E2E + 2 поздних GRE + 2 disabled/no-address BW, 4 source E2E, 3 unit, TypeScript и production frontend прошли. Профильный test lint чистый. В странице остаются 6 прежних эффектных lint ошибок и 2 предупреждения; GRE эффект устранён без suppressions.
- Далее 10.62: проверить точность подписи/preview DNS (сейчас имитирует /resolve server=1.1.1.1, backend использует DNS приложения); затем URL выбор без fallback, ros-src-address контракт, backend probes ошибки/аргументы/метрики, адаптивные снимки и локальная приёмка. /probes пока не принят, этап 10 не завершён.
- Остаток окна при сохранении около 4%; RouterOS/БД/внешний DNS не проверены. История локальная, отмена HTTP не подтверждает остановку устройства. Расписание/формы сбора остаются 11Б.
+24
View File
@@ -62,3 +62,27 @@ for(const permission of ["mm:servers:read","mm:servers:write"])test("Диагн
else{await expect(run(page)).toBeEnabled();await run(page).click();await expect(page.getByText("РАЗРЕШЕНО",{exact:true})).toBeVisible();expect(posts).toBe(1)}
expect(greReads).toBe(0);await page.getByRole("button",{name:/BW-тест/}).click();await expect(page.getByRole("status").filter({hasText:"Для выбора GRE требуется mm:filters:read"})).toBeVisible();await expect(run(page)).toBeDisabled()
})
const tunnel={id:"*1",serverId:"1",name:"GRE-Тест",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1",remoteInnerIp:"10.0.0.2",poolId:"",ipsec:null,mtu:1476,keepaliveInterval:10,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"}
for(const kind of ["API","malformed","foreign"])test("Диагностика: GRE "+kind+" отличается от пустого списка",async({page})=>{
let fail=true;await page.route("**/api/filters/gre-tunnels?*",r=>r.fulfill(fail?(kind==="API"?{status:503,json:{error:"GRE отказ"}}:{json:{tunnels:kind==="foreign"?[{...tunnel,serverId:"2"}]:[{}]}}):{json:{tunnels:[tunnel]}}))
await page.route("**/api/servers/1/probes/run",r=>r.fulfill({json:{output:"BW подтверждён"}}))
await page.goto("/probes");await page.getByRole("button",{name:/BW-тест/}).click();await expect(page.getByRole("alert",{name:"GRE диагностики"})).toBeVisible();await expect(run(page)).toBeDisabled()
fail=false;await page.getByRole("button",{name:"Повторить GRE"}).click();await expect(run(page)).toBeEnabled();await run(page).click();await expect(page.getByText("BW подтверждён",{exact:true})).toBeVisible()
})
test("Диагностика: подтверждённый пустой GRE каталог",async({page})=>{
await page.goto("/probes");await page.getByRole("button",{name:/BW-тест/}).click();await expect(page.getByRole("status").filter({hasText:"У выбранного устройства нет GRE-туннелей"})).toBeVisible();await expect(page.getByRole("alert",{name:"GRE диагностики"})).toHaveCount(0);await expect(run(page)).toBeDisabled()
})
for(const fails of [false,true])test("Диагностика: поздний GRE прежнего устройства "+fails,async({page})=>{
let started=false,completed=false,release!:()=>void;const gate=new Promise<void>(r=>release=r)
await page.route("**/api/servers",r=>r.fulfill({json:[...devices,{...devices[0],id:2,name:"Второй",host:"192.0.2.2"}]}))
await page.route("**/api/filters/gre-tunnels?*",async r=>{if(new URL(r.request().url()).searchParams.get("serverId")==="2")return r.fulfill({json:{tunnels:[]}});started=true;await gate;try{await r.fulfill(fails?{status:503,json:{error:"СТАРЫЙ GRE ОТКАЗ"}}:{json:{tunnels:[tunnel]}})}catch{}finally{completed=true}})
await page.goto("/probes");await expect.poll(()=>started).toBe(true);await page.getByRole("option",{name:/Второй/}).click();await page.getByRole("button",{name:/BW-тест/}).click();await expect(page.getByRole("status").filter({hasText:"У выбранного устройства нет GRE-туннелей"})).toBeVisible();release();await expect.poll(()=>completed).toBe(true)
await expect(page.getByRole("alert",{name:"GRE диагностики"})).toHaveCount(0);await expect(page.getByText("GRE-Тест",{exact:true})).toHaveCount(0);await expect(run(page)).toBeDisabled()
})
for(const invalid of ["disabled","no-address"])test("Диагностика: BW недоступен для GRE "+invalid,async({page})=>{
let posts=0;await page.route("**/api/filters/gre-tunnels?*",r=>r.fulfill({json:{tunnels:[{...tunnel,enabled:invalid!=="disabled",remoteAddress:invalid==="no-address"?"":tunnel.remoteAddress}]}}));await page.route("**/api/servers/1/probes/run",r=>{posts++;return r.fulfill({json:{output:"НЕ ДОЛЖНО ВЫПОЛНЯТЬСЯ"}})})
await page.goto("/probes");await page.getByRole("button",{name:/BW-тест/}).click();await expect(page.getByRole("status").filter({hasText:"Выберите включённый GRE с адресом назначения"})).toBeVisible();await expect(run(page)).toBeDisabled();expect(posts).toBe(0)
})