Compare commits

..
3 Commits
Author SHA1 Message Date
Denozordec 564aae21f0 fix(statistics): выровнять трафик-данные с картой сети
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m40s
Docker images / frontend-image (push) Successful in 3m28s
Docker images / updater-image (push) Successful in 50s
Docker images / backend-image (push) Successful in 3m5s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Окно периода клампится к текущему моменту: «Сегодня»/«7 д» больше не делят байты на ещё не наступившие часы суток (avgBps/bps были занижены).
- Пресет «24 ч» шлёт точные ISO-границы «сейчас−24ч … сейчас» вместо дат «вчера…сегодня» (~48 ч); подпись диапазона и подсветка пресета сверяются по длительности окна.
- Таксономия сервисов как на карте: DNS/SSH/BGP/WireGuard/GRE и пустые метки сворачиваются в «Прочее» (normalizeFactService) в разбивке сервисов и pivot country×service.
- Тесты: кламп окна, ISO-24ч, будущее «to», невалидный диапазон, нормализация меток.
2026-09-12 13:22:06 +07:00
Denozordec 0fdd2fc1e1 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
- Контракт: countryServiceGroups (группа сервисов страны) в flowMapHopsDto, тип FlowMapCountryServiceGroup.
- Бэкенд: вложенная агрегация brand×ISO в том же проходе hops; рёбра страна→сервис, пути с реальным EN; cap 8 / min 4, доля от байтов страны.
- Хелпер mapCountryServiceNodeId: id вида cc:us|svc:google, сервисы разных стран не смешиваются.
- Карта сети: клик по стране сдвигает колонку стран и раскрывает столбец её сервисов; остальные страны приглушены; панели страны и вложенного сервиса.
- Тесты: группы US/US+NL, рёбра от страны, cap/min вложенного слоя; mock countryServiceGroups для демо-режима.
2026-09-12 12:47:30 +07:00
DenozordecandCursor f15a7348db feat(traffic-flow): enhance country handling in flow analytics
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m19s
Docker images / frontend-image (push) Successful in 3m26s
Docker images / updater-image (push) Successful in 47s
Docker images / backend-image (push) Successful in 2m23s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Introduced country-specific handling in traffic flow analytics, including new mappings for country nodes and edges.
- Added mock data for countries and their respective service paths to improve visualization in the Network Map.
- Updated the `countryName` function to utilize `Intl.DisplayNames` for better localization of country names.
- Enhanced tests to validate country handling and ensure accurate representation in flow analytics.

