fix(ui): показывать ошибки и свежесть NetFlow карты
This commit is contained in:
1 parent
e47e2b4029
commit
1bce4e1ea2
7 files changed
+139
-45
No files matched your search
@@ -10,7 +10,7 @@ import {
|
|||||||
type ServerStatus,
|
type ServerStatus,
|
||||||
type ServerType,
|
type ServerType,
|
||||||
} from "@/lib/data"
|
} from "@/lib/data"
|
||||||
import {validateNetworkMapSnapshot,type NetworkMapServerRow} from "@/lib/network-map-response"
|
import {validateNetworkMapSnapshot,networkMapFlowSchema,validateNetworkMapDns,type NetworkMapServerRow} from "@/lib/network-map-response"
|
||||||
import { useDataSource } from "@/lib/data-source"
|
import { useDataSource } from "@/lib/data-source"
|
||||||
import { requestJson } from "@/shared/api/http-client"
|
import { requestJson } from "@/shared/api/http-client"
|
||||||
import {
|
import {
|
||||||
@@ -58,7 +58,7 @@ import {
|
|||||||
matchNetflowForWan,
|
matchNetflowForWan,
|
||||||
type MatchedNetflowHop,
|
type MatchedNetflowHop,
|
||||||
} from "@/lib/map-netflow-hops"
|
} from "@/lib/map-netflow-hops"
|
||||||
import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
||||||
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
|
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
|
||||||
import { CountryFlagSvg } from "@/components/network-map/country-flag-svg"
|
import { CountryFlagSvg } from "@/components/network-map/country-flag-svg"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -1165,6 +1165,12 @@ function NetworkMapContent() {
|
|||||||
const [mapCountryLoaded, setMapCountryLoaded] = useState(true)
|
const [mapCountryLoaded, setMapCountryLoaded] = useState(true)
|
||||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||||
|
const [flowError,setFlowError]=useState<string|null>(null)
|
||||||
|
const [flowReadAt,setFlowReadAt]=useState<string|null>(null)
|
||||||
|
const [flowListener,setFlowListener]=useState(false)
|
||||||
|
const [flowLoading,setFlowLoading]=useState(false)
|
||||||
|
const [flowRefresh,setFlowRefresh]=useState(0)
|
||||||
|
const [dnsError,setDnsError]=useState<string|null>(null)
|
||||||
const [dataError, setDataError] = useState<string | null>(null)
|
const [dataError, setDataError] = useState<string | null>(null)
|
||||||
const [dataLoading, setDataLoading] = useState(false)
|
const [dataLoading, setDataLoading] = useState(false)
|
||||||
const [hasSnapshot,setHasSnapshot]=useState(false)
|
const [hasSnapshot,setHasSnapshot]=useState(false)
|
||||||
@@ -1195,7 +1201,7 @@ function NetworkMapContent() {
|
|||||||
if(failed.length)throw new Error(failed.join("; "))
|
if(failed.length)throw new Error(failed.join("; "))
|
||||||
let snapshot:ReturnType<typeof validateNetworkMapSnapshot>
|
let snapshot:ReturnType<typeof validateNetworkMapSnapshot>
|
||||||
try {snapshot=validateNetworkMapSnapshot(...parts.map(part=>part.status==="fulfilled"?part.value:null) as [unknown,unknown,unknown])}
|
try {snapshot=validateNetworkMapSnapshot(...parts.map(part=>part.status==="fulfilled"?part.value:null) as [unknown,unknown,unknown])}
|
||||||
catch(error){throw new Error("Недостоверный снимок карты: "+(error instanceof Error?error.message:"ошибка данных"))}
|
catch(error){throw new Error("Недостоверный снимок карты: "+(error instanceof Error?(error.name==="ZodError"?"неполные или некорректные поля API":error.message):"ошибка данных"))}
|
||||||
const rows=snapshot.servers
|
const rows=snapshot.servers
|
||||||
const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel)
|
const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel)
|
||||||
if(!current())return
|
if(!current())return
|
||||||
@@ -1214,9 +1220,10 @@ function NetworkMapContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hostnames = [...hostSet]
|
const hostnames = [...hostSet]
|
||||||
|
setGreResolvedIpv4ByHost({});setDnsError(null)
|
||||||
if (hostnames.length > 0) {
|
if (hostnames.length > 0) {
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch<{ results: Record<string, string | null> }>(
|
const res = await apiFetch<unknown>(
|
||||||
"/api/network/resolve-hosts",
|
"/api/network/resolve-hosts",
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1225,10 +1232,14 @@ function NetworkMapContent() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if(!current())return
|
if(!current())return
|
||||||
setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(res.results)))
|
const results=validateNetworkMapDns(res,hostnames)
|
||||||
} catch {
|
setGreResolvedIpv4ByHost(Object.fromEntries(greResolvedMapFromApi(results)))
|
||||||
|
const missing=Object.keys(results).filter(host=>results[host]===null)
|
||||||
|
setDnsError(missing.length?"DNS не разрешён: "+missing.join(", "):null)
|
||||||
|
} catch(error) {
|
||||||
if(!current())return
|
if(!current())return
|
||||||
setGreResolvedIpv4ByHost({})
|
setGreResolvedIpv4ByHost({})
|
||||||
|
setDnsError(error instanceof Error?(error.name==="ZodError"?"Некорректный ответ разрешения имён":error.message):"Ошибка DNS")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setGreResolvedIpv4ByHost({})
|
setGreResolvedIpv4ByHost({})
|
||||||
@@ -1381,56 +1392,53 @@ function NetworkMapContent() {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!showNetflow && !showServices) {
|
if (!showNetflow && !showServices) return
|
||||||
queueMicrotask(() => {
|
|
||||||
setMapHops([])
|
|
||||||
setMapServices([])
|
|
||||||
setMapServiceEdges([])
|
|
||||||
setMapServicePaths([])
|
|
||||||
setMapCountries([])
|
|
||||||
setMapCountryEdges([])
|
|
||||||
setMapCountryPaths([])
|
|
||||||
setMapCountryServiceGroups([])
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
let epoch=0
|
||||||
|
let inFlight=false
|
||||||
let ac: AbortController | null = null
|
let ac: AbortController | null = null
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
|
if(cancelled||inFlight)return
|
||||||
|
inFlight=true
|
||||||
|
const request=++epoch
|
||||||
|
setFlowLoading(true)
|
||||||
ac?.abort()
|
ac?.abort()
|
||||||
ac = new AbortController()
|
ac = new AbortController()
|
||||||
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal })
|
apiFetch<unknown>("/api/traffic/flow/map-hops?range=5m", { signal: ac.signal })
|
||||||
.then((res) => {
|
.then((raw) => {
|
||||||
if (cancelled) return
|
if (cancelled||request!==epoch) return
|
||||||
setMapHops(res.hops ?? [])
|
const res=networkMapFlowSchema.parse(raw)
|
||||||
setMapServices(res.services ?? [])
|
setMapHops(res.hops)
|
||||||
setMapServiceEdges(res.serviceEdges ?? [])
|
setMapServices(res.services)
|
||||||
setMapServicePaths(res.servicePaths ?? [])
|
setMapServiceEdges(res.serviceEdges)
|
||||||
setMapCountries(res.countries ?? [])
|
setMapServicePaths(res.servicePaths)
|
||||||
setMapCountryEdges(res.countryEdges ?? [])
|
setMapCountries(res.countries)
|
||||||
setMapCountryPaths(res.countryPaths ?? [])
|
setMapCountryEdges(res.countryEdges)
|
||||||
setMapCountryServiceGroups(res.countryServiceGroups ?? [])
|
setMapCountryPaths(res.countryPaths)
|
||||||
|
setMapCountryServiceGroups(res.countryServiceGroups)
|
||||||
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
|
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
|
||||||
setMapNamedBytes(res.namedBytes ?? 0)
|
setMapNamedBytes(res.namedBytes)
|
||||||
setMapTotalBytes(res.totalBytes ?? 0)
|
setMapTotalBytes(res.totalBytes)
|
||||||
if (res.asnLoaded != null) setMapAsnLoaded(res.asnLoaded)
|
if (res.asnLoaded != null) setMapAsnLoaded(res.asnLoaded)
|
||||||
if (res.countryLoaded != null) setMapCountryLoaded(res.countryLoaded)
|
if (res.countryLoaded != null) setMapCountryLoaded(res.countryLoaded)
|
||||||
if (res.windowSec) setMapWindowSec(res.windowSec)
|
setMapWindowSec(res.windowSec)
|
||||||
|
setFlowListener(res.live);setFlowReadAt(new Date().toISOString());setFlowError(null)
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
if (cancelled) return
|
if (cancelled||request!==epoch) return
|
||||||
const name = err instanceof Error ? err.name : ""
|
const name = err instanceof Error ? err.name : ""
|
||||||
if (name === "AbortError") return
|
if (name === "AbortError") return
|
||||||
})
|
setFlowError(err instanceof Error?(err.name==="ZodError"?"Некорректный ответ: требуется полный снимок за 5 минут":err.message):"Ошибка NetFlow")
|
||||||
|
}).finally(()=>{inFlight=false;if(!cancelled&&request===epoch)setFlowLoading(false)})
|
||||||
}
|
}
|
||||||
tick()
|
queueMicrotask(()=>{if(!cancelled)tick()})
|
||||||
const id = window.setInterval(tick, 4000)
|
const id = window.setInterval(tick, 4000)
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true;epoch++
|
||||||
ac?.abort()
|
ac?.abort()
|
||||||
window.clearInterval(id)
|
window.clearInterval(id)
|
||||||
}
|
}
|
||||||
}, [useLiveData, showNetflow, showServices, apiFetch])
|
}, [useLiveData, showNetflow, showServices, apiFetch,flowRefresh])
|
||||||
|
|
||||||
const effectiveSatPos = useMemo(() => {
|
const effectiveSatPos = useMemo(() => {
|
||||||
const out: Record<string, { x: number; y: number }[]> = {}
|
const out: Record<string, { x: number; y: number }[]> = {}
|
||||||
@@ -2029,7 +2037,7 @@ function NetworkMapContent() {
|
|||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{useLiveData && (
|
{useLiveData && (
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => void loadLive()} disabled={dataLoading}>
|
<Button type="button" variant="outline" size="sm" onClick={() => {void loadLive();setFlowRefresh(value=>value+1)}} disabled={dataLoading}>
|
||||||
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
<RefreshCwIcon className={cn("size-4", dataLoading && "animate-spin")} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2142,11 +2150,11 @@ function NetworkMapContent() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
|
<p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
|
||||||
{mapSharePct > 0
|
{useLiveData&&!flowReadAt?"Порог доли неизвестен":mapSharePct > 0
|
||||||
? `Порог доли ≥ ${mapSharePct}% · Настройки → NetFlow`
|
? `Порог доли ≥ ${mapSharePct}% · Настройки → NetFlow`
|
||||||
: "Порог доли выключен (все узлы, макс. 20) · Настройки → NetFlow"}
|
: "Порог доли выключен (все узлы, макс. 20) · Настройки → NetFlow"}
|
||||||
</p>
|
</p>
|
||||||
{destMode === "countries" && !mapCountryLoaded && (
|
{destMode === "countries" && (!useLiveData||flowReadAt) && !mapCountryLoaded && (
|
||||||
<p className="px-3 pb-1 text-[10px] text-amber-500 leading-snug">
|
<p className="px-3 pb-1 text-[10px] text-amber-500 leading-snug">
|
||||||
GeoIP Country не загружен, страны из RIPE-кэша
|
GeoIP Country не загружен, страны из RIPE-кэша
|
||||||
</p>
|
</p>
|
||||||
@@ -2170,6 +2178,10 @@ function NetworkMapContent() {
|
|||||||
<span className="text-[10px] border border-border rounded px-1.5 py-0.5">
|
<span className="text-[10px] border border-border rounded px-1.5 py-0.5">
|
||||||
{useLiveData ? (readAt ? "Снимок API · ответ "+new Date(readAt).toLocaleTimeString("ru-RU")+" (время браузера)" : "Данные карты неизвестны") : "Демо · mock"}
|
{useLiveData ? (readAt ? "Снимок API · ответ "+new Date(readAt).toLocaleTimeString("ru-RU")+" (время браузера)" : "Данные карты неизвестны") : "Демо · mock"}
|
||||||
</span>
|
</span>
|
||||||
|
{useLiveData&&<span>{showNetflow||showServices?(flowReadAt?"NetFlow · 5 мин · ответ "+new Date(flowReadAt).toLocaleTimeString("ru-RU")+" (время браузера) · "+(flowListener?"приёмник включён":"приёмник выключен"):"Данные NetFlow неизвестны"):"NetFlow: отображение и обновление выключены"}</span>}
|
||||||
|
{useLiveData&&flowLoading&&(showNetflow||showServices)&&<span role="status">Обновление NetFlow…</span>}
|
||||||
|
{useLiveData&&flowError&&(showNetflow||showServices)&&<span role="alert" className="text-destructive break-all">NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}</span>}
|
||||||
|
{useLiveData&&dnsError&&<span role="alert" className="text-destructive break-all">DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.</span>}
|
||||||
{dataLoading && <span role="status">Обновление снимка карты…</span>}
|
{dataLoading && <span role="status">Обновление снимка карты…</span>}
|
||||||
{dataError && (
|
{dataError && (
|
||||||
<span role="alert" className="text-[10px] text-destructive break-all">
|
<span role="alert" className="text-[10px] text-destructive break-all">
|
||||||
@@ -3132,7 +3144,7 @@ function NetworkMapContent() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!mapAsnLoaded && destMode === "services" && (
|
{!mapAsnLoaded && (!useLiveData||flowReadAt) && destMode === "services" && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||||
<span className="text-xs text-muted-foreground">GeoLite2 ASN</span>
|
<span className="text-xs text-muted-foreground">GeoLite2 ASN</span>
|
||||||
<span className="text-xs font-mono font-medium text-amber-500">не загружена</span>
|
<span className="text-xs font-mono font-medium text-amber-500">не загружена</span>
|
||||||
|
|||||||
@@ -26,11 +26,19 @@
|
|||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.47; оптимизатор принят локально, карта сети в работе |
|
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.48; оптимизатор принят локально, карта сети в работе |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
## Блок 10.48 — достоверность NetFlow и DNS карты (2026-10-01)
|
||||||
|
|
||||||
|
- NetFlow принимает полный DTO backend за 5 минут: поля/flags/счётчики не заменяются default нулями. При отказе сохраняется предыдущий снимок, первоначальная неизвестность и время ответа браузера обозначены отдельно; статус приёмника не выдаётся за свежесть измерения. Обновить повторяет оба независимых чтения.
|
||||||
|
- Опрос не перекрывает и не отменяет медленный запрос каждые 4 секунды. Cleanup/epoch запрещают старый ответ; скрытие обеих flow групп приостанавливает обновление без потери последнего снимка.
|
||||||
|
- DNS envelope проверяет точный набор имён и IPv4/null. Новая topology не использует прежний DNS; отказ/неразрешённые имена явно предупреждают о неподтверждённых связях. Неполные API данные выводят краткую понятную ошибку вместо технического JSON Zod.
|
||||||
|
- 6 unit, 20 разных E2E (19 общих + новый slow, 8 Flow повторены после финального изменения) и TypeScript прошли. Новых lint проблем нет. Первые Flow фикстуры не перехватывали health и получили ECONNREFUSED; фикстуры исправлены, повтор чистый.
|
||||||
|
- Далее отдельные source/race E2E, достоверность модельных WAN линий/выбранных объектов, затем URL и доступность/адаптивность карты.
|
||||||
|
|
||||||
## Блок 10.47 — измерения карты без синтетических live значений (2026-10-01)
|
## Блок 10.47 — измерения карты без синтетических live значений (2026-10-01)
|
||||||
|
|
||||||
- CPU/RAM/uptime читаются независимо из API, сохраняют нули и неизвестность; HDD, температура и история CPU не генерируются для live. Указано фактическое polledAt либо неизвестное время. Демо-ресурсы доступны только с явным demo=true.
|
- CPU/RAM/uptime читаются независимо из API, сохраняют нули и неизвестность; HDD, температура и история CPU не генерируются для live. Указано фактическое polledAt либо неизвестное время. Демо-ресурсы доступны только с явным demo=true.
|
||||||
|
|||||||
@@ -251,3 +251,12 @@ Shared контракт и backend observed исходные значения/д
|
|||||||
- 4 unit, 19 E2E карты/дашборда и TypeScript прошли. Lint сравнен с HEAD: новых проблем нет; прежние prefer-const в layout и React правила карты будут устранены при её приёмке.
|
- 4 unit, 19 E2E карты/дашборда и TypeScript прошли. Lint сравнен с HEAD: новых проблем нет; прежние prefer-const в layout и React правила карты будут устранены при её приёмке.
|
||||||
- Далее Flow Map/DNS: строгие envelopes, частичный отказ/свежесть/гонки; модельные линии, URL-контекст, доступность и адаптивность ещё впереди.
|
- Далее Flow Map/DNS: строгие envelopes, частичный отказ/свежесть/гонки; модельные линии, URL-контекст, доступность и адаптивность ещё впереди.
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 10.48 — достоверность NetFlow и DNS карты (2026-10-01)
|
||||||
|
|
||||||
|
- NetFlow принимает полный DTO backend за 5 минут: поля/flags/счётчики не заменяются default нулями. При отказе сохраняется предыдущий снимок, первоначальная неизвестность и время ответа браузера обозначены отдельно; статус приёмника не выдаётся за свежесть измерения. Обновить повторяет оба независимых чтения.
|
||||||
|
- Опрос не перекрывает и не отменяет медленный запрос каждые 4 секунды. Cleanup/epoch запрещают старый ответ; скрытие обеих flow групп приостанавливает обновление без потери последнего снимка.
|
||||||
|
- DNS envelope проверяет точный набор имён и IPv4/null. Новая topology не использует прежний DNS; отказ/неразрешённые имена явно предупреждают о неподтверждённых связях. Неполные API данные выводят краткую понятную ошибку вместо технического JSON Zod.
|
||||||
|
- 6 unit, 20 разных E2E (19 общих + новый slow, 8 Flow повторены после финального изменения) и TypeScript прошли. Новых lint проблем нет. Первые Flow фикстуры не перехватывали health и получили ECONNREFUSED; фикстуры исправлены, повтор чистый.
|
||||||
|
- Далее отдельные source/race E2E, достоверность модельных WAN линий/выбранных объектов, затем URL и доступность/адаптивность карты.
|
||||||
|
|
||||||
@@ -13,3 +13,19 @@ test("network map refuses partial GRE and foreign failure/probe contexts",()=>{
|
|||||||
const probe={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"",dstInterface:"",enabled:true,lastTxAvgMbps:null,lastRxAvgMbps:null,lastPingRttMs:null,lastPingLossPct:null,lastPingAt:null}
|
const probe={id:"p",srcServerId:"1",dstServerId:"2",srcInterface:"",dstInterface:"",enabled:true,lastTxAvgMbps:null,lastRxAvgMbps:null,lastPingRttMs:null,lastPingLossPct:null,lastPingAt:null}
|
||||||
assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[]},{probes:[probe]}),/неизвестному/)
|
assert.throws(()=>validateNetworkMapSnapshot([server],{tunnels:[]},{probes:[probe]}),/неизвестному/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
import {networkMapFlowSchema,validateNetworkMapDns} from "./network-map-response"
|
||||||
|
test("map Flow envelope retains zeros and refuses missing metadata and contradictory totals",()=>{
|
||||||
|
const empty={"hops":[],"live":false,"rangeMinutes":5,"windowSec":300,"totalBytes":0,"namedBytes":0,"unclassifiedBytes":0,"asnLoaded":false,"countryLoaded":false,"services":[],"serviceEdges":[],"servicePaths":[],"countries":[],"countryEdges":[],"countryPaths":[],"countryServiceGroups":[],"mapServiceMinSharePct":5,"dedupApplied":false,"excludeMeshApplied":false,"excludeOverlayApplied":false}
|
||||||
|
assert.equal(networkMapFlowSchema.parse(empty).totalBytes,0)
|
||||||
|
assert.equal(networkMapFlowSchema.parse(empty).live,false)
|
||||||
|
assert.throws(()=>networkMapFlowSchema.parse({...empty,totalBytes:undefined}))
|
||||||
|
assert.throws(()=>networkMapFlowSchema.parse({...empty,namedBytes:1}))
|
||||||
|
assert.throws(()=>networkMapFlowSchema.parse({...empty,rangeMinutes:10}))
|
||||||
|
})
|
||||||
|
test("map DNS matches exactly requested names and accepts explicit unresolved null",()=>{
|
||||||
|
assert.deepEqual(validateNetworkMapDns({results:{"router.example":null}},["router.example"]),{"router.example":null})
|
||||||
|
assert.throws(()=>validateNetworkMapDns({results:{}},["router.example"]))
|
||||||
|
assert.throws(()=>validateNetworkMapDns({results:{"foreign.example":"192.0.2.1"}},["router.example"]))
|
||||||
|
assert.throws(()=>validateNetworkMapDns({results:{"router.example":"not-ip"}},["router.example"]))
|
||||||
|
})
|
||||||
@@ -18,3 +18,13 @@ export function validateNetworkMapSnapshot(servers:unknown,gre:unknown,probes:un
|
|||||||
if(tunnels.failures.length)throw new Error("Неполный каталог GRE: "+tunnels.failures.map(failure=>failure.serverName||failure.serverId).join(", "))
|
if(tunnels.failures.length)throw new Error("Неполный каталог GRE: "+tunnels.failures.map(failure=>failure.serverName||failure.serverId).join(", "))
|
||||||
return {servers:inventory,tunnels:tunnels.tunnels,probes:measurements.probes}
|
return {servers:inventory,tunnels:tunnels.tunnels,probes:measurements.probes}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import {flowMapHopsDtoSchema} from "@mmapp/contracts/traffic-flow"
|
||||||
|
import {isIpv4String} from "./gre-endpoint-resolve"
|
||||||
|
export const networkMapFlowSchema=flowMapHopsDtoSchema.required().refine(data=>data.rangeMinutes===5&&data.namedBytes<=data.totalBytes&&data.unclassifiedBytes<=data.totalBytes)
|
||||||
|
export function validateNetworkMapDns(raw:unknown,hosts:string[]){
|
||||||
|
const data=z.object({results:z.record(z.string(),z.string().refine(isIpv4String).nullable())}).parse(raw)
|
||||||
|
const requested=new Set(hosts.map(host=>host.toLowerCase()))
|
||||||
|
if(Object.keys(data.results).length!==requested.size||Object.keys(data.results).some(host=>!requested.has(host)))throw new Error("DNS-ответ не соответствует запрошенным именам")
|
||||||
|
return data.results
|
||||||
|
}
|
||||||
@@ -6,11 +6,11 @@ for(const part of ["servers","gre","probes"] as const)for(const malformed of [fa
|
|||||||
await page.route("**/health",r=>r.fulfill({json:{ok:true}}))
|
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(failing&&p===paths[part]?(malformed?{json:{}}:{status:403,json:{error:"Нет доступа к данным"}}):{json:body(p)})})
|
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(failing&&p===paths[part]?(malformed?{json:{}}:{status:403,json:{error:"Нет доступа к данным"}}):{json:body(p)})})
|
||||||
await page.goto("/network-map")
|
await page.goto("/network-map")
|
||||||
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка"})).toBeVisible()
|
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка карты"})).toBeVisible()
|
||||||
await expect(page.getByText("Данные карты неизвестны",{exact:true})).toBeVisible()
|
await expect(page.getByText("Данные карты неизвестны",{exact:true})).toBeVisible()
|
||||||
failing=false;await page.getByRole("button",{name:"Обновить",exact:true}).click()
|
failing=false;await page.getByRole("button",{name:"Обновить",exact:true}).click()
|
||||||
await expect(page.getByText("Роутер",{exact:true}).first()).toBeVisible()
|
await expect(page.getByText("Роутер",{exact:true}).first()).toBeVisible()
|
||||||
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка"})).toHaveCount(0)
|
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка карты"})).toHaveCount(0)
|
||||||
})
|
})
|
||||||
for(const part of ["servers","gre","probes"] as const)test("Карта: частичный отказ сохраняет снимок "+part,async({page})=>{
|
for(const part of ["servers","gre","probes"] as const)test("Карта: частичный отказ сохраняет снимок "+part,async({page})=>{
|
||||||
let failing=false
|
let failing=false
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import {test,expect} from "@playwright/test"
|
||||||
|
const empty={"hops":[],"live":false,"rangeMinutes":5,"windowSec":300,"totalBytes":0,"namedBytes":0,"unclassifiedBytes":0,"asnLoaded":false,"countryLoaded":false,"services":[],"serviceEdges":[],"servicePaths":[],"countries":[],"countryEdges":[],"countryPaths":[],"countryServiceGroups":[],"mapServiceMinSharePct":5,"dedupApplied":false,"excludeMeshApplied":false,"excludeOverlayApplied":false}
|
||||||
|
const flowPath="/api/traffic/flow/map-hops"
|
||||||
|
test.beforeEach(async({page})=>{await page.route("**/health",r=>r.fulfill({json:{ok:true}}))})
|
||||||
|
const server={id:1,name:"Router",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online"}
|
||||||
|
for(const malformed of [false,true])test("Карта: NetFlow отказ и повтор "+malformed,async({page})=>{
|
||||||
|
let failing=true
|
||||||
|
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(p===flowPath?(failing?(malformed?{json:{}}:{status:503,json:{error:"NetFlow недоступен"}}):{json:empty}):{json:p==="/api/servers"?[server]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/auth/config"?{required:false}:{}})})
|
||||||
|
await page.goto("/network-map")
|
||||||
|
await expect(page.getByRole("alert").filter({hasText:"Первоначальная загрузка NetFlow"})).toBeVisible()
|
||||||
|
await expect(page.getByText("Данные NetFlow неизвестны",{exact:true})).toBeVisible()
|
||||||
|
failing=false;await page.getByRole("button",{name:"Обновить",exact:true}).click()
|
||||||
|
await expect(page.getByText(/NetFlow · 5 мин · ответ/)).toContainText("приёмник выключен")
|
||||||
|
await expect(page.getByRole("alert").filter({hasText:"NetFlow:"})).toHaveCount(0)
|
||||||
|
})
|
||||||
|
test("Карта: NetFlow сохраняет сервис при фоновом сбое",async({page})=>{
|
||||||
|
let failing=false
|
||||||
|
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill(p===flowPath?(failing?{status:403,json:{error:"Нет доступа к NetFlow"}}:{json:{...empty,live:true,totalBytes:100,namedBytes:100,services:[{id:"svc:one",label:"Сервис сохранён",category:"Web",bytes:100,bps:0,share:1}]}}):{json:p==="/api/servers"?[server]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/auth/config"?{required:false}:{}})})
|
||||||
|
await page.goto("/network-map");await expect(page.getByText("Сервис сохранён",{exact:true}).first()).toBeVisible()
|
||||||
|
failing=true;await page.getByRole("button",{name:"Обновить",exact:true}).click()
|
||||||
|
await expect(page.getByRole("alert").filter({hasText:"Сохранён предыдущий снимок NetFlow"})).toBeVisible()
|
||||||
|
await expect(page.getByText("Сервис сохранён",{exact:true}).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
for(const result of ["missing","foreign","unresolved","success"])test("Карта: DNS "+result,async({page})=>{
|
||||||
|
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p===flowPath?empty:p==="/api/servers"?[{...server,wanUplinks:[{id:"w",name:"WAN",iface:"ether1",isp:"ISP",ip:"router.example",maxDl:100,maxUl:100}]}]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/network/resolve-hosts"?(result==="missing"?{}:{results:result==="foreign"?{"foreign.example":"192.0.2.2"}:{"router.example":result==="unresolved"?null:"192.0.2.2"}}):p==="/api/auth/config"?{required:false}:{}})})
|
||||||
|
await page.goto("/network-map")
|
||||||
|
await expect(page.getByText("Router",{exact:true}).first()).toBeVisible()
|
||||||
|
if(result==="success")await expect(page.getByRole("alert").filter({hasText:"DNS:"})).toHaveCount(0)
|
||||||
|
else await expect(page.getByRole("alert").filter({hasText:"Связи по неизвестному адресу"})).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Карта: медленный NetFlow не прерывается периодическим опросом",async({page})=>{
|
||||||
|
let requests=0,release!:()=>void;const gate=new Promise<void>(resolve=>{release=resolve})
|
||||||
|
await page.clock.install()
|
||||||
|
await page.route("**/api/**",async r=>{const p=new URL(r.request().url()).pathname;if(p===flowPath){requests++;await gate;return r.fulfill({json:empty})}return r.fulfill({json:p==="/api/servers"?[server]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/auth/config"?{required:false}:{}})})
|
||||||
|
await page.goto("/network-map");await expect.poll(()=>requests).toBe(1)
|
||||||
|
await page.clock.runFor(5000);expect(requests).toBe(1)
|
||||||
|
release();await expect(page.getByText(/NetFlow · 5 мин · ответ/)).toBeVisible()
|
||||||
|
})
|
||||||
Reference in new issue
Block a user