feat(traffic-flow): раскрытие сервисов страны на карте сети
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Контракт: countryServiceGroups (группа сервисов страны) в flowMapHopsDto, тип FlowMapCountryServiceGroup. - Бэкенд: вложенная агрегация brand×ISO в том же проходе hops; рёбра страна→сервис, пути с реальным EN; cap 8 / min 4, доля от байтов страны. - Хелпер mapCountryServiceNodeId: id вида cc:us|svc:google, сервисы разных стран не смешиваются. - Карта сети: клик по стране сдвигает колонку стран и раскрывает столбец её сервисов; остальные страны приглушены; панели страны и вложенного сервиса. - Тесты: группы US/US+NL, рёбра от страны, cap/min вложенного слоя; mock countryServiceGroups для демо-режима.
This commit is contained in:
+302
-13
@@ -27,7 +27,9 @@ import {
|
||||
findServerByGreRemote,
|
||||
greSourceWanIndexOnMap,
|
||||
greTunnelProbe,
|
||||
placeCountryServiceNodes,
|
||||
placeServiceNodes,
|
||||
SERVICE_COL_W,
|
||||
type GreMapEdge,
|
||||
type WanJhEdge,
|
||||
} from "@/lib/network-map-layout"
|
||||
@@ -55,7 +57,7 @@ import {
|
||||
matchNetflowForWan,
|
||||
type MatchedNetflowHop,
|
||||
} 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 { CountryFlagSvg } from "@/components/network-map/country-flag-svg"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -324,6 +326,29 @@ const MOCK_MAP_COUNTRY_PATHS: FlowMapServicePath[] = [
|
||||
{ clientId: "u2", clientName: "Bob", viaId: "srv1", viaName: "mt-msk-core-01", enId: "srv3", enName: "mt-fra-edge-01", serviceId: "cc:de", bytes: 9_000_000, bps: 3_600_000 },
|
||||
]
|
||||
|
||||
/** Доли — от байтов страны 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"
|
||||
|
||||
@@ -782,6 +807,8 @@ function ServiceNode({
|
||||
isDragged,
|
||||
destMode,
|
||||
iso,
|
||||
dim,
|
||||
shareLabel,
|
||||
onClick,
|
||||
onMouseDown,
|
||||
}: {
|
||||
@@ -794,6 +821,10 @@ function ServiceNode({
|
||||
isDragged: boolean
|
||||
destMode: DestMode
|
||||
iso?: string
|
||||
/** Приглушение узла при раскрытии другой страны (остаётся на холсте). */
|
||||
dim?: boolean
|
||||
/** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */
|
||||
shareLabel?: string
|
||||
onClick: () => void
|
||||
onMouseDown: (e: React.MouseEvent) => void
|
||||
}) {
|
||||
@@ -804,11 +835,11 @@ function ServiceNode({
|
||||
<g
|
||||
transform={`translate(${x},${y})`}
|
||||
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) }}
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
<title>{`${label} · ${serviceSharePct(share)} payload окна`}</title>
|
||||
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
|
||||
{isSel && (
|
||||
<rect
|
||||
x={-bw / 2 - 6}
|
||||
@@ -1154,6 +1185,7 @@ export default function NetworkMapPage() {
|
||||
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 [mapNamedBytes, setMapNamedBytes] = useState(0)
|
||||
const [mapTotalBytes, setMapTotalBytes] = useState(0)
|
||||
@@ -1258,6 +1290,7 @@ export default function NetworkMapPage() {
|
||||
setMapCountries(MOCK_MAP_COUNTRIES)
|
||||
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
|
||||
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
|
||||
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
|
||||
setMapSharePct(5)
|
||||
setMapNamedBytes(0)
|
||||
setMapTotalBytes(0)
|
||||
@@ -1290,6 +1323,8 @@ export default function NetworkMapPage() {
|
||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||
const [selected, setSelected] = useState<Server | 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()))
|
||||
@@ -1297,15 +1332,32 @@ export default function NetworkMapPage() {
|
||||
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
|
||||
? (
|
||||
(destMode === "countries" ? mapCountries : mapServices)
|
||||
.find((s) => s.id === selectedService.id) ?? selectedService
|
||||
.find((s) => s.id === selectedService.id)
|
||||
?? nestedServices.find((s) => s.id === selectedService.id)
|
||||
?? selectedService
|
||||
)
|
||||
: null
|
||||
const selectedNestedService = liveSelectedService
|
||||
&& nestedServices.some((s) => s.id === liveSelectedService.id)
|
||||
? liveSelectedService
|
||||
: null
|
||||
const expandedCountry = expandedCountryId
|
||||
? mapCountries.find((c) => c.id === expandedCountryId) ?? null
|
||||
: null
|
||||
const [highlightedPath, setHighlightedPath] = useState<{ viaId: string; enId: string; serviceId: string } | null>(null)
|
||||
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
@@ -1359,6 +1411,7 @@ export default function NetworkMapPage() {
|
||||
setMapCountries(MOCK_MAP_COUNTRIES)
|
||||
setMapCountryEdges(MOCK_MAP_COUNTRY_EDGES)
|
||||
setMapCountryPaths(MOCK_MAP_COUNTRY_PATHS)
|
||||
setMapCountryServiceGroups(MOCK_COUNTRY_SERVICE_GROUPS)
|
||||
setMapSharePct(5)
|
||||
setMapCountryLoaded(true)
|
||||
})
|
||||
@@ -1373,6 +1426,7 @@ export default function NetworkMapPage() {
|
||||
setMapCountries([])
|
||||
setMapCountryEdges([])
|
||||
setMapCountryPaths([])
|
||||
setMapCountryServiceGroups([])
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -1391,6 +1445,7 @@ export default function NetworkMapPage() {
|
||||
setMapCountries(res.countries ?? [])
|
||||
setMapCountryEdges(res.countryEdges ?? [])
|
||||
setMapCountryPaths(res.countryPaths ?? [])
|
||||
setMapCountryServiceGroups(res.countryServiceGroups ?? [])
|
||||
if (res.mapServiceMinSharePct != null) setMapSharePct(res.mapServiceMinSharePct)
|
||||
setMapNamedBytes(res.namedBytes ?? 0)
|
||||
setMapTotalBytes(res.totalBytes ?? 0)
|
||||
@@ -1627,8 +1682,24 @@ export default function NetworkMapPage() {
|
||||
.map((s) => nodePosById[s.id])
|
||||
.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(
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1713,6 +1784,7 @@ export default function NetworkMapPage() {
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
setExpandedCountryId(null)
|
||||
}
|
||||
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
||||
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
||||
@@ -1810,6 +1882,7 @@ export default function NetworkMapPage() {
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
setExpandedCountryId(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1845,12 +1918,33 @@ export default function NetworkMapPage() {
|
||||
|
||||
// ── Side panel ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!selectedService) return
|
||||
if (!destNodes.some((s) => s.id === selectedService.id)) {
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
if (
|
||||
expandedCountryId
|
||||
&& (!mapCountries.some((s) => s.id === expandedCountryId)
|
||||
|| !mapCountryServiceGroups.some((g) => g.countryId === expandedCountryId))
|
||||
) {
|
||||
queueMicrotask(() => setExpandedCountryId(null))
|
||||
}
|
||||
}, [destMode, destNodes, selectedService])
|
||||
}, [mapCountries, mapCountryServiceGroups, expandedCountryId])
|
||||
useEffect(() => {
|
||||
if (!selectedService) return
|
||||
const stillVisible =
|
||||
destNodes.some((s) => s.id === selectedService.id)
|
||||
|| nestedServices.some((s) => s.id === selectedService.id)
|
||||
if (!stillVisible) {
|
||||
queueMicrotask(() => {
|
||||
setSelectedService(null)
|
||||
setHighlightedPath(null)
|
||||
})
|
||||
}
|
||||
}, [destMode, destNodes, nestedServices, selectedService])
|
||||
useEffect(() => {
|
||||
if (!showServices) queueMicrotask(() => setExpandedCountryId(null))
|
||||
}, [showServices])
|
||||
// Сдвиг колонки стран меняет систему координат: сбрасываем drag-овчины сервисов.
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => setServicePositions({}))
|
||||
}, [expandedCountryId])
|
||||
function selectServer(s: Server) {
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
@@ -1865,6 +1959,10 @@ export default function NetworkMapPage() {
|
||||
setSelWanIdx(null)
|
||||
setHighlightedPath(null)
|
||||
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) {
|
||||
setSelectedGreEdge(null)
|
||||
@@ -2413,6 +2511,7 @@ export default function NetworkMapPage() {
|
||||
y={pos.y}
|
||||
isSel={selectedService?.id === svc.id}
|
||||
isVis
|
||||
dim={Boolean(expandedCountryGroup) && svc.id !== expandedCountryId}
|
||||
isDragged={draggedSvcId === svc.id}
|
||||
destMode={destMode}
|
||||
iso={svc.label}
|
||||
@@ -2425,6 +2524,31 @@ export default function NetworkMapPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Сервисы раскрытой страны (второй столбец справа) ── */}
|
||||
{expandedCountryGroup && nestedServices.map((svc) => {
|
||||
const pos = nestedPosById[svc.id]
|
||||
if (!pos) return null
|
||||
return (
|
||||
<ServiceNode
|
||||
key={svc.id}
|
||||
label={svc.label}
|
||||
share={svc.share}
|
||||
x={pos.x}
|
||||
y={pos.y}
|
||||
isSel={selectedService?.id === svc.id}
|
||||
isVis
|
||||
isDragged={draggedSvcId === svc.id}
|
||||
destMode="services"
|
||||
shareLabel="трафика страны"
|
||||
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
|
||||
onClick={() => {
|
||||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||||
selectService(svc)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── EN → destination services (поверх узлов, чтобы пунктир не прятался) ── */}
|
||||
{visibleServiceEdges.map((edge) => {
|
||||
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
|
||||
@@ -2455,7 +2579,8 @@ export default function NetworkMapPage() {
|
||||
&& highlightedPath.serviceId === edge.toId,
|
||||
)
|
||||
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 enName = mapServers.find((s) => s.id === edge.fromId)?.name ?? edge.fromId
|
||||
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
|
||||
@@ -2463,7 +2588,7 @@ export default function NetworkMapPage() {
|
||||
return (
|
||||
<g
|
||||
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" }}
|
||||
>
|
||||
<title>{pathTitle}</title>
|
||||
@@ -2509,6 +2634,81 @@ export default function NetworkMapPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Раскрытая страна → её сервисы ── */}
|
||||
{expandedCountryGroup?.edges.map((edge) => {
|
||||
const from = servicePosById[edge.fromId]
|
||||
const to = nestedPosById[edge.toId]
|
||||
if (!from || !to) return null
|
||||
const hop: MatchedNetflowHop = {
|
||||
bytes: edge.bytes,
|
||||
bps: edge.bps,
|
||||
bpsFwd: edge.bpsFwd,
|
||||
bpsRev: edge.bpsRev,
|
||||
}
|
||||
const clipped = clipSegmentCircleToRect(
|
||||
from.x,
|
||||
from.y,
|
||||
MAP_SERVICE_NODE_W / 2,
|
||||
to.x,
|
||||
to.y,
|
||||
MAP_SERVICE_NODE_W / 2,
|
||||
MAP_SERVICE_NODE_H / 2,
|
||||
)
|
||||
const { mx, my } = edgeBadgePosition(clipped.x1, clipped.y1, clipped.x2, clipped.y2, 0.55, 16)
|
||||
const hl = selectedService?.id === edge.toId
|
||||
const svc = nestedServices.find((s) => s.id === edge.toId)
|
||||
const country = mapCountries.find((s) => s.id === edge.fromId)
|
||||
const clientLabel = (edge.clients?.map((c) => c.name).filter(Boolean).join(", ") || edge.clientName || "—")
|
||||
const pathTitle = `${clientLabel} → ${destDisplayLabel(country, "countries", edge.fromId)} → ${edge.toId.split("|")[1] ?? edge.toId}`
|
||||
return (
|
||||
<g
|
||||
key={`${edge.fromId}|${edge.toId}`}
|
||||
opacity={hl ? 1 : 0.72}
|
||||
style={{ transition: "opacity 0.3s" }}
|
||||
>
|
||||
<title>{pathTitle}</title>
|
||||
<line
|
||||
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={hopHasRate(hop) ? 2 : 1.3}
|
||||
strokeDasharray="4 4"
|
||||
opacity="0.9"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<line
|
||||
x1={clipped.x1} y1={clipped.y1} x2={clipped.x2} y2={clipped.y2}
|
||||
stroke="#00000000"
|
||||
strokeWidth={14}
|
||||
strokeLinecap="round"
|
||||
style={{ cursor: "pointer" }}
|
||||
onPointerDown={(ev) => { ev.stopPropagation() }}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
if (svc) selectService(svc)
|
||||
}}
|
||||
/>
|
||||
{showAnimDots && hopHasRate(hop) && (
|
||||
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
|
||||
<animateMotion dur="2.6s" repeatCount="indefinite"
|
||||
path={`M ${clipped.x1} ${clipped.y1} L ${clipped.x2} ${clipped.y2}`} />
|
||||
</circle>
|
||||
)}
|
||||
{hopHasRate(hop) && (
|
||||
<NetflowRateBadge
|
||||
mx={mx}
|
||||
my={my}
|
||||
hop={hop}
|
||||
onOpen={(ev) => {
|
||||
ev.stopPropagation()
|
||||
const hit = nestedServices.find((s) => s.id === edge.toId)
|
||||
if (hit) selectService(hit)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Hover tooltip ── */}
|
||||
{hoveredNode && !isDragging && (
|
||||
<SvgTooltip n={hoveredNode} />
|
||||
@@ -2613,7 +2813,7 @@ export default function NetworkMapPage() {
|
||||
satPos={effectiveSatPos}
|
||||
wanJhEdges={visibleWanJhEdges}
|
||||
homeRouters={homeRouters}
|
||||
servicePos={servicePosById}
|
||||
servicePos={{ ...servicePosById, ...nestedPosById }}
|
||||
onClose={() => setShowMinimap(false)}
|
||||
onPan={(x, y) => setPan({ x, y })}
|
||||
/>
|
||||
@@ -2848,6 +3048,71 @@ export default function NetworkMapPage() {
|
||||
})()}
|
||||
</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 ? (
|
||||
<>
|
||||
<div className="flex items-start gap-2 px-4 py-3 border-b">
|
||||
@@ -2924,6 +3189,30 @@ export default function NetworkMapPage() {
|
||||
</span>
|
||||
</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>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">Выход</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
|
||||
Reference in New Issue
Block a user