fix(ui): завершить доступное управление картой сети
No files matched your search
@@ -671,7 +671,7 @@ function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: {
|
function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onPointerDown }: {
|
||||||
n: Server & { x: number; y: number }
|
n: Server & { x: number; y: number }
|
||||||
labelScale?: number
|
labelScale?: number
|
||||||
isSel: boolean; isVis: boolean; isDragged: boolean
|
isSel: boolean; isVis: boolean; isDragged: boolean
|
||||||
@@ -681,7 +681,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
|
|||||||
onDblClick: () => void
|
onDblClick: () => void
|
||||||
onHoverChange: (hov: boolean) => void
|
onHoverChange: (hov: boolean) => void
|
||||||
onContextMenu: (e: React.MouseEvent) => void
|
onContextMenu: (e: React.MouseEvent) => void
|
||||||
onMouseDown: (e: React.MouseEvent) => void
|
onPointerDown: (e: React.PointerEvent<SVGGElement>) => void
|
||||||
}) {
|
}) {
|
||||||
const ss = STATUS_STYLE[n.status]
|
const ss = STATUS_STYLE[n.status]
|
||||||
const ts = TYPE_STYLE[n.type]
|
const ts = TYPE_STYLE[n.type]
|
||||||
@@ -692,7 +692,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat
|
|||||||
transform={`translate(${n.x},${n.y})`}
|
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) }}
|
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
|
||||||
onClick={e => { e.stopPropagation(); onClick() }}
|
onClick={e => { e.stopPropagation(); onClick() }}
|
||||||
onDoubleClick={e => { e.stopPropagation(); onDblClick() }}
|
onDoubleClick={e => { e.stopPropagation(); onDblClick() }}
|
||||||
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); onContextMenu(e) }}
|
onContextMenu={e => { e.preventDefault(); e.stopPropagation(); onContextMenu(e) }}
|
||||||
@@ -785,7 +785,7 @@ function ServiceNode({
|
|||||||
dim,
|
dim,
|
||||||
shareLabel,
|
shareLabel,
|
||||||
onClick,
|
onClick,
|
||||||
onMouseDown,
|
onPointerDown,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
share: number
|
share: number
|
||||||
@@ -801,7 +801,7 @@ function ServiceNode({
|
|||||||
/** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */
|
/** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */
|
||||||
shareLabel?: string
|
shareLabel?: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
onMouseDown: (e: React.MouseEvent) => void
|
onPointerDown: (e: React.PointerEvent<SVGGElement>) => void
|
||||||
}) {
|
}) {
|
||||||
const bw = MAP_SERVICE_NODE_W
|
const bw = MAP_SERVICE_NODE_W
|
||||||
const bh = MAP_SERVICE_NODE_H
|
const bh = MAP_SERVICE_NODE_H
|
||||||
@@ -812,7 +812,7 @@ function ServiceNode({
|
|||||||
transform={`translate(${x},${y})`}
|
transform={`translate(${x},${y})`}
|
||||||
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
||||||
opacity={isVis ? (dim ? 0.35 : 1) : 0.08}
|
opacity={isVis ? (dim ? 0.35 : 1) : 0.08}
|
||||||
onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }}
|
onPointerDown={(e) => { e.stopPropagation(); onPointerDown(e) }}
|
||||||
onClick={(e) => { e.stopPropagation(); onClick() }}
|
onClick={(e) => { e.stopPropagation(); onClick() }}
|
||||||
>
|
>
|
||||||
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
|
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
|
||||||
@@ -916,12 +916,12 @@ function ServicePathList({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: {
|
function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onPointerDown }: {
|
||||||
x: number; y: number
|
x: number; y: number
|
||||||
wan: { name: string; isp: string; maxDl: number; maxUl: number }
|
wan: { name: string; isp: string; maxDl: number; maxUl: number }
|
||||||
color: string; active: boolean; isSel: boolean; isDragged: boolean
|
color: string; active: boolean; isSel: boolean; isDragged: boolean
|
||||||
onSelect: () => void
|
onSelect: () => void
|
||||||
onMouseDown: (e: React.MouseEvent) => void
|
onPointerDown: (e: React.PointerEvent<SVGGElement>) => void
|
||||||
}) {
|
}) {
|
||||||
const r = 18
|
const r = 18
|
||||||
return (
|
return (
|
||||||
@@ -929,7 +929,7 @@ function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMo
|
|||||||
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}}
|
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}}
|
||||||
transform={`translate(${x},${y})`}
|
transform={`translate(${x},${y})`}
|
||||||
style={{ cursor: isDragged ? "grabbing" : "grab" }}
|
style={{ cursor: isDragged ? "grabbing" : "grab" }}
|
||||||
onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
|
onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }}
|
||||||
onClick={e => { e.stopPropagation(); onSelect() }}>
|
onClick={e => { e.stopPropagation(); onSelect() }}>
|
||||||
{active && (
|
{active && (
|
||||||
<circle r={r + 6} fill={color} opacity="0.12">
|
<circle r={r + 6} fill={color} opacity="0.12">
|
||||||
@@ -1766,6 +1766,8 @@ function NetworkMapContent() {
|
|||||||
return () => svg.removeEventListener("wheel", handler)
|
return () => svg.removeEventListener("wheel", handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const returnToMapControl=useCallback(()=>{queueMicrotask(()=>document.querySelector<HTMLSelectElement>("[data-map-device-select]")?.focus())},[])
|
||||||
|
|
||||||
// ── Zoom helpers ──────────────────────────────────────────────────────────
|
// ── Zoom helpers ──────────────────────────────────────────────────────────
|
||||||
const applyZoomCenter=useCallback((factor: number) => {
|
const applyZoomCenter=useCallback((factor: number) => {
|
||||||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
|
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
|
||||||
@@ -1798,6 +1800,7 @@ function NetworkMapContent() {
|
|||||||
setSelectedService(null)
|
setSelectedService(null)
|
||||||
setHighlightedPath(null)
|
setHighlightedPath(null)
|
||||||
setExpandedCountryId(null)
|
setExpandedCountryId(null)
|
||||||
|
returnToMapControl()
|
||||||
}
|
}
|
||||||
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
||||||
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
||||||
@@ -1807,16 +1810,21 @@ function NetworkMapContent() {
|
|||||||
}
|
}
|
||||||
window.addEventListener("keydown", onKey)
|
window.addEventListener("keydown", onKey)
|
||||||
return () => window.removeEventListener("keydown", onKey)
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
}, [applyZoomCenter,fitX,fitY,fitZoom,setSelected,updateUrl,ctxMenu,showLayers])
|
}, [applyZoomCenter,fitX,fitY,fitZoom,setSelected,updateUrl,ctxMenu,showLayers,returnToMapControl])
|
||||||
|
|
||||||
// ── Drag ─────────────────────────────────────────────────────────────────
|
// ── Drag ─────────────────────────────────────────────────────────────────
|
||||||
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
|
function onSvgPointerDown(e: React.PointerEvent<SVGSVGElement>) {
|
||||||
|
if(!e.isPrimary||e.button!==0)return
|
||||||
|
if(e.target!==e.currentTarget&&!(e.target instanceof Element&&e.target.hasAttribute("data-map-background")))return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
suppressClickRef.current=false
|
||||||
// Only pan on background clicks (node mousedown stops propagation)
|
// Only pan on background clicks (node mousedown stops propagation)
|
||||||
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y, moved: false }
|
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y, moved: false }
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSvgMouseMove(e: React.MouseEvent<SVGSVGElement>) {
|
function onSvgPointerMove(e: React.PointerEvent<SVGSVGElement>) {
|
||||||
|
if(!e.isPrimary)return
|
||||||
const nd = nodeDragRef.current
|
const nd = nodeDragRef.current
|
||||||
if (nd) {
|
if (nd) {
|
||||||
const dx = e.clientX - nd.startX
|
const dx = e.clientX - nd.startX
|
||||||
@@ -1863,8 +1871,10 @@ function NetworkMapContent() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSvgMouseUp() {
|
function onSvgPointerUp(e:React.PointerEvent<SVGSVGElement>) {
|
||||||
|
if(!e.isPrimary||e.button!==0)return
|
||||||
if (finishNodeDrag()) return
|
if (finishNodeDrag()) return
|
||||||
|
if(!dragRef.current)return
|
||||||
const moved = dragRef.current?.moved ?? false
|
const moved = dragRef.current?.moved ?? false
|
||||||
dragRef.current = null
|
dragRef.current = null
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
@@ -1879,15 +1889,24 @@ function NetworkMapContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Node drag start ──────────────────────────────────────────────────────
|
// ── Node drag start ──────────────────────────────────────────────────────
|
||||||
function onNodeMouseDown(e: React.MouseEvent, n: Server & { x: number; y: number }) {
|
function onNodePointerDown(e: React.PointerEvent<SVGGElement>, n: Server & {x: number; y: number}) {
|
||||||
|
if(!e.isPrimary||e.button!==0)return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
suppressClickRef.current=false
|
||||||
nodeDragRef.current = { kind: "server", nodeId: n.id, startX: e.clientX, startY: e.clientY, origX: n.x, origY: n.y, moved: false }
|
nodeDragRef.current = { kind: "server", nodeId: n.id, startX: e.clientX, startY: e.clientY, origX: n.x, origY: n.y, moved: false }
|
||||||
setDraggedNodeId(n.id)
|
setDraggedNodeId(n.id)
|
||||||
}
|
}
|
||||||
function onSatMouseDown(e: React.MouseEvent, homeId: string, wanIdx: number, x: number, y: number) {
|
function onSatPointerDown(e: React.PointerEvent<SVGGElement>, homeId: string, wanIdx: number, x: number, y: number) {
|
||||||
|
if(!e.isPrimary||e.button!==0)return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
suppressClickRef.current=false
|
||||||
nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
|
nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
|
||||||
setDraggedSatKey(`${homeId}-${wanIdx}`)
|
setDraggedSatKey(`${homeId}-${wanIdx}`)
|
||||||
}
|
}
|
||||||
function onServiceMouseDown(e: React.MouseEvent, svcId: string, x: number, y: number) {
|
function onServicePointerDown(e: React.PointerEvent<SVGGElement>, svcId: string, x: number, y: number) {
|
||||||
|
if(!e.isPrimary||e.button!==0)return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
suppressClickRef.current=false
|
||||||
nodeDragRef.current = { kind: "service", svcId, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
|
nodeDragRef.current = { kind: "service", svcId, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false }
|
||||||
setDraggedSvcId(svcId)
|
setDraggedSvcId(svcId)
|
||||||
}
|
}
|
||||||
@@ -2062,7 +2081,7 @@ function NetworkMapContent() {
|
|||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="button" variant="outline" size="sm" onClick={exportSvg}>
|
<Button type="button" variant="outline" size="sm" onClick={exportSvg} disabled={useLiveData&&(!hasSnapshot||!mapServers.length)}>
|
||||||
<DownloadIcon aria-hidden="true" className="size-4" />Экспорт SVG
|
<DownloadIcon aria-hidden="true" className="size-4" />Экспорт SVG
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -2237,17 +2256,19 @@ function NetworkMapContent() {
|
|||||||
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">Обозначения карты</summary>
|
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">Обозначения карты</summary>
|
||||||
<div className="mt-2 space-y-1"><p>HR · домашний роутер</p><p>JH · промежуточный узел</p><p>EN · выходной узел</p><p>Круг · устройство / WAN</p><p>Прямоугольник · сервис / страна</p><p>Пунктир WAN → JH · модель связности</p><p>Цвет · статус; неизвестно ≠ оффлайн</p></div>
|
<div className="mt-2 space-y-1"><p>HR · домашний роутер</p><p>JH · промежуточный узел</p><p>EN · выходной узел</p><p>Круг · устройство / WAN</p><p>Прямоугольник · сервис / страна</p><p>Пунктир WAN → JH · модель связности</p><p>Цвет · статус; неизвестно ≠ оффлайн</p></div>
|
||||||
</details>
|
</details>
|
||||||
|
<p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p>
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef} role="group" aria-label="Интерактивная карта сети"
|
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
|
||||||
|
className="focus-visible:outline-2 focus-visible:outline-ring"
|
||||||
|
onKeyDown={e=>{if(e.target!==e.currentTarget||e.ctrlKey||e.metaKey||e.altKey)return;const delta=40/zoom;if(e.key==="ArrowLeft"||e.key==="ArrowRight"||e.key==="ArrowUp"||e.key==="ArrowDown"){e.preventDefault();e.stopPropagation();setPan(value=>({x:value.x+(e.key==="ArrowLeft"?-delta:e.key==="ArrowRight"?delta:0),y:value.y+(e.key==="ArrowUp"?-delta:e.key==="ArrowDown"?delta:0)}))}}}
|
||||||
viewBox={`${pan.x} ${pan.y} ${W / zoom} ${viewportHeight / zoom}`}
|
viewBox={`${pan.x} ${pan.y} ${W / zoom} ${viewportHeight / zoom}`}
|
||||||
style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none" }}
|
style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none", touchAction:"none" }}
|
||||||
onMouseDown={e => { e.preventDefault(); onSvgMouseDown(e) }}
|
onPointerDown={onSvgPointerDown}
|
||||||
onMouseMove={onSvgMouseMove}
|
onPointerMove={onSvgPointerMove}
|
||||||
onMouseUp={onSvgMouseUp}
|
onPointerUp={onSvgPointerUp}
|
||||||
onMouseLeave={() => {
|
onPointerLeave={()=>setHoveredId(null)}
|
||||||
finishNodeDrag()
|
onPointerCancel={()=>{finishNodeDrag();suppressClickRef.current=false;dragRef.current=null;setIsDragging(false)}}
|
||||||
dragRef.current = null; setIsDragging(false); setHoveredId(null)
|
onLostPointerCapture={()=>{finishNodeDrag();dragRef.current=null;setIsDragging(false)}}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<pattern id="map-dots" width="22" height="22" patternUnits="userSpaceOnUse">
|
<pattern id="map-dots" width="22" height="22" patternUnits="userSpaceOnUse">
|
||||||
@@ -2262,7 +2283,7 @@ function NetworkMapContent() {
|
|||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* Background grid */}
|
{/* Background grid */}
|
||||||
<rect width={W} height={H} fill="url(#map-dots)" />
|
<rect data-map-background x={pan.x} y={pan.y} width={W/zoom} height={viewportHeight/zoom} fill="url(#map-dots)" />
|
||||||
|
|
||||||
{/* ── GRE edges ── */}
|
{/* ── GRE edges ── */}
|
||||||
{greEdges.map((e, i) => {
|
{greEdges.map((e, i) => {
|
||||||
@@ -2474,7 +2495,7 @@ function NetworkMapContent() {
|
|||||||
onDblClick={() => { selectServer(n); focusNode(n.x, n.y) }}
|
onDblClick={() => { selectServer(n); focusNode(n.x, n.y) }}
|
||||||
onHoverChange={h => setHoveredId(h ? n.id : null)}
|
onHoverChange={h => setHoveredId(h ? n.id : null)}
|
||||||
onContextMenu={e => setCtxMenu({ x: e.clientX, y: e.clientY, server: n })}
|
onContextMenu={e => setCtxMenu({ x: e.clientX, y: e.clientY, server: n })}
|
||||||
onMouseDown={e => onNodeMouseDown(e, n)}
|
onPointerDown={e => onNodePointerDown(e, n)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -2498,7 +2519,7 @@ function NetworkMapContent() {
|
|||||||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||||||
selectWan(router, wIdx)
|
selectWan(router, wIdx)
|
||||||
}}
|
}}
|
||||||
onMouseDown={e => onSatMouseDown(e, router.id, wIdx, sat.x, sat.y)}
|
onPointerDown={e => onSatPointerDown(e, router.id, wIdx, sat.x, sat.y)}
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
@@ -2521,7 +2542,7 @@ function NetworkMapContent() {
|
|||||||
isDragged={draggedSvcId === svc.id}
|
isDragged={draggedSvcId === svc.id}
|
||||||
destMode={destMode}
|
destMode={destMode}
|
||||||
iso={svc.label}
|
iso={svc.label}
|
||||||
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
|
onPointerDown={(e) => onServicePointerDown(e, svc.id, pos.x, pos.y)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||||||
selectService(svc)
|
selectService(svc)
|
||||||
@@ -2546,7 +2567,7 @@ function NetworkMapContent() {
|
|||||||
isDragged={draggedSvcId === svc.id}
|
isDragged={draggedSvcId === svc.id}
|
||||||
destMode="services"
|
destMode="services"
|
||||||
shareLabel="трафика страны"
|
shareLabel="трафика страны"
|
||||||
onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)}
|
onPointerDown={(e) => onServicePointerDown(e, svc.id, pos.x, pos.y)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
if (suppressClickRef.current) { suppressClickRef.current = false; return }
|
||||||
selectService(svc)
|
selectService(svc)
|
||||||
@@ -2722,7 +2743,7 @@ function NetworkMapContent() {
|
|||||||
|
|
||||||
{/* ── Keyboard hints (viewport-fixed) ── */}
|
{/* ── Keyboard hints (viewport-fixed) ── */}
|
||||||
{showHints && (
|
{showHints && (
|
||||||
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + H / zoom - 14})`}>
|
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + viewportHeight / zoom - 14})`}>
|
||||||
{[
|
{[
|
||||||
["Scroll", "zoom"],
|
["Scroll", "zoom"],
|
||||||
["+/-", "zoom in/out"],
|
["+/-", "zoom in/out"],
|
||||||
@@ -2745,6 +2766,10 @@ function NetworkMapContent() {
|
|||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
{useLiveData&&!hasSnapshot&&dataLoading&&<p role="status" className="absolute inset-0 flex items-center justify-center bg-background/80 text-sm">Загружаем карту…</p>}
|
||||||
|
{(hasSnapshot||!useLiveData)&&!nodes.length&&<div role="status" className="absolute inset-0 flex flex-col gap-2 items-center justify-center bg-background/90 p-4 text-center"><p className="font-medium">Устройств на карте пока нет</p><p className="text-sm text-muted-foreground">В текущем источнике нет устройств для построения схемы.</p><Link href="/servers" className="text-sm underline">Открыть серверы</Link></div>}
|
||||||
|
{!!nodes.length&&!nodes.some(isVisible)&&<div role="status" className="absolute left-3 right-3 top-16 rounded-md border bg-popover p-3 text-sm"><p>Узлы по заданным условиям не найдены.</p><Button type="button" variant="outline" size="sm" className="mt-2" onClick={()=>updateUrl({q:null,filter:null})}>Сбросить срез карты</Button></div>}
|
||||||
|
|
||||||
{/* ── Zoom controls ── */}
|
{/* ── Zoom controls ── */}
|
||||||
<ZoomControls
|
<ZoomControls
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
@@ -2815,7 +2840,7 @@ function NetworkMapContent() {
|
|||||||
<HomeIcon aria-hidden="true" className="size-3.5" />
|
<HomeIcon aria-hidden="true" className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
aria-label="Закрыть детали GRE" onClick={() => setSelectedGreEdge(null)}
|
aria-label="Закрыть детали GRE" onClick={() => {setSelectedGreEdge(null);returnToMapControl()}}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors">
|
className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
<XIcon aria-hidden="true" className="size-4" />
|
<XIcon aria-hidden="true" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -3011,7 +3036,7 @@ function NetworkMapContent() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
aria-label="Закрыть детали назначения" onClick={() => setSelectedService(null)}
|
aria-label="Закрыть детали назначения" onClick={() => {setSelectedService(null);returnToMapControl()}}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<XIcon aria-hidden="true" className="size-4" />
|
<XIcon aria-hidden="true" className="size-4" />
|
||||||
@@ -3206,7 +3231,7 @@ function NetworkMapContent() {
|
|||||||
aria-label="Навести на карте" title="Навести на карте">
|
aria-label="Навести на карте" title="Навести на карте">
|
||||||
<HomeIcon aria-hidden="true" className="size-3.5" />
|
<HomeIcon aria-hidden="true" className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" aria-label="Закрыть детали устройства" onClick={() => { setSelected(null); setSelWanIdx(null) }}
|
<button type="button" aria-label="Закрыть детали устройства" onClick={() => { setSelected(null); setSelWanIdx(null); returnToMapControl() }}
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors">
|
className="text-muted-foreground hover:text-foreground transition-colors">
|
||||||
<XIcon aria-hidden="true" className="size-4" />
|
<XIcon aria-hidden="true" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -189,4 +189,4 @@ HTTP-фикстуры; локальная приёмка: 43 основных +
|
|||||||
| Оболочка · [Frame](https://reui.io/components/frame?ref=mcp) / [API](https://reui.io/docs/components/base/frame?ref=mcp) | [Обзор](ux-ui/screens/network-map-overview-desktop.png) | [390 px](ux-ui/screens/network-map-overview-mobile.png), [320 px](ux-ui/screens/network-map-overview-narrow.png) | Источник → свежесть → слои/фильтры → карта |
|
| Оболочка · [Frame](https://reui.io/components/frame?ref=mcp) / [API](https://reui.io/docs/components/base/frame?ref=mcp) | [Обзор](ux-ui/screens/network-map-overview-desktop.png) | [390 px](ux-ui/screens/network-map-overview-mobile.png), [320 px](ux-ui/screens/network-map-overview-narrow.png) | Источник → свежесть → слои/фильтры → карта |
|
||||||
| Детали · Frame / [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Детали](ux-ui/screens/network-map-detail-desktop.png) | [390 px](ux-ui/screens/network-map-detail-mobile.png), [320 px](ux-ui/screens/network-map-detail-narrow.png) | Устройство → фактические поля → действия → возврат |
|
| Детали · Frame / [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Детали](ux-ui/screens/network-map-detail-desktop.png) | [390 px](ux-ui/screens/network-map-detail-mobile.png), [320 px](ux-ui/screens/network-map-detail-narrow.png) | Устройство → фактические поля → действия → возврат |
|
||||||
|
|
||||||
Оболочка и клавиатурные действия проверены (35 + 12 E2E). Камера и читаемость малой топологии доработаны в 10.53 (16 E2E + 2 unit). Следом pointer/touch и пустые состояния; итоговая приёмка карты ещё не завершена.
|
Оболочка и клавиатурные действия проверены (35 + 12 E2E). Камера и читаемость малой топологии доработаны в 10.53 (16 E2E + 2 unit). Pointer/touch и пустые состояния приняты в 10.54: 39 основных + 12 source E2E, 10 unit и production frontend build. Реальные RouterOS/JWT/БД остаются внешней приёмкой; далее терминал.
|
||||||
@@ -26,13 +26,22 @@
|
|||||||
| 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.53; оптимизатор принят локально, карта сети в работе |
|
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.54; оптимизатор и карта приняты локально, далее терминал |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 10.54 — Pointer/touch и локальная приёмка карты (2026-10-01)
|
||||||
|
|
||||||
|
- Pointer Events/capture удерживают drag за пределами canvas; основной указатель отделён от остальных, cancel/lost capture завершают жест без залипания. Touch-action отключает прокрутку только на SVG; детали продолжают прокручиваться. Фокусируемый canvas поддерживает стрелки, zoom/fit имеют клавиатурные альтернативы.
|
||||||
|
- Закрытие деталей кнопкой/Escape возвращает фокус к выбору устройства. Пустой inventory явно предлагает серверы; поиск без результатов даёт сброс только среза. Без подтверждённого снимка экспорт недоступен; загрузка объявляется role=status.
|
||||||
|
- Карта принята локально: 39 основных + 12 source E2E, 10 unit, TypeScript, lint и production frontend build прошли. ReUI Frame checklist повторён, адаптивные снимки обновлены. Touch проверен через реальный Chromium input protocol; физических телефонов в приёмке нет.
|
||||||
|
- Границы: HTTP фикстуры не подтверждают RouterOS/JWT/БД; WAN пунктир остаётся моделью, Flow свежесть — время браузерного ответа; SVG флаги зависят от существующего CDN. Фильтр сохраняет приглушённые узлы как контекст, pan/zoom/drag локальные. SVG XML валиден, сторонние SVG viewers отдельно не проверены.
|
||||||
|
- Далее 10.55: терминал — API-контракт, выбранное устройство/host из URL, достоверность выполнения и ошибки/повтор/права. Этап 10 ещё не завершён.
|
||||||
|
|
||||||
## Блок 10.53 — камера и читаемость карты (2026-10-01)
|
## Блок 10.53 — камера и читаемость карты (2026-10-01)
|
||||||
|
|
||||||
- ViewBox учитывает реальное соотношение сторон canvas; начальное вписывание включает устройства, WAN, сервисы и подписи. Оно выполняется один раз на источник и не сбрасывает ручной zoom после обновления API. Кнопка/горячая клавиша вписывают текущие объекты; wheel/drag используют ту же геометрию.
|
- ViewBox учитывает реальное соотношение сторон canvas; начальное вписывание включает устройства, WAN, сервисы и подписи. Оно выполняется один раз на источник и не сбрасывает ручной zoom после обновления API. Кнопка/горячая клавиша вписывают текущие объекты; wheel/drag используют ту же геометрию.
|
||||||
|
|||||||
@@ -301,3 +301,12 @@ Shared контракт и backend observed исходные значения/д
|
|||||||
- Название вынесено под узел; на телефоне размер текста компенсирует SVG масштаб, длинная подпись сокращена при сохранённом полном accessible name/деталях. Страна обозначена ISO при отсутствии площадки и до загрузки внешнего изображения флага. Легенда — доступные раскрываемые обозначения вне SVG.
|
- Название вынесено под узел; на телефоне размер текста компенсирует SVG масштаб, длинная подпись сокращена при сохранённом полном accessible name/деталях. Страна обозначена ISO при отсутствии площадки и до загрузки внешнего изображения флага. Легенда — доступные раскрываемые обозначения вне SVG.
|
||||||
- WAN карточка получила Enter/Space и aria-pressed. 2 camera unit + 16 E2E layout/details/URL, TypeScript и lint прошли; после ISO fallback повторены 5 layout E2E, TypeScript и lint. Снимки обновлены.
|
- WAN карточка получила Enter/Space и aria-pressed. 2 camera unit + 16 E2E layout/details/URL, TypeScript и lint прошли; после ISO fallback повторены 5 layout E2E, TypeScript и lint. Снимки обновлены.
|
||||||
- Далее pointer/touch, закрытие деталей с возвратом фокуса, пустой inventory/пустой срез; затем production build и локальная приёмка карты. Реальная сеть не проверена.
|
- Далее pointer/touch, закрытие деталей с возвратом фокуса, пустой inventory/пустой срез; затем production build и локальная приёмка карты. Реальная сеть не проверена.
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 10.54 — Pointer/touch и локальная приёмка карты (2026-10-01)
|
||||||
|
|
||||||
|
- Pointer Events/capture удерживают drag за пределами canvas; основной указатель отделён от остальных, cancel/lost capture завершают жест без залипания. Touch-action отключает прокрутку только на SVG; детали продолжают прокручиваться. Фокусируемый canvas поддерживает стрелки, zoom/fit имеют клавиатурные альтернативы.
|
||||||
|
- Закрытие деталей кнопкой/Escape возвращает фокус к выбору устройства. Пустой inventory явно предлагает серверы; поиск без результатов даёт сброс только среза. Без подтверждённого снимка экспорт недоступен; загрузка объявляется role=status.
|
||||||
|
- Карта принята локально: 39 основных + 12 source E2E, 10 unit, TypeScript, lint и production frontend build прошли. ReUI Frame checklist повторён, адаптивные снимки обновлены. Touch проверен через реальный Chromium input protocol; физических телефонов в приёмке нет.
|
||||||
|
- Границы: HTTP фикстуры не подтверждают RouterOS/JWT/БД; WAN пунктир остаётся моделью, Flow свежесть — время браузерного ответа; SVG флаги зависят от существующего CDN. Фильтр сохраняет приглушённые узлы как контекст, pan/zoom/drag локальные. SVG XML валиден, сторонние SVG viewers отдельно не проверены.
|
||||||
|
- Далее 10.55: терминал — API-контракт, выбранное устройство/host из URL, достоверность выполнения и ошибки/повтор/права. Этап 10 ещё не завершён.
|
||||||
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 59 KiB |
@@ -62,3 +62,39 @@ test("Карта: пропорции камеры, ручной масштаб
|
|||||||
const wan=page.getByRole("button",{name:"WAN WAN",exact:true});await wan.focus();await wan.press("Enter")
|
const wan=page.getByRole("button",{name:"WAN WAN",exact:true});await wan.focus();await wan.press("Enter")
|
||||||
await expect(page).toHaveURL(/wan=0/);await expect(wan).toHaveAttribute("aria-pressed","true")
|
await expect(page).toHaveURL(/wan=0/);await expect(wan).toHaveAttribute("aria-pressed","true")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("Карта: pointer capture, клавиатурный pan и возврат фокуса",async({page})=>{
|
||||||
|
await page.goto("/network-map");const map=page.getByRole("group",{name:"Интерактивная карта сети"}),node=page.getByRole("button",{name:/^Устройство Домашний/})
|
||||||
|
await expect(node).toBeVisible();await map.focus();const before=await map.getAttribute("viewBox");await map.press("ArrowDown");expect(await map.getAttribute("viewBox")).not.toBe(before)
|
||||||
|
await page.getByRole("combobox",{name:"Устройство на карте"}).selectOption("1")
|
||||||
|
const transform=await node.getAttribute("transform"),point=await node.evaluate((g:SVGGElement)=>{const matrix=g.getScreenCTM()!;return {x:matrix.e,y:matrix.f}})
|
||||||
|
await page.mouse.move(point.x,point.y);await page.mouse.down();await page.mouse.move(point.x+80,point.y+25,{steps:5});await page.mouse.up()
|
||||||
|
expect(await node.getAttribute("transform")).not.toBe(transform);await expect(page).toHaveURL(/server=1/)
|
||||||
|
await page.getByRole("button",{name:"Закрыть детали устройства"}).click();await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeFocused()
|
||||||
|
const box=(await map.boundingBox())!,pan=await map.getAttribute("viewBox")
|
||||||
|
await page.mouse.move(box.x+15,box.y+box.height-50);await page.mouse.down();await page.mouse.move(box.x-30,box.y+box.height-80,{steps:5});await page.mouse.up()
|
||||||
|
expect(await map.getAttribute("viewBox")).not.toBe(pan)
|
||||||
|
const stopped=await map.getAttribute("viewBox");await page.mouse.move(box.x+100,box.y+100);expect(await map.getAttribute("viewBox")).toBe(stopped)
|
||||||
|
})
|
||||||
|
test("Карта: touch pan и отмена жеста без залипания",async({page})=>{
|
||||||
|
await page.setViewportSize({width:390,height:900});await page.goto("/network-map")
|
||||||
|
const map=page.getByRole("group",{name:"Интерактивная карта сети"});await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeEnabled()
|
||||||
|
expect(await map.evaluate(e=>getComputedStyle(e).touchAction)).toBe("none")
|
||||||
|
const box=(await map.boundingBox())!,before=await map.getAttribute("viewBox"),session=await page.context().newCDPSession(page)
|
||||||
|
await session.send("Input.dispatchTouchEvent",{type:"touchStart",touchPoints:[{x:box.x+20,y:box.y+box.height-70,id:1}]})
|
||||||
|
await session.send("Input.dispatchTouchEvent",{type:"touchMove",touchPoints:[{x:box.x+70,y:box.y+box.height-100,id:1}]})
|
||||||
|
await session.send("Input.dispatchTouchEvent",{type:"touchCancel",touchPoints:[]})
|
||||||
|
expect(await map.getAttribute("viewBox")).not.toBe(before)
|
||||||
|
const stopped=await map.getAttribute("viewBox");await page.mouse.move(box.x+150,box.y+50);expect(await map.getAttribute("viewBox")).toBe(stopped)
|
||||||
|
await page.getByRole("button",{name:/^Устройство Домашний/}).click();await expect(page.getByRole("complementary",{name:"Детали объекта карты"})).toBeVisible()
|
||||||
|
})
|
||||||
|
test("Карта: пустой источник отличается от пустого среза",async({page})=>{
|
||||||
|
await page.goto("/network-map");await expect(page.getByRole("combobox",{name:"Устройство на карте"})).toBeEnabled()
|
||||||
|
await page.getByRole("textbox",{name:"Поиск узла"}).fill("нет такого узла");await expect(page.getByRole("status").filter({hasText:"Узлы по заданным условиям"})).toBeVisible()
|
||||||
|
await page.getByRole("button",{name:"Сбросить срез карты"}).click();await expect(page.getByRole("textbox",{name:"Поиск узла"})).toHaveValue("")
|
||||||
|
await expect(page.getByRole("button",{name:/^Устройство Домашний/})).toBeVisible()
|
||||||
|
await page.route("**/api/servers",r=>r.fulfill({json:[]}));await page.route("**/api/filters/gre-tunnels",r=>r.fulfill({json:{tunnels:[],failures:[]}}))
|
||||||
|
await page.getByRole("button",{name:"Обновить",exact:true}).click()
|
||||||
|
await expect(page.getByRole("status").filter({hasText:"Устройств на карте пока нет"})).toBeVisible();await expect(page.getByRole("link",{name:"Открыть серверы",exact:true})).toHaveAttribute("href","/servers")
|
||||||
|
await expect(page.getByRole("button",{name:"Экспорт SVG",exact:true})).toBeDisabled()
|
||||||
|
})
|
||||||