Co-authored-by: Cursor <[email protected]>
2026-09-12 11:38:37 +07:00
13 changed files with 1090 additions and 164 deletions
+455 -27
View File
@@ -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,8 +57,9 @@ 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 { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { StatusBadge } from "@/components/status-badge" import { StatusBadge } from "@/components/status-badge"
import { StatusDot } from "@/components/status-dot" import { StatusDot } from "@/components/status-dot"
@@ -69,7 +72,7 @@ import {
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { formatServicePathLabel, formatServicePathTitle } from "@/lib/format-service-path-label" import { formatServicePathLabel, formatServicePathTitle } from "@/lib/format-service-path-label"
import Link from "next/link" import Link from "next/link"
import { Flag } from "@/components/flag" import { Flag, countryName } from "@/components/flag"
// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─ // ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─
@@ -285,6 +288,12 @@ const MOCK_MAP_SERVICES: FlowMapService[] = [
{ id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 }, { id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 },
] ]
const MOCK_MAP_COUNTRIES: FlowMapService[] = [
{ id: "cc:us", label: "US", category: "Страна", bytes: 22_000_000, bps: 8_800_000, share: 0.38 },
{ id: "cc:nl", label: "NL", category: "Страна", bytes: 14_000_000, bps: 5_600_000, share: 0.31 },
{ id: "cc:de", label: "DE", category: "Страна", bytes: 9_000_000, bps: 3_600_000, share: 0.21 },
]
const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [ const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv2", toId: "svc:google", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] }, { fromId: "srv2", toId: "svc:google", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] }, { fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
@@ -293,6 +302,14 @@ const MOCK_MAP_SERVICE_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] }, { fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
] ]
const MOCK_MAP_COUNTRY_EDGES: FlowMapServiceEdge[] = [
{ fromId: "srv2", toId: "cc:us", bytes: 14_000_000, bps: 5_600_000, bpsFwd: 4_200_000, bpsRev: 1_400_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "cc:us", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
{ fromId: "srv2", toId: "cc:nl", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000, clientName: "Alice", clients: [{ id: "u1", name: "Alice" }] },
{ fromId: "srv3", toId: "cc:nl", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
{ fromId: "srv3", toId: "cc:de", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000, clientName: "Bob", clients: [{ id: "u2", name: "Bob" }] },
]
const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [ const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:google", bytes: 14_000_000, bps: 5_600_000 }, { clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "svc:google", bytes: 14_000_000, bps: 5_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "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: "svc:google", bytes: 8_000_000, bps: 3_200_000 },
@@ -301,6 +318,56 @@ const MOCK_MAP_SERVICE_PATHS: FlowMapServicePath[] = [
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "svc:aws", 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: "svc:aws", bytes: 9_000_000, bps: 3_600_000 },
] ]
const MOCK_MAP_COUNTRY_PATHS: FlowMapServicePath[] = [
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:us", bytes: 14_000_000, bps: 5_600_000 },
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:us", bytes: 8_000_000, bps: 3_200_000 },
{ clientId: "u1", clientName: "Alice", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv2", enName: "mt-spb-edge-01", serviceId: "cc:nl", 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:nl", bytes: 5_000_000, bps: 2_000_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"
type DestMode = "services" | "countries"
function readDestMode(): DestMode {
if (typeof window === "undefined") return "services"
try {
return sessionStorage.getItem(DEST_MODE_KEY) === "countries" ? "countries" : "services"
} catch {
return "services"
}
}
function destDisplayLabel(node: FlowMapService | undefined, mode: DestMode, fallback = ""): string {
if (!node) return fallback
if (mode !== "countries") return node.label
if (node.id === "cc:other" || node.label === "Прочее") return "Прочее"
return countryName(node.label)
}
function servicePathKey(p: Pick<FlowMapServicePath, "clientId" | "viaId" | "enId" | "serviceId">): string { function servicePathKey(p: Pick<FlowMapServicePath, "clientId" | "viaId" | "enId" | "serviceId">): string {
return `${p.clientId}|${p.viaId}|${p.enId}|${p.serviceId}` return `${p.clientId}|${p.viaId}|${p.enId}|${p.serviceId}`
} }
@@ -738,6 +805,10 @@ function ServiceNode({
isSel, isSel,
isVis, isVis,
isDragged, isDragged,
destMode,
iso,
dim,
shareLabel,
onClick, onClick,
onMouseDown, onMouseDown,
}: { }: {
@@ -748,20 +819,27 @@ function ServiceNode({
isSel: boolean isSel: boolean
isVis: boolean isVis: boolean
isDragged: boolean isDragged: boolean
destMode: DestMode
iso?: string
/** Приглушение узла при раскрытии другой страны (остаётся на холсте). */
dim?: boolean
/** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */
shareLabel?: string
onClick: () => void onClick: () => void
onMouseDown: (e: React.MouseEvent) => void onMouseDown: (e: React.MouseEvent) => void
}) { }) {
const bw = MAP_SERVICE_NODE_W const bw = MAP_SERVICE_NODE_W
const bh = MAP_SERVICE_NODE_H const bh = MAP_SERVICE_NODE_H
const flagIso = destMode === "countries" && iso && iso !== "Прочее" ? iso : ""
return ( return (
<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}
@@ -786,7 +864,9 @@ function ServiceNode({
strokeWidth={isSel ? 2.2 : 1.4} strokeWidth={isSel ? 2.2 : 1.4}
/> />
<g transform="translate(-11,-24)" pointerEvents="none"> <g transform="translate(-11,-24)" pointerEvents="none">
<ServiceBrandIcon label={label} size={22} /> {flagIso
? <CountryFlagSvg iso={flagIso} size={22} />
: <ServiceBrandIcon label={destMode === "countries" ? "Прочее" : label} size={22} />}
</g> </g>
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace"> <text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
{label} {label}
@@ -804,6 +884,7 @@ function ServicePathList({
services, services,
highlight, highlight,
viaMode, viaMode,
destMode,
onToggle, onToggle,
}: { }: {
paths: FlowMapServicePath[] paths: FlowMapServicePath[]
@@ -811,6 +892,7 @@ function ServicePathList({
services: FlowMapService[] services: FlowMapService[]
highlight: { viaId: string; enId: string; serviceId: string } | null highlight: { viaId: string; enId: string; serviceId: string } | null
viaMode: "via" | "service" viaMode: "via" | "service"
destMode: DestMode
onToggle: (p: FlowMapServicePath) => void onToggle: (p: FlowMapServicePath) => void
}) { }) {
if (paths.length === 0) { if (paths.length === 0) {
@@ -823,13 +905,14 @@ function ServicePathList({
const via = servers.find((s) => s.id === p.viaId) const via = servers.find((s) => s.id === p.viaId)
const en = servers.find((s) => s.id === p.enId) const en = servers.find((s) => s.id === p.enId)
const svc = services.find((s) => s.id === p.serviceId) const svc = services.find((s) => s.id === p.serviceId)
const destLabel = destDisplayLabel(svc, destMode, p.serviceId)
const label = formatServicePathLabel(p, viaMode, { const label = formatServicePathLabel(p, viaMode, {
viaName: via?.name, viaName: via?.name,
viaSite: via?.site, viaSite: via?.site,
enName: en?.name, enName: en?.name,
serviceLabel: svc?.label, serviceLabel: destLabel,
}) })
const title = formatServicePathTitle(label, svc?.label ?? p.serviceId) const title = formatServicePathTitle(label, destLabel)
const active = Boolean( const active = Boolean(
highlight highlight
&& highlight.viaId === p.viaId && highlight.viaId === p.viaId
@@ -1099,11 +1182,16 @@ export default function NetworkMapPage() {
const [mapServices, setMapServices] = useState<FlowMapService[]>([]) const [mapServices, setMapServices] = useState<FlowMapService[]>([])
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([]) const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
const [mapServicePaths, setMapServicePaths] = useState<FlowMapServicePath[]>([]) const [mapServicePaths, setMapServicePaths] = useState<FlowMapServicePath[]>([])
const [mapCountries, setMapCountries] = useState<FlowMapService[]>([])
const [mapCountryEdges, setMapCountryEdges] = useState<FlowMapServiceEdge[]>([])
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)
const [mapWindowSec, setMapWindowSec] = useState(300) const [mapWindowSec, setMapWindowSec] = useState(300)
const [mapAsnLoaded, setMapAsnLoaded] = useState(true) const [mapAsnLoaded, setMapAsnLoaded] = useState(true)
const [mapCountryLoaded, setMapCountryLoaded] = useState(true)
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */ /** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({}) const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
const [dataError, setDataError] = useState<string | null>(null) const [dataError, setDataError] = useState<string | null>(null)
@@ -1199,9 +1287,14 @@ export default function NetworkMapPage() {
setMapServices(MOCK_MAP_SERVICES) setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES) setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS) setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapCountries(MOCK_MAP_COUNTRIES)
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
setMapSharePct(5) setMapSharePct(5)
setMapNamedBytes(0) setMapNamedBytes(0)
setMapTotalBytes(0) setMapTotalBytes(0)
setMapCountryLoaded(true)
setDataError(null) setDataError(null)
}) })
return return
@@ -1230,8 +1323,40 @@ 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")
useEffect(() => {
queueMicrotask(() => setDestModeState(readDestMode()))
}, [])
function setDestMode(mode: DestMode) {
setDestModeState(mode)
setSelectedService(null)
setExpandedCountryId(null)
setHighlightedPath(null)
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
? (mapServices.find((s) => s.id === selectedService.id) ?? selectedService) ? (
(destMode === "countries" ? mapCountries : mapServices)
.find((s) => s.id === selectedService.id)
?? nestedServices.find((s) => s.id === selectedService.id)
?? selectedService
)
: null
const selectedNestedService = liveSelectedService
&& nestedServices.some((s) => s.id === liveSelectedService.id)
? liveSelectedService
: null
const expandedCountry = expandedCountryId
? mapCountries.find((c) => c.id === expandedCountryId) ?? null
: 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)
@@ -1283,7 +1408,12 @@ export default function NetworkMapPage() {
setMapServices(MOCK_MAP_SERVICES) setMapServices(MOCK_MAP_SERVICES)
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES) setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
setMapServicePaths(MOCK_MAP_SERVICE_PATHS) setMapServicePaths(MOCK_MAP_SERVICE_PATHS)
setMapCountries(MOCK_MAP_COUNTRIES)
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
setMapSharePct(5) setMapSharePct(5)
setMapCountryLoaded(true)
}) })
return return
} }
@@ -1293,6 +1423,10 @@ export default function NetworkMapPage() {
setMapServices([]) setMapServices([])
setMapServiceEdges([]) setMapServiceEdges([])
setMapServicePaths([]) setMapServicePaths([])
setMapCountries([])
setMapCountryEdges([])
setMapCountryPaths([])
setMapCountryServiceGroups([])
}) })
return return
} }
@@ -1308,10 +1442,15 @@ export default function NetworkMapPage() {
setMapServices(res.services ?? []) setMapServices(res.services ?? [])
setMapServiceEdges(res.serviceEdges ?? []) setMapServiceEdges(res.serviceEdges ?? [])
setMapServicePaths(res.servicePaths ?? []) setMapServicePaths(res.servicePaths ?? [])
setMapCountries(res.countries ?? [])
setMapCountryEdges(res.countryEdges ?? [])
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)
if (res.asnLoaded != null) setMapAsnLoaded(res.asnLoaded) if (res.asnLoaded != null) setMapAsnLoaded(res.asnLoaded)
if (res.countryLoaded != null) setMapCountryLoaded(res.countryLoaded)
if (res.windowSec) setMapWindowSec(res.windowSec) if (res.windowSec) setMapWindowSec(res.windowSec)
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
@@ -1515,9 +1654,13 @@ export default function NetworkMapPage() {
return m return m
}, [homeRouters, wanJhEdges, mapHops, showNetflow]) }, [homeRouters, wanJhEdges, mapHops, showNetflow])
const visibleMapServices = showServices ? mapServices : [] const destNodes = destMode === "countries" ? mapCountries : mapServices
const destEdges = destMode === "countries" ? mapCountryEdges : mapServiceEdges
const destPaths = destMode === "countries" ? mapCountryPaths : mapServicePaths
const visibleMapServices = showServices ? destNodes : []
const visibleServiceEdges = showServices const visibleServiceEdges = showServices
? drawableServiceEdges(visibleMapServices, mapServiceEdges, mapServers, greEdges, nodePosById) ? drawableServiceEdges(visibleMapServices, destEdges, mapServers, greEdges, nodePosById)
: [] : []
const nodes = mapServers const nodes = mapServers
@@ -1539,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 ─────────────────────────────────────────────────────────────────────
@@ -1625,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)
@@ -1722,6 +1882,7 @@ export default function NetworkMapPage() {
setSelectedGreEdge(null) setSelectedGreEdge(null)
setSelectedService(null) setSelectedService(null)
setHighlightedPath(null) setHighlightedPath(null)
setExpandedCountryId(null)
} }
} }
@@ -1756,6 +1917,34 @@ export default function NetworkMapPage() {
} }
// ── Side panel ──────────────────────────────────────────────────────────── // ── Side panel ────────────────────────────────────────────────────────────
useEffect(() => {
if (
expandedCountryId
&& (!mapCountries.some((s) => s.id === expandedCountryId)
|| !mapCountryServiceGroups.some((g) => g.countryId === expandedCountryId))
) {
queueMicrotask(() => setExpandedCountryId(null))
}
}, [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)
@@ -1770,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)
@@ -1919,6 +2112,27 @@ export default function NetworkMapPage() {
)} )}
</div> </div>
{/* Dest overlay: services vs countries */}
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
{([
{ value: "services" as const, label: "Сервисы" },
{ value: "countries" as const, label: "Страны" },
]).map((b) => (
<button
key={b.value}
onClick={() => setDestMode(b.value)}
className={cn(
"px-2.5 py-1 text-xs rounded transition-colors whitespace-nowrap",
destMode === b.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{b.label}
</button>
))}
</div>
{/* Layers dropdown */} {/* Layers dropdown */}
<div className="relative"> <div className="relative">
<button <button
@@ -1937,7 +2151,7 @@ export default function NetworkMapPage() {
{([ {([
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" }, { key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
{ key: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, hint: "" }, { key: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, hint: "" },
{ key: "showServices", label: "Сервисы", val: showServices, set: setShowServices, hint: "" }, { key: "showServices", label: "Назначения", val: showServices, set: setShowServices, hint: "" },
{ key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" }, { key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" }, { key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" }, { key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
@@ -1964,9 +2178,14 @@ export default function NetworkMapPage() {
))} ))}
<p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug"> <p className="px-3 pt-1.5 pb-1 text-[10px] text-muted-foreground leading-snug">
{mapSharePct > 0 {mapSharePct > 0
? `Порог доли сервиса${mapSharePct}% · Настройки → NetFlow` ? `Порог доли ≥ ${mapSharePct}% · Настройки → NetFlow`
: "Порог доли выключен (все бренды, макс. 20) · Настройки → NetFlow"} : "Порог доли выключен (все узлы, макс. 20) · Настройки → NetFlow"}
</p> </p>
{destMode === "countries" && !mapCountryLoaded && (
<p className="px-3 pb-1 text-[10px] text-amber-500 leading-snug">
GeoIP Country не загружен, страны из RIPE-кэша
</p>
)}
{(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).length > 0) && ( {(Object.keys(nodePositions).length > 0 || Object.keys(satPositions).length > 0 || Object.keys(servicePositions).length > 0) && (
<div className="border-t border-border/50 mt-1 pt-1"> <div className="border-t border-border/50 mt-1 pt-1">
<button <button
@@ -2283,6 +2502,32 @@ export default function NetworkMapPage() {
{visibleMapServices.map((svc) => { {visibleMapServices.map((svc) => {
const pos = servicePosById[svc.id] const pos = servicePosById[svc.id]
if (!pos) return null if (!pos) return null
return (
<ServiceNode
key={svc.id}
label={destDisplayLabel(svc, destMode)}
share={svc.share}
x={pos.x}
y={pos.y}
isSel={selectedService?.id === svc.id}
isVis
dim={Boolean(expandedCountryGroup) && svc.id !== expandedCountryId}
isDragged={draggedSvcId === svc.id}
destMode={destMode}
iso={svc.label}
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return }
selectService(svc)
}}
/>
)
})}
{/* ── Сервисы раскрытой страны (второй столбец справа) ── */}
{expandedCountryGroup && nestedServices.map((svc) => {
const pos = nestedPosById[svc.id]
if (!pos) return null
return ( return (
<ServiceNode <ServiceNode
key={svc.id} key={svc.id}
@@ -2293,6 +2538,8 @@ export default function NetworkMapPage() {
isSel={selectedService?.id === svc.id} isSel={selectedService?.id === svc.id}
isVis isVis
isDragged={draggedSvcId === svc.id} isDragged={draggedSvcId === svc.id}
destMode="services"
shareLabel="трафика страны"
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)} onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
onClick={() => { onClick={() => {
if (suppressClickRef.current) { suppressClickRef.current = false; return } if (suppressClickRef.current) { suppressClickRef.current = false; return }
@@ -2332,15 +2579,16 @@ 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 || "—")
const pathTitle = `${clientLabel}${enName}${svc?.label ?? edge.toId}` const pathTitle = `${clientLabel}${enName}${destDisplayLabel(svc, destMode, edge.toId)}`
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>
@@ -2386,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} />
@@ -2424,7 +2747,9 @@ export default function NetworkMapPage() {
<g transform="translate(10, 164)"> <g transform="translate(10, 164)">
<rect width="14" height="14" rx="4" fill="#08202c" stroke="#22d3ee" strokeWidth="1.2" /> <rect width="14" height="14" rx="4" fill="#08202c" stroke="#22d3ee" strokeWidth="1.2" />
<text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">Сервис</text> <text x="22" y="11" fontSize="8.5" fill="#cbd5e1" fontFamily="system-ui">
{destMode === "countries" ? "Страна" : "Сервис"}
</text>
</g> </g>
<line x1="10" y1="186" x2="130" y2="186" stroke="rgba(255,255,255,0.07)" strokeWidth="1" /> <line x1="10" y1="186" x2="130" y2="186" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
@@ -2488,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 })}
/> />
@@ -2723,16 +3048,87 @@ 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">
<div className="mt-0.5"> <div className="mt-0.5">
<ServiceBrandIcon label={liveSelectedService.label} size={22} /> {destMode === "countries" && liveSelectedService.label !== "Прочее" && liveSelectedService.id !== "cc:other"
? <Flag code={liveSelectedService.label} size={22} />
: <ServiceBrandIcon label={destMode === "countries" ? "Прочее" : liveSelectedService.label} size={22} />}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-mono font-semibold text-sm truncate">{liveSelectedService.label}</p> <p className="font-mono font-semibold text-sm truncate">
{destDisplayLabel(liveSelectedService, destMode)}
</p>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Конечный сервис · {liveSelectedService.category} {destMode === "countries"
? `Конечная страна${liveSelectedService.label !== "Прочее" ? ` · ${liveSelectedService.label}` : ""}`
: `Конечный сервис · ${liveSelectedService.category}`}
</p> </p>
</div> </div>
<button <button
@@ -2769,12 +3165,18 @@ export default function NetworkMapPage() {
</span> </span>
</div> </div>
)} )}
{!mapAsnLoaded && ( {!mapAsnLoaded && destMode === "services" && (
<div className="flex items-center justify-between py-2 border-b border-border/50"> <div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">GeoLite2 ASN</span> <span className="text-xs text-muted-foreground">GeoLite2 ASN</span>
<span className="text-xs font-mono font-medium text-amber-500">не загружена</span> <span className="text-xs font-mono font-medium text-amber-500">не загружена</span>
</div> </div>
)} )}
{destMode === "countries" && !mapCountryLoaded && (
<div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">GeoIP Country</span>
<span className="text-xs font-mono font-medium text-amber-500">RIPE-кэш</span>
</div>
)}
<div className="flex items-center justify-between py-2 border-b border-border/50"> <div className="flex items-center justify-between py-2 border-b border-border/50">
<span className="text-xs text-muted-foreground">Скорость</span> <span className="text-xs text-muted-foreground">Скорость</span>
<span className="text-xs font-mono font-medium"> <span className="text-xs font-mono font-medium">
@@ -2787,12 +3189,36 @@ 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">
{visibleServiceEdges.filter((e) => e.toId === liveSelectedService.id).map((e) => { {visibleServiceEdges.filter((e) => e.toId === liveSelectedService.id).map((e) => {
const src = mapServers.find((s) => s.id === e.fromId) const src = mapServers.find((s) => s.id === e.fromId)
const enPaths = mapServicePaths const enPaths = destPaths
.filter((p) => p.serviceId === liveSelectedService.id && p.enId === e.fromId) .filter((p) => p.serviceId === liveSelectedService.id && p.enId === e.fromId)
.slice() .slice()
.sort((a, b) => b.bps - a.bps) .sort((a, b) => b.bps - a.bps)
@@ -2807,9 +3233,10 @@ export default function NetworkMapPage() {
<ServicePathList <ServicePathList
paths={enPaths} paths={enPaths}
servers={mapServers} servers={mapServers}
services={mapServices} services={destNodes}
highlight={highlightedPath} highlight={highlightedPath}
viaMode="via" viaMode="via"
destMode={destMode}
onToggle={togglePathHighlight} onToggle={togglePathHighlight}
/> />
</div> </div>
@@ -3048,7 +3475,7 @@ export default function NetworkMapPage() {
<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>
<ServicePathList <ServicePathList
paths={mapServicePaths paths={destPaths
.filter((p) => ( .filter((p) => (
selected.type === "exit-node" selected.type === "exit-node"
? p.enId === selected.id ? p.enId === selected.id
@@ -3057,9 +3484,10 @@ export default function NetworkMapPage() {
.slice() .slice()
.sort((a, b) => b.bps - a.bps)} .sort((a, b) => b.bps - a.bps)}
servers={mapServers} servers={mapServers}
services={mapServices} services={destNodes}
highlight={highlightedPath} highlight={highlightedPath}
viaMode="service" viaMode="service"
destMode={destMode}
onToggle={togglePathHighlight} onToggle={togglePathHighlight}
/> />
</div> </div>
@@ -1,5 +1,5 @@
import assert from "node:assert/strict" import assert from "node:assert/strict"
import { getStatistics, getStatisticsPivot, parseStatisticsPeriod, pivotDimsConflict } from "./statistics-aggregate.js" import { getStatistics, getStatisticsPivot, normalizeFactService, parseStatisticsPeriod, pivotDimsConflict } from "./statistics-aggregate.js"
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js" import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
import { setRefreshIfacesForTests } from "./traffic-flow-ifaces.js" import { setRefreshIfacesForTests } from "./traffic-flow-ifaces.js"
import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js" import { invalidateFlowCatalogCache } from "./traffic-flow-topology.js"
@@ -24,6 +24,43 @@ import { STATISTICS_UNBOUND_USER_ID } from "@mmapp/contracts/statistics"
assert.equal(pivotDimsConflict("country", "service"), false) assert.equal(pivotDimsConflict("country", "service"), false)
} }
{
const nowMs = Date.parse("2026-09-12T12:00:00Z")
// «Сегодня»: окно до сейчас, не до конца суток — иначе avgBps размывается будущими часами.
const today = parseStatisticsPeriod("2026-09-12", "2026-09-12", nowMs)
assert.ok(today)
assert.equal(today.windowSec, 12 * 3600)
assert.equal(today.grain, "hour")
assert.equal(today.toDayExclusive, "2026-09-13", "дневные факты текущего дня не теряем")
// Прошлые периоды не клампятся.
const past = parseStatisticsPeriod("2026-09-10", "2026-09-10", nowMs)
assert.ok(past)
assert.equal(past.windowSec, 86_400)
// ISO-диапазон ровно 24 часа.
const iso24 = parseStatisticsPeriod("2026-09-11T12:00:00Z", "2026-09-12T12:00:00Z", nowMs)
assert.ok(iso24)
assert.equal(iso24.windowSec, 86_400)
assert.equal(iso24.grain, "hour")
// «to» далеко в будущем клампится к сейчас.
const futureTo = parseStatisticsPeriod("2026-09-11", "2026-09-20", nowMs)
assert.ok(futureTo)
assert.equal(futureTo.windowSec, 86_400 + 12 * 3600)
// Полностью будущий диапазон невалиден.
assert.equal(parseStatisticsPeriod("2026-09-13", "2026-09-14", nowMs), null)
}
{
// Таксономия сервисов как на карте: skip-список сворачивается в «Прочее».
assert.equal(normalizeFactService("Google"), "Google")
assert.equal(normalizeFactService("DNS"), "Прочее")
assert.equal(normalizeFactService("SSH"), "Прочее")
assert.equal(normalizeFactService("BGP"), "Прочее")
assert.equal(normalizeFactService("WireGuard"), "Прочее")
assert.equal(normalizeFactService("GRE"), "Прочее")
assert.equal(normalizeFactService("Прочее"), "Прочее")
assert.equal(normalizeFactService(""), "Прочее")
}
if (!(await withPgOrSkip())) { if (!(await withPgOrSkip())) {
console.log("statistics-aggregate.test.ts: skip") console.log("statistics-aggregate.test.ts: skip")
process.exit(0) process.exit(0)
+26 -7
View File
@@ -27,6 +27,13 @@ import {
wanIfaceLabel, wanIfaceLabel,
} from "./traffic-flow-facts-filter.js" } from "./traffic-flow-facts-filter.js"
import { getServerCatalog, loadFlowTopology, type FlowTopology } from "./traffic-flow-topology.js" import { getServerCatalog, loadFlowTopology, type FlowTopology } from "./traffic-flow-topology.js"
import { OTHER_SERVICE, isNamedInternetService } from "./traffic-flow-brands.js"
/** Так же, как на карте сети: DNS/SSH/BGP/туннели и пустые метки — не отдельные сервисы, а «Прочее». */
export function normalizeFactService(label: string): string {
const s = String(label ?? "").trim()
return isNamedInternetService(s, "") ? s : OTHER_SERVICE
}
const TOP_N = 200 const TOP_N = 200
const HOUR_WINDOW_MS = 48 * 3600_000 const HOUR_WINDOW_MS = 48 * 3600_000
@@ -58,7 +65,7 @@ function addUtcDays(day: string, n: number): string {
} }
/** Parse from/to. Date-only `to` is inclusive (end of that UTC day). */ /** Parse from/to. Date-only `to` is inclusive (end of that UTC day). */
export function parseStatisticsPeriod(fromRaw: string, toRaw: string): ParsedPeriod | null { export function parseStatisticsPeriod(fromRaw: string, toRaw: string, nowMs: number = Date.now()): ParsedPeriod | null {
const from = Date.parse(fromRaw.includes("T") ? fromRaw : `${fromRaw}T00:00:00Z`) const from = Date.parse(fromRaw.includes("T") ? fromRaw : `${fromRaw}T00:00:00Z`)
const toHasTime = toRaw.includes("T") const toHasTime = toRaw.includes("T")
const to = Date.parse(toHasTime ? toRaw : `${toRaw}T00:00:00Z`) const to = Date.parse(toHasTime ? toRaw : `${toRaw}T00:00:00Z`)
@@ -75,6 +82,9 @@ export function parseStatisticsPeriod(fromRaw: string, toRaw: string): ParsedPer
toDayExclusive = addUtcDays(toUtcDay(toDate), 1) toDayExclusive = addUtcDays(toUtcDay(toDate), 1)
toDate = new Date(`${toDayExclusive}T00:00:00Z`) toDate = new Date(`${toDayExclusive}T00:00:00Z`)
} }
// Конец периода в будущем (например, «сегодня»): окно длится только до сейчас,
// иначе avgBps размывается ещё не наступившими часами суток.
if (toDate.getTime() > nowMs) toDate = new Date(nowMs)
if (toDate.getTime() <= from) return null if (toDate.getTime() <= from) return null
const windowSec = Math.max(1, Math.round((toDate.getTime() - from) / 1000)) const windowSec = Math.max(1, Math.round((toDate.getTime() - from) / 1000))
const grain: "hour" | "day" = toDate.getTime() - from <= HOUR_WINDOW_MS ? "hour" : "day" const grain: "hour" | "day" = toDate.getTime() - from <= HOUR_WINDOW_MS ? "hour" : "day"
@@ -553,12 +563,14 @@ export async function getStatistics(query: StatisticsQuery): Promise<StatisticsD
period.windowSec, period.windowSec,
) )
const services = toBreakdown( const services = toBreakdown(
serviceRows.map((r) => ({ Object.entries(serviceRows.reduce<Record<string, { bytes: number; packets: number }>>((acc, r) => {
id: r.id, const key = normalizeFactService(r.id)
label: r.id, const prev = acc[key] ?? { bytes: 0, packets: 0 }
bytes: Number(r.bytes) || 0, prev.bytes += Number(r.bytes) || 0
packets: Number(r.packets) || 0, prev.packets += Number(r.packets) || 0
})), acc[key] = prev
return acc
}, {})).map(([label, v]) => ({ id: label, label, bytes: v.bytes, packets: v.packets })),
bytes, bytes,
period.windowSec, period.windowSec,
) )
@@ -702,6 +714,13 @@ export async function getStatisticsPivot(query: StatisticsPivotQuery): Promise<S
GROUP BY 1, 2 GROUP BY 1, 2
`, [...join.params, ...where.params]) `, [...join.params, ...where.params])
if (query.row === "service" || query.col === "service") {
for (const r of raw) {
if (query.row === "service") r.row_id = normalizeFactService(String(r.row_id ?? ""))
if (query.col === "service") r.col_id = normalizeFactService(String(r.col_id ?? ""))
}
}
if (query.row === "iface" || query.col === "iface") { if (query.row === "iface" || query.col === "iface") {
const ifaceServerIds: number[] = [] const ifaceServerIds: number[] = []
for (const r of raw) { for (const r of raw) {
@@ -8,6 +8,8 @@ import {
OTHER_SERVICE, OTHER_SERVICE,
isNamedInternetService, isNamedInternetService,
mapServiceNodeId, mapServiceNodeId,
mapCountryNodeId,
mapCountryServiceNodeId,
resolveFlowBrand, resolveFlowBrand,
resolveRipeCountry, resolveRipeCountry,
} from "./traffic-flow-brands.js" } from "./traffic-flow-brands.js"
@@ -44,6 +46,13 @@ assert.equal(isNamedInternetService("DNS", "DNS"), false)
assert.equal(mapServiceNodeId("AWS"), "svc:aws") assert.equal(mapServiceNodeId("AWS"), "svc:aws")
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare") assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
assert.equal(mapServiceNodeId("Прочее"), "svc:other") assert.equal(mapServiceNodeId("Прочее"), "svc:other")
assert.equal(mapCountryNodeId("US"), "cc:us")
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)?.service, "Apple")
assert.equal(brandByAsn(714)?.category, "CDN") assert.equal(brandByAsn(714)?.category, "CDN")
@@ -364,3 +364,15 @@ export function mapServiceNodeId(label: string): string {
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
return `svc:${slug || "unknown"}` return `svc:${slug || "unknown"}`
} }
/** `US` → `cc:us`; неизвестная / пустая → `cc:other`. */
export function mapCountryNodeId(code: string): string {
const iso = normalizeIsoCountry(code)
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" } 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,
@@ -306,6 +308,15 @@ try {
assert.ok(googleEdge) assert.ok(googleEdge)
assert.equal(googleEdge.clientName, "Alice") assert.equal(googleEdge.clientName, "Alice")
assert.equal((six.serviceEdges ?? []).reduce((n, e) => n + e.bytes, 0), 10_000) assert.equal((six.serviceEdges ?? []).reduce((n, e) => n + e.bytes, 0), 10_000)
const us = six.countries?.find((s) => s.id === "cc:us")
assert.ok(us, "Google ripe country US")
assert.equal(us.label, "US")
const usEdge = six.countryEdges?.find((e) => e.toId === "cc:us" && e.fromId === "9")
assert.ok(usEdge)
assert.ok(!(six.countryEdges ?? []).some((e) => e.toId.startsWith("svc:")), "страны не смешиваются с svc:*")
const usPath = six.countryPaths?.find((p) => p.serviceId === "cc:us" && p.enId === "9")
assert.ok(usPath)
assert.equal(usPath.clientName, "Alice")
} finally { } finally {
resetFlowRingsForTests() resetFlowRingsForTests()
resetIfaceCacheForTests() resetIfaceCacheForTests()
@@ -810,4 +821,180 @@ try {
resetFlowCatalogForTests() 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 split = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
const us = split.countries?.find((s) => s.id === "cc:us")
const nl = split.countries?.find((s) => s.id === "cc:nl")
assert.ok(us, "US из ripe Google")
assert.ok(nl, "NL из ripe Cloudflare")
assert.equal(us.bytes, 5000)
assert.equal(nl.bytes, 5000)
assert.ok(split.countryEdges?.some((e) => e.toId === "cc:us" && e.fromId === "9"))
assert.ok(split.countryEdges?.some((e) => e.toId === "cc:nl" && e.fromId === "9"))
assert.ok(!(split.countryEdges ?? []).some((e) => e.toId.startsWith("svc:")))
assert.ok(split.serviceEdges?.some((e) => e.toId === "svc:google"))
assert.ok(!(split.serviceEdges ?? []).some((e) => e.toId.startsWith("cc:")))
assert.ok(split.countryPaths?.some((p) => p.serviceId === "cc:nl" && p.enId === "9"))
} 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()
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")
+253 -119
View File
@@ -1,9 +1,10 @@
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, mapServiceNodeId } 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 { refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
import { dedupFlowRowsAcrossExporters, dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js" import { dedupFlowRowsAcrossExporters, dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js" import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
@@ -20,6 +21,11 @@ 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 = "Страна"
const HOPS_CACHE_TTL_MS = 2000 const HOPS_CACHE_TTL_MS = 2000
export interface FlowMapHopsQuery { export interface FlowMapHopsQuery {
@@ -64,6 +70,32 @@ interface DstAcc {
fromBytes: Map<string, FromAcc> fromBytes: Map<string, FromAcc>
} }
interface DestTotal {
label: string
category: string
bytes: number
}
interface DestEdgeAcc {
fromId: string
toId: string
bytes: number
bytesFwd: number
bytesRev: number
clients: Map<string, string>
}
interface DestPathAcc {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
bytes: number
}
function bumpClient(clients: Map<string, ClientAcc>, bytes: number, client: { userId: string; name: string } | null): void { function bumpClient(clients: Map<string, ClientAcc>, bytes: number, client: { userId: string; name: string } | null): void {
const id = client?.userId || "—" const id = client?.userId || "—"
const name = client?.name || "—" const name = client?.name || "—"
@@ -100,12 +132,194 @@ 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 {
const prev = totals.get(id)
if (prev) {
prev.bytes += bytes
return
}
totals.set(id, { label, category, bytes })
}
function bumpDestFrom(
edges: Map<string, DestEdgeAcc>,
paths: Map<string, DestPathAcc>,
toId: string,
from: FromAcc,
exporterId: string,
fromId: string,
enName: string,
viaName: string,
/** fromId ребра, если отличается от EN (вложенный слой: страна → сервис). */
edgeFromId: string = fromId,
): void {
const edgeKey = `${edgeFromId}|${toId}`
const prevEdge = edges.get(edgeKey)
const namedClients = new Map<string, string>()
for (const [id, c] of from.clients) {
if (id !== "—") namedClients.set(id, c.name)
}
if (prevEdge) {
prevEdge.bytes += from.bytes
prevEdge.bytesFwd += from.bytes
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
} else {
edges.set(edgeKey, {
fromId: edgeFromId,
toId,
bytes: from.bytes,
bytesFwd: from.bytes,
bytesRev: 0,
clients: namedClients,
})
}
for (const [clientId, c] of from.clients) {
const pathKey = `${clientId}|${fromId}|${toId}`
const prevPath = paths.get(pathKey)
if (prevPath) {
prevPath.bytes += c.bytes
if (exporterId !== fromId && prevPath.viaId === fromId) {
prevPath.viaId = exporterId
prevPath.viaName = viaName
}
if (prevPath.clientName === "—" && c.name !== "—") prevPath.clientName = c.name
} else {
paths.set(pathKey, {
clientId,
clientName: c.name,
viaId: exporterId,
viaName,
enId: fromId,
enName,
serviceId: toId,
bytes: c.bytes,
})
}
}
}
function finalizeDestLayer(
totals: Map<string, DestTotal>,
edges: Map<string, DestEdgeAcc>,
paths: Map<string, DestPathAcc>,
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()]
.map(([id, s]) => ({
id,
label: s.label,
category: s.category,
bytes: s.bytes,
bps: (s.bytes * 8) / windowSec,
share: shareBase > 0 ? s.bytes / shareBase : 0,
}))
.sort((a, b) => b.bytes - a.bytes),
minSharePct,
cap,
minNodes,
)
const keep = new Set(nodes.map((s) => s.id))
const outEdges: FlowMapServiceEdge[] = [...edges.values()]
.filter((e) => keep.has(e.toId))
.map((e) => {
const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
const first = clients[0]
return {
fromId: e.fromId,
toId: e.toId,
bytes: e.bytes,
bps: (e.bytes * 8) / windowSec,
bpsFwd: (e.bytesFwd * 8) / windowSec,
bpsRev: (e.bytesRev * 8) / windowSec,
...(first ? { clientId: first.id, clientName: first.name } : {}),
...(clients.length ? { clients } : {}),
}
})
.sort((a, b) => b.bytes - a.bytes)
const outPaths: FlowMapServicePath[] = [...paths.values()]
.filter((p) => keep.has(p.serviceId))
.map((p) => ({
clientId: p.clientId,
clientName: p.clientName,
viaId: p.viaId,
viaName: p.viaName,
enId: p.enId,
enName: p.enName,
serviceId: p.serviceId,
bytes: p.bytes,
bps: (p.bytes * 8) / windowSec,
}))
.sort((a, b) => b.bps - a.bps)
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 }
}
const iso = resolveRipeCountry(ripe.country, ripe.asn, ripe.holder)
if (!iso) {
return { id: mapCountryNodeId(""), label: OTHER_SERVICE, category: MAP_COUNTRY_CATEGORY }
}
return { id: mapCountryNodeId(iso), label: iso, category: MAP_COUNTRY_CATEGORY }
} }
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string { function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
@@ -368,25 +582,15 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
} }
} }
const svcTotals = new Map<string, { label: string; category: string; bytes: number }>() const svcTotals = new Map<string, DestTotal>()
const svcEdges = new Map<string, { const svcEdges = new Map<string, DestEdgeAcc>()
fromId: string const svcPaths = new Map<string, DestPathAcc>()
toId: string const ccTotals = new Map<string, DestTotal>()
bytes: number const ccEdges = new Map<string, DestEdgeAcc>()
bytesFwd: number const ccPaths = new Map<string, DestPathAcc>()
bytesRev: number const nestedTotals = new Map<string, DestTotal>()
clients: Map<string, string> const nestedEdges = new Map<string, DestEdgeAcc>()
}>() const nestedPaths = new Map<string, DestPathAcc>()
const svcPaths = new Map<string, {
clientId: string
clientName: string
viaId: string
viaName: string
enId: string
enName: string
serviceId: string
bytes: number
}>()
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
@@ -423,58 +627,20 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
const classified = dst && dst !== "__other__" const classified = dst && dst !== "__other__"
? mapInternetBrand(dst, acc.proto, acc.dstPort, acc.srcPort, ripe) ? mapInternetBrand(dst, acc.proto, acc.dstPort, acc.srcPort, ripe)
: { service: OTHER_SERVICE, category: OTHER_SERVICE } : { service: OTHER_SERVICE, category: OTHER_SERVICE }
const toId = mapServiceNodeId(classified.service) const svcId = mapServiceNodeId(classified.service)
const prevSvc = svcTotals.get(toId) bumpDestTotal(svcTotals, svcId, classified.service, classified.category, acc.bytes)
if (prevSvc) prevSvc.bytes += acc.bytes const country = countryDestFromRipe(ripe, dst)
else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: 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
const edgeKey = `${fromId}|${toId}`
const prevEdge = svcEdges.get(edgeKey)
const namedClients = new Map<string, string>()
for (const [id, c] of from.clients) {
if (id !== "—") namedClients.set(id, c.name)
}
if (prevEdge) {
prevEdge.bytes += from.bytes
prevEdge.bytesFwd += from.bytes
for (const [id, name] of namedClients) prevEdge.clients.set(id, name)
} else {
svcEdges.set(edgeKey, {
fromId,
toId,
bytes: from.bytes,
bytesFwd: from.bytes,
bytesRev: 0,
clients: namedClients,
})
}
const enName = nodeName(fromId) const enName = nodeName(fromId)
const viaName = nodeName(exporterId) const viaName = nodeName(exporterId)
for (const [clientId, c] of from.clients) { bumpDestFrom(svcEdges, svcPaths, svcId, from, exporterId, fromId, enName, viaName)
const pathKey = `${clientId}|${fromId}|${toId}` bumpDestFrom(ccEdges, ccPaths, country.id, from, exporterId, fromId, enName, viaName)
const prevPath = svcPaths.get(pathKey) bumpDestFrom(nestedEdges, nestedPaths, nestedId, from, exporterId, fromId, enName, viaName, country.id)
if (prevPath) {
prevPath.bytes += c.bytes
if (exporterId !== fromId && prevPath.viaId === fromId) {
prevPath.viaId = exporterId
prevPath.viaName = viaName
}
if (prevPath.clientName === "—" && c.name !== "—") prevPath.clientName = c.name
} else {
svcPaths.set(pathKey, {
clientId,
clientName: c.name,
viaId: exporterId,
viaName,
enId: fromId,
enName,
serviceId: toId,
bytes: c.bytes,
})
}
}
} }
} }
@@ -483,52 +649,16 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
.reduce((n, s) => n + s.bytes, 0) .reduce((n, s) => n + s.bytes, 0)
const unclassifiedBytes = Math.max(0, totalBytes - namedBytes) const unclassifiedBytes = Math.max(0, totalBytes - namedBytes)
const shareBase = totalBytes > 0 ? totalBytes : namedBytes const shareBase = totalBytes > 0 ? totalBytes : namedBytes
const services = pickMapServices( const servicesOut = finalizeDestLayer(svcTotals, svcEdges, svcPaths, windowSec, minSharePct, shareBase)
[...svcTotals.entries()] const countriesOut = finalizeDestLayer(ccTotals, ccEdges, ccPaths, windowSec, minSharePct, shareBase)
.map(([id, s]) => ({ const countryServiceGroups = buildCountryServiceGroups(
id, nestedTotals,
label: s.label, nestedEdges,
category: s.category, nestedPaths,
bytes: s.bytes, countriesOut.nodes,
bps: (s.bytes * 8) / windowSec, windowSec,
share: shareBase > 0 ? s.bytes / shareBase : 0,
}))
.sort((a, b) => b.bytes - a.bytes),
minSharePct, minSharePct,
) )
const keepSvc = new Set(services.map((s) => s.id))
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
.filter((e) => keepSvc.has(e.toId))
.map((e) => {
const clients = [...e.clients.entries()].map(([id, name]) => ({ id, name }))
const first = clients[0]
return {
fromId: e.fromId,
toId: e.toId,
bytes: e.bytes,
bps: (e.bytes * 8) / windowSec,
bpsFwd: (e.bytesFwd * 8) / windowSec,
bpsRev: (e.bytesRev * 8) / windowSec,
...(first ? { clientId: first.id, clientName: first.name } : {}),
...(clients.length ? { clients } : {}),
}
})
.sort((a, b) => b.bytes - a.bytes)
const servicePaths: FlowMapServicePath[] = [...svcPaths.values()]
.filter((p) => keepSvc.has(p.serviceId))
.map((p) => ({
clientId: p.clientId,
clientName: p.clientName,
viaId: p.viaId,
viaName: p.viaName,
enId: p.enId,
enName: p.enName,
serviceId: p.serviceId,
bytes: p.bytes,
bps: (p.bytes * 8) / windowSec,
}))
.sort((a, b) => b.bps - a.bps)
const listener = getFlowListenerState() const listener = getFlowListenerState()
const geo = geoipReadersStatus() const geo = geoipReadersStatus()
@@ -544,9 +674,13 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
unclassifiedBytes, unclassifiedBytes,
asnLoaded: geo.asnLoaded, asnLoaded: geo.asnLoaded,
countryLoaded: geo.countryLoaded, countryLoaded: geo.countryLoaded,
services, services: servicesOut.nodes,
serviceEdges, serviceEdges: servicesOut.edges,
servicePaths, servicePaths: servicesOut.paths,
countries: countriesOut.nodes,
countryEdges: countriesOut.edges,
countryPaths: countriesOut.paths,
countryServiceGroups,
mapServiceMinSharePct: minSharePct, mapServiceMinSharePct: minSharePct,
dedupApplied: wantDedup, dedupApplied: wantDedup,
excludeMeshApplied: excludeMesh, excludeMeshApplied: excludeMesh,
+29 -2
View File
@@ -20,9 +20,29 @@ const COUNTRY_NAMES: Record<string, string> = {
HK: "Гонконг", HK: "Гонконг",
} }
/** Country name in Russian (fallback to code) */ let regionNames: Intl.DisplayNames | null | undefined
function regionDisplayName(iso: string): string | undefined {
try {
if (regionNames === undefined) {
regionNames = typeof Intl !== "undefined" && "DisplayNames" in Intl
? new Intl.DisplayNames(["ru"], { type: "region" })
: null
}
return regionNames?.of(iso) ?? undefined
} catch {
return undefined
}
}
/** Country name in Russian (fallback to ISO code) */
export function countryName(code: string): string { export function countryName(code: string): string {
return COUNTRY_NAMES[code.toUpperCase()] ?? code const iso = code.toUpperCase()
if (!iso) return code
if (COUNTRY_NAMES[iso]) return COUNTRY_NAMES[iso]
const intl = regionDisplayName(iso)
if (intl && intl !== iso) return intl
return iso
} }
interface FlagProps { interface FlagProps {
@@ -39,6 +59,13 @@ function nearestCdnSize(px: number): number {
return CDN_SIZES.find(s => s >= px) ?? CDN_SIZES[CDN_SIZES.length - 1] return CDN_SIZES.find(s => s >= px) ?? CDN_SIZES[CDN_SIZES.length - 1]
} }
/** CDN URL for SVG `<image href>` (flagcdn widths only). */
export function flagCdnUrl(code: string, size = 40): string | null {
const lower = code.toLowerCase()
if (!/^[a-z]{2}$/.test(lower)) return null
return `https://flagcdn.com/w${nearestCdnSize(size)}/${lower}.png`
}
/** /**
* Renders a flag <img> for a given ISO 3166-1 alpha-2 country code. * Renders a flag <img> for a given ISO 3166-1 alpha-2 country code.
* Source: https://flagcdn.com — free CDN, no API key needed. * Source: https://flagcdn.com — free CDN, no API key needed.
@@ -0,0 +1,17 @@
"use client"
import { flagCdnUrl } from "@/components/flag"
export function CountryFlagSvg({ iso, size = 22 }: { iso: string; size?: number }) {
const url = flagCdnUrl(iso, size)
if (!url) return null
const h = Math.round(size * 0.75)
return (
<image
href={url}
width={size}
height={h}
preserveAspectRatio="xMidYMid meet"
/>
)
}
+29 -7
View File
@@ -48,13 +48,24 @@ export function parseYmd(s: string): Date | null {
return Number.isNaN(d.getTime()) ? null : d return Number.isNaN(d.getTime()) ? null : d
} }
/** Значение периода — дата (YYYY-MM-DD) или полный ISO-таймстамп (пресет «24 ч»). */
export function parseRangeDate(s: string): Date | null {
if (s.includes("T")) {
const d = new Date(s)
return Number.isNaN(d.getTime()) ? null : d
}
return parseYmd(s)
}
export function rangeForPreset(preset: PeriodPreset, now = new Date()): DateRangeYmd { export function rangeForPreset(preset: PeriodPreset, now = new Date()): DateRangeYmd {
const to = formatYmd(now) const to = formatYmd(now)
if (preset === "today") return { from: to, to } if (preset === "today") return { from: to, to }
if (preset === "24h") { if (preset === "24h") {
const from = new Date(now) // Последние 24 часа от сейчас: полный ISO, иначе date-only «вчера…сегодня» даёт до ~48 ч.
from.setDate(from.getDate() - 1) return {
return { from: formatYmd(from), to } from: new Date(now.getTime() - 24 * 3600_000).toISOString(),
to: now.toISOString(),
}
} }
if (preset === "7d") { if (preset === "7d") {
const from = new Date(now) const from = new Date(now)
@@ -110,15 +121,20 @@ export function rangeToSelector(range: DateRangeYmd): DateSelectorValue {
return { return {
period: "day", period: "day",
operator: "between", operator: "between",
startDate: parseYmd(range.from) ?? undefined, startDate: parseRangeDate(range.from) ?? undefined,
endDate: parseYmd(range.to) ?? undefined, endDate: parseRangeDate(range.to) ?? undefined,
} }
} }
function formatRangeLabel(range: DateRangeYmd): string { function formatRangeLabel(range: DateRangeYmd): string {
const from = parseYmd(range.from) const from = parseRangeDate(range.from)
const to = parseYmd(range.to) const to = parseRangeDate(range.to)
if (!from || !to) return "Период" if (!from || !to) return "Период"
if (range.from.includes("T") || range.to.includes("T")) {
return formatYmd(from) === formatYmd(to)
? `${format(from, "d MMM yyyy HH:mm", { locale: ru })} ${format(to, "HH:mm", { locale: ru })}`
: `${format(from, "d MMM HH:mm", { locale: ru })} ${format(to, "d MMM HH:mm", { locale: ru })}`
}
if (range.from === range.to) return format(from, "d MMM yyyy", { locale: ru }) if (range.from === range.to) return format(from, "d MMM yyyy", { locale: ru })
return `${format(from, "d MMM", { locale: ru })} ${format(to, "d MMM yyyy", { locale: ru })}` return `${format(from, "d MMM", { locale: ru })} ${format(to, "d MMM yyyy", { locale: ru })}`
} }
@@ -145,6 +161,12 @@ export function PeriodSelector({
) )
const activePreset = PRESETS.find((p) => { const activePreset = PRESETS.find((p) => {
// «24 ч» хранится ISO-таймстампами: сверяем разбором (длительность окна), а не строками.
if (p.id === "24h") {
const f = parseRangeDate(range.from)
const t = parseRangeDate(range.to)
return Boolean(f && t && Math.abs((t.getTime() - f.getTime()) - 24 * 3600_000) < 60_000)
}
const r = rangeForPreset(p.id) const r = rangeForPreset(p.id)
return r.from === range.from && r.to === range.to return r.from === range.from && r.to === range.to
})?.id })?.id
+21
View File
@@ -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 концов.
+13
View File
@@ -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(),
@@ -329,6 +337,10 @@ export const flowMapHopsDtoSchema = z.object({
services: z.array(flowMapServiceDtoSchema).optional(), services: z.array(flowMapServiceDtoSchema).optional(),
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(), serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
servicePaths: z.array(flowMapServicePathDtoSchema).optional(), servicePaths: z.array(flowMapServicePathDtoSchema).optional(),
countries: z.array(flowMapServiceDtoSchema).optional(),
countryEdges: z.array(flowMapServiceEdgeDtoSchema).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(),
@@ -353,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>
+1 -1
View File
File diff suppressed because one or more lines are too long