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:
@@ -9,6 +9,7 @@ import {
|
||||
isNamedInternetService,
|
||||
mapServiceNodeId,
|
||||
mapCountryNodeId,
|
||||
mapCountryServiceNodeId,
|
||||
resolveFlowBrand,
|
||||
resolveRipeCountry,
|
||||
} 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("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)?.category, "CDN")
|
||||
|
||||
@@ -371,3 +371,8 @@ export function mapCountryNodeId(code: string): string {
|
||||
if (!iso) return "cc:other"
|
||||
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"
|
||||
import {
|
||||
buildFlowMapHops,
|
||||
MAP_COUNTRY_SERVICE_MIN_NODES,
|
||||
MAP_COUNTRY_SERVICE_NODE_CAP,
|
||||
MAP_SERVICE_MIN_NODES,
|
||||
MAP_SERVICE_NODE_CAP,
|
||||
pickMapServices,
|
||||
@@ -866,4 +868,133 @@ try {
|
||||
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")
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 { userInterfaceBindings } from "../db/schema.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 { refreshFlowCatalogInBackground } from "./traffic-flow-classify.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_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 = "Страна"
|
||||
const HOPS_CACHE_TTL_MS = 2000
|
||||
|
||||
@@ -128,12 +132,17 @@ export function clampMapServiceMinSharePct(n: unknown): number {
|
||||
}
|
||||
|
||||
/** Доля от payload окна; порог ИЛИ топ-N, затем cap. */
|
||||
export function pickMapServices(ranked: FlowMapService[], minSharePct: number): FlowMapService[] {
|
||||
if (minSharePct <= 0) return ranked.slice(0, MAP_SERVICE_NODE_CAP)
|
||||
export function pickMapServices(
|
||||
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
|
||||
return ranked
|
||||
.filter((s, i) => s.share >= minShare || i < MAP_SERVICE_MIN_NODES)
|
||||
.slice(0, MAP_SERVICE_NODE_CAP)
|
||||
.filter((s, i) => s.share >= minShare || i < minNodes)
|
||||
.slice(0, cap)
|
||||
}
|
||||
|
||||
function bumpDestTotal(totals: Map<string, DestTotal>, id: string, label: string, category: string, bytes: number): void {
|
||||
@@ -154,8 +163,10 @@ function bumpDestFrom(
|
||||
fromId: string,
|
||||
enName: string,
|
||||
viaName: string,
|
||||
/** fromId ребра, если отличается от EN (вложенный слой: страна → сервис). */
|
||||
edgeFromId: string = fromId,
|
||||
): void {
|
||||
const edgeKey = `${fromId}|${toId}`
|
||||
const edgeKey = `${edgeFromId}|${toId}`
|
||||
const prevEdge = edges.get(edgeKey)
|
||||
const namedClients = new Map<string, string>()
|
||||
for (const [id, c] of from.clients) {
|
||||
@@ -167,7 +178,7 @@ function bumpDestFrom(
|
||||
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
|
||||
} else {
|
||||
edges.set(edgeKey, {
|
||||
fromId,
|
||||
fromId: edgeFromId,
|
||||
toId,
|
||||
bytes: from.bytes,
|
||||
bytesFwd: from.bytes,
|
||||
@@ -207,6 +218,8 @@ function finalizeDestLayer(
|
||||
windowSec: number,
|
||||
minSharePct: number,
|
||||
shareBase: number,
|
||||
cap: number = MAP_SERVICE_NODE_CAP,
|
||||
minNodes: number = MAP_SERVICE_MIN_NODES,
|
||||
): { nodes: FlowMapService[]; edges: FlowMapServiceEdge[]; paths: FlowMapServicePath[] } {
|
||||
const nodes = pickMapServices(
|
||||
[...totals.entries()]
|
||||
@@ -220,6 +233,8 @@ function finalizeDestLayer(
|
||||
}))
|
||||
.sort((a, b) => b.bytes - a.bytes),
|
||||
minSharePct,
|
||||
cap,
|
||||
minNodes,
|
||||
)
|
||||
const keep = new Set(nodes.map((s) => s.id))
|
||||
const outEdges: FlowMapServiceEdge[] = [...edges.values()]
|
||||
@@ -256,6 +271,46 @@ function finalizeDestLayer(
|
||||
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 } {
|
||||
if (!destKey || destKey === "__other__" || !ripe?.ok) {
|
||||
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 ccEdges = new Map<string, DestEdgeAcc>()
|
||||
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()) {
|
||||
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)
|
||||
const country = countryDestFromRipe(ripe, dst)
|
||||
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) {
|
||||
const fromId = anchorEnId(exporterId)
|
||||
if (!fromId) continue
|
||||
@@ -580,6 +640,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
||||
const viaName = nodeName(exporterId)
|
||||
bumpDestFrom(svcEdges, svcPaths, svcId, 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 servicesOut = finalizeDestLayer(svcTotals, svcEdges, svcPaths, 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 geo = geoipReadersStatus()
|
||||
@@ -611,6 +680,7 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
||||
countries: countriesOut.nodes,
|
||||
countryEdges: countriesOut.edges,
|
||||
countryPaths: countriesOut.paths,
|
||||
countryServiceGroups,
|
||||
mapServiceMinSharePct: minSharePct,
|
||||
dedupApplied: wantDedup,
|
||||
excludeMeshApplied: excludeMesh,
|
||||
|
||||
Reference in New Issue
Block a user