Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db64621122 |
+290
-16
@@ -17,11 +17,14 @@ import {
|
||||
buildServerResourceMap,
|
||||
buildWanJhEdges,
|
||||
computeNetworkMapLayout,
|
||||
NETWORK_MAP_H,
|
||||
NETWORK_MAP_LAYOUT_REVISION,
|
||||
NETWORK_MAP_PIPELINE_Y,
|
||||
NETWORK_MAP_W,
|
||||
findServerByGreRemote,
|
||||
greSourceWanIndexOnMap,
|
||||
greTunnelProbe,
|
||||
placeServiceNodes,
|
||||
type GreMapEdge,
|
||||
type WanJhEdge,
|
||||
} from "@/lib/network-map-layout"
|
||||
@@ -49,7 +52,8 @@ import {
|
||||
matchNetflowForWan,
|
||||
type MatchedNetflowHop,
|
||||
} from "@/lib/map-netflow-hops"
|
||||
import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow"
|
||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
|
||||
import { ServiceBrandIcon } from "@/components/network-map/service-brand-icon"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
@@ -235,8 +239,8 @@ function Sparkline({ history }: { history: number[] }) {
|
||||
|
||||
// ─── Canvas dimensions ────────────────────────────────────────────────────────
|
||||
|
||||
const W = 1060
|
||||
const H = 580
|
||||
const W = NETWORK_MAP_W
|
||||
const H = NETWORK_MAP_H
|
||||
const ZOOM_MIN = 0.2
|
||||
const ZOOM_MAX = 6
|
||||
|
||||
@@ -271,6 +275,24 @@ const TYPE_LABELS: Record<ServerType, string> = {
|
||||
"home-router": "Home Router",
|
||||
}
|
||||
|
||||
const MOCK_MAP_SERVICES: FlowMapService[] = [
|
||||
{ id: "svc:google", label: "Google", category: "Веб", bytes: 22_000_000, bps: 8_800_000, share: 0.22 },
|
||||
{ id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 14_000_000, bps: 5_600_000, share: 0.14 },
|
||||
{ id: "svc:aws", label: "AWS", category: "CDN", bytes: 9_000_000, bps: 3_600_000, share: 0.09 },
|
||||
]
|
||||
|
||||
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 },
|
||||
{ fromId: "srv3", toId: "svc:google", bytes: 8_000_000, bps: 3_200_000, bpsFwd: 2_400_000, bpsRev: 800_000 },
|
||||
{ fromId: "srv2", toId: "svc:cloudflare", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_800_000, bpsRev: 800_000 },
|
||||
{ fromId: "srv3", toId: "svc:cloudflare", bytes: 5_000_000, bps: 2_000_000, bpsFwd: 1_500_000, bpsRev: 500_000 },
|
||||
{ fromId: "srv3", toId: "svc:aws", bytes: 9_000_000, bps: 3_600_000, bpsFwd: 2_700_000, bpsRev: 900_000 },
|
||||
]
|
||||
|
||||
function serviceSharePct(share: number): string {
|
||||
return `${Math.round(share * 100)}%`
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function pingColor(ms: number | null) {
|
||||
@@ -608,6 +630,74 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
|
||||
)
|
||||
}
|
||||
|
||||
function ServiceNode({
|
||||
label,
|
||||
share,
|
||||
x,
|
||||
y,
|
||||
isSel,
|
||||
isVis,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
share: number
|
||||
x: number
|
||||
y: number
|
||||
isSel: boolean
|
||||
isVis: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
const bw = 86
|
||||
const bh = 58
|
||||
return (
|
||||
<g
|
||||
transform={`translate(${x},${y})`}
|
||||
style={{ cursor: "pointer", transition: "opacity 0.25s" }}
|
||||
opacity={isVis ? 1 : 0.08}
|
||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||
>
|
||||
<title>{`${label} · ${serviceSharePct(share)} трафика окна`}</title>
|
||||
{isSel && (
|
||||
<rect
|
||||
x={-bw / 2 - 6}
|
||||
y={-bh / 2 - 6}
|
||||
width={bw + 12}
|
||||
height={bh + 12}
|
||||
rx="12"
|
||||
fill="none"
|
||||
stroke="rgba(56,189,248,0.55)"
|
||||
strokeWidth="1.5"
|
||||
strokeDasharray="4 3"
|
||||
/>
|
||||
)}
|
||||
<rect
|
||||
x={-bw / 2}
|
||||
y={-bh / 2}
|
||||
width={bw}
|
||||
height={bh}
|
||||
rx="10"
|
||||
fill="#08202c"
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={isSel ? 2.2 : 1.4}
|
||||
/>
|
||||
<foreignObject x={-14} y={-24} width={28} height={28} style={{ overflow: "visible", pointerEvents: "none" }}>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "center", width: 28, height: 28 }}
|
||||
{...({ xmlns: "http://www.w3.org/1999/xhtml" } as Record<string, string>)}
|
||||
>
|
||||
<ServiceBrandIcon label={label} size={22} />
|
||||
</div>
|
||||
</foreignObject>
|
||||
<text textAnchor="middle" y="14" fontSize="8.5" fontWeight="700" fill="#e0f2fe" fontFamily="ui-monospace,monospace">
|
||||
{label}
|
||||
</text>
|
||||
<text textAnchor="middle" y="25" fontSize="7.5" fill="#67e8f9" fontFamily="ui-monospace,monospace">
|
||||
{serviceSharePct(share)}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
|
||||
x: number; y: number
|
||||
wan: { name: string; isp: string; maxDl: number; maxUl: number }
|
||||
@@ -723,13 +813,14 @@ function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void })
|
||||
|
||||
const MM_W = 172, MM_H = 94
|
||||
|
||||
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, onClose, onPan }: {
|
||||
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: {
|
||||
pan: { x: number; y: number }; zoom: number
|
||||
nodes: (Server & { x: number; y: number })[]
|
||||
greEdges: GreMapEdge[]
|
||||
satPos: Record<string, { x: number; y: number }[]>
|
||||
wanJhEdges: WanJhEdge[]
|
||||
homeRouters: Server[]
|
||||
servicePos: Record<string, { x: number; y: number }>
|
||||
onClose: () => void
|
||||
onPan: (x: number, y: number) => void
|
||||
}) {
|
||||
@@ -782,6 +873,10 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
|
||||
fill={WAN_COLORS[i] + "33"} stroke={WAN_COLORS[i]} strokeWidth="3" opacity="0.7" />
|
||||
))
|
||||
)}
|
||||
{Object.entries(servicePos).map(([id, p]) => (
|
||||
<rect key={id} x={p.x - 14} y={p.y - 10} width="28" height="20" rx="4"
|
||||
fill="#08202c" stroke="#22d3ee" strokeWidth="3" opacity="0.85" />
|
||||
))}
|
||||
{/* viewport rect */}
|
||||
<rect x={pan.x} y={pan.y} width={W / zoom} height={H / zoom}
|
||||
fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.6)" strokeWidth="6" rx="6" />
|
||||
@@ -841,6 +936,8 @@ export default function NetworkMapPage() {
|
||||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
||||
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
||||
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
|
||||
const [mapServices, setMapServices] = useState<FlowMapService[]>([])
|
||||
const [mapServiceEdges, setMapServiceEdges] = useState<FlowMapServiceEdge[]>([])
|
||||
/** FQDN из GRE outer → IPv4 (ответ POST /api/network/resolve-hosts), для матчинга с WAN. */
|
||||
const [greResolvedIpv4ByHost, setGreResolvedIpv4ByHost] = useState<Record<string, string>>({})
|
||||
const [dataError, setDataError] = useState<string | null>(null)
|
||||
@@ -932,6 +1029,9 @@ export default function NetworkMapPage() {
|
||||
setMapGreTunnels(mockGreTunnels)
|
||||
setSpeedProbes([])
|
||||
setGreResolvedIpv4ByHost({})
|
||||
setMapHops([])
|
||||
setMapServices(MOCK_MAP_SERVICES)
|
||||
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
|
||||
setDataError(null)
|
||||
})
|
||||
return
|
||||
@@ -959,6 +1059,7 @@ export default function NetworkMapPage() {
|
||||
|
||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||
const [selected, setSelected] = useState<Server | null>(null)
|
||||
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
|
||||
const [selWanIdx, setSelWanIdx] = useState<number | null>(null)
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null)
|
||||
|
||||
@@ -993,21 +1094,44 @@ export default function NetworkMapPage() {
|
||||
const [search, setSearch] = useState("")
|
||||
const [showPingBadges, setShowPingBadges] = useState(true)
|
||||
const [showNetflow, setShowNetflow] = useState(true)
|
||||
const [showServices, setShowServices] = useState(true)
|
||||
const [showAnimDots, setShowAnimDots] = useState(true)
|
||||
const [showMinimap, setShowMinimap] = useState(true)
|
||||
const [showHints, setShowHints] = useState(false)
|
||||
const [showLayers, setShowLayers] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!useLiveData || !showNetflow) {
|
||||
queueMicrotask(() => setMapHops([]))
|
||||
if (!useLiveData) {
|
||||
queueMicrotask(() => {
|
||||
setMapHops([])
|
||||
setMapServices(MOCK_MAP_SERVICES)
|
||||
setMapServiceEdges(MOCK_MAP_SERVICE_EDGES)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!showNetflow && !showServices) {
|
||||
queueMicrotask(() => {
|
||||
setMapHops([])
|
||||
setMapServices([])
|
||||
setMapServiceEdges([])
|
||||
})
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const tick = () => {
|
||||
apiFetch<FlowMapHopsDto>("/api/traffic/flow/map-hops?range=5m")
|
||||
.then((res) => { if (!cancelled) setMapHops(res.hops ?? []) })
|
||||
.catch(() => { if (!cancelled) setMapHops([]) })
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
setMapHops(res.hops ?? [])
|
||||
setMapServices(res.services ?? [])
|
||||
setMapServiceEdges(res.serviceEdges ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setMapHops([])
|
||||
setMapServices([])
|
||||
setMapServiceEdges([])
|
||||
})
|
||||
}
|
||||
tick()
|
||||
const id = window.setInterval(tick, 4000)
|
||||
@@ -1015,7 +1139,7 @@ export default function NetworkMapPage() {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [useLiveData, showNetflow, apiFetch])
|
||||
}, [useLiveData, showNetflow, showServices, apiFetch])
|
||||
|
||||
const effectiveSatPos = useMemo(() => {
|
||||
const out: Record<string, { x: number; y: number }[]> = {}
|
||||
@@ -1203,6 +1327,11 @@ export default function NetworkMapPage() {
|
||||
return m
|
||||
}, [homeRouters, wanJhEdges, mapHops, showNetflow])
|
||||
|
||||
const visibleMapServices = showServices ? mapServices : []
|
||||
const visibleServiceEdges = showServices ? mapServiceEdges.filter((e) =>
|
||||
visibleMapServices.some((s) => s.id === e.toId),
|
||||
) : []
|
||||
|
||||
const nodes = mapServers
|
||||
.map((s) => ({ ...s, ...nodePosById[s.id]! }))
|
||||
// Визуальный приоритет: HR поверх JH, JH поверх EN.
|
||||
@@ -1215,6 +1344,14 @@ export default function NetworkMapPage() {
|
||||
})
|
||||
const nodeById = Object.fromEntries(nodes.map((n) => [n.id, n]))
|
||||
|
||||
const servicePosById = placeServiceNodes(
|
||||
visibleMapServices.map((s) => s.id),
|
||||
mapServers
|
||||
.filter((s) => s.type === "exit-node")
|
||||
.map((s) => nodePosById[s.id])
|
||||
.filter((p): p is { x: number; y: number } => Boolean(p)),
|
||||
)
|
||||
|
||||
// ── Refs ─────────────────────────────────────────────────────────────────────
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
|
||||
@@ -1289,7 +1426,7 @@ export default function NetworkMapPage() {
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) }
|
||||
if (e.key === "Escape") { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
|
||||
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
||||
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
||||
if (e.key === "0" || e.key.toLowerCase() === "f") fitView()
|
||||
@@ -1377,7 +1514,7 @@ export default function NetworkMapPage() {
|
||||
const moved = dragRef.current?.moved ?? false
|
||||
dragRef.current = null
|
||||
setIsDragging(false)
|
||||
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null) }
|
||||
if (!moved) { setSelected(null); setSelWanIdx(null); setSelectedGreEdge(null); setSelectedService(null) }
|
||||
}
|
||||
|
||||
// ── Node drag start ──────────────────────────────────────────────────────
|
||||
@@ -1409,12 +1546,20 @@ export default function NetworkMapPage() {
|
||||
// ── Side panel ────────────────────────────────────────────────────────────
|
||||
function selectServer(s: Server) {
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setSelected(prev => prev?.id === s.id ? null : s)
|
||||
setSelWanIdx(null)
|
||||
setHoveredId(null)
|
||||
}
|
||||
function selectService(svc: FlowMapService) {
|
||||
setSelectedGreEdge(null)
|
||||
setSelected(null)
|
||||
setSelWanIdx(null)
|
||||
setSelectedService((prev: FlowMapService | null) => prev?.id === svc.id ? null : svc)
|
||||
}
|
||||
function selectWan(s: Server, wanIdx: number) {
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setSelected(s)
|
||||
setSelWanIdx(prev => prev === wanIdx && selected?.id === s.id ? null : wanIdx)
|
||||
}
|
||||
@@ -1424,6 +1569,7 @@ export default function NetworkMapPage() {
|
||||
const home = mapServers.find((s) => s.id === edge.homeId)
|
||||
if (!home) return
|
||||
setSelectedGreEdge(null)
|
||||
setSelectedService(null)
|
||||
setSelected(home)
|
||||
setSelWanIdx(edge.wanIdx)
|
||||
setHoveredId(null)
|
||||
@@ -1565,6 +1711,7 @@ export default function NetworkMapPage() {
|
||||
{([
|
||||
{ key: "showPingBadges", label: "Ping-значки", val: showPingBadges, set: setShowPingBadges, hint: "P" },
|
||||
{ key: "showNetflow", label: "NetFlow", val: showNetflow, set: setShowNetflow, hint: "" },
|
||||
{ key: "showServices", label: "Сервисы", val: showServices, set: setShowServices, hint: "" },
|
||||
{ key: "showAnimDots", label: "Анимация трафика", val: showAnimDots, set: setShowAnimDots, hint: "" },
|
||||
{ key: "showMinimap", label: "Минимап", val: showMinimap, set: setShowMinimap, hint: "M" },
|
||||
{ key: "showHints", label: "Горячие клавиши", val: showHints, set: setShowHints, hint: "" },
|
||||
@@ -1705,6 +1852,7 @@ export default function NetworkMapPage() {
|
||||
ev.stopPropagation()
|
||||
setSelectedGreEdge(e)
|
||||
setSelected(null)
|
||||
setSelectedService(null)
|
||||
setSelWanIdx(null)
|
||||
}
|
||||
return (
|
||||
@@ -1845,6 +1993,50 @@ export default function NetworkMapPage() {
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── EN/JH → destination services ── */}
|
||||
{visibleServiceEdges.map((edge) => {
|
||||
const from = nodeById[edge.fromId] ?? nodePosById[edge.fromId]
|
||||
const to = servicePosById[edge.toId]
|
||||
if (!from || !to) return null
|
||||
const hop: MatchedNetflowHop = {
|
||||
bytes: edge.bytes,
|
||||
bps: edge.bps,
|
||||
bpsFwd: edge.bpsFwd,
|
||||
bpsRev: edge.bpsRev,
|
||||
}
|
||||
const { mx, my } = edgeBadgePosition(from.x, from.y, to.x, to.y, 0.55, 16)
|
||||
const hl = selectedService?.id === edge.toId || selected?.id === edge.fromId
|
||||
return (
|
||||
<g key={`${edge.fromId}|${edge.toId}`} opacity={hl ? 1 : 0.72} style={{ transition: "opacity 0.3s" }}>
|
||||
<line
|
||||
x1={from.x} y1={from.y} x2={to.x} y2={to.y}
|
||||
stroke="#22d3ee"
|
||||
strokeWidth={hopHasRate(hop) ? 2.2 : 1.3}
|
||||
strokeDasharray="5 5"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{showAnimDots && hopHasRate(hop) && (
|
||||
<circle r="3" fill="#67e8f9" opacity="0.85" pointerEvents="none">
|
||||
<animateMotion dur="2.6s" repeatCount="indefinite"
|
||||
path={`M ${from.x} ${from.y} L ${to.x} ${to.y}`} />
|
||||
</circle>
|
||||
)}
|
||||
{hopHasRate(hop) && (
|
||||
<NetflowRateBadge
|
||||
mx={mx}
|
||||
my={my}
|
||||
hop={hop}
|
||||
onOpen={(ev) => {
|
||||
ev.stopPropagation()
|
||||
const svc = visibleMapServices.find((s) => s.id === edge.toId)
|
||||
if (svc) selectService(svc)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Server nodes ── */}
|
||||
{nodes.map(n => (
|
||||
<ServerNode
|
||||
@@ -1891,6 +2083,26 @@ export default function NetworkMapPage() {
|
||||
}).filter(Boolean)
|
||||
})}
|
||||
|
||||
{visibleMapServices.map((svc) => {
|
||||
const pos = servicePosById[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
|
||||
onClick={() => {
|
||||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||||
selectService(svc)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* ── Hover tooltip ── */}
|
||||
{hoveredNode && !isDragging && (
|
||||
<SvgTooltip n={hoveredNode} />
|
||||
@@ -1898,7 +2110,7 @@ export default function NetworkMapPage() {
|
||||
|
||||
{/* ── Legend (viewport-fixed) ── */}
|
||||
<g transform={`translate(${pan.x + 14}, ${pan.y + 14})`}>
|
||||
<rect width="140" height="224" rx="8"
|
||||
<rect width="140" height="250" rx="8"
|
||||
fill="rgba(6,13,26,0.88)" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
|
||||
<text x="10" y="22" fontSize="8" fontWeight="700" fill="#64748b"
|
||||
fontFamily="system-ui" letterSpacing="0.08em">ЛЕГЕНДА</text>
|
||||
@@ -1927,12 +2139,17 @@ export default function NetworkMapPage() {
|
||||
</g>
|
||||
))}
|
||||
|
||||
<line x1="10" y1="166" x2="130" y2="166" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
|
||||
<g transform="translate(10, 164)">
|
||||
<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>
|
||||
</g>
|
||||
|
||||
<text x="10" y="180" fontSize="7.5" fontWeight="700" fill="#475569"
|
||||
<line x1="10" y1="186" x2="130" y2="186" stroke="rgba(255,255,255,0.07)" strokeWidth="1" />
|
||||
|
||||
<text x="10" y="200" fontSize="7.5" fontWeight="700" fill="#475569"
|
||||
fontFamily="system-ui" letterSpacing="0.05em">WAN АПЛИНКИ</text>
|
||||
{WAN_COLORS.slice(0, 2).map((c, i) => (
|
||||
<g key={i} transform={`translate(10, ${190 + i * 14})`}>
|
||||
<g key={i} transform={`translate(10, ${210 + i * 14})`}>
|
||||
<circle cx="5" cy="4" r="4" fill={c} opacity="0.9" />
|
||||
<text x="16" y="8" fontSize="8" fill="#94a3b8" fontFamily="ui-monospace,monospace">
|
||||
WAN{i + 1}
|
||||
@@ -1988,6 +2205,7 @@ export default function NetworkMapPage() {
|
||||
satPos={effectiveSatPos}
|
||||
wanJhEdges={visibleWanJhEdges}
|
||||
homeRouters={homeRouters}
|
||||
servicePos={servicePosById}
|
||||
onClose={() => setShowMinimap(false)}
|
||||
onPan={(x, y) => setPan({ x, y })}
|
||||
/>
|
||||
@@ -2016,7 +2234,7 @@ export default function NetworkMapPage() {
|
||||
</div>
|
||||
|
||||
{/* ── Side panel (узел или выбранное GRE-ребро) ── */}
|
||||
{(selectedGreEdge || selected) && (
|
||||
{(selectedGreEdge || selected || selectedService) && (
|
||||
<div className="border-l flex flex-col overflow-hidden shrink-0 bg-background" style={{ width: 300 }}>
|
||||
{selectedGreEdge ? (
|
||||
<>
|
||||
@@ -2222,6 +2440,62 @@ export default function NetworkMapPage() {
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
) : selectedService ? (
|
||||
<>
|
||||
<div className="flex items-start gap-2 px-4 py-3 border-b">
|
||||
<div className="mt-0.5">
|
||||
<ServiceBrandIcon label={selectedService.label} size={22} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-mono font-semibold text-sm truncate">{selectedService.label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Конечный сервис · {selectedService.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(selectedService.share)}</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: selectedService.bytes,
|
||||
bps: selectedService.bps,
|
||||
bpsFwd: selectedService.bps,
|
||||
bpsRev: 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">С узлов</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{visibleServiceEdges.filter((e) => e.toId === selectedService.id).map((e) => {
|
||||
const src = mapServers.find((s) => s.id === e.fromId)
|
||||
return (
|
||||
<div key={`${e.fromId}|${e.toId}`} className="flex items-center justify-between text-xs">
|
||||
<span className="font-mono truncate">{src?.name ?? e.fromId}</span>
|
||||
<span className="font-mono text-emerald-400 tabular-nums">
|
||||
{formatNetflowRate({ bytes: e.bytes, bps: e.bps, bpsFwd: e.bpsFwd, bpsRev: e.bpsRev })}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : selected ? (
|
||||
<>
|
||||
<div className="flex items-start gap-2 px-4 py-3 border-b">
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
countryFromHolder,
|
||||
lookupBrand,
|
||||
OTHER_SERVICE,
|
||||
isNamedInternetService,
|
||||
mapServiceNodeId,
|
||||
resolveRipeCountry,
|
||||
} from "./traffic-flow-brands.js"
|
||||
|
||||
@@ -21,9 +23,17 @@ assert.equal(brandByAsn(15169)?.category, "Веб")
|
||||
assert.equal(lookupBrand("208.65.153.1", 0)?.service, "YouTube")
|
||||
assert.equal(brandByAsn(32590)?.service, "Steam")
|
||||
assert.equal(brandByAsn(32590)?.category, "Игры")
|
||||
assert.equal(brandByAsn(16509)?.service, "AWS")
|
||||
assert.equal(brandByAsn(57976)?.service, "Blizzard")
|
||||
assert.equal(brandByAsn(401115)?.service, "ChatGPT")
|
||||
assert.equal(lookupBrand("1.1.1.1", 13335)?.service, "Cloudflare")
|
||||
assert.equal(lookupBrand("203.0.113.9", 64500), null)
|
||||
assert.equal(OTHER_SERVICE, "Прочее")
|
||||
assert.equal(isNamedInternetService("Google", "Веб"), true)
|
||||
assert.equal(isNamedInternetService("Прочее", "Прочее"), false)
|
||||
assert.equal(isNamedInternetService("GRE", "Туннель"), false)
|
||||
assert.equal(isNamedInternetService("DNS", "DNS"), false)
|
||||
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
|
||||
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
|
||||
|
||||
console.log("traffic-flow-brands.test.ts: ok")
|
||||
|
||||
@@ -12,11 +12,12 @@ const ASN_BRANDS = new Map<number, BrandHit>([
|
||||
[209242, { service: "Cloudflare", category: "CDN" }],
|
||||
[54113, { service: "Fastly", category: "CDN" }],
|
||||
[20940, { service: "Akamai", category: "CDN" }],
|
||||
[16509, { service: "Amazon", category: "CDN" }],
|
||||
[14618, { service: "Amazon", category: "CDN" }],
|
||||
[16509, { service: "AWS", category: "CDN" }],
|
||||
[14618, { service: "AWS", category: "CDN" }],
|
||||
[8075, { service: "Microsoft", category: "CDN" }],
|
||||
[13238, { service: "Yandex", category: "CDN" }],
|
||||
[32590, { service: "Steam", category: "Игры" }],
|
||||
[57976, { service: "Blizzard", category: "Игры" }],
|
||||
[2906, { service: "Netflix", category: "Видео / стриминг" }],
|
||||
[40027, { service: "Netflix", category: "Видео / стриминг" }],
|
||||
[15169, { service: "Google", category: "Веб" }],
|
||||
@@ -41,6 +42,7 @@ const ASN_HQ_COUNTRY = new Map<number, string>([
|
||||
[8075, "US"],
|
||||
[15169, "US"],
|
||||
[32590, "US"],
|
||||
[57976, "US"],
|
||||
[2906, "US"],
|
||||
[40027, "US"],
|
||||
[36040, "US"],
|
||||
@@ -105,3 +107,32 @@ export function brandByCidr(ip: string): BrandHit | null {
|
||||
export function lookupBrand(ip: string, asn: number): BrandHit | null {
|
||||
return brandByCidr(ip) || brandByAsn(asn)
|
||||
}
|
||||
|
||||
const SKIP_MAP_SERVICES = new Set([
|
||||
OTHER_SERVICE,
|
||||
"GRE",
|
||||
"ESP",
|
||||
"WireGuard",
|
||||
"DNS",
|
||||
"SSH",
|
||||
"BGP",
|
||||
])
|
||||
|
||||
const SKIP_MAP_CATEGORIES = new Set(["Туннель", "DNS", "SSH", "BGP"])
|
||||
|
||||
/** Именованный интернет-сервис для карты (не туннель и не «Прочее»). */
|
||||
export function isNamedInternetService(service: string, category: string): boolean {
|
||||
const s = service.trim()
|
||||
const c = category.trim()
|
||||
if (!s || SKIP_MAP_SERVICES.has(s) || SKIP_MAP_CATEGORIES.has(c)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function mapServiceNodeId(label: string): string {
|
||||
const slug = label
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
return `svc:${slug || "unknown"}`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
disableRipeEnqueueForTests,
|
||||
disableRipePersistForTests,
|
||||
resetRipeCacheForTests,
|
||||
seedRipeCacheForTests,
|
||||
} from "./traffic-flow-ripe.js"
|
||||
|
||||
disableCatalogFetchForTests()
|
||||
@@ -154,4 +155,119 @@ try {
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
|
||||
console.log("traffic-flow-map-hops.test.ts: hops ok")
|
||||
|
||||
function googleRipe() {
|
||||
seedRipeCacheForTests({
|
||||
prefix: "8.8.8.0/24",
|
||||
asn: 15169,
|
||||
country: "US",
|
||||
lat: 37.4,
|
||||
lng: -122.1,
|
||||
holder: "GOOGLE",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
function payloadFlow(dst: string, bytes: number) {
|
||||
return {
|
||||
src: "10.100.1.17",
|
||||
dst,
|
||||
proto: 6,
|
||||
srcPort: 51234,
|
||||
dstPort: 443,
|
||||
bytes,
|
||||
packets: Math.max(1, Math.round(bytes / 1200)),
|
||||
inIface: "2",
|
||||
outIface: "3",
|
||||
nextHop: "198.51.100.1",
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const six = buildFlowMapHops({ minutes: 5 })
|
||||
assert.equal(six.totalBytes, 10_000)
|
||||
const google = six.services?.find((s) => s.id === "svc:google")
|
||||
assert.ok(google, "Google ≥ 5%")
|
||||
assert.ok(google.share >= 0.05)
|
||||
assert.ok(six.serviceEdges?.some((e) => e.toId === "svc:google" && e.fromId === "9"))
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
}
|
||||
|
||||
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", 400),
|
||||
payloadFlow("203.0.113.50", 9600),
|
||||
])
|
||||
try {
|
||||
const four = buildFlowMapHops({ minutes: 5 })
|
||||
assert.equal(four.totalBytes, 10_000)
|
||||
assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden")
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
}
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
seedFlowTopologyForTests(topo)
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "gre-client" },
|
||||
{ ".id": "*3", name: "gre-jh-en" },
|
||||
])
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
{
|
||||
src: "203.0.113.10",
|
||||
dst: "198.51.100.1",
|
||||
proto: 47,
|
||||
srcPort: 0,
|
||||
dstPort: 0,
|
||||
bytes: 9_000,
|
||||
packets: 90,
|
||||
inIface: "3",
|
||||
outIface: "3",
|
||||
},
|
||||
payloadFlow("203.0.113.50", 1000),
|
||||
])
|
||||
try {
|
||||
const greOnly = buildFlowMapHops({ minutes: 5, excludeOverlay: false })
|
||||
assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service")
|
||||
} finally {
|
||||
seedFlowTopologyForTests(null)
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
resetFlowCatalogForTests()
|
||||
}
|
||||
|
||||
console.log("traffic-flow-map-hops.test.ts: ok")
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FlowMapHop, FlowMapHopsDto } from "@mmapp/contracts/traffic-flow"
|
||||
import type { FlowMapHop, FlowMapHopsDto, FlowMapService, FlowMapServiceEdge } from "@mmapp/contracts/traffic-flow"
|
||||
import { db } from "../db/index.js"
|
||||
import { servers, userInterfaceBindings } from "../db/schema.js"
|
||||
import { flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||
import {
|
||||
isNamedInternetService,
|
||||
mapServiceNodeId,
|
||||
} from "./traffic-flow-brands.js"
|
||||
import { classifyFlowDst, refreshFlowCatalogInBackground } from "./traffic-flow-classify.js"
|
||||
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
||||
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
|
||||
import { resolveIfaceName } from "./traffic-flow-ifaces.js"
|
||||
import { classifyFlowPlane, shouldKeepPlane } from "./traffic-flow-planes.js"
|
||||
import { enqueueRipeMisses, lookupRipeCached } from "./traffic-flow-ripe.js"
|
||||
import { loadFlowTopology, resolveEn } from "./traffic-flow-topology.js"
|
||||
|
||||
export const MAP_SERVICE_SHARE_THRESHOLD = 0.05
|
||||
|
||||
export interface FlowMapHopsQuery {
|
||||
minutes: number
|
||||
serverId?: number
|
||||
@@ -114,6 +122,12 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
|
||||
|
||||
const working = wantDedup ? dedupFlowRowsMaxBytes(matched) : matched
|
||||
const hops = new Map<string, HopAcc>()
|
||||
const svcTotals = new Map<string, { label: string; category: string; bytes: number }>()
|
||||
const svcEdges = new Map<string, { fromId: string; toId: string; bytes: number; bytesFwd: number; bytesRev: number }>()
|
||||
const dsts = new Set<string>()
|
||||
let totalBytes = 0
|
||||
|
||||
refreshFlowCatalogInBackground()
|
||||
|
||||
for (const r of working) {
|
||||
const inRes = resolveIfaceName(r.serverId, r.inIface)
|
||||
@@ -199,8 +213,62 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
|
||||
}, r.bytes, "fwd")
|
||||
}
|
||||
}
|
||||
|
||||
totalBytes += r.bytes
|
||||
dsts.add(r.dst)
|
||||
const ripe = lookupRipeCached(r.dst)
|
||||
const classified = classifyFlowDst(r.dst, r.proto, r.dstPort, r.srcPort, ripe)
|
||||
if (isNamedInternetService(classified.service, classified.category)) {
|
||||
const toId = mapServiceNodeId(classified.service)
|
||||
const prevSvc = svcTotals.get(toId)
|
||||
if (prevSvc) prevSvc.bytes += r.bytes
|
||||
else svcTotals.set(toId, { label: classified.service, category: classified.category, bytes: r.bytes })
|
||||
|
||||
const svcEn = enOut ?? enIn
|
||||
const svcFromId = svcEn ? String(svcEn.id) : fromId
|
||||
const edgeKey = `${svcFromId}|${toId}`
|
||||
const prevEdge = svcEdges.get(edgeKey)
|
||||
if (prevEdge) {
|
||||
prevEdge.bytes += r.bytes
|
||||
prevEdge.bytesFwd += r.bytes
|
||||
} else {
|
||||
svcEdges.set(edgeKey, {
|
||||
fromId: svcFromId,
|
||||
toId,
|
||||
bytes: r.bytes,
|
||||
bytesFwd: r.bytes,
|
||||
bytesRev: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enqueueRipeMisses(dsts)
|
||||
|
||||
const services: FlowMapService[] = [...svcTotals.entries()]
|
||||
.map(([id, s]) => ({
|
||||
id,
|
||||
label: s.label,
|
||||
category: s.category,
|
||||
bytes: s.bytes,
|
||||
bps: (s.bytes * 8) / windowSec,
|
||||
share: totalBytes > 0 ? s.bytes / totalBytes : 0,
|
||||
}))
|
||||
.filter((s) => s.share >= MAP_SERVICE_SHARE_THRESHOLD)
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
const keepSvc = new Set(services.map((s) => s.id))
|
||||
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
||||
.filter((e) => keepSvc.has(e.toId))
|
||||
.map((e) => ({
|
||||
fromId: e.fromId,
|
||||
toId: e.toId,
|
||||
bytes: e.bytes,
|
||||
bps: (e.bytes * 8) / windowSec,
|
||||
bpsFwd: (e.bytesFwd * 8) / windowSec,
|
||||
bpsRev: (e.bytesRev * 8) / windowSec,
|
||||
}))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
|
||||
const listener = getFlowListenerState()
|
||||
return {
|
||||
hops: [...hops.values()]
|
||||
@@ -209,6 +277,9 @@ export function buildFlowMapHops(q: FlowMapHopsQuery): FlowMapHopsDto {
|
||||
live: listener.bound,
|
||||
rangeMinutes: q.minutes,
|
||||
windowSec,
|
||||
totalBytes,
|
||||
services,
|
||||
serviceEdges,
|
||||
dedupApplied: wantDedup,
|
||||
excludeMeshApplied: excludeMesh,
|
||||
excludeOverlayApplied: excludeOverlay,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
function slug(label: string): string {
|
||||
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
function GenericCloud({ size }: { size: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
|
||||
<path
|
||||
d="M7.5 18h9.2A4.3 4.3 0 0 0 21 13.8a4.2 4.2 0 0 0-3.7-4.2A6.1 6.1 0 0 0 6.2 11 3.8 3.8 0 0 0 3 14.7 3.7 3.7 0 0 0 6.8 18Z"
|
||||
fill="#38bdf8"
|
||||
opacity="0.92"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandSvg({ children, size }: { children: ReactNode; size: number }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: number }) {
|
||||
switch (slug(label)) {
|
||||
case "cloudflare":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M6.2 15.4h12.4c1.6 0 2.6-1.1 2.4-2.4-.2-1.4-1.4-2.1-2.8-2.1-.3-2.4-2.3-4.1-4.8-4.1-1.9 0-3.5 1-4.4 2.5-.4-.2-.9-.3-1.4-.3-1.7 0-3.1 1.3-3.2 3-.1 1.8 1.3 3.4 3.2 3.4Z" fill="#F38020" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "google":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M12 11.2h10.2A10 10 0 1 0 12 22a9.6 9.6 0 0 0 6.6-2.6l-2.7-2.1A5.8 5.8 0 1 1 12 6.2c1.5 0 2.8.5 3.8 1.5l2.6-2.6A9.8 9.8 0 0 0 12 2Z" fill="#4285F4" />
|
||||
<path d="M3.2 7.2 6.6 9.7A5.8 5.8 0 0 1 16 8.2l2.7-2.6A9.8 9.8 0 0 0 3.2 7.2Z" fill="#EA4335" />
|
||||
<path d="M12 22a9.6 9.6 0 0 0 6.6-2.6l-2.7-2.1A5.8 5.8 0 0 1 6.5 14.4L3 17A9.8 9.8 0 0 0 12 22Z" fill="#34A853" />
|
||||
<path d="M21.8 12.2H12v3.6h5.6A5.5 5.5 0 0 1 15.9 17.3l2.7 2.1A9.4 9.4 0 0 0 22 12.2Z" fill="#FBBC05" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "aws":
|
||||
case "amazon":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M6.2 8.2 12 5.4l5.8 2.8v3.4L12 14.6 6.2 11.6Z" fill="#232F3E" />
|
||||
<path d="M5.2 15.6c3.6 2.6 9.8 2.7 13.6 0" fill="none" stroke="#FF9900" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "steam":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<circle cx="12" cy="12" r="10" fill="#1b2838" />
|
||||
<circle cx="8.2" cy="14.4" r="3.1" fill="#66c0f4" />
|
||||
<circle cx="15.4" cy="9.2" r="3.6" fill="#c7d5e0" />
|
||||
<circle cx="15.4" cy="9.2" r="1.5" fill="#1b2838" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "blizzard":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M6 5h7.4c3 0 4.8 1.6 4.8 4.1 0 1.8-1 3.1-2.6 3.7 2 .5 3.2 2 3.2 4.1 0 2.8-2.1 4.6-5.6 4.6H6Z" fill="#00AEFF" />
|
||||
<path d="M9.2 8.2h3.4c1.2 0 1.8.6 1.8 1.5s-.6 1.5-1.8 1.5H9.2Zm0 5.2h3.8c1.3 0 2 .6 2 1.6s-.7 1.6-2 1.6H9.2Z" fill="#06121f" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "youtube":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<rect x="2" y="6" width="20" height="12" rx="3" fill="#FF0000" />
|
||||
<path d="M10.2 9.2v5.6L15.6 12Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "netflix":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M6 3h3.2l5.6 18H11.6Z" fill="#E50914" />
|
||||
<path d="M14.8 3H18v18h-3.2Z" fill="#B81D24" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "microsoft":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<rect x="3" y="3" width="8" height="8" fill="#F25022" />
|
||||
<rect x="13" y="3" width="8" height="8" fill="#7FBA00" />
|
||||
<rect x="3" y="13" width="8" height="8" fill="#00A4EF" />
|
||||
<rect x="13" y="13" width="8" height="8" fill="#FFB900" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "meta":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M4 14.5c1.8-4.2 4-7.5 6.4-7.5 1.6 0 2.5 1.3 4.6 6.3 1.4 3.4 2.2 4.7 3.4 4.7 1.8 0 3.6-2.6 4.6-5" fill="none" stroke="#0081FB" strokeWidth="2.2" strokeLinecap="round" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "telegram":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<circle cx="12" cy="12" r="10" fill="#229ED9" />
|
||||
<path d="M7.2 12.1 16.8 8.4 15 16.2l-3.1-1.8-1.6 1.6-.2-2.6Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "discord":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M7.2 5.8 8.6 4.6c2.1.8 4.2 1.2 6.4 1.2h.8L17 5.8c1.8 2.4 2.6 5.4 2.4 8.6-1.6 1.2-3.3 2.1-5.2 2.6L13 15.2c.7-.2 1.3-.6 1.8-1.1-2 .9-4.2.9-6.2 0 .5.5 1.1.9 1.8 1.1L8.8 17c-1.9-.5-3.6-1.4-5.2-2.6C3.4 11.2 4.2 8.2 6 5.8Z" fill="#5865F2" />
|
||||
<circle cx="9.2" cy="11.2" r="1.2" fill="#fff" />
|
||||
<circle cx="14.8" cy="11.2" r="1.2" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "twitch":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M5 4h14v10.2l-4 4H11l-2.2 2.2H7.2V18.2H5Z" fill="#9146FF" />
|
||||
<path d="M7.4 6.4h1.8v5.2H7.4Zm4 0h1.8v5.2H11.4Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "tiktok":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M14.2 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H14.2Z" fill="#25F4EE" />
|
||||
<path d="M13.4 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H13.4Z" fill="#FE2C55" transform="translate(1.2 1)" />
|
||||
</BrandSvg>
|
||||
)
|
||||
default:
|
||||
return <GenericCloud size={size} />
|
||||
}
|
||||
}
|
||||
@@ -35,9 +35,10 @@ export function greTunnelProbe(t: GreTunnel): TunnelProbe {
|
||||
}
|
||||
}
|
||||
|
||||
const W = 1060
|
||||
const W = 1240
|
||||
const H = 580
|
||||
const MARGIN = 72
|
||||
const SERVICE_COL_W = 150
|
||||
|
||||
/** Одна горизонтальная «полка» на карте: Home → JH → Exit слева направо. */
|
||||
export const NETWORK_MAP_PIPELINE_Y = 300
|
||||
@@ -68,7 +69,9 @@ function layerOfServer(s: Server): number | null {
|
||||
* Увеличивать при изменении алгоритма раскладки спутников/узлов.
|
||||
* Страница карты сбрасывает сохранённые перетаскивания при смене значения (в т.ч. после hot reload).
|
||||
*/
|
||||
export const NETWORK_MAP_LAYOUT_REVISION = 6
|
||||
export const NETWORK_MAP_W = W
|
||||
export const NETWORK_MAP_H = H
|
||||
export const NETWORK_MAP_LAYOUT_REVISION = 7
|
||||
|
||||
export interface WanJhEdge {
|
||||
homeId: string
|
||||
@@ -254,7 +257,7 @@ export function computeNetworkMapLayout(
|
||||
const nodePos: Record<string, { x: number; y: number }> = {}
|
||||
const wanSatPos: Record<string, { x: number; y: number }[]> = {}
|
||||
|
||||
const span = W - 2 * MARGIN
|
||||
const span = W - 2 * MARGIN - SERVICE_COL_W
|
||||
const laneGap = Math.min(44, span * 0.04)
|
||||
const laneW = (span - 2 * laneGap) / 3
|
||||
|
||||
@@ -425,6 +428,28 @@ export function computeNetworkMapLayout(
|
||||
return { nodePos, wanSatPos }
|
||||
}
|
||||
|
||||
/** Колонка конечных сервисов справа от EN. */
|
||||
export function placeServiceNodes(
|
||||
serviceIds: string[],
|
||||
enPositions: Array<{ x: number; y: number }>,
|
||||
): Record<string, { x: number; y: number }> {
|
||||
const out: Record<string, { x: number; y: number }> = {}
|
||||
if (serviceIds.length === 0) return out
|
||||
const minY = MARGIN + 70
|
||||
const maxY = H - 72
|
||||
const x = W - MARGIN - SERVICE_COL_W / 2
|
||||
const enYs = enPositions.map((p) => p.y).filter((y) => Number.isFinite(y))
|
||||
const centerY = enYs.length ? enYs.reduce((a, b) => a + b, 0) / enYs.length : (minY + maxY) / 2
|
||||
const n = serviceIds.length
|
||||
const gap = Math.min(96, (maxY - minY) / Math.max(1, n))
|
||||
const span = gap * (n - 1)
|
||||
const start = clamp(centerY - span / 2, minY, maxY - span)
|
||||
serviceIds.forEach((id, i) => {
|
||||
out[id] = { x, y: n === 1 ? clamp(centerY, minY, maxY) : start + i * gap }
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Суммарная задержка «дом → JH» в миллисекундах: те же поля `Server.latency`, что показываются в разделе Серверы.
|
||||
* Отдельного ICMP по ребру нет — это не замер линии, а сумма каталожных latency концов.
|
||||
|
||||
@@ -263,11 +263,32 @@ export const flowMapHopDtoSchema = z.object({
|
||||
bpsRev: z.number().nonnegative(),
|
||||
})
|
||||
|
||||
export const flowMapServiceDtoSchema = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
category: z.string(),
|
||||
bytes: z.number().nonnegative(),
|
||||
bps: z.number().nonnegative(),
|
||||
share: z.number().min(0).max(1),
|
||||
})
|
||||
|
||||
export const flowMapServiceEdgeDtoSchema = z.object({
|
||||
fromId: z.string(),
|
||||
toId: z.string(),
|
||||
bytes: z.number().nonnegative(),
|
||||
bps: z.number().nonnegative(),
|
||||
bpsFwd: z.number().nonnegative(),
|
||||
bpsRev: z.number().nonnegative(),
|
||||
})
|
||||
|
||||
export const flowMapHopsDtoSchema = z.object({
|
||||
hops: z.array(flowMapHopDtoSchema),
|
||||
live: z.boolean(),
|
||||
rangeMinutes: z.number().int().positive(),
|
||||
windowSec: z.number().positive(),
|
||||
totalBytes: z.number().nonnegative().optional(),
|
||||
services: z.array(flowMapServiceDtoSchema).optional(),
|
||||
serviceEdges: z.array(flowMapServiceEdgeDtoSchema).optional(),
|
||||
dedupApplied: z.boolean(),
|
||||
excludeMeshApplied: z.boolean(),
|
||||
excludeOverlayApplied: z.boolean(),
|
||||
@@ -287,4 +308,6 @@ export type FlowMonthlyDto = z.infer<typeof flowMonthlyDtoSchema>
|
||||
export type FlowPurgeDto = z.infer<typeof flowPurgeDtoSchema>
|
||||
export type FlowMapHopKind = z.infer<typeof flowMapHopKindSchema>
|
||||
export type FlowMapHop = z.infer<typeof flowMapHopDtoSchema>
|
||||
export type FlowMapService = z.infer<typeof flowMapServiceDtoSchema>
|
||||
export type FlowMapServiceEdge = z.infer<typeof flowMapServiceEdgeDtoSchema>
|
||||
export type FlowMapHopsDto = z.infer<typeof flowMapHopsDtoSchema>
|
||||
|
||||
Reference in New Issue
Block a user