feat(traffic-flow): раскрытие сервисов страны на карте сети
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Контракт: countryServiceGroups (группа сервисов страны) в flowMapHopsDto, тип FlowMapCountryServiceGroup. - Бэкенд: вложенная агрегация brand×ISO в том же проходе hops; рёбра страна→сервис, пути с реальным EN; cap 8 / min 4, доля от байтов страны. - Хелпер mapCountryServiceNodeId: id вида cc:us|svc:google, сервисы разных стран не смешиваются. - Карта сети: клик по стране сдвигает колонку стран и раскрывает столбец её сервисов; остальные страны приглушены; панели страны и вложенного сервиса. - Тесты: группы US/US+NL, рёбра от страны, cap/min вложенного слоя; mock countryServiceGroups для демо-режима.
This commit is contained in:
+302
-13
@@ -27,7 +27,9 @@ import {
|
|||||||
findServerByGreRemote,
|
findServerByGreRemote,
|
||||||
greSourceWanIndexOnMap,
|
greSourceWanIndexOnMap,
|
||||||
greTunnelProbe,
|
greTunnelProbe,
|
||||||
|
placeCountryServiceNodes,
|
||||||
placeServiceNodes,
|
placeServiceNodes,
|
||||||
|
SERVICE_COL_W,
|
||||||
type GreMapEdge,
|
type GreMapEdge,
|
||||||
type WanJhEdge,
|
type WanJhEdge,
|
||||||
} from "@/lib/network-map-layout"
|
} from "@/lib/network-map-layout"
|
||||||
@@ -55,7 +57,7 @@ import {
|
|||||||
matchNetflowForWan,
|
matchNetflowForWan,
|
||||||
type MatchedNetflowHop,
|
type MatchedNetflowHop,
|
||||||
} from "@/lib/map-netflow-hops"
|
} from "@/lib/map-netflow-hops"
|
||||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapHopsDto, 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"
|
||||||
@@ -324,6 +326,29 @@ const MOCK_MAP_COUNTRY_PATHS: FlowMapServicePath[] = [
|
|||||||
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:de", bytes: 9_000_000, bps: 3_600_000 },
|
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:de", bytes: 9_000_000, bps: 3_600_000 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** Доли — от байтов страны cc:us (22M из моков выше). */
|
||||||
|
const MOCK_COUNTRY_SERVICE_GROUPS: FlowMapCountryServiceGroup[] = [
|
||||||
|
{
|
||||||
|
countryId: "cc:us",
|
||||||
|
services: [
|
||||||
|
{ id: "cc:us|svc:google", label: "Google", category: "Веб", bytes: 12_000_000, bps: 4_800_000, share: 12 / 22 },
|
||||||
|
{ id: "cc:us|svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 7_000_000, bps: 2_800_000, share: 7 / 22 },
|
||||||
|
{ id: "cc:us|svc:aws", label: "AWS", category: "CDN", bytes: 3_000_000, bps: 1_200_000, share: 3 / 22 },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ fromId: "cc:us", toId: "cc:us|svc:google", bytes: 12_000_000, bps: 4_800_000, bpsFwd: 9_000_000, bpsRev: 3_000_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
|
||||||
|
{ fromId: "cc:us", toId: "cc:us|svc:cloudflare", bytes: 7_000_000, bps: 2_800_000, bpsFwd: 5_250_000, bpsRev: 1_750_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
|
||||||
|
{ fromId: "cc:us", toId: "cc:us|svc:aws", bytes: 3_000_000, bps: 1_200_000, bpsFwd: 2_250_000, bpsRev: 750_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
|
||||||
|
],
|
||||||
|
paths: [
|
||||||
|
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us|svc:google", bytes: 8_000_000, bps: 3_200_000 },
|
||||||
|
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us|svc:google", bytes: 4_000_000, bps: 1_600_000 },
|
||||||
|
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us|svc:cloudflare", bytes: 7_000_000, bps: 2_800_000 },
|
||||||
|
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us|svc:aws", bytes: 3_000_000, bps: 1_200_000 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const DEST_MODE_KEY = "mm-network-map-dest-mode"
|
const DEST_MODE_KEY = "mm-network-map-dest-mode"
|
||||||
type DestMode = "services" | "countries"
|
type DestMode = "services" | "countries"
|
||||||
|
|
||||||
@@ -782,6 +807,8 @@ function ServiceNode({
|
|||||||
isDragged,
|
isDragged,
|
||||||
destMode,
|
destMode,
|
||||||
iso,
|
iso,
|
||||||
|
dim,
|
||||||
|
shareLabel,
|
||||||
onClick,
|
onClick,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
}: {
|
}: {
|
||||||
@@ -794,6 +821,10 @@ function ServiceNode({
|
|||||||
isDragged: boolean
|
isDragged: boolean
|
||||||
destMode: DestMode
|
destMode: DestMode
|
||||||
iso?: string
|
iso?: string
|
||||||
|
/** Приглушение узла при раскрытии другой страны (остаётся на холсте). */
|
||||||
|
dim?: boolean
|
||||||
|
/** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */
|
||||||
|
shareLabel?: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
onMouseDown: (e: React.MouseEvent) => void
|
onMouseDown: (e: React.MouseEvent) => void
|
||||||
}) {
|
}) {
|
||||||
@@ -804,11 +835,11 @@ function ServiceNode({
|
|||||||
<g
|
<g
|
||||||
transform={`translate(${x},${y})`}
|
transform={`translate(${x},${y})`}
|
||||||
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
||||||
opacity={isVis ? 1 : 0.08}
|
opacity={isVis ? (dim ? 0.35 : 1) : 0.08}
|
||||||
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
|
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
|
||||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||||
>
|
>
|
||||||
<title>{`${label} · ${serviceSharePct(share)} payload окна`}</title>
|
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
|
||||||
{isSel && (
|
{isSel && (
|
||||||
<rect
|
<rect
|
||||||
x={-bw / 2 - 6}
|
x={-bw / 2 - 6}
|
||||||
@@ -1154,6 +1185,7 @@ export default function NetworkMapPage() {
|
|||||||
const [mapCountries, setMapCountries] = useState<FlowMapService[]>([])
|
const [mapCountries, setMapCountries] = useState<FlowMapService[]>([])
|
||||||
const [mapCountryEdges, setMapCountryEdges] = useState<FlowMapServiceEdge[]>([])
|
const [mapCountryEdges, setMapCountryEdges] = useState<FlowMapServiceEdge[]>([])
|
||||||
const [mapCountryPaths, setMapCountryPaths] = useState<FlowMapServicePath[]>([])
|
const [mapCountryPaths, setMapCountryPaths] = useState<FlowMapServicePath[]>([])
|
||||||
|
const [mapCountryServiceGroups, setMapCountryServiceGroups] = useState<FlowMapCountryServiceGroup[]>([])
|
||||||
const [mapSharePct, setMapSharePct] = useState(5)
|
const [mapSharePct, setMapSharePct] = useState(5)
|
||||||
const [mapNamedBytes, setMapNamedBytes] = useState(0)
|
const [mapNamedBytes, setMapNamedBytes] = useState(0)
|
||||||
const [mapTotalBytes, setMapTotalBytes] = useState(0)
|
const [mapTotalBytes, setMapTotalBytes] = useState(0)
|
||||||
@@ -1258,6 +1290,7 @@ export default function NetworkMapPage() {
|
|||||||
setMapCountries(MOCK_MAP_COUNTRIES)
|
setMapCountries(MOCK_MAP_COUNTRIES)
|
||||||
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
|
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
|
||||||
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
|
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
|
||||||
|
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
|
||||||
setMapSharePct(5)
|
setMapSharePct(5)
|
||||||
setMapNamedBytes(0)
|
setMapNamedBytes(0)
|
||||||
setMapTotalBytes(0)
|
setMapTotalBytes(0)
|
||||||
@@ -1290,6 +1323,8 @@ export default function NetworkMapPage() {
|
|||||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||||
const [selected, setSelected] = useState<Server | null>(null)
|
const [selected, setSelected] = useState<Server | null>(null)
|
||||||
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
|
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
|
||||||
|
/** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */
|
||||||
|
const [expandedCountryId, setExpandedCountryId] = useState<string | null>(null)
|
||||||
const [destMode, setDestModeState] = useState<DestMode>("services")
|
const [destMode, setDestModeState] = useState<DestMode>("services")
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueMicrotask(() => setDestModeState(readDestMode()))
|
queueMicrotask(() => setDestModeState(readDestMode()))
|
||||||
@@ -1297,15 +1332,32 @@ export default function NetworkMapPage() {
|
|||||||
function setDestMode(mode: DestMode) {
|
function setDestMode(mode: DestMode) {
|
||||||
setDestModeState(mode)
|
setDestModeState(mode)
|
||||||
setSelectedService(null)
|
setSelectedService(null)
|
||||||
|
setExpandedCountryId(null)
|
||||||
setHighlightedPath(null)
|
setHighlightedPath(null)
|
||||||
try { sessionStorage.setItem(DEST_MODE_KEY, mode) } catch { /* private mode */ }
|
try { sessionStorage.setItem(DEST_MODE_KEY, mode) } catch { /* private mode */ }
|
||||||
}
|
}
|
||||||
|
const expandedCountryGroup = useMemo(
|
||||||
|
() => destMode === "countries" && expandedCountryId
|
||||||
|
? mapCountryServiceGroups.find((g) => g.countryId === expandedCountryId) ?? null
|
||||||
|
: null,
|
||||||
|
[destMode, expandedCountryId, mapCountryServiceGroups],
|
||||||
|
)
|
||||||
|
const nestedServices = useMemo(() => expandedCountryGroup?.services ?? [], [expandedCountryGroup])
|
||||||
const liveSelectedService = selectedService
|
const liveSelectedService = selectedService
|
||||||
? (
|
? (
|
||||||
(destMode === "countries" ? mapCountries : mapServices)
|
(destMode === "countries" ? mapCountries : mapServices)
|
||||||
.find((s) => s.id === selectedService.id) ?? selectedService
|
.find((s) => s.id === selectedService.id)
|
||||||
|
?? nestedServices.find((s) => s.id === selectedService.id)
|
||||||
|
?? selectedService
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
|
const selectedNestedService = liveSelectedService
|
||||||
|
&& nestedServices.some((s) => s.id === liveSelectedService.id)
|
||||||
|
? liveSelectedService
|
||||||
|
: null
|
||||||
|
const expandedCountry = expandedCountryId
|
||||||
|
? mapCountries.find((c) => c.id === expandedCountryId) ?? null
|
||||||
|
: null
|
||||||
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
|
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
|
||||||
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
|
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
|
||||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||||
@@ -1359,6 +1411,7 @@ export default function NetworkMapPage() {
|
|||||||
setMapCountries(MOCK_MAP_COUNTRIES)
|
setMapCountries(MOCK_MAP_COUNTRIES)
|
||||||
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
|
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
|
||||||
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
|
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
|
||||||
|
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
|
||||||
setMapSharePct(5)
|
setMapSharePct(5)
|
||||||
setMapCountryLoaded(true)
|
setMapCountryLoaded(true)
|
||||||
})
|
})
|
||||||
@@ -1373,6 +1426,7 @@ export default function NetworkMapPage() {
|
|||||||
setMapCountries([])
|
setMapCountries([])
|
||||||
setMapCountryEdges([])
|
setMapCountryEdges([])
|
||||||
setMapCountryPaths([])
|
setMapCountryPaths([])
|
||||||
|
setMapCountryServiceGroups([])
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1391,6 +1445,7 @@ export default function NetworkMapPage() {
|
|||||||
setMapCountries(res.countries ?? [])
|
setMapCountries(res.countries ?? [])
|
||||||
setMapCountryEdges(res.countryEdges ?? [])
|
setMapCountryEdges(res.countryEdges ?? [])
|
||||||
setMapCountryPaths(res.countryPaths ?? [])
|
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 ?? 0)
|
||||||
setMapTotalBytes(res.totalBytes ?? 0)
|
setMapTotalBytes(res.totalBytes ?? 0)
|
||||||
@@ -1627,8 +1682,24 @@ export default function NetworkMapPage() {
|
|||||||
.map((s) => nodePosById[s.id])
|
.map((s) => nodePosById[s.id])
|
||||||
.filter((p): p is { x: number; y: number } => Boolean(p)),
|
.filter((p): p is { x: number; y: number } => Boolean(p)),
|
||||||
)
|
)
|
||||||
|
// Раскрытая страна: колонка стран уходит влево, правый x занимает столбец её сервисов.
|
||||||
|
const countryColShift = expandedCountryGroup ? SERVICE_COL_W : 0
|
||||||
|
const autoDestPos = countryColShift
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(autoServicePos).map(([id, p]) => [id, { x: p.x - countryColShift, y: p.y }]),
|
||||||
|
)
|
||||||
|
: autoServicePos
|
||||||
const servicePosById = Object.fromEntries(
|
const servicePosById = Object.fromEntries(
|
||||||
visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoServicePos[s.id]!]),
|
visibleMapServices.map((s) => [s.id, servicePositions[s.id] ?? autoDestPos[s.id]!]),
|
||||||
|
)
|
||||||
|
const autoNestedPos = placeCountryServiceNodes(
|
||||||
|
nestedServices.map((s) => s.id),
|
||||||
|
expandedCountryId ? servicePosById[expandedCountryId] ?? autoDestPos[expandedCountryId] : undefined,
|
||||||
|
)
|
||||||
|
const nestedPosById = Object.fromEntries(
|
||||||
|
nestedServices
|
||||||
|
.map((s) => [s.id, servicePositions[s.id] ?? autoNestedPos[s.id]] as const)
|
||||||
|
.filter((entry): entry is readonly [string, { x: number; y: number }] => Boolean(entry[1])),
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── Refs ─────────────────────────────────────────────────────────────────────
|
// ── Refs ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -1713,6 +1784,7 @@ export default function NetworkMapPage() {
|
|||||||
setSelectedGreEdge(null)
|
setSelectedGreEdge(null)
|
||||||
setSelectedService(null)
|
setSelectedService(null)
|
||||||
setHighlightedPath(null)
|
setHighlightedPath(null)
|
||||||
|
setExpandedCountryId(null)
|
||||||
}
|
}
|
||||||
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
||||||
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
||||||
@@ -1810,6 +1882,7 @@ export default function NetworkMapPage() {
|
|||||||
setSelectedGreEdge(null)
|
setSelectedGreEdge(null)
|
||||||
setSelectedService(null)
|
setSelectedService(null)
|
||||||
setHighlightedPath(null)
|
setHighlightedPath(null)
|
||||||
|
setExpandedCountryId(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1845,12 +1918,33 @@ export default function NetworkMapPage() {
|
|||||||
|
|
||||||
// ── Side panel ────────────────────────────────────────────────────────────
|
// ── Side panel ────────────────────────────────────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedService) return
|
if (
|
||||||
if (!destNodes.some((s) => s.id === selectedService.id)) {
|
expandedCountryId
|
||||||
setSelectedService(null)
|
&& (!mapCountries.some((s) => s.id === expandedCountryId)
|
||||||
setHighlightedPath(null)
|
|| !mapCountryServiceGroups.some((g) => g.countryId === expandedCountryId))
|
||||||
|
) {
|
||||||
|
queueMicrotask(() => setExpandedCountryId(null))
|
||||||
}
|
}
|
||||||
}, [destMode, destNodes, selectedService])
|
}, [mapCountries, mapCountryServiceGroups, expandedCountryId])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedService) return
|
||||||
|
const stillVisible =
|
||||||
|
destNodes.some((s) => s.id === selectedService.id)
|
||||||
|
|| nestedServices.some((s) => s.id === selectedService.id)
|
||||||
|
if (!stillVisible) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setSelectedService(null)
|
||||||
|
setHighlightedPath(null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [destMode, destNodes, nestedServices, selectedService])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showServices) queueMicrotask(() => setExpandedCountryId(null))
|
||||||
|
}, [showServices])
|
||||||
|
// Сдвиг колонки стран меняет систему координат: сбрасываем drag-овчины сервисов.
|
||||||
|
useEffect(() => {
|
||||||
|
queueMicrotask(() => setServicePositions({}))
|
||||||
|
}, [expandedCountryId])
|
||||||
function selectServer(s: Server) {
|
function selectServer(s: Server) {
|
||||||
setSelectedGreEdge(null)
|
setSelectedGreEdge(null)
|
||||||
setSelectedService(null)
|
setSelectedService(null)
|
||||||
@@ -1865,6 +1959,10 @@ export default function NetworkMapPage() {
|
|||||||
setSelWanIdx(null)
|
setSelWanIdx(null)
|
||||||
setHighlightedPath(null)
|
setHighlightedPath(null)
|
||||||
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
|
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
|
||||||
|
// Клик по стране в режиме «Страны» раскрывает столбец её сервисов; повторный — сворачивает.
|
||||||
|
if (destMode === "countries" && svc.id.startsWith("cc:") && !svc.id.includes("|")) {
|
||||||
|
setExpandedCountryId((prev) => (prev === svc.id ? null : svc.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function selectWan(s: Server, wanIdx: number) {
|
function selectWan(s: Server, wanIdx: number) {
|
||||||
setSelectedGreEdge(null)
|
setSelectedGreEdge(null)
|
||||||
@@ -2413,6 +2511,7 @@ export default function NetworkMapPage() {
|
|||||||
y={pos.y}
|
y={pos.y}
|
||||||
isSel={selectedService?.id === svc.id}
|
isSel={selectedService?.id === svc.id}
|
||||||
isVis
|
isVis
|
||||||
|
dim={Boolean(expandedCountryGroup) && svc.id !== expandedCountryId}
|
||||||
isDragged={draggedSvcId === svc.id}
|
isDragged={draggedSvcId === svc.id}
|
||||||
destMode={destMode}
|
destMode={destMode}
|
||||||
iso={svc.label}
|
iso={svc.label}
|
||||||
@@ -2425,6 +2524,31 @@ export default function NetworkMapPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* ── Сервисы раскрытой страны (второй столбец справа) ── */}
|
||||||
|
{expandedCountryGroup && nestedServices.map((svc) => {
|
||||||
|
const pos = nestedPosById[svc.id]
|
||||||
|
if (!pos) return null
|
||||||
|
return (
|
||||||
|
<ServiceNode
|
||||||
|
key={svc.id}
|
||||||
|
label={svc.label}
|
||||||
|
share={svc.share}
|
||||||
|
x={pos.x}
|
||||||
|
y={pos.y}
|
||||||
|
isSel={selectedService?.id === svc.id}
|
||||||
|
isVis
|
||||||
|
isDragged={draggedSvcId === svc.id}
|
||||||
|
destMode="services"
|
||||||
|
shareLabel="трафика страны"
|
||||||
|
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
|
||||||
|
onClick={() => {
|
||||||
|
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||||||
|
selectService(svc)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* ── EN → destination services (поверх узлов, чтобы пунктир не прятался) ── */}
|
{/* ── EN → destination services (поверх узлов, чтобы пунктир не прятался) ── */}
|
||||||
{visibleServiceEdges.map((edge) => {
|
{visibleServiceEdges.map((edge) => {
|
||||||
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
|
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
|
||||||
@@ -2455,7 +2579,8 @@ export default function NetworkMapPage() {
|
|||||||
&& highlightedPath.serviceId === edge.toId,
|
&& highlightedPath.serviceId === edge.toId,
|
||||||
)
|
)
|
||||||
const pathDim = Boolean(highlightedPath) && !pathHit
|
const pathDim = Boolean(highlightedPath) && !pathHit
|
||||||
const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId))
|
const hl = pathHit || (!highlightedPath && (selectedService?.id === edge.toId || selected?.id === edge.fromId || expandedCountryId === edge.toId))
|
||||||
|
const countryDim = !hl && Boolean(expandedCountryGroup) && edge.toId !== expandedCountryId
|
||||||
const svc = visibleMapServices.find((s) => s.id === edge.toId)
|
const svc = visibleMapServices.find((s) => s.id === edge.toId)
|
||||||
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
|
const enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
|
||||||
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
|
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
|
||||||
@@ -2463,7 +2588,7 @@ export default function NetworkMapPage() {
|
|||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
key={`${edge.fromId}|${edge.toId}`}
|
key={`${edge.fromId}|${edge.toId}`}
|
||||||
opacity={pathDim ? 0.12 : hl ? 1 : 0.72}
|
opacity={pathDim ? 0.12 : hl ? 1 : countryDim ? 0.35 : 0.72}
|
||||||
style={{ transition: "opacity 0.3s" }}
|
style={{ transition: "opacity 0.3s" }}
|
||||||
>
|
>
|
||||||
<title>{pathTitle}</title>
|
<title>{pathTitle}</title>
|
||||||
@@ -2509,6 +2634,81 @@ export default function NetworkMapPage() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* ── Раскрытая страна → её сервисы ── */}
|
||||||
|
{expandedCountryGroup?.edges.map((edge) => {
|
||||||
|
const from = servicePosById[edge.fromId]
|
||||||
|
const to = nestedPosById[edge.toId]
|
||||||
|
if (!from || !to) return null
|
||||||
|
const hop: MatchedNetflowHop = {
|
||||||
|
bytes: edge.bytes,
|
||||||
|
bps: edge.bps,
|
||||||
|
bpsFwd: edge.bpsFwd,
|
||||||
|
bpsRev: edge.bpsRev,
|
||||||
|
}
|
||||||
|
const clipped = clipSegmentCircleToRect(
|
||||||
|
from.x,
|
||||||
|
from.y,
|
||||||
|
MAP_SERVICE_NODE_W / 2,
|
||||||
|
to.x,
|
||||||
|
to.y,
|
||||||
|
MAP_SERVICE_NODE_W / 2,
|
||||||
|
MAP_SERVICE_NODE_H / 2,
|
||||||
|
)
|
||||||
|
const { mx, my } = edgeBadgePosition(clipped.x1, clipped.y1, clipped.x2, clipped.y2, 0.55, 16)
|
||||||
|
const hl = selectedService?.id === edge.toId
|
||||||
|
const svc = nestedServices.find((s) => s.id === edge.toId)
|
||||||
|
const country = mapCountries.find((s) => s.id === edge.fromId)
|
||||||
|
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
|
||||||
|
const pathTitle = `${clientLabel} → ${destDisplayLabel(country, "countries", edge.fromId)} → ${edge.toId.split("|")[1] ?? edge.toId}`
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={`${edge.fromId}|${edge.toId}`}
|
||||||
|
opacity={hl ? 1 : 0.72}
|
||||||
|
style={{ transition: "opacity 0.3s" }}
|
||||||
|
>
|
||||||
|
<title>{pathTitle}</title>
|
||||||
|
<line
|
||||||
|
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
|
||||||
|
stroke="#22d3ee"
|
||||||
|
strokeWidth={hopHasRate(hop) ? 2 : 1.3}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
opacity="0.9"
|
||||||
|
pointerEvents="none"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
|
||||||
|
stroke="#00000000"
|
||||||
|
strokeWidth={14}
|
||||||
|
strokeLinecap="round"
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onPointerDown={(ev) => { ev.stopPropagation() }}
|
||||||
|
onClick={(ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
if (svc) selectService(svc)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showAnimDots && hopHasRate(hop) && (
|
||||||
|
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
|
||||||
|
<animateMotion dur="2.6s" repeatCount="indefinite"
|
||||||
|
path={`M ${clipped.x1} ${clipped.y1} L ${clipped.x2} ${clipped.y2}`} />
|
||||||
|
</circle>
|
||||||
|
)}
|
||||||
|
{hopHasRate(hop) && (
|
||||||
|
<NetflowRateBadge
|
||||||
|
mx={mx}
|
||||||
|
my={my}
|
||||||
|
hop={hop}
|
||||||
|
onOpen={(ev) => {
|
||||||
|
ev.stopPropagation()
|
||||||
|
const hit = nestedServices.find((s) => s.id === edge.toId)
|
||||||
|
if (hit) selectService(hit)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* ── Hover tooltip ── */}
|
{/* ── Hover tooltip ── */}
|
||||||
{hoveredNode && !isDragging && (
|
{hoveredNode && !isDragging && (
|
||||||
<SvgTooltip n={hoveredNode} />
|
<SvgTooltip n={hoveredNode} />
|
||||||
@@ -2613,7 +2813,7 @@ export default function NetworkMapPage() {
|
|||||||
satPos={effectiveSatPos}
|
satPos={effectiveSatPos}
|
||||||
wanJhEdges={visibleWanJhEdges}
|
wanJhEdges={visibleWanJhEdges}
|
||||||
homeRouters={homeRouters}
|
homeRouters={homeRouters}
|
||||||
servicePos={servicePosById}
|
servicePos={{ ...servicePosById, ...nestedPosById }}
|
||||||
onClose={() => setShowMinimap(false)}
|
onClose={() => setShowMinimap(false)}
|
||||||
onPan={(x, y) => setPan({ x, y })}
|
onPan={(x, y) => setPan({ x, y })}
|
||||||
/>
|
/>
|
||||||
@@ -2848,6 +3048,71 @@ export default function NetworkMapPage() {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : selectedNestedService ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start gap-2 px-4 py-3 border-b">
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<ServiceBrandIcon label={selectedNestedService.label} size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-mono font-semibold text-sm truncate">
|
||||||
|
{selectedNestedService.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{`Сервис в ${destDisplayLabel(expandedCountry ?? undefined, "countries")} · ${selectedNestedService.category}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedService(null)}
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-0">
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||||
|
<span className="text-xs text-muted-foreground">Доля страны</span>
|
||||||
|
<span className="text-xs font-mono font-medium text-cyan-400">{serviceSharePct(selectedNestedService.share)}</span>
|
||||||
|
</div>
|
||||||
|
{mapTotalBytes > 0 && (
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||||
|
<span className="text-xs text-muted-foreground">Доля окна</span>
|
||||||
|
<span className="text-xs font-mono font-medium text-cyan-400">
|
||||||
|
{serviceSharePct(selectedNestedService.bytes / mapTotalBytes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||||
|
<span className="text-xs text-muted-foreground">Скорость</span>
|
||||||
|
<span className="text-xs font-mono font-medium">
|
||||||
|
{formatNetflowRate({
|
||||||
|
bytes: selectedNestedService.bytes,
|
||||||
|
bps: selectedNestedService.bps,
|
||||||
|
bpsFwd: selectedNestedService.bps,
|
||||||
|
bpsRev: 0,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
|
||||||
|
<ServicePathList
|
||||||
|
paths={(expandedCountryGroup?.paths ?? [])
|
||||||
|
.filter((p) => p.serviceId === selectedNestedService.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.bps - a.bps)}
|
||||||
|
servers={mapServers}
|
||||||
|
services={nestedServices}
|
||||||
|
highlight={highlightedPath}
|
||||||
|
viaMode="via"
|
||||||
|
destMode="services"
|
||||||
|
onToggle={togglePathHighlight}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : liveSelectedService ? (
|
) : liveSelectedService ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-start gap-2 px-4 py-3 border-b">
|
<div className="flex items-start gap-2 px-4 py-3 border-b">
|
||||||
@@ -2924,6 +3189,30 @@ export default function NetworkMapPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{expandedCountryGroup && liveSelectedService.id === expandedCountryGroup.countryId && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Сервисы в стране</p>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{expandedCountryGroup.services.map((svc) => (
|
||||||
|
<button
|
||||||
|
key={svc.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => selectService(svc)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors",
|
||||||
|
selectedService?.id === svc.id ? "bg-cyan-500/15 ring-1 ring-cyan-500/40" : "hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<ServiceBrandIcon label={svc.label} size={14} />
|
||||||
|
<span className="font-mono truncate">{svc.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-cyan-400 tabular-nums shrink-0">{serviceSharePct(svc.share)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
isNamedInternetService,
|
isNamedInternetService,
|
||||||
mapServiceNodeId,
|
mapServiceNodeId,
|
||||||
mapCountryNodeId,
|
mapCountryNodeId,
|
||||||
|
mapCountryServiceNodeId,
|
||||||
resolveFlowBrand,
|
resolveFlowBrand,
|
||||||
resolveRipeCountry,
|
resolveRipeCountry,
|
||||||
} from "./traffic-flow-brands.js"
|
} from "./traffic-flow-brands.js"
|
||||||
@@ -50,6 +51,8 @@ assert.equal(mapCountryNodeId("nl"), "cc:nl")
|
|||||||
assert.equal(mapCountryNodeId(""), "cc:other")
|
assert.equal(mapCountryNodeId(""), "cc:other")
|
||||||
assert.equal(mapCountryNodeId("Прочее"), "cc:other")
|
assert.equal(mapCountryNodeId("Прочее"), "cc:other")
|
||||||
assert.equal(mapCountryNodeId("EU"), "cc:other")
|
assert.equal(mapCountryNodeId("EU"), "cc:other")
|
||||||
|
assert.equal(mapCountryServiceNodeId("cc:us", "svc:google"), "cc:us|svc:google")
|
||||||
|
assert.equal(mapCountryServiceNodeId("cc:other", "svc:other"), "cc:other|svc:other")
|
||||||
|
|
||||||
assert.equal(brandByAsn(714)?.service, "Apple")
|
assert.equal(brandByAsn(714)?.service, "Apple")
|
||||||
assert.equal(brandByAsn(714)?.category, "CDN")
|
assert.equal(brandByAsn(714)?.category, "CDN")
|
||||||
|
|||||||
@@ -371,3 +371,8 @@ export function mapCountryNodeId(code: string): string {
|
|||||||
if (!iso) return "cc:other"
|
if (!iso) return "cc:other"
|
||||||
return `cc:${iso.toLowerCase()}`
|
return `cc:${iso.toLowerCase()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** id сервиса внутри страны: `cc:us|svc:google` — Google в US и NL не смешиваются в одном payload. */
|
||||||
|
export function mapCountryServiceNodeId(countryId: string, serviceId: string): string {
|
||||||
|
return `${countryId}|${serviceId}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
} from "./traffic-flow-ingest.js"
|
} from "./traffic-flow-ingest.js"
|
||||||
import {
|
import {
|
||||||
buildFlowMapHops,
|
buildFlowMapHops,
|
||||||
|
MAP_COUNTRY_SERVICE_MIN_NODES,
|
||||||
|
MAP_COUNTRY_SERVICE_NODE_CAP,
|
||||||
MAP_SERVICE_MIN_NODES,
|
MAP_SERVICE_MIN_NODES,
|
||||||
MAP_SERVICE_NODE_CAP,
|
MAP_SERVICE_NODE_CAP,
|
||||||
pickMapServices,
|
pickMapServices,
|
||||||
@@ -866,4 +868,133 @@ try {
|
|||||||
resetFlowCatalogForTests()
|
resetFlowCatalogForTests()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
googleRipe()
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
payloadFlow("8.8.8.8", 600),
|
||||||
|
payloadFlow("203.0.113.50", 9400),
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const nested = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||||
|
const usGroup = nested.countryServiceGroups?.find((g) => g.countryId === "cc:us")
|
||||||
|
assert.ok(usGroup, "группа сервисов cc:us")
|
||||||
|
const nestedGoogle = usGroup.services.find((s) => s.id === "cc:us|svc:google")
|
||||||
|
assert.ok(nestedGoogle, "cc:us|svc:google в группе")
|
||||||
|
assert.equal(nestedGoogle.label, "Google")
|
||||||
|
assert.ok(Math.abs(nestedGoogle.share - 1) < 0.01, "доля от байтов страны (600/600), не окна")
|
||||||
|
const nestedEdge = usGroup.edges.find((e) => e.toId === "cc:us|svc:google")
|
||||||
|
assert.ok(nestedEdge)
|
||||||
|
assert.equal(nestedEdge.fromId, "cc:us", "ребро вложенного слоя: страна → сервис")
|
||||||
|
assert.equal(nestedEdge.bytes, 600)
|
||||||
|
assert.ok(!usGroup.edges.some((e) => e.fromId.startsWith("svc:")), "fromId вложенных рёбер не svc:*")
|
||||||
|
const nestedPath = usGroup.paths.find((p) => p.serviceId === "cc:us|svc:google")
|
||||||
|
assert.ok(nestedPath)
|
||||||
|
assert.equal(nestedPath.enId, "9", "путь держит реальный EN для подсветки HR→JH→EN")
|
||||||
|
assert.equal(nestedPath.clientName, "Alice")
|
||||||
|
const otherGroup = nested.countryServiceGroups?.find((g) => g.countryId === "cc:other")
|
||||||
|
assert.ok(otherGroup, "группа cc:other (Прочее-страна)")
|
||||||
|
assert.ok(otherGroup.services.some((s) => s.id === "cc:other|svc:other"))
|
||||||
|
} finally {
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
googleRipe()
|
||||||
|
seedRipeCacheForTests({
|
||||||
|
prefix: "185.45.12.0/24",
|
||||||
|
asn: 13335,
|
||||||
|
country: "NL",
|
||||||
|
lat: 52.3,
|
||||||
|
lng: 4.9,
|
||||||
|
holder: "CLOUDFLARENET, NL",
|
||||||
|
ok: true,
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
})
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
payloadFlow("8.8.8.8", 5000),
|
||||||
|
payloadFlow("185.45.12.10", 5000),
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const nestedSplit = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||||
|
const usNested = nestedSplit.countryServiceGroups?.find((g) => g.countryId === "cc:us")
|
||||||
|
const nlNested = nestedSplit.countryServiceGroups?.find((g) => g.countryId === "cc:nl")
|
||||||
|
assert.ok(usNested && nlNested, "группы у обеих стран")
|
||||||
|
assert.ok(usNested.services.every((s) => s.id.startsWith("cc:us|")), "сервисы US только cc:us|*")
|
||||||
|
assert.ok(nlNested.services.every((s) => s.id.startsWith("cc:nl|")), "сервисы NL только cc:nl|*")
|
||||||
|
assert.ok(usNested.services.some((s) => s.id === "cc:us|svc:google"), "Google в US")
|
||||||
|
assert.ok(nlNested.services.some((s) => s.id === "cc:nl|svc:cloudflare"), "Cloudflare в NL")
|
||||||
|
assert.ok(!nlNested.services.some((s) => s.id === "cc:us|svc:google"), "Google US не утек в NL")
|
||||||
|
assert.ok(usNested.edges.every((e) => e.fromId === "cc:us"))
|
||||||
|
assert.ok(nlNested.edges.every((e) => e.fromId === "cc:nl"))
|
||||||
|
} finally {
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
disableRipeEnqueueForTests()
|
||||||
|
seedFlowTopologyForTests(topo)
|
||||||
|
rememberServerIfaces(7, [
|
||||||
|
{ ".id": "*2", name: "gre-client" },
|
||||||
|
{ ".id": "*3", name: "gre-jh-en" },
|
||||||
|
])
|
||||||
|
googleRipe()
|
||||||
|
for (const b of smallBrands) seedRipeAsn(b.ip, b.asn, b.holder)
|
||||||
|
ingestParsedFlowsForServerForTests(7, [
|
||||||
|
payloadFlow("8.8.8.8", 5000),
|
||||||
|
...smallBrands.map((b) => payloadFlow(b.ip, b.bytes)),
|
||||||
|
])
|
||||||
|
try {
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const nestedTop = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||||
|
const usTop = nestedTop.countryServiceGroups?.find((g) => g.countryId === "cc:us")
|
||||||
|
assert.ok(usTop)
|
||||||
|
assert.equal(
|
||||||
|
usTop.services.length,
|
||||||
|
MAP_COUNTRY_SERVICE_MIN_NODES,
|
||||||
|
"мелкий хвост держится минимумом узлов внутри страны",
|
||||||
|
)
|
||||||
|
assert.ok(usTop.services.some((s) => s.id === "cc:us|svc:google"))
|
||||||
|
resetFlowMapHopsCacheForTests()
|
||||||
|
const nestedAll = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||||||
|
const usAll = nestedAll.countryServiceGroups?.find((g) => g.countryId === "cc:us")
|
||||||
|
assert.ok(usAll)
|
||||||
|
assert.equal(usAll.services.length, MAP_COUNTRY_SERVICE_NODE_CAP, "cap вложенного слоя = 8")
|
||||||
|
assert.ok(!usAll.services.some((s) => s.id === "cc:us|svc:epic"), "ранг 9+ скрыт")
|
||||||
|
assert.ok(!usAll.services.some((s) => s.id === "cc:us|svc:riot"))
|
||||||
|
} finally {
|
||||||
|
seedFlowTopologyForTests(null)
|
||||||
|
resetFlowRingsForTests()
|
||||||
|
resetIfaceCacheForTests()
|
||||||
|
resetRipeCacheForTests()
|
||||||
|
resetFlowCatalogForTests()
|
||||||
|
}
|
||||||
|
|
||||||
console.log("traffic-flow-map-hops.test.ts: ok")
|
console.log("traffic-flow-map-hops.test.ts: ok")
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
import type { FlowMapCountryServiceGroup, FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge, FlowMapServicePath } from "@mmapp/contracts/traffic-flow"
|
||||||
import { db } from "../db/index.js"
|
import { db } from "../db/index.js"
|
||||||
import { userInterfaceBindings } from "../db/schema.js"
|
import { userInterfaceBindings } from "../db/schema.js"
|
||||||
import { flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
import { flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||||
import { OTHER_SERVICE, isNamedInternetService, mapCountryNodeId, mapServiceNodeId, resolveRipeCountry } from "./traffic-flow-brands.js"
|
import { OTHER_SERVICE, isNamedInternetService, mapCountryNodeId, mapCountryServiceNodeId, mapServiceNodeId, resolveRipeCountry } from "./traffic-flow-brands.js"
|
||||||
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
|
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
|
||||||
import { refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
|
import { refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
|
||||||
import { dedupFlowRowsAcrossExporters, dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
import { dedupFlowRowsAcrossExporters, dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
||||||
@@ -21,6 +21,10 @@ export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
|||||||
export const MAP_SERVICE_NODE_CAP = 20
|
export const MAP_SERVICE_NODE_CAP = 20
|
||||||
/** Минимум узлов-брендов на карте, даже если доля ниже порога. */
|
/** Минимум узлов-брендов на карте, даже если доля ниже порога. */
|
||||||
export const MAP_SERVICE_MIN_NODES = 8
|
export const MAP_SERVICE_MIN_NODES = 8
|
||||||
|
/** Cap сервисов внутри раскрытой страны (база доли — байты страны, не окна). */
|
||||||
|
export const MAP_COUNTRY_SERVICE_NODE_CAP = 8
|
||||||
|
/** Минимум узлов-сервисов внутри страны, даже если доля ниже порога. */
|
||||||
|
export const MAP_COUNTRY_SERVICE_MIN_NODES = 4
|
||||||
export const MAP_COUNTRY_CATEGORY = "Страна"
|
export const MAP_COUNTRY_CATEGORY = "Страна"
|
||||||
const HOPS_CACHE_TTL_MS = 2000
|
const HOPS_CACHE_TTL_MS = 2000
|
||||||
|
|
||||||
@@ -128,12 +132,17 @@ export function clampMapServiceMinSharePct(n: unknown): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Доля от payload окна; порог ИЛИ топ-N, затем cap. */
|
/** Доля от payload окна; порог ИЛИ топ-N, затем cap. */
|
||||||
export function pickMapServices(ranked: FlowMapService[], minSharePct: number): FlowMapService[] {
|
export function pickMapServices(
|
||||||
if (minSharePct <= 0) return ranked.slice(0, MAP_SERVICE_NODE_CAP)
|
ranked: FlowMapService[],
|
||||||
|
minSharePct: number,
|
||||||
|
cap: number = MAP_SERVICE_NODE_CAP,
|
||||||
|
minNodes: number = MAP_SERVICE_MIN_NODES,
|
||||||
|
): FlowMapService[] {
|
||||||
|
if (minSharePct <= 0) return ranked.slice(0, cap)
|
||||||
const minShare = minSharePct / 100
|
const minShare = minSharePct / 100
|
||||||
return ranked
|
return ranked
|
||||||
.filter((s, i) => s.share >= minShare || i < MAP_SERVICE_MIN_NODES)
|
.filter((s, i) => s.share >= minShare || i < minNodes)
|
||||||
.slice(0, MAP_SERVICE_NODE_CAP)
|
.slice(0, cap)
|
||||||
}
|
}
|
||||||
|
|
||||||
function bumpDestTotal(totals: Map<string, DestTotal>, id: string, label: string, category: string, bytes: number): void {
|
function bumpDestTotal(totals: Map<string, DestTotal>, id: string, label: string, category: string, bytes: number): void {
|
||||||
@@ -154,8 +163,10 @@ function bumpDestFrom(
|
|||||||
fromId: string,
|
fromId: string,
|
||||||
enName: string,
|
enName: string,
|
||||||
viaName: string,
|
viaName: string,
|
||||||
|
/** fromId ребра, если отличается от EN (вложенный слой: страна → сервис). */
|
||||||
|
edgeFromId: string = fromId,
|
||||||
): void {
|
): void {
|
||||||
const edgeKey = `${fromId}|${toId}`
|
const edgeKey = `${edgeFromId}|${toId}`
|
||||||
const prevEdge = edges.get(edgeKey)
|
const prevEdge = edges.get(edgeKey)
|
||||||
const namedClients = new Map<string, string>()
|
const namedClients = new Map<string, string>()
|
||||||
for (const [id, c] of from.clients) {
|
for (const [id, c] of from.clients) {
|
||||||
@@ -167,7 +178,7 @@ function bumpDestFrom(
|
|||||||
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
|
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
|
||||||
} else {
|
} else {
|
||||||
edges.set(edgeKey, {
|
edges.set(edgeKey, {
|
||||||
fromId,
|
fromId: edgeFromId,
|
||||||
toId,
|
toId,
|
||||||
bytes: from.bytes,
|
bytes: from.bytes,
|
||||||
bytesFwd: from.bytes,
|
bytesFwd: from.bytes,
|
||||||
@@ -207,6 +218,8 @@ function finalizeDestLayer(
|
|||||||
windowSec: number,
|
windowSec: number,
|
||||||
minSharePct: number,
|
minSharePct: number,
|
||||||
shareBase: number,
|
shareBase: number,
|
||||||
|
cap: number = MAP_SERVICE_NODE_CAP,
|
||||||
|
minNodes: number = MAP_SERVICE_MIN_NODES,
|
||||||
): { nodes: FlowMapService[]; edges: FlowMapServiceEdge[]; paths: FlowMapServicePath[] } {
|
): { nodes: FlowMapService[]; edges: FlowMapServiceEdge[]; paths: FlowMapServicePath[] } {
|
||||||
const nodes = pickMapServices(
|
const nodes = pickMapServices(
|
||||||
[...totals.entries()]
|
[...totals.entries()]
|
||||||
@@ -220,6 +233,8 @@ function finalizeDestLayer(
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => b.bytes - a.bytes),
|
.sort((a, b) => b.bytes - a.bytes),
|
||||||
minSharePct,
|
minSharePct,
|
||||||
|
cap,
|
||||||
|
minNodes,
|
||||||
)
|
)
|
||||||
const keep = new Set(nodes.map((s) => s.id))
|
const keep = new Set(nodes.map((s) => s.id))
|
||||||
const outEdges: FlowMapServiceEdge[] = [...edges.values()]
|
const outEdges: FlowMapServiceEdge[] = [...edges.values()]
|
||||||
@@ -256,6 +271,46 @@ function finalizeDestLayer(
|
|||||||
return { nodes, edges: outEdges, paths: outPaths }
|
return { nodes, edges: outEdges, paths: outPaths }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Группы сервисов по странам: только страны, прошедшие отбор слоя стран; доля — от байтов страны, не окна. */
|
||||||
|
function buildCountryServiceGroups(
|
||||||
|
nestedTotals: Map<string, DestTotal>,
|
||||||
|
nestedEdges: Map<string, DestEdgeAcc>,
|
||||||
|
nestedPaths: Map<string, DestPathAcc>,
|
||||||
|
countryNodes: FlowMapService[],
|
||||||
|
windowSec: number,
|
||||||
|
minSharePct: number,
|
||||||
|
): FlowMapCountryServiceGroup[] {
|
||||||
|
const groups: FlowMapCountryServiceGroup[] = []
|
||||||
|
for (const country of countryNodes) {
|
||||||
|
const prefix = `${country.id}|`
|
||||||
|
const totals = new Map<string, DestTotal>()
|
||||||
|
const edges = new Map<string, DestEdgeAcc>()
|
||||||
|
const paths = new Map<string, DestPathAcc>()
|
||||||
|
for (const [id, t] of nestedTotals) {
|
||||||
|
if (id.startsWith(prefix)) totals.set(id, t)
|
||||||
|
}
|
||||||
|
if (totals.size === 0) continue
|
||||||
|
for (const [key, e] of nestedEdges) {
|
||||||
|
if (e.toId.startsWith(prefix)) edges.set(key, e)
|
||||||
|
}
|
||||||
|
for (const [key, p] of nestedPaths) {
|
||||||
|
if (p.serviceId.startsWith(prefix)) paths.set(key, p)
|
||||||
|
}
|
||||||
|
const out = finalizeDestLayer(
|
||||||
|
totals,
|
||||||
|
edges,
|
||||||
|
paths,
|
||||||
|
windowSec,
|
||||||
|
minSharePct,
|
||||||
|
country.bytes,
|
||||||
|
MAP_COUNTRY_SERVICE_NODE_CAP,
|
||||||
|
MAP_COUNTRY_SERVICE_MIN_NODES,
|
||||||
|
)
|
||||||
|
groups.push({ countryId: country.id, services: out.nodes, edges: out.edges, paths: out.paths })
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
function countryDestFromRipe(ripe: FlowIpMeta | null, destKey: string): { id: string; label: string; category: string } {
|
function countryDestFromRipe(ripe: FlowIpMeta | null, destKey: string): { id: string; label: string; category: string } {
|
||||||
if (!destKey || destKey === "__other__" || !ripe?.ok) {
|
if (!destKey || destKey === "__other__" || !ripe?.ok) {
|
||||||
return { id: mapCountryNodeId(""), label: OTHER_SERVICE, category: MAP_COUNTRY_CATEGORY }
|
return { id: mapCountryNodeId(""), label: OTHER_SERVICE, category: MAP_COUNTRY_CATEGORY }
|
||||||
@@ -533,6 +588,9 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
|||||||
const ccTotals = new Map<string, DestTotal>()
|
const ccTotals = new Map<string, DestTotal>()
|
||||||
const ccEdges = new Map<string, DestEdgeAcc>()
|
const ccEdges = new Map<string, DestEdgeAcc>()
|
||||||
const ccPaths = new Map<string, DestPathAcc>()
|
const ccPaths = new Map<string, DestPathAcc>()
|
||||||
|
const nestedTotals = new Map<string, DestTotal>()
|
||||||
|
const nestedEdges = new Map<string, DestEdgeAcc>()
|
||||||
|
const nestedPaths = new Map<string, DestPathAcc>()
|
||||||
|
|
||||||
for (const h of hops.values()) {
|
for (const h of hops.values()) {
|
||||||
if (h.kind !== "gre" || !h.toId) continue
|
if (h.kind !== "gre" || !h.toId) continue
|
||||||
@@ -573,6 +631,8 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
|||||||
bumpDestTotal(svcTotals, svcId, classified.service, classified.category, acc.bytes)
|
bumpDestTotal(svcTotals, svcId, classified.service, classified.category, acc.bytes)
|
||||||
const country = countryDestFromRipe(ripe, dst)
|
const country = countryDestFromRipe(ripe, dst)
|
||||||
bumpDestTotal(ccTotals, country.id, country.label, country.category, acc.bytes)
|
bumpDestTotal(ccTotals, country.id, country.label, country.category, acc.bytes)
|
||||||
|
const nestedId = mapCountryServiceNodeId(country.id, svcId)
|
||||||
|
bumpDestTotal(nestedTotals, nestedId, classified.service, classified.category, acc.bytes)
|
||||||
for (const [exporterId, from] of acc.fromBytes) {
|
for (const [exporterId, from] of acc.fromBytes) {
|
||||||
const fromId = anchorEnId(exporterId)
|
const fromId = anchorEnId(exporterId)
|
||||||
if (!fromId) continue
|
if (!fromId) continue
|
||||||
@@ -580,6 +640,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
|||||||
const viaName = nodeName(exporterId)
|
const viaName = nodeName(exporterId)
|
||||||
bumpDestFrom(svcEdges, svcPaths, svcId, from, exporterId, fromId, enName, viaName)
|
bumpDestFrom(svcEdges, svcPaths, svcId, from, exporterId, fromId, enName, viaName)
|
||||||
bumpDestFrom(ccEdges, ccPaths, country.id, from, exporterId, fromId, enName, viaName)
|
bumpDestFrom(ccEdges, ccPaths, country.id, from, exporterId, fromId, enName, viaName)
|
||||||
|
bumpDestFrom(nestedEdges, nestedPaths, nestedId, from, exporterId, fromId, enName, viaName, country.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,6 +651,14 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
|||||||
const shareBase = totalBytes > 0 ? totalBytes : namedBytes
|
const shareBase = totalBytes > 0 ? totalBytes : namedBytes
|
||||||
const servicesOut = finalizeDestLayer(svcTotals, svcEdges, svcPaths, windowSec, minSharePct, shareBase)
|
const servicesOut = finalizeDestLayer(svcTotals, svcEdges, svcPaths, windowSec, minSharePct, shareBase)
|
||||||
const countriesOut = finalizeDestLayer(ccTotals, ccEdges, ccPaths, windowSec, minSharePct, shareBase)
|
const countriesOut = finalizeDestLayer(ccTotals, ccEdges, ccPaths, windowSec, minSharePct, shareBase)
|
||||||
|
const countryServiceGroups = buildCountryServiceGroups(
|
||||||
|
nestedTotals,
|
||||||
|
nestedEdges,
|
||||||
|
nestedPaths,
|
||||||
|
countriesOut.nodes,
|
||||||
|
windowSec,
|
||||||
|
minSharePct,
|
||||||
|
)
|
||||||
|
|
||||||
const listener = getFlowListenerState()
|
const listener = getFlowListenerState()
|
||||||
const geo = geoipReadersStatus()
|
const geo = geoipReadersStatus()
|
||||||
@@ -611,6 +680,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
|||||||
countries: countriesOut.nodes,
|
countries: countriesOut.nodes,
|
||||||
countryEdges: countriesOut.edges,
|
countryEdges: countriesOut.edges,
|
||||||
countryPaths: countriesOut.paths,
|
countryPaths: countriesOut.paths,
|
||||||
|
countryServiceGroups,
|
||||||
mapServiceMinSharePct: minSharePct,
|
mapServiceMinSharePct: minSharePct,
|
||||||
dedupApplied: wantDedup,
|
dedupApplied: wantDedup,
|
||||||
excludeMeshApplied: excludeMesh,
|
excludeMeshApplied: excludeMesh,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const W = 1240
|
|||||||
const H = 580
|
const H = 580
|
||||||
const MARGIN = 72
|
const MARGIN = 72
|
||||||
const SERVICE_COL_W = 150
|
const SERVICE_COL_W = 150
|
||||||
|
export { SERVICE_COL_W }
|
||||||
|
|
||||||
/** Карточка конечного сервиса на карте (центр = позиция узла). */
|
/** Карточка конечного сервиса на карте (центр = позиция узла). */
|
||||||
export const MAP_SERVICE_NODE_W = 86
|
export const MAP_SERVICE_NODE_W = 86
|
||||||
@@ -454,6 +455,26 @@ export function placeServiceNodes(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Столбец сервисов раскрытой страны: тот же правый x, по вертикали вокруг Y страны. */
|
||||||
|
export function placeCountryServiceNodes(
|
||||||
|
serviceIds: string[],
|
||||||
|
parentPos: { x: number; y: number } | undefined,
|
||||||
|
): Record<string, { x: number; y: number }> {
|
||||||
|
const out: Record<string, { x: number; y: number }> = {}
|
||||||
|
if (serviceIds.length === 0 || !parentPos) return out
|
||||||
|
const minY = MARGIN + 70
|
||||||
|
const maxY = H - 72
|
||||||
|
const x = W - MARGIN - SERVICE_COL_W / 2
|
||||||
|
const n = serviceIds.length
|
||||||
|
const gap = Math.min(80, (maxY - minY) / Math.max(1, n))
|
||||||
|
const span = gap * (n - 1)
|
||||||
|
const start = clamp(parentPos.y - span / 2, minY, maxY - span)
|
||||||
|
serviceIds.forEach((id, i) => {
|
||||||
|
out[id] = { x, y: n === 1 ? clamp(parentPos.y, minY, maxY) : start + i * gap }
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
|
* Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
|
||||||
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
||||||
|
|||||||
@@ -316,6 +316,14 @@ export const flowMapServicePathDtoSchema = z.object({
|
|||||||
bps: z.number().nonnegative(),
|
bps: z.number().nonnegative(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** Сервисы внутри одной страны: id вида `cc:us|svc:google`, рёбра от `countryId`. */
|
||||||
|
export const flowMapCountryServiceGroupDtoSchema = z.object({
|
||||||
|
countryId: z.string(),
|
||||||
|
services: z.array(flowMapServiceDtoSchema),
|
||||||
|
edges: z.array(flowMapServiceEdgeDtoSchema),
|
||||||
|
paths: z.array(flowMapServicePathDtoSchema),
|
||||||
|
})
|
||||||
|
|
||||||
export const flowMapHopsDtoSchema = z.object({
|
export const flowMapHopsDtoSchema = z.object({
|
||||||
hops: z.array(flowMapHopDtoSchema),
|
hops: z.array(flowMapHopDtoSchema),
|
||||||
live: z.boolean(),
|
live: z.boolean(),
|
||||||
@@ -332,6 +340,7 @@ export const flowMapHopsDtoSchema = z.object({
|
|||||||
countries: z.array(flowMapServiceDtoSchema).optional(),
|
countries: z.array(flowMapServiceDtoSchema).optional(),
|
||||||
countryEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
|
countryEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
|
||||||
countryPaths: z.array(flowMapServicePathDtoSchema).optional(),
|
countryPaths: z.array(flowMapServicePathDtoSchema).optional(),
|
||||||
|
countryServiceGroups: z.array(flowMapCountryServiceGroupDtoSchema).optional(),
|
||||||
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
|
mapServiceMinSharePct: z.number().min(0).max(100).optional(),
|
||||||
dedupApplied: z.boolean(),
|
dedupApplied: z.boolean(),
|
||||||
excludeMeshApplied: z.boolean(),
|
excludeMeshApplied: z.boolean(),
|
||||||
@@ -356,4 +365,5 @@ export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema>
|
|||||||
export type FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
|
export type FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
|
||||||
export type FlowMapServiceEdge = z.infer<typeof flowMapServiceEdgeDtoSchema>
|
export type FlowMapServiceEdge = z.infer<typeof flowMapServiceEdgeDtoSchema>
|
||||||
export type FlowMapServicePath = z.infer<typeof flowMapServicePathDtoSchema>
|
export type FlowMapServicePath = z.infer<typeof flowMapServicePathDtoSchema>
|
||||||
|
export type FlowMapCountryServiceGroup = z.infer<typeof flowMapCountryServiceGroupDtoSchema>
|
||||||
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
|
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user