fix(ui): обновлять детали и пояснять модель карты сети

This commit is contained in:
Denozordec committed 2026-10-01 02:15:52 +07:00
1 parent 6cb27b2aab
commit 0cdb43a29d
6 files changed
+133 -63

No files matched your search

+66 -60
View File
@@ -436,7 +436,7 @@ function pingColor(ms: number | null) {
return "#fb923c" return "#fb923c"
} }
function latencyColor(ms: number | null) { function latencyColor(ms: number | null) {
if (ms == null) return "#f87171" if (ms == null) return "var(--muted-foreground)"
if (ms > 60) return "#fbbf24" if (ms > 60) return "#fbbf24"
return "#4ade80" return "#4ade80"
} }
@@ -506,7 +506,7 @@ function PingBadge({ mx, my, ping, dl, ul, color }: {
</text> </text>
{hasSpeed && ( {hasSpeed && (
<text textAnchor="middle" y="10" fontSize="7" fill="#64748b" fontFamily="ui-monospace,monospace"> <text textAnchor="middle" y="10" fontSize="7" fill="#64748b" fontFamily="ui-monospace,monospace">
{`↓${dl} ↑${ul}`} {`↓${dl??"—"} ↑${ul??"—"}`}
</text> </text>
)} )}
</g> </g>
@@ -524,6 +524,7 @@ function GreEdgeMetricBadge({
rttFromMonitor, rttFromMonitor,
throughputFromMonitor, throughputFromMonitor,
outerSummary, outerSummary,
probe,
}: { }: {
mx: number mx: number
my: number my: number
@@ -536,11 +537,9 @@ function GreEdgeMetricBadge({
throughputFromMonitor?: boolean throughputFromMonitor?: boolean
/** Внешние IP GRE и WAN — только в подсказке при наведении */ /** Внешние IP GRE и WAN — только в подсказке при наведении */
outerSummary?: string outerSummary?: string
probe?: GreSpeedProbeSnapshot
}) { }) {
const hasSpeed = const hasSpeed = dl != null || ul != null
dl != null &&
ul != null &&
(throughputFromMonitor || dl > 0 || ul > 0)
const bw = 62 const bw = 62
const bh = 24 + (hasSpeed ? 14 : 0) const bh = 24 + (hasSpeed ? 14 : 0)
const v1 = pingMs == null ? "—" : `${pingMs} мс` const v1 = pingMs == null ? "—" : `${pingMs} мс`
@@ -548,8 +547,9 @@ function GreEdgeMetricBadge({
(outerSummary ? `${outerSummary}. ` : "") + (outerSummary ? `${outerSummary}. ` : "") +
(rttFromMonitor (rttFromMonitor
? "RTT — ping/BT из «Мониторинг → скорость». " ? "RTT — ping/BT из «Мониторинг → скорость». "
: "RTT — модель по данным туннеля. ") + : pingMs==null?"RTT неизвестен. ":"RTT — демонстрационная модель. ") +
(throughputFromMonitor ? "Строка ниже — TX/RX с пробы. " : "") + (throughputFromMonitor ? "Строка ниже — TX/RX с пробы. " : "") +
(probe?"Проба "+probe.id+"; время Ping: "+(probe.lastPingAt?new Date(probe.lastPingAt).toLocaleString("ru-RU"):"неизвестно")+"; время TX/RX неизвестно. ":"") +
"Клик — панель справа." "Клик — панель справа."
return ( return (
<g <g
@@ -679,7 +679,9 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
const ts = TYPE_STYLE[n.type] const ts = TYPE_STYLE[n.type]
return ( return (
<g transform={`translate(${n.x},${n.y})`} <g role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1}
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
transform={`translate(${n.x},${n.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.07} opacity={isVis ? 1 : 0.07}
onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }} onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
@@ -691,8 +693,6 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
> >
{n.status === "online" && isVis && ( {n.status === "online" && isVis && (
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7"> <circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
<animate attributeName="r" values={`${ts.r+10};${ts.r+18};${ts.r+10}`} dur="3s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.8;0.15;0.8" dur="3s" repeatCount="indefinite" />
</circle> </circle>
)} )}
{n.status === "degraded" && isVis && ( {n.status === "degraded" && isVis && (
@@ -753,13 +753,13 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
)} )}
{!hideCatalogLatency && ( {!hideCatalogLatency && (
<text textAnchor="middle" y="12" fontSize="8.5" fill={latencyColor(n.latency)} fontFamily="ui-monospace,monospace"> <text textAnchor="middle" y="12" fontSize="8.5" fill={latencyColor(n.latency)} fontFamily="ui-monospace,monospace">
{n.latency == null ? "офлайн" : `${n.latency} мс`} {n.latency == null ? "—" : `${n.latency} мс`}
</text> </text>
)} )}
{n.type === "home-router" && ( {(n.name && (
<text textAnchor="middle" y={hideCatalogLatency ? 15 : ts.r + 18} fontSize="8.5" fill="#94a3b8" <text textAnchor="middle" y={hideCatalogLatency ? 15 : ts.r + 18} fontSize="8.5" fill="#94a3b8"
fontFamily="ui-monospace,monospace">{shortName(n.name)}</text> fontFamily="ui-monospace,monospace">{shortName(n.name)}</text>
)} ))}
</g> </g>
) )
} }
@@ -1147,6 +1147,7 @@ function NetworkMapContent() {
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
const [mapServers, setMapServers] = useState<Server[]>([]) const [mapServers, setMapServers] = useState<Server[]>([])
const [sessionCounts,setSessionCounts]=useState<Record<string,number|null>>({})
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([]) const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([]) const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
const [mapHops, setMapHops] = useState<FlowMapHop[]>([]) const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
@@ -1206,6 +1207,7 @@ function NetworkMapContent() {
const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel) const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel)
if(!current())return if(!current())return
setMapServers(rows.map(mapBackendRowToServer));setMapGreTunnels(mappedTunnels) setMapServers(rows.map(mapBackendRowToServer));setMapGreTunnels(mappedTunnels)
setSessionCounts(Object.fromEntries(rows.map(row=>[String(row.id),row.sessions??null])))
setSpeedProbes(snapshot.probes.map(probe=>({...probe,srcInterface:probe.srcInterface||undefined,dstInterface:probe.dstInterface||undefined}))) setSpeedProbes(snapshot.probes.map(probe=>({...probe,srcInterface:probe.srcInterface||undefined,dstInterface:probe.dstInterface||undefined})))
setHasSnapshot(true);setReadAt(new Date().toISOString());setDataError(null) setHasSnapshot(true);setReadAt(new Date().toISOString());setDataError(null)
const hostSet = new Set<string>(collectGreEndpointHostnames(mappedTunnels)) const hostSet = new Set<string>(collectGreEndpointHostnames(mappedTunnels))
@@ -1283,7 +1285,7 @@ function NetworkMapContent() {
() => computeNetworkMapLayout(mapServers, mapGreTunnels, greResolvedMap), () => computeNetworkMapLayout(mapServers, mapGreTunnels, greResolvedMap),
[mapServers, mapGreTunnels, greResolvedMap], [mapServers, mapGreTunnels, greResolvedMap],
) )
const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers), [mapServers]) const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers).map(edge=>({...edge,active:!useLiveData&&edge.active})), [mapServers,useLiveData])
const srvResMap = useMemo(() => buildServerResourceMap(mapServers, !useLiveData), [mapServers,useLiveData]) const srvResMap = useMemo(() => buildServerResourceMap(mapServers, !useLiveData), [mapServers,useLiveData])
const homeRouters = useMemo( const homeRouters = useMemo(
() => mapServers.filter((s) => s.type === "home-router"), () => mapServers.filter((s) => s.type === "home-router"),
@@ -1296,7 +1298,8 @@ function NetworkMapContent() {
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
// ── Interaction ───────────────────────────────────────────────────────────── // ── Interaction ─────────────────────────────────────────────────────────────
const [selected, setSelected] = useState<Server | null>(null) const [selectedStored, setSelected] = useState<Server | null>(null)
const selected=selectedStored?mapServers.find(server=>server.id===selectedStored.id)??null:null
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null) const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
/** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */ /** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */
const [expandedCountryId, setExpandedCountryId] = useState<string | null>(null) const [expandedCountryId, setExpandedCountryId] = useState<string | null>(null)
@@ -1350,9 +1353,7 @@ function NetworkMapContent() {
if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) { if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) {
// Важно: без явного сброса старые drag-координаты остаются в state // Важно: без явного сброса старые drag-координаты остаются в state
// и перекрывают новый авто-лейаут на live-данных. // и перекрывают новый авто-лейаут на live-данных.
setNodePositions({}) queueMicrotask(()=>{setNodePositions({});setSatPositions({});setServicePositions({})})
setSatPositions({})
setServicePositions({})
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION)) sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
} }
} catch { } catch {
@@ -1363,7 +1364,7 @@ function NetworkMapContent() {
// ── Context menu ──────────────────────────────────────────────────────────── // ── Context menu ────────────────────────────────────────────────────────────
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null) const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
const [selectedGreEdge, setSelectedGreEdge] = useState<GreMapEdge | null>(null) const [selectedGreStored, setSelectedGreEdge] = useState<GreMapEdge | null>(null)
// ── Toggles ───────────────────────────────────────────────────────────────── // ── Toggles ─────────────────────────────────────────────────────────────────
const [filter, setFilter] = useState<FilterKey>("all") const [filter, setFilter] = useState<FilterKey>("all")
@@ -1524,6 +1525,8 @@ function NetworkMapContent() {
[greEdges, speedProbes, greResolvedMap], [greEdges, speedProbes, greResolvedMap],
) )
const selectedGreEdge=selectedGreStored?greEdges.find(edge=>greEdgeKey(edge)===greEdgeKey(selectedGreStored))??null:null
/** WAN→JH: не показывать GreEdgeMetricBadge со speed, если те же метрики уже на GRE (один бейдж на сегмент). */ /** WAN→JH: не показывать GreEdgeMetricBadge со speed, если те же метрики уже на GRE (один бейдж на сегмент). */
const suppressWanJhSpeedBadgeDuplicate = useMemo(() => { const suppressWanJhSpeedBadgeDuplicate = useMemo(() => {
const s = new Set<string>() const s = new Set<string>()
@@ -1697,8 +1700,7 @@ function NetworkMapContent() {
const zoomRef = useRef(zoom) const zoomRef = useRef(zoom)
const panRef = useRef(pan) const panRef = useRef(pan)
zoomRef.current = zoom useEffect(()=>{zoomRef.current=zoom;panRef.current=pan},[zoom,pan])
panRef.current = pan
// ── SVG export ─────────────────────────────────────────────────────────── // ── SVG export ───────────────────────────────────────────────────────────
function exportSvg() { function exportSvg() {
@@ -1746,6 +1748,27 @@ function NetworkMapContent() {
return () => svg.removeEventListener("wheel", handler) return () => svg.removeEventListener("wheel", handler)
}, []) }, [])
// ── Zoom helpers ──────────────────────────────────────────────────────────
const applyZoomCenter=useCallback((factor: number) => {
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
setPan(p => ({
x: p.x + (W / zoom - W / newZoom) / 2,
y: p.y + (H / zoom - H / newZoom) / 2,
}))
setZoom(newZoom)
},[zoom])
const fitView=useCallback(() => {
setPan({ x: 0, y: 0 })
setZoom(1)
},[])
function focusNode(x: number, y: number) {
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) })
setZoom(newZoom)
}
// ── Keyboard shortcuts ──────────────────────────────────────────────────── // ── Keyboard shortcuts ────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -1766,28 +1789,7 @@ function NetworkMapContent() {
} }
window.addEventListener("keydown", onKey) window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey)
}, [zoom, pan]) // eslint-disable-line react-hooks/exhaustive-deps -- applyZoomCenter/fitView ниже; handler обновляется через [zoom, pan] }, [applyZoomCenter,fitView])
// ── Zoom helpers ──────────────────────────────────────────────────────────
function applyZoomCenter(factor: number) {
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
setPan(p => ({
x: p.x + (W / zoom - W / newZoom) / 2,
y: p.y + (H / zoom - H / newZoom) / 2,
}))
setZoom(newZoom)
}
function fitView() {
setPan({ x: 0, y: 0 })
setZoom(1)
}
function focusNode(x: number, y: number) {
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) })
setZoom(newZoom)
}
// ── Drag ───────────────────────────────────────────────────────────────── // ── Drag ─────────────────────────────────────────────────────────────────
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) { function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
@@ -1966,7 +1968,7 @@ function NetworkMapContent() {
setHoveredId(null) setHoveredId(null)
} }
const connectedTunnels = selected const connectedTunnels = useMemo(()=>selected
? mapGreTunnels.filter((t) => { ? mapGreTunnels.filter((t) => {
if (t.serverId === selected.id) return true if (t.serverId === selected.id) return true
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap) const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
@@ -1983,7 +1985,7 @@ function NetworkMapContent() {
selectedWanIps.includes(local) selectedWanIps.includes(local)
) )
}) })
: [] : [],[selected,mapGreTunnels,mapServers,greResolvedMap])
const grePrimaryByGroupKey = useMemo(() => { const grePrimaryByGroupKey = useMemo(() => {
if (!selected) return new Map<string, string>() if (!selected) return new Map<string, string>()
const group = new Map<string, GreTunnel[]>() const group = new Map<string, GreTunnel[]>()
@@ -2182,6 +2184,7 @@ function NetworkMapContent() {
{useLiveData&&flowLoading&&(showNetflow||showServices)&&<span role="status">Обновление NetFlow…</span>} {useLiveData&&flowLoading&&(showNetflow||showServices)&&<span role="status">Обновление NetFlow…</span>}
{useLiveData&&flowError&&(showNetflow||showServices)&&<span role="alert" className="text-destructive break-all">NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}</span>} {useLiveData&&flowError&&(showNetflow||showServices)&&<span role="alert" className="text-destructive break-all">NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}</span>}
{useLiveData&&dnsError&&<span role="alert" className="text-destructive break-all">DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.</span>} {useLiveData&&dnsError&&<span role="alert" className="text-destructive break-all">DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.</span>}
<span>Пунктир WAN→JH — модель связности; маршрут не подтверждён.</span>
{dataLoading && <span role="status">Обновление снимка карты…</span>} {dataLoading && <span role="status">Обновление снимка карты…</span>}
{dataError && ( {dataError && (
<span role="alert" className="text-[10px] text-destructive break-all"> <span role="alert" className="text-[10px] text-destructive break-all">
@@ -2327,6 +2330,7 @@ function NetworkMapContent() {
dl={merged.dlMbps} dl={merged.dlMbps}
ul={merged.ulMbps} ul={merged.ulMbps}
onOpen={openGreDetail} onOpen={openGreDetail}
probe={spGre}
rttFromMonitor={rttFromMonitor} rttFromMonitor={rttFromMonitor}
throughputFromMonitor={throughputFromMonitor} throughputFromMonitor={throughputFromMonitor}
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)} outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
@@ -2395,7 +2399,7 @@ function NetworkMapContent() {
const kj = wanJhEdgeMapKey(edge) const kj = wanJhEdgeMapKey(edge)
const sp = speedProbeByWanJhKey.get(kj) const sp = speedProbeByWanJhKey.get(kj)
const merged = mergeGreMetricsWithSpeedProbe(sp, { const merged = mergeGreMetricsWithSpeedProbe(sp, {
pingMs: edge.pingMs, pingMs: useLiveData?null:edge.pingMs,
dlMbps: null, dlMbps: null,
ulMbps: null, ulMbps: null,
}) })
@@ -2411,6 +2415,7 @@ function NetworkMapContent() {
dl={merged.dlMbps} dl={merged.dlMbps}
ul={merged.ulMbps} ul={merged.ulMbps}
onOpen={(ev) => openWanJhSpeedDetail(ev, edge)} onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
probe={sp}
rttFromMonitor={sp?.lastPingRttMs != null} rttFromMonitor={sp?.lastPingRttMs != null}
throughputFromMonitor={ throughputFromMonitor={
sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null
@@ -2418,7 +2423,7 @@ function NetworkMapContent() {
/> />
) )
} }
return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} /> return <PingBadge mx={mx} my={my} ping={useLiveData?null:edge.pingMs} color={pingColor(useLiveData?null:edge.pingMs)} />
})()} })()}
{showNetflow && hopHasRate(wanFlow) && ( {showNetflow && hopHasRate(wanFlow) && (
<NetflowRateBadge <NetflowRateBadge
@@ -2744,7 +2749,7 @@ function NetworkMapContent() {
<line x1="38" y1="4" x2="60" y2="4" stroke={c} <line x1="38" y1="4" x2="60" y2="4" stroke={c}
strokeWidth={i === 0 ? 2 : 1.2} strokeDasharray={i === 0 ? "none" : "4 3"} opacity="0.8" /> strokeWidth={i === 0 ? 2 : 1.2} strokeDasharray={i === 0 ? "none" : "4 3"} opacity="0.8" />
<text x="66" y="8" fontSize="7.5" fill="#475569" fontFamily="system-ui"> <text x="66" y="8" fontSize="7.5" fill="#475569" fontFamily="system-ui">
{i === 0 ? "primary" : "backup"} {useLiveData?"модель":i === 0 ? "primary (демо)" : "backup (демо)"}
</text> </text>
</g> </g>
))} ))}
@@ -2901,7 +2906,7 @@ function NetworkMapContent() {
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<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 className="text-xs text-muted-foreground">
{rttMon ? "Мониторинг (ping/BT)" : "Модель RTT"} {rttMon ? "Мониторинг (ping/BT)" : useLiveData?"Ping неизвестен":"Модель RTT (демо)"}
</span> </span>
<span <span
className="text-xs font-mono font-semibold" className="text-xs font-mono font-semibold"
@@ -2912,11 +2917,11 @@ function NetworkMapContent() {
</div> </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 className="text-xs text-muted-foreground">
{bwMon ? "TX / RX (BT)" : "Скорость канала (модель)"} {bwMon ? "TX / RX (BT)" : useLiveData?"TX / RX неизвестны":"Скорость канала (демо)"}
</span> </span>
<span className="text-xs font-mono font-semibold text-sky-400"> <span className="text-xs font-mono font-semibold text-sky-400">
{merged.dlMbps != null && merged.ulMbps != null {(merged.dlMbps != null || merged.ulMbps != null)
? `↓${merged.dlMbps} ↑${merged.ulMbps}` ? `↓${merged.dlMbps??"—"} ↑${merged.ulMbps??"—"}`
: "—"} : "—"}
</span> </span>
</div> </div>
@@ -2935,8 +2940,9 @@ function NetworkMapContent() {
<p className="text-[10px] text-muted-foreground pt-2 leading-snug"> <p className="text-[10px] text-muted-foreground pt-2 leading-snug">
{merged.hasSpeedMonitor {merged.hasSpeedMonitor
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. " ? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. "
: "«Модель RTT» и «скорость канала» — демо до появления подходящей speed-пробы в «Мониторинг → скорость». "} : useLiveData?"Измерения GRE неизвестны: нет подходящей пробы. ":"Измерения демонстрационные. "}
Поток — живой NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала. {spGr?"Проба "+spGr.id+" · время Ping: "+(spGr.lastPingAt?new Date(spGr.lastPingAt).toLocaleString("ru-RU"):"неизвестно")+" · время TX/RX неизвестно. ":""}
Поток — снимок NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
</p> </p>
</div> </div>
</div> </div>
@@ -3267,7 +3273,7 @@ function NetworkMapContent() {
["Модель", selected.model], ["Модель", selected.model],
["RouterOS", selected.os], ["RouterOS", selected.os],
...(selected.type !== "home-router" ...(selected.type !== "home-router"
? [["ASN", selected.asn], ["Сессий", String(selected.sessions)]] ? [["ASN", selected.asn], ["Сессий", useLiveData?(sessionCounts[selected.id]==null?"—":String(sessionCounts[selected.id])):String(selected.sessions)]]
: [["LAN", selected.lanSubnet ?? "—"]]), : [["LAN", selected.lanSubnet ?? "—"]]),
].map(([k, v]) => ( ].map(([k, v]) => (
<div key={k} className="flex items-center justify-between py-2 border-b border-border/50 last:border-0"> <div key={k} className="flex items-center justify-between py-2 border-b border-border/50 last:border-0">
@@ -3278,7 +3284,7 @@ function NetworkMapContent() {
<div className="flex items-center justify-between py-2"> <div className="flex items-center justify-between py-2">
<span className="text-xs text-muted-foreground">Задержка</span> <span className="text-xs text-muted-foreground">Задержка</span>
<span className="text-xs font-mono font-semibold" style={{ color: latencyColor(selected.latency) }}> <span className="text-xs font-mono font-semibold" style={{ color: latencyColor(selected.latency) }}>
{selected.latency == null ? "— офлайн" : `${selected.latency} мс`} {selected.latency == null ? "—" : `${selected.latency} мс`}
</span> </span>
</div> </div>
</div> </div>
@@ -3310,7 +3316,7 @@ function NetworkMapContent() {
"ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full", "ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full",
isActive ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground", isActive ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
)}> )}>
{isActive ? "primary" : "backup"} {useLiveData?"Аплинк каталога":isActive ? "primary (демо)" : "backup (демо)"}
</span> </span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -3339,7 +3345,7 @@ function NetworkMapContent() {
{myEdges.length > 0 && ( {myEdges.length > 0 && (
<div className="mt-2 pt-2 border-t border-border/40"> <div className="mt-2 pt-2 border-t border-border/40">
<p className="text-[9px] uppercase text-muted-foreground tracking-wider mb-1.5"> <p className="text-[9px] uppercase text-muted-foreground tracking-wider mb-1.5">
Подключения к JH Модель связей с JH · Ping модели
</p> </p>
{myEdges.map((e) => { {myEdges.map((e) => {
const jh = mapServers.find(s => s.id === e.jhId) const jh = mapServers.find(s => s.id === e.jhId)
@@ -3405,7 +3411,7 @@ function NetworkMapContent() {
"text-[10px] font-medium px-1.5 py-0.5 rounded-full", "text-[10px] font-medium px-1.5 py-0.5 rounded-full",
isPrimary ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground", isPrimary ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
)}> )}>
{isPrimary ? "primary" : "backup"} {isPrimary ? "Основная на схеме" : "Дополнительная на схеме"}
</span> </span>
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}> <span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"} {t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
@@ -3418,7 +3424,7 @@ function NetworkMapContent() {
</span> </span>
{t.ipsec {t.ipsec
? <span className="text-[10px] text-emerald-400 flex items-center gap-0.5"><LockIcon className="size-2.5" />IPsec</span> ? <span className="text-[10px] text-emerald-400 flex items-center gap-0.5"><LockIcon className="size-2.5" />IPsec</span>
: <span className="text-[10px] text-muted-foreground flex items-center gap-0.5"><LockOpenIcon className="size-2.5" />Plain</span>} : <span className="text-[10px] text-muted-foreground flex items-center gap-0.5"><LockOpenIcon className="size-2.5" />{useLiveData?"IPsec неизвестен":"Plain (демо)"}</span>}
</div> </div>
{showMetrics && ( {showMetrics && (
<div className="flex items-center justify-between pt-1 border-t border-border/30"> <div className="flex items-center justify-between pt-1 border-t border-border/30">
+9 -1
View File
@@ -26,11 +26,19 @@
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.49; оптимизатор принят локально, карта сети в работе | | 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.50; оптимизатор принят локально, карта сети в работе |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
## Блок 10.50 — актуальные детали и модель связности карты (2026-10-01)
- Открытые детали server/GRE заново разрешаются по стабильной идентичности в текущем снимке: свежие name/status/resources не остаются на сохранённом объекте. Unknown latency/sessions/IPsec выводятся как неизвестность, не offline/0/Plain. Primary/backup GRE — только приоритет визуализации; WAN API — аплинк каталога.
- Пунктир WAN→JH прямо обозначен моделью связности. Live модель не анимируется как активный маршрут и не показывает сумму latency как RTT линии; Ping модели подписан отдельно в деталях. Probe значения сохраняют нули/частичные DL/UL; подпись показывает probe ID, дату Ping и неизвестное время скорости.
- Узлы всех ролей получили имя устройства и keyboard Enter/Space. Пульсация размера нарушала стабильность области кнопки; удалена, имя видно и для JH/Exit. Прежние React set-state/refs/immutability/exhaustive-deps и prefer-const устранены без подавления lint.
- 24 E2E карты, TypeScript и lint карты/helpers/tests прошли. Новая проверка подтверждает 0 Ping, дробную DL при неизвестной UL и старую дату пробы. Реальная сеть остаётся внешней приёмкой.
- Далее URL-контекст, клавиатурные действия остальных объектов и адаптивность/Frame/screenshots карты.
## Блок 10.49 — поздние ответы при смене источника карты (2026-10-01) ## Блок 10.49 — поздние ответы при смене источника карты (2026-10-01)
- Добавлена отдельная source-конфигурация Playwright (:3101, исходный backend :8000 и новый :8101). 12 сценариев snapshot/Flow/DNS × backend/mock × успех/отказ подтверждают, что прежняя карта, сервис и ошибка не попадают в новый источник, а старый ответ не запускает дополнительные чтения. - Добавлена отдельная source-конфигурация Playwright (:3101, исходный backend :8000 и новый :8101). 12 сценариев snapshot/Flow/DNS × backend/mock × успех/отказ подтверждают, что прежняя карта, сервис и ошибка не попадают в новый источник, а старый ответ не запускает дополнительные чтения.
+9
View File
@@ -267,3 +267,12 @@ Shared контракт и backend observed исходные значения/д
- Все 12 E2E и lint новых тестов/config прошли. Default suite исключает source-файл; Next серверы запускаются последовательно. - Все 12 E2E и lint новых тестов/config прошли. Default suite исключает source-файл; Next серверы запускаются последовательно.
- Далее выбранные детали из текущего снимка, подписи модельных WAN связей и прежние lint ошибки карты; затем URL, клавиатура, телефон и визуальная приёмка. - Далее выбранные детали из текущего снимка, подписи модельных WAN связей и прежние lint ошибки карты; затем URL, клавиатура, телефон и визуальная приёмка.
## Блок 10.50 — актуальные детали и модель связности карты (2026-10-01)
- Открытые детали server/GRE заново разрешаются по стабильной идентичности в текущем снимке: свежие name/status/resources не остаются на сохранённом объекте. Unknown latency/sessions/IPsec выводятся как неизвестность, не offline/0/Plain. Primary/backup GRE — только приоритет визуализации; WAN API — аплинк каталога.
- Пунктир WAN→JH прямо обозначен моделью связности. Live модель не анимируется как активный маршрут и не показывает сумму latency как RTT линии; Ping модели подписан отдельно в деталях. Probe значения сохраняют нули/частичные DL/UL; подпись показывает probe ID, дату Ping и неизвестное время скорости.
- Узлы всех ролей получили имя устройства и keyboard Enter/Space. Пульсация размера нарушала стабильность области кнопки; удалена, имя видно и для JH/Exit. Прежние React set-state/refs/immutability/exhaustive-deps и prefer-const устранены без подавления lint.
- 24 E2E карты, TypeScript и lint карты/helpers/tests прошли. Новая проверка подтверждает 0 Ping, дробную DL при неизвестной UL и старую дату пробы. Реальная сеть остаётся внешней приёмкой.
- Далее URL-контекст, клавиатурные действия остальных объектов и адаптивность/Frame/screenshots карты.
+1
View File
@@ -16,6 +16,7 @@ export interface GreSpeedProbeSnapshot {
dstInterface?: string dstInterface?: string
lastTxAvgMbps?: number | null lastTxAvgMbps?: number | null
lastRxAvgMbps?: number | null lastRxAvgMbps?: number | null
lastPingAt?: string | null
lastPingRttMs?: number | null lastPingRttMs?: number | null
} }
+2 -2
View File
@@ -826,8 +826,8 @@ export function buildGreMapEdges(
} }
for (const t of tunnels) { for (const t of tunnels) {
let a = servers.find((s) => s.id === t.serverId) const a = servers.find((s) => s.id === t.serverId)
let b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost) const b = findServerByGreRemote(servers, t.remoteAddress, resolvedIpv4ByHost)
if (!a || !b) continue if (!a || !b) continue
if (a.id === b.id) continue if (a.id === b.id) continue
+46
View File
@@ -0,0 +1,46 @@
import {test,expect} from "@playwright/test"
const empty={"hops":[],"live":false,"rangeMinutes":5,"windowSec":300,"totalBytes":0,"namedBytes":0,"unclassifiedBytes":0,"asnLoaded":false,"countryLoaded":false,"services":[],"serviceEdges":[],"servicePaths":[],"countries":[],"countryEdges":[],"countryPaths":[],"countryServiceGroups":[],"mapServiceMinSharePct":5,"dedupApplied":false,"excludeMeshApplied":false,"excludeOverlayApplied":false}
const wan={id:"w",name:"WAN",iface:"ether1",isp:"ISP",ip:"192.0.2.1",maxDl:100,maxUl:100}
const home={id:1,name:"Домашний",host:"192.0.2.1",type:"home-router",country:"RU",enabled:true,status:"online",latency:2,wanUplinks:[wan]}
const jh={id:2,name:"Jump",host:"192.0.2.2",type:"jump-host",country:"RU",enabled:true,status:"online",latency:3}
const tunnel={id:"t1",serverId:"1",name:"gre-home",localAddress:"192.0.2.1",remoteAddress:"192.0.2.2",localInnerIp:"10.0.0.1",remoteInnerIp:"10.0.0.2",poolId:"",ipsec:null,mtu:1476,keepaliveInterval:10,keepaliveRetries:3,dscp:"inherit",clampTcpMss:true,allowFastPath:false,comment:"",enabled:true,status:"up"}
test.beforeEach(async({page})=>{await page.route("**/health",r=>r.fulfill({json:{ok:true}}))})
test("Карта: открытые детали обновляются вместе со снимком",async({page})=>{
let changed=false
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[{...jh,name:changed?"Новый Jump":"Jump",status:changed?null:"online",cpuLoad:changed?0:20}]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})})
await page.goto("/network-map");await page.getByRole("button",{name:"Устройство Jump",exact:true}).click()
await expect(page.getByText("20%",{exact:true})).toBeVisible()
const sessions=page.getByText("Сессий",{exact:true}).locator("..");await expect(sessions).toHaveText("Сессий—")
changed=true;await page.getByRole("button",{name:"Обновить",exact:true}).click()
await expect(page.getByText("0%",{exact:true})).toBeVisible();await expect(page.locator("span").filter({hasText:/^Неизвестно$/}).first()).toBeVisible()
await expect(page.getByText("Jump",{exact:true})).toHaveCount(0);await expect(page.getByText("Новый Jump",{exact:true}).last()).toBeVisible()
})
test("Карта: модель WAN не утверждает активный маршрут и RTT линии",async({page})=>{
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[home,jh]:p==="/api/filters/gre-tunnels"?{tunnels:[],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})})
await page.goto("/network-map");await expect(page.getByText("Пунктир WAN→JH — модель связности; маршрут не подтверждён.",{exact:true})).toBeVisible()
await expect(page.locator("svg text").filter({hasText:/^5 мс$/})).toHaveCount(0)
await page.getByText("Домашний",{exact:true}).first().click()
await expect(page.getByText("Аплинк каталога",{exact:true})).toBeVisible();await expect(page.getByText("Модель связей с JH · Ping модели",{exact:true})).toBeVisible()
await expect(page.getByText("primary",{exact:true})).toHaveCount(0);await expect(page.getByText("backup",{exact:true})).toHaveCount(0)
})
test("Карта: выбранный GRE обновляется и неизвестный IPsec не означает Plain",async({page})=>{
let changed=false
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[home,jh]:p==="/api/filters/gre-tunnels"?{tunnels:[{...tunnel,name:changed?"gre-new":"gre-home",status:changed?"down":"up"}],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})})
await page.goto("/network-map")
await page.locator("g").filter({has:page.locator("title",{hasText:"RTT неизвестен."})}).last().click()
await expect(page.getByText("gre-home",{exact:true}).last()).toBeVisible();await expect(page.getByText("Ping неизвестен",{exact:true})).toBeVisible()
changed=true;await page.getByRole("button",{name:"Обновить",exact:true}).click()
await expect(page.getByText("gre-new",{exact:true}).last()).toBeVisible();await expect(page.getByText("Недоступен",{exact:true})).toBeVisible()
await page.getByText("Домашний",{exact:true}).first().click();await expect(page.getByText("IPsec неизвестен",{exact:true})).toBeVisible()
})
test("Карта: GRE сохраняет частичное измерение, ноль Ping и дату пробы",async({page})=>{
const probe={id:"measured-probe",srcServerId:"1",dstServerId:"2",srcInterface:"gre-home",dstInterface:"",enabled:true,lastPingRttMs:0,lastPingLossPct:0,lastTxAvgMbps:0.125,lastRxAvgMbps:null,lastPingAt:"2020-01-01T00:00:00Z"}
await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return r.fulfill({json:p==="/api/servers"?[home,jh]:p==="/api/filters/gre-tunnels"?{tunnels:[tunnel],failures:[]}:p==="/api/uptime/speed-probes"?{probes:[probe]}:p==="/api/traffic/flow/map-hops"?empty:p==="/api/auth/config"?{required:false}:{}})})
await page.goto("/network-map")
await page.locator("g").filter({has:page.locator("title",{hasText:"Проба measured-probe"})}).last().click()
await expect(page.getByText("0 мс",{exact:true}).last()).toBeVisible()
await expect(page.getByText("↓0.125 ↑—",{exact:true}).last()).toBeVisible()
await expect(page.getByText("Проба measured-probe",{exact:false}).last()).toContainText("2020")
await expect(page.getByText("Проба measured-probe",{exact:false}).last()).toContainText("время TX/RX неизвестно")
})