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
}