From d39e3454aaae49a9b47e400136eb4adf945f64c5 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 9 Sep 2026 16:53:06 +0700 Subject: [PATCH] =?UTF-8?q?feat(network-map):=20=D0=BF=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20ASN-=D0=B1=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D1=8B=20=D0=BF=D0=BE=20=D0=B4=D0=BE=D0=BB=D0=B5=20?= =?UTF-8?q?=D1=81=D1=80=D0=B5=D0=B4=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8?= =?UTF-8?q?=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Долю считать среди именованных брендов, а не от всего окна. На карте всегда оставлять топ-8 и добавить иконки новых брендов. Co-authored-by: Cursor --- .../services/traffic-flow-map-hops.test.ts | 170 +++++++++++++++++- backend/src/services/traffic-flow-map-hops.ts | 40 +++-- components/network-map/service-brand-icon.tsx | 111 ++++++++++++ 3 files changed, 303 insertions(+), 18 deletions(-) diff --git a/backend/src/services/traffic-flow-map-hops.test.ts b/backend/src/services/traffic-flow-map-hops.test.ts index b966dbf..8543023 100644 --- a/backend/src/services/traffic-flow-map-hops.test.ts +++ b/backend/src/services/traffic-flow-map-hops.test.ts @@ -4,7 +4,13 @@ import { ingestParsedFlowsForServerForTests, resetFlowRingsForTests, } from "./traffic-flow-ingest.js" -import { buildFlowMapHops, resetFlowMapHopsCacheForTests } from "./traffic-flow-map-hops.js" +import { + buildFlowMapHops, + MAP_SERVICE_MIN_NODES, + MAP_SERVICE_NODE_CAP, + pickMapServices, + resetFlowMapHopsCacheForTests, +} from "./traffic-flow-map-hops.js" import { withPgOrSkip } from "../test/pg.js" import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js" import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js" @@ -15,6 +21,55 @@ import { seedRipeCacheForTests, } from "./traffic-flow-ripe.js" +{ + const googleOnly = pickMapServices( + [{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 1 }], + 5, + ) + assert.equal(googleOnly.length, 1) + assert.equal(googleOnly[0]?.share, 1) + + const twoNamed = pickMapServices( + [ + { id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 0.5 }, + { id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 400, bps: 0, share: 0.5 }, + ], + 5, + ) + assert.equal(twoNamed.length, 2) + + const tinyTail = pickMapServices( + [ + { id: "svc:google", label: "Google", category: "Веб", bytes: 9000, bps: 0, share: 0.9 }, + ...Array.from({ length: 9 }, (_, i) => ({ + id: `svc:t${i}`, + label: `T${i}`, + category: "Веб", + bytes: 100, + bps: 0, + share: 0.01, + })), + ], + 5, + ) + assert.equal(tinyTail.length, MAP_SERVICE_MIN_NODES) + assert.equal(tinyTail.at(-1)?.id, "svc:t6") + + const allOff = pickMapServices( + Array.from({ length: 25 }, (_, i) => ({ + id: `svc:n${i}`, + label: `N${i}`, + category: "Веб", + bytes: 25 - i, + bps: 0, + share: 0.04, + })), + 0, + ) + assert.equal(allOff.length, MAP_SERVICE_NODE_CAP) + console.log("traffic-flow-map-hops.test.ts: pickMapServices ok") +} + if (!(await withPgOrSkip())) { console.log("traffic-flow-map-hops.test.ts: skip") process.exit(0) @@ -178,6 +233,19 @@ function googleRipe() { }) } +function seedRipeAsn(ip: string, asn: number, holder: string) { + seedRipeCacheForTests({ + prefix: `${ip}/32`, + asn, + country: "US", + lat: 37.4, + lng: -122.1, + holder, + ok: true, + fetchedAt: Date.now(), + }) +} + function payloadFlow(dst: string, bytes: number) { return { src: "10.100.1.17", @@ -241,10 +309,106 @@ try { resetFlowMapHopsCacheForTests() const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 }) assert.equal(four.totalBytes, 10_000) - assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden") + const googleFour = four.services?.find((s) => s.id === "svc:google") + assert.ok(googleFour, "единственный бренд виден при 4% от окна") + assert.ok(googleFour.share >= 0.99, "доля среди брендов ≈ 1") resetFlowMapHopsCacheForTests() const off = await buildFlowMapHops({ minutes: 5, minSharePct: 0 }) - assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google 4%") + assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google") +} finally { + resetFlowRingsForTests() + resetIfaceCacheForTests() + resetRipeCacheForTests() +} + +resetFlowRingsForTests() +resetIfaceCacheForTests() +resetRipeCacheForTests() +disableRipeEnqueueForTests() +seedFlowTopologyForTests(topo) +rememberServerIfaces(7, [ + { ".id": "*2", name: "gre-client" }, + { ".id": "*3", name: "gre-jh-en" }, +]) +ingestParsedFlowsForServerForTests(7, [ + payloadFlow("8.8.8.8", 400), + payloadFlow("104.18.35.51", 400), + payloadFlow("203.0.113.50", 9200), +]) +try { + resetFlowMapHopsCacheForTests() + const two = await buildFlowMapHops({ minutes: 5, minSharePct: 5 }) + const googleTwo = two.services?.find((s) => s.id === "svc:google") + const cfTwo = two.services?.find((s) => s.id === "svc:cloudflare") + assert.ok(googleTwo, "Google среди брендов") + assert.ok(cfTwo, "Cloudflare среди брендов") + assert.ok(googleTwo.share >= 0.05) + assert.ok(cfTwo.share >= 0.05) +} finally { + resetFlowRingsForTests() + resetIfaceCacheForTests() + resetRipeCacheForTests() +} + +resetFlowRingsForTests() +resetIfaceCacheForTests() +resetRipeCacheForTests() +disableRipeEnqueueForTests() +seedFlowTopologyForTests(topo) +rememberServerIfaces(7, [ + { ".id": "*2", name: "gre-client" }, + { ".id": "*3", name: "gre-jh-en" }, +]) +seedRipeAsn("162.254.192.71", 32590, "VALVE-CORP") +ingestParsedFlowsForServerForTests(7, [ + payloadFlow("162.254.192.71", 2000), +]) +try { + resetFlowMapHopsCacheForTests() + const steam = await buildFlowMapHops({ minutes: 5, minSharePct: 5 }) + assert.ok(steam.services?.some((s) => s.id === "svc:steam"), "Steam AS32590 на карте") +} finally { + resetFlowRingsForTests() + resetIfaceCacheForTests() + resetRipeCacheForTests() +} + +const smallBrands: Array<{ ip: string; asn: number; holder: string; bytes: number; id: string }> = [ + { ip: "203.0.113.1", asn: 714, holder: "APPLE-ENGINEERING", bytes: 400, id: "svc:apple" }, + { ip: "203.0.113.2", asn: 36459, holder: "GITHUB", bytes: 390, id: "svc:github" }, + { ip: "203.0.113.3", asn: 54876, holder: "GITLAB", bytes: 380, id: "svc:gitlab" }, + { ip: "203.0.113.4", asn: 8403, holder: "SPOTIFY", bytes: 370, id: "svc:spotify" }, + { ip: "203.0.113.5", asn: 13414, holder: "TWITTER", bytes: 360, id: "svc:x" }, + { ip: "203.0.113.6", asn: 47541, holder: "VKONTAKTE", bytes: 350, id: "svc:vk" }, + { ip: "203.0.113.7", asn: 30103, holder: "ZOOM", bytes: 340, id: "svc:zoom" }, + { ip: "203.0.113.8", asn: 395701, holder: "EPIC-GAMES", bytes: 330, id: "svc:epic" }, + { ip: "203.0.113.9", asn: 6507, holder: "RIOT-GAMES", bytes: 320, id: "svc:riot" }, +] + +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 top = await buildFlowMapHops({ minutes: 5, minSharePct: 5 }) + const ids = new Set((top.services ?? []).map((s) => s.id)) + assert.equal(top.services?.length, MAP_SERVICE_MIN_NODES, "топ-8 брендов на карте") + assert.ok(ids.has("svc:google")) + for (const b of smallBrands.slice(0, 7)) assert.ok(ids.has(b.id), b.id) + assert.ok(!ids.has("svc:epic"), "хвост ниже ранга 8 скрыт") + assert.ok(!ids.has("svc:riot")) } finally { resetFlowRingsForTests() resetIfaceCacheForTests() diff --git a/backend/src/services/traffic-flow-map-hops.ts b/backend/src/services/traffic-flow-map-hops.ts index 8b762b8..84be2e6 100644 --- a/backend/src/services/traffic-flow-map-hops.ts +++ b/backend/src/services/traffic-flow-map-hops.ts @@ -20,6 +20,8 @@ import { flowDataEpoch } from "./traffic-flow-engine.js" export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5 export const MAP_SERVICE_NODE_CAP = 20 +/** Минимум узлов-брендов на карте, даже если доля ниже порога. */ +export const MAP_SERVICE_MIN_NODES = 8 const HOPS_CACHE_TTL_MS = 2000 export interface FlowMapHopsQuery { @@ -99,6 +101,15 @@ export function clampMapServiceMinSharePct(n: unknown): number { return Math.min(100, Math.max(0, v)) } +/** Доля среди именованных брендов; порог ИЛИ топ-N, затем cap. */ +export function pickMapServices(ranked: FlowMapService[], minSharePct: number): FlowMapService[] { + if (minSharePct <= 0) return ranked.slice(0, MAP_SERVICE_NODE_CAP) + const minShare = minSharePct / 100 + return ranked + .filter((s, i) => s.share >= minShare || i < MAP_SERVICE_MIN_NODES) + .slice(0, MAP_SERVICE_NODE_CAP) +} + function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string { return JSON.stringify({ epoch: flowDataEpoch(), @@ -436,21 +447,20 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number } } - const minShare = minSharePct / 100 - let services: FlowMapService[] = [...svcTotals.entries()] - .map(([id, s]) => ({ - id, - label: s.label, - category: s.category, - bytes: s.bytes, - bps: (s.bytes * 8) / windowSec, - share: totalBytes > 0 ? s.bytes / totalBytes : 0, - })) - .sort((a, b) => b.bytes - a.bytes) - if (minSharePct > 0) { - services = services.filter((s) => s.share >= minShare) - } - services = services.slice(0, MAP_SERVICE_NODE_CAP) + const namedBytes = [...svcTotals.values()].reduce((n, s) => n + s.bytes, 0) + const services = pickMapServices( + [...svcTotals.entries()] + .map(([id, s]) => ({ + id, + label: s.label, + category: s.category, + bytes: s.bytes, + bps: (s.bytes * 8) / windowSec, + share: namedBytes > 0 ? s.bytes / namedBytes : 0, + })) + .sort((a, b) => b.bytes - a.bytes), + minSharePct, + ) const keepSvc = new Set(services.map((s) => s.id)) const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()] .filter((e) => keepSvc.has(e.toId)) diff --git a/components/network-map/service-brand-icon.tsx b/components/network-map/service-brand-icon.tsx index e9b73e5..945f9d6 100644 --- a/components/network-map/service-brand-icon.tsx +++ b/components/network-map/service-brand-icon.tsx @@ -125,6 +125,117 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n ) + case "apple": + return ( + + + + + ) + case "github": + return ( + + + + + ) + case "gitlab": + return ( + + + + + + + + ) + case "spotify": + return ( + + + + + ) + case "x": + return ( + + + + + ) + case "vk": + return ( + + + + + ) + case "zoom": + return ( + + + + + + ) + case "epic": + return ( + + + + + ) + case "riot": + return ( + + + + + ) + case "playstation": + return ( + + + + + ) + case "roblox": + return ( + + + + + ) + case "digitalocean": + return ( + + + + + + ) + case "hetzner": + return ( + + + + + ) + case "ovh": + return ( + + + + + ) + case "chatgpt": + return ( + + + + + + ) default: return }