diff --git a/netx_api/topology_lldp.py b/netx_api/topology_lldp.py
index 62d778a..d1365c6 100644
--- a/netx_api/topology_lldp.py
+++ b/netx_api/topology_lldp.py
@@ -550,3 +550,51 @@ def _kv(text: str, pattern: str) -> str:
if not m:
return ""
return str(m.group(1) or "").strip()
+
+
+# Long media names → short canonical form (case-insensitive prefix).
+_IFNAME_PREFIXES: tuple[tuple[str, str], ...] = (
+ ("tengigabitethernet", "te"),
+ ("ten-gigabitethernet", "te"),
+ ("gigabitethernet", "gi"),
+ ("fastethernet", "fa"),
+ ("ethernet", "eth"),
+ ("xgigabitethernet", "xge"),
+ ("100ge", "100ge"),
+ ("40ge", "40ge"),
+ ("25ge", "25ge"),
+ ("10ge", "10ge"),
+ ("ge-trunk", "ge-trunk"),
+ ("eth-trunk", "eth-trunk"),
+ ("port-channel", "po"),
+ ("portchannel", "po"),
+ ("loopback", "lo"),
+ ("vlanif", "vlanif"),
+ ("vlan", "vlan"),
+ ("mgmteth", "mgmt"),
+ ("management", "mgmt"),
+ ("hundredgige", "hu"),
+ ("fiftygige", "fi"),
+ ("fortygige", "fo"),
+ ("twentyfivegige", "twe"),
+ ("twogigabitethernet", "tw"),
+)
+
+
+def normalize_ifname(name: str) -> str:
+ """Canonicalize interface names so Gi0/0 and GigabitEthernet0/0 share a key."""
+ raw = str(name or "").strip()
+ if not raw:
+ return ""
+ s = re.sub(r"\s+", "", raw).lower()
+ s = s.replace("_", "/")
+ for long, short in _IFNAME_PREFIXES:
+ if s.startswith(long):
+ rest = s[len(long) :]
+ if rest.startswith((":", "/", "-")) or rest == "" or rest[0].isdigit():
+ if rest.startswith(":"):
+ rest = rest[1:]
+ return f"{short}{rest}"
+ break
+ # Already-short forms: gi0/0, te1/0/1, xge0/0/1, 10ge1/0/1
+ return s
diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py
index c114efa..512d477 100644
--- a/netx_api/topology_schemas.py
+++ b/netx_api/topology_schemas.py
@@ -34,6 +34,7 @@ class TopologyNodeIn(BaseModel):
label: str = ""
x: float = 0.0
y: float = 0.0
+ created_at: datetime | None = None
class TopologyEdgeIn(BaseModel):
@@ -46,6 +47,8 @@ class TopologyEdgeIn(BaseModel):
stroke_color: str = Field(default="", max_length=32)
stroke_width: int = Field(default=0, ge=0, le=12)
line_style: str = Field(default="", max_length=16)
+ discovered_at: datetime | None = None
+ created_at: datetime | None = None
class TopologyNodeOut(BaseModel):
diff --git a/netx_api/topology_service.py b/netx_api/topology_service.py
index 13ea610..81a5494 100644
--- a/netx_api/topology_service.py
+++ b/netx_api/topology_service.py
@@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from .cli_resolve import get_default_profile, infer_device_type_vendor
from .models import ManagedNE, TopologyEdge, TopologyMap, TopologyNode, UmeInventoryNE
from .ne_exec import execute_managed_ne_commands
-from .topology_lldp import NeighborHit, parse_neighbor_output, pick_neighbor_command
+from .topology_lldp import NeighborHit, normalize_ifname, parse_neighbor_output, pick_neighbor_command
from .topology_schemas import (
TopologyDiscoverNeResult,
TopologyDiscoverOut,
@@ -251,6 +251,8 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
tid = str(e.target_node_id or "").strip()
if sid not in node_ids or tid not in node_ids:
raise HTTPException(status_code=400, detail="edge_endpoint_not_in_nodes")
+ if sid == tid:
+ raise HTTPException(status_code=400, detail="edge_self_loop")
src = str(e.source or "manual").strip().lower() or "manual"
if src not in {"manual", "lldp", "cdp", "stale"}:
raise HTTPException(status_code=400, detail="invalid_edge_source")
@@ -262,27 +264,47 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
normalized_edges.append((e, src, color, width, line))
now = _utcnow()
+ prev_nodes = {
+ str(n.id): {"created_at": n.created_at}
+ for n in db.query(TopologyNode).filter(TopologyNode.map_id == row.id).all()
+ }
+ prev_edges = {
+ str(e.id): {"created_at": e.created_at, "discovered_at": e.discovered_at}
+ for e in db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).all()
+ }
db.query(TopologyEdge).filter(TopologyEdge.map_id == row.id).delete(synchronize_session=False)
db.query(TopologyNode).filter(TopologyNode.map_id == row.id).delete(synchronize_session=False)
+ db.expire_all()
for n in nodes_in:
+ nid = str(n.id).strip()
+ prev = prev_nodes.get(nid) or {}
+ created = getattr(n, "created_at", None) or prev.get("created_at") or now
db.add(
TopologyNode(
- id=str(n.id).strip(),
+ id=nid,
map_id=row.id,
managed_ne_id=str(n.managed_ne_id or "").strip(),
ume_ne_id=str(n.ume_ne_id or "").strip(),
label=str(n.label or "").strip()[:256],
x=float(n.x or 0),
y=float(n.y or 0),
- created_at=now,
+ created_at=created,
updated_at=now,
)
)
for e, src, color, width, line in normalized_edges:
+ eid = str(e.id).strip() or uuid4().hex
+ prev = prev_edges.get(eid) or {}
+ created = getattr(e, "created_at", None) or prev.get("created_at") or now
+ client_discovered = getattr(e, "discovered_at", None)
+ if src in {"lldp", "cdp", "stale"}:
+ discovered = client_discovered or prev.get("discovered_at") or now
+ else:
+ discovered = None
db.add(
TopologyEdge(
- id=str(e.id).strip() or uuid4().hex,
+ id=eid,
map_id=row.id,
source_node_id=str(e.source_node_id).strip(),
target_node_id=str(e.target_node_id).strip(),
@@ -292,8 +314,8 @@ def put_graph(db: Session, map_id: str, body: TopologyGraphPut) -> TopologyGraph
stroke_color=color,
stroke_width=width,
line_style=line,
- discovered_at=now if src in {"lldp", "cdp", "stale"} else None,
- created_at=now,
+ discovered_at=discovered,
+ created_at=created,
updated_at=now,
)
)
@@ -345,9 +367,11 @@ def _match_neighbor_to_node(
def _edge_pair_key(a: str, b: str, local_port: str, remote_port: str) -> tuple[str, str, str, str]:
+ lp = normalize_ifname(local_port)
+ rp = normalize_ifname(remote_port)
if a <= b:
- return (a, b, local_port, remote_port)
- return (b, a, remote_port, local_port)
+ return (a, b, lp, rp)
+ return (b, a, rp, lp)
def _discover_target_for_node(
diff --git a/tests/test_topology.py b/tests/test_topology.py
index 6529fb2..1b8fd03 100644
--- a/tests/test_topology.py
+++ b/tests/test_topology.py
@@ -323,6 +323,75 @@ class TopologyServiceTests(unittest.TestCase):
with self.assertRaises(HTTPException):
svc.get_graph(self.db, mid)
+ def test_put_preserves_discovered_at_and_rejects_self_loop(self) -> None:
+ created = svc.create_map(self.db, TopologyMapCreate(name="Preserve", remark=""))
+ mid = created.id
+ first = svc.put_graph(
+ self.db,
+ mid,
+ TopologyGraphPut(
+ nodes=[
+ TopologyNodeIn(id="n1", label="A", x=0, y=0),
+ TopologyNodeIn(id="n2", label="B", x=10, y=0),
+ ],
+ edges=[
+ TopologyEdgeIn(
+ id="e1",
+ source_node_id="n1",
+ target_node_id="n2",
+ source="lldp",
+ source_port="Gi0/0",
+ target_port="Gi0/1",
+ )
+ ],
+ ),
+ )
+ discovered = first.edges[0].discovered_at
+ self.assertIsNotNone(discovered)
+
+ second = svc.put_graph(
+ self.db,
+ mid,
+ TopologyGraphPut(
+ nodes=[
+ TopologyNodeIn(id="n1", label="A", x=1, y=1),
+ TopologyNodeIn(id="n2", label="B", x=11, y=1),
+ ],
+ edges=[
+ TopologyEdgeIn(
+ id="e1",
+ source_node_id="n1",
+ target_node_id="n2",
+ source="lldp",
+ source_port="Gi0/0",
+ target_port="Gi0/1",
+ stroke_color="#0ea5e9",
+ )
+ ],
+ ),
+ )
+ self.assertEqual(second.edges[0].discovered_at, discovered)
+ self.assertEqual(second.edges[0].stroke_color, "#0ea5e9")
+
+ with self.assertRaises(HTTPException) as ctx:
+ svc.put_graph(
+ self.db,
+ mid,
+ TopologyGraphPut(
+ nodes=[TopologyNodeIn(id="n1", label="A", x=0, y=0)],
+ edges=[
+ TopologyEdgeIn(
+ id="bad",
+ source_node_id="n1",
+ target_node_id="n1",
+ source="manual",
+ )
+ ],
+ ),
+ )
+ self.assertEqual(ctx.exception.detail, "edge_self_loop")
+ svc.delete_map(self.db, mid)
+
def test_discover_matches_by_name_and_skips_manual(self) -> None:
suffix = uuid4().hex[:8]
ne_a_id = f"nea-{suffix}"
@@ -528,5 +597,20 @@ class TopologyServiceTests(unittest.TestCase):
self.db.commit()
+class IfnameNormalizeTests(unittest.TestCase):
+ def test_cisco_huawei_aliases(self) -> None:
+ self.assertEqual(lldp.normalize_ifname("GigabitEthernet0/0"), "gi0/0")
+ self.assertEqual(lldp.normalize_ifname("Gi0/0"), "gi0/0")
+ self.assertEqual(lldp.normalize_ifname("TenGigabitEthernet1/0/1"), "te1/0/1")
+ self.assertEqual(lldp.normalize_ifname("XGigabitEthernet0/0/1"), "xge0/0/1")
+ self.assertEqual(lldp.normalize_ifname("Eth-Trunk1"), "eth-trunk1")
+ self.assertEqual(lldp.normalize_ifname(""), "")
+
+ def test_edge_pair_key_aliases_match(self) -> None:
+ a = svc._edge_pair_key("n1", "n2", "GigabitEthernet0/0", "Gi0/1")
+ b = svc._edge_pair_key("n1", "n2", "Gi0/0", "GigabitEthernet0/1")
+ self.assertEqual(a, b)
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/web/WEB.md b/web/WEB.md
index 754b7a1..b09017b 100644
--- a/web/WEB.md
+++ b/web/WEB.md
@@ -124,7 +124,8 @@ src/
- 侧栏网元可点击或拖放到画布;发现链路后可选自动层次布局并写回坐标
- 支持对齐、网格吸附、轻量撤销/重做、链路图例
- 选中链路可手动定制颜色 / 线型(实线·虚线·点线)/ 粗细,保存后持久化;空值回退到来源默认样式(人工灰 / 发现蓝虚线 / 未发现红虚线)
-- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式在右键菜单中调整
+- 「显示」里可改人工 / 发现 / 未发现三类默认样式(本机记住);单链路样式与端口在右键菜单中调整;网元可右键重命名
+- 未保存切换地图会确认;Ctrl+S 保存;发现可取消;禁止自环与重复连线;PUT 保留 `discovered_at`;发现边键对端口名做规范化
## WebCRT
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index 1b9a1f1..fc92f5d 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -891,6 +891,8 @@ const en = {
paletteHint: "Click or drag onto the canvas",
filterPh: "Filter name / IP",
save: "Save",
+ saveDirty: "Save *",
+ unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
saving: "Saving…",
saved: "Topology saved",
discover: "Discover all links",
@@ -900,6 +902,10 @@ const en = {
discoverFail: "Discovery failed: {{detail}}",
discoverOneNeedNe: "This node is not linked to a scannable NE",
selectMap: "Select or create a topology map",
+ renameNode: "Rename",
+ renameNodePrompt: "Node display name",
+ sourcePort: "Local port",
+ targetPort: "Remote port",
canvasHint:
"Use toolbar modes (select/pan/drag/connect), box-select, and layout. Blue dashed=discovered, red=missing. Shortcuts V/H/A/C, Ctrl+Z undo.",
selected: "Selected",
@@ -973,6 +979,7 @@ const en = {
paletteLoading: "Loading…",
discoverReport: "Discovery progress",
discoverClose: "Close",
+ discoverCancel: "Cancel discovery",
discoverProgress: "Scanning {{count}} managed NE(s) on the map (serial LLDP; please wait)…",
discoverProgressLive: "Scanning {{index}}/{{total}}: {{name}}",
discoverSummary: "Scanned {{scanned}} · added {{added}} · updated {{updated}} · missing {{stale}} · failed {{failed}}",
diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts
index 3e4faed..16292c4 100644
--- a/web/src/i18n/zh.ts
+++ b/web/src/i18n/zh.ts
@@ -888,6 +888,8 @@ const zh = {
paletteHint: "点击或拖到画布添加",
filterPh: "筛选名称 / IP",
save: "保存",
+ saveDirty: "保存 *",
+ unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
saving: "保存中…",
saved: "拓扑已保存",
discover: "发现全量链路",
@@ -897,6 +899,10 @@ const zh = {
discoverFail: "发现失败:{{detail}}",
discoverOneNeedNe: "该节点未关联可扫描网元",
selectMap: "请选择或新建一张拓扑图",
+ renameNode: "重命名",
+ renameNodePrompt: "网元显示名称",
+ sourcePort: "本端端口",
+ targetPort: "对端端口",
canvasHint:
"工具栏切换选择/平移/拖动/连线;框选多选;布局可自动构图。蓝虚线=发现链路,红虚线=未发现。快捷键 V/H/A/C,Ctrl+Z 撤销。",
selected: "已选节点",
@@ -970,6 +976,7 @@ const zh = {
paletteLoading: "加载中…",
discoverReport: "发现进度",
discoverClose: "关闭",
+ discoverCancel: "取消发现",
discoverProgress: "正在扫描画布上 {{count}} 台托管网元(串行登录采集 LLDP,请稍候)…",
discoverProgressLive: "正在扫描 {{index}}/{{total}}:{{name}}",
discoverSummary: "扫描 {{scanned}} 台 · 新增 {{added}} · 更新 {{updated}} · 未发现 {{stale}} · 失败 {{failed}}",
diff --git a/web/src/index.css b/web/src/index.css
index a502b76..bae3df5 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -2578,7 +2578,8 @@ pre {
border-radius: 2px;
}
-.topo-ctx__style-row select {
+.topo-ctx__style-row select,
+.topo-ctx__style-input {
flex: 1 1 auto;
max-width: 108px;
height: 24px;
@@ -2591,6 +2592,11 @@ pre {
padding: 0 6px;
}
+.topo-ctx__style-input {
+ max-width: 120px;
+ min-width: 0;
+}
+
/* Beat .app-main button { color:#1f2937; background:#fff } so hover stays readable. */
.app-main .topo-ctx__item,
.topo-ctx__item {
diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx
index c010bd6..c861e3a 100644
--- a/web/src/pages/TopologyPage.tsx
+++ b/web/src/pages/TopologyPage.tsx
@@ -184,7 +184,7 @@ function NeNode({ data, selected }: NodeProps>) {
/>
- {bits.join(" · ")}
+ {bits.join(" ? ")}
);
}
@@ -198,6 +198,7 @@ type EdgeStyleData = {
stroke_color?: string;
stroke_width?: number;
line_style?: string;
+ discovered_at?: string | null;
};
type EdgeLineStyle = "solid" | "dashed" | "dotted";
@@ -319,7 +320,7 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau
}));
const rfEdges: Edge[] = edges.map((e) => {
const src = e.source || "manual";
- const label = [e.source_port, e.target_port].filter(Boolean).join(" ↔ ");
+ const label = [e.source_port, e.target_port].filter(Boolean).join(" ? ");
const data: EdgeStyleData = {
source: src,
source_port: e.source_port || "",
@@ -327,6 +328,7 @@ function graphToFlow(nodes: TopologyNodeItem[], edges: TopologyEdgeItem[], defau
stroke_color: e.stroke_color || "",
stroke_width: Number(e.stroke_width || 0),
line_style: e.line_style || "",
+ discovered_at: e.discovered_at ?? null,
};
return withEdgeVisual(
{
@@ -366,6 +368,7 @@ function flowToGraphPayload(nodes: Node[], edges: Edge[]) {
stroke_color: String(data.stroke_color || ""),
stroke_width: Number(data.stroke_width || 0),
line_style: String(data.line_style || ""),
+ discovered_at: data.discovered_at ?? null,
};
}),
};
@@ -377,6 +380,9 @@ export function TopologyPage() {
const queryClient = useQueryClient();
const [mapId, setMapId] = useState("");
const [keyword, setKeyword] = useState("");
+ const [debouncedKeyword, setDebouncedKeyword] = useState("");
+ const [dirty, setDirty] = useState(false);
+ const [historyTick, setHistoryTick] = useState(0);
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
const [ctxMenu, setCtxMenu] = useState(null);
const [hideIp, setHideIp] = useState(true);
@@ -414,6 +420,52 @@ export function TopologyPage() {
const redoRef = useRef([]);
const historyLockRef = useRef(false);
const connectClickRef = useRef(null);
+ const canUndo = historyTick >= 0 && historyRef.current.length > 0;
+ const canRedo = historyTick >= 0 && redoRef.current.length > 0;
+
+ const markDirty = useCallback(() => {
+ dirtyRef.current = true;
+ setDirty(true);
+ }, []);
+
+ const clearDirty = useCallback(() => {
+ dirtyRef.current = false;
+ setDirty(false);
+ }, []);
+
+ const bumpHistory = useCallback(() => {
+ setHistoryTick((n) => n + 1);
+ }, []);
+
+ const confirmDiscardIfDirty = useCallback(() => {
+ if (!dirtyRef.current) return true;
+ return window.confirm(t("topology.unsavedConfirm"));
+ }, [t]);
+
+ const selectMap = useCallback(
+ (id: string) => {
+ if (id === mapId) return;
+ if (!confirmDiscardIfDirty()) return;
+ setMapId(id);
+ },
+ [mapId, confirmDiscardIfDirty],
+ );
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => setDebouncedKeyword(keyword.trim()), 300);
+ return () => window.clearTimeout(timer);
+ }, [keyword]);
+
+ useEffect(() => {
+ const onBeforeUnload = (e: BeforeUnloadEvent) => {
+ if (!dirtyRef.current) return;
+ e.preventDefault();
+ e.returnValue = "";
+ };
+ window.addEventListener("beforeunload", onBeforeUnload);
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
+ }, []);
+
const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]);
const displayOpts = useMemo(
() => ({ hideIp, hideVendor, hidePorts, connectMode: toolMode === "connect" }),
@@ -441,10 +493,10 @@ export function TopologyPage() {
});
const neQuery = useQuery({
- queryKey: [...queryKeys.managedNeAll, "topology-palette", keyword],
+ queryKey: [...queryKeys.managedNeAll, "topology-palette", debouncedKeyword],
queryFn: () =>
fetchManagedNe({
- keyword: keyword.trim(),
+ keyword: debouncedKeyword,
vendor: "",
connectStatus: "",
page: 1,
@@ -454,10 +506,10 @@ export function TopologyPage() {
});
const umeQuery = useQuery({
- queryKey: ["umeInventoryNe", "topology-palette", keyword],
+ queryKey: ["umeInventoryNe", "topology-palette", debouncedKeyword],
queryFn: () =>
fetchUmeNe({
- keyword: keyword.trim(),
+ keyword: debouncedKeyword,
page: 1,
pageSize: 100,
}),
@@ -478,7 +530,8 @@ export function TopologyPage() {
setEdges(rfEdges);
historyRef.current = [];
redoRef.current = [];
- dirtyRef.current = false;
+ clearDirty();
+ bumpHistory();
historyLockRef.current = false;
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
// edgeDefaults applied separately so changing defaults won't reload the whole graph.
@@ -520,7 +573,8 @@ export function TopologyPage() {
},
];
redoRef.current = [];
- }, [nodes, edges]);
+ bumpHistory();
+ }, [nodes, edges, bumpHistory]);
const undo = useCallback(() => {
const prev = historyRef.current.pop();
@@ -532,9 +586,10 @@ export function TopologyPage() {
historyLockRef.current = true;
setNodes(prev.nodes);
setEdges(prev.edges);
- dirtyRef.current = true;
+ markDirty();
+ bumpHistory();
historyLockRef.current = false;
- }, [nodes, edges, setNodes, setEdges]);
+ }, [nodes, edges, setNodes, setEdges, markDirty, bumpHistory]);
const redo = useCallback(() => {
const next = redoRef.current.pop();
@@ -546,9 +601,10 @@ export function TopologyPage() {
historyLockRef.current = true;
setNodes(next.nodes);
setEdges(next.edges);
- dirtyRef.current = true;
+ markDirty();
+ bumpHistory();
historyLockRef.current = false;
- }, [nodes, edges, setNodes, setEdges]);
+ }, [nodes, edges, setNodes, setEdges, markDirty, bumpHistory]);
const applyLayout = useCallback(
async (kind: LayoutKind, opts?: { onlySelected?: boolean; persist?: boolean }) => {
@@ -563,19 +619,19 @@ export function TopologyPage() {
pushHistory();
const next = layoutGraph(nodes, edges, kind, { onlyIds });
setNodes(next);
- dirtyRef.current = true;
+ markDirty();
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 40);
if (opts?.persist && mapId) {
try {
const graph = await putTopologyGraph(mapId, flowToGraphPayload(next, edges));
- dirtyRef.current = false;
+ clearDirty();
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
} catch (err) {
showError(String(err));
}
}
},
- [nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t],
+ [nodes, edges, setNodes, pushHistory, mapId, queryClient, showError, t, markDirty, clearDirty],
);
const applyAlign = useCallback(
@@ -587,9 +643,9 @@ export function TopologyPage() {
}
pushHistory();
setNodes(alignNodes(nodes, ids, kind));
- dirtyRef.current = true;
+ markDirty();
},
- [nodes, setNodes, pushHistory, showError, t],
+ [nodes, setNodes, pushHistory, showError, t, markDirty],
);
const renameMapMut = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
@@ -616,6 +672,7 @@ export function TopologyPage() {
mutationFn: () => createTopologyMap({ name: t("topology.newMapName") }),
onSuccess: async (row) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
+ clearDirty();
setMapId(row.id);
showOk(t("topology.newMap"));
// Prompt rename right after create — default name is a placeholder.
@@ -629,6 +686,7 @@ export function TopologyPage() {
onSuccess: async (_out, id) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
if (mapId === id) {
+ clearDirty();
setMapId("");
setNodes([]);
setEdges([]);
@@ -640,7 +698,7 @@ export function TopologyPage() {
const saveMut = useMutation({
mutationFn: () => putTopologyGraph(mapId, flowToGraphPayload(nodes, edges)),
onSuccess: async (graph) => {
- dirtyRef.current = false;
+ clearDirty();
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(t("topology.saved"));
@@ -679,7 +737,7 @@ export function TopologyPage() {
try {
if (dirtyRef.current) {
await putTopologyGraph(mapId, flowToGraphPayload(nodes, edges));
- dirtyRef.current = false;
+ clearDirty();
}
const out = await discoverTopologyNeighborsStream(
mapId,
@@ -723,12 +781,12 @@ export function TopologyPage() {
try {
const graph = await putTopologyGraph(mapId, flowToGraphPayload(rfNodes, rfEdges));
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
- dirtyRef.current = false;
+ clearDirty();
} catch {
- dirtyRef.current = true;
+ markDirty();
}
} else {
- dirtyRef.current = false;
+ clearDirty();
}
historyLockRef.current = true;
setNodes(rfNodes);
@@ -775,10 +833,21 @@ export function TopologyPage() {
failed: discoverLiveResults.filter((r) => !r.ok).length,
};
+ const isValidConnection = useCallback(
+ (connection: Connection | Edge) => {
+ const source = String(connection.source || "");
+ const target = String(connection.target || "");
+ if (!source || !target || source === target) return false;
+ return !edges.some((e) => e.source === source && e.target === target);
+ },
+ [edges],
+ );
+
const onConnect = useCallback(
(connection: Connection) => {
+ if (!isValidConnection(connection)) return;
pushHistory();
- dirtyRef.current = true;
+ markDirty();
connectClickRef.current = null;
const data: EdgeStyleData = {
source: "manual",
@@ -787,6 +856,7 @@ export function TopologyPage() {
stroke_color: "",
stroke_width: 0,
line_style: "",
+ discovered_at: null,
};
setEdges((eds) =>
addEdge(
@@ -803,7 +873,7 @@ export function TopologyPage() {
),
);
},
- [setEdges, pushHistory, edgeDefaults],
+ [setEdges, pushHistory, edgeDefaults, markDirty, isValidConnection],
);
const addNodeAt = useCallback(
@@ -817,7 +887,7 @@ export function TopologyPage() {
const ne = (neQuery.data?.items || []).find((x) => x.id === item.managed_ne_id);
if (!ne) return;
pushHistory();
- dirtyRef.current = true;
+ markDirty();
setNodes((prev) => [
...prev,
{
@@ -841,7 +911,7 @@ export function TopologyPage() {
if (!ne) return;
const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim();
pushHistory();
- dirtyRef.current = true;
+ markDirty();
setNodes((prev) => [
...prev,
{
@@ -942,12 +1012,14 @@ export function TopologyPage() {
const patchSelectedEdgeStyle = useCallback(
(
- patch: Partial>,
+ patch: Partial<
+ Pick
+ >,
opts?: { skipHistory?: boolean },
) => {
if (!selectedEdgeId) return;
if (!opts?.skipHistory) pushHistory();
- dirtyRef.current = true;
+ markDirty();
setEdges((eds) =>
eds.map((e) => {
if (e.id !== selectedEdgeId) return e;
@@ -958,14 +1030,34 @@ export function TopologyPage() {
stroke_width:
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
+ source_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "",
+ target_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "",
};
- return withEdgeVisual({ ...e, data }, edgeDefaults);
+ const portLabel = [data.source_port, data.target_port].filter(Boolean).join(" ? ");
+ return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults);
}),
);
},
- [selectedEdgeId, setEdges, pushHistory, edgeDefaults],
+ [selectedEdgeId, setEdges, pushHistory, edgeDefaults, markDirty],
);
+ const renameSelectedNode = useCallback(() => {
+ if (!selectedNode) return;
+ const next = window.prompt(t("topology.renameNodePrompt"), selectedNode.data.label || "");
+ if (next == null) return;
+ const label = next.trim();
+ if (!label || label === selectedNode.data.label) {
+ setCtxMenu(null);
+ return;
+ }
+ pushHistory();
+ markDirty();
+ setNodes((ns) =>
+ ns.map((n) => (n.id === selectedNode.id ? { ...n, data: { ...n.data, label } } : n)),
+ );
+ setCtxMenu(null);
+ }, [selectedNode, t, pushHistory, markDirty, setNodes]);
+
const staleEdgeCount = useMemo(
() =>
edges.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") === "stale")
@@ -1020,7 +1112,7 @@ export function TopologyPage() {
const edgeIds = new Set(edges.filter((e) => e.selected).map((e) => e.id));
if (nodeIds.size === 0 && edgeIds.size === 0) return;
pushHistory();
- dirtyRef.current = true;
+ markDirty();
setNodes((ns) => ns.filter((n) => !nodeIds.has(n.id)));
setEdges((es) =>
es.filter((e) => !edgeIds.has(e.id) && !nodeIds.has(e.source) && !nodeIds.has(e.target)),
@@ -1047,6 +1139,11 @@ export function TopologyPage() {
connectClickRef.current = null;
return;
}
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
+ e.preventDefault();
+ if (mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
+ return;
+ }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
e.preventDefault();
undo();
@@ -1071,18 +1168,27 @@ export function TopologyPage() {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
- }, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges]);
+ }, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut]);
useEffect(() => {
if (!ctxMenu) return;
const onScroll = () => closeCtxMenu();
+ const onPointerDown = (e: MouseEvent) => {
+ const el = e.target as HTMLElement | null;
+ if (el?.closest?.(".topo-ctx")) return;
+ closeCtxMenu();
+ };
window.addEventListener("scroll", onScroll, true);
- return () => window.removeEventListener("scroll", onScroll, true);
+ window.addEventListener("mousedown", onPointerDown, true);
+ return () => {
+ window.removeEventListener("scroll", onScroll, true);
+ window.removeEventListener("mousedown", onPointerDown, true);
+ };
}, [ctxMenu, closeCtxMenu]);
const removeNodeById = (nodeId: string) => {
pushHistory();
- dirtyRef.current = true;
+ markDirty();
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
setEdges((es) => es.filter((e) => e.source !== nodeId && e.target !== nodeId));
closeCtxMenu();
@@ -1090,7 +1196,7 @@ export function TopologyPage() {
const removeEdgeById = (edgeId: string) => {
pushHistory();
- dirtyRef.current = true;
+ markDirty();
setEdges((es) => es.filter((e) => e.id !== edgeId));
setSelectedEdgeId((cur) => (cur === edgeId ? null : cur));
closeCtxMenu();
@@ -1100,7 +1206,7 @@ export function TopologyPage() {
const n = staleEdgeCount;
if (n <= 0) return;
pushHistory();
- dirtyRef.current = true;
+ markDirty();
setEdges((es) =>
es.filter((e) => String((e.data as { source?: string } | undefined)?.source || "") !== "stale"),
);
@@ -1237,7 +1343,7 @@ export function TopologyPage() {
name,
ip: ne.ip_address || "",
vendor: "ZTE",
- meta: `${ne.ip_address || "-"} · ${ne.ne_type || "UME"}`,
+ meta: `${ne.ip_address || "-"} ? ${ne.ne_type || "UME"}`,
connect_status: ne.connection_status || "",
};
});
@@ -1250,7 +1356,7 @@ export function TopologyPage() {
name: ne.name || ne.ip_address,
ip: ne.ip_address,
vendor: ne.vendor,
- meta: `${ne.ip_address} · ${ne.vendor}`,
+ meta: `${ne.ip_address} ? ${ne.vendor}`,
connect_status: ne.connect_status,
}));
}, [paletteSource, neQuery.data, umeQuery.data]);
@@ -1291,7 +1397,10 @@ export function TopologyPage() {
) : null}
@@ -1727,7 +1861,7 @@ export function TopologyPage() {
{r.ok ? "OK" : "FAIL"}
- {r.ne_ip ? `${r.ne_ip} · ` : ""}
+ {r.ne_ip ? `${r.ne_ip} ? ` : ""}
{r.ok
? t("topology.discoverNeOk")
.replace("{{neighbors}}", String(r.neighbors))
@@ -1773,17 +1907,18 @@ export function TopologyPage() {
onNodeDragStart={() => pushHistory()}
onNodesChange={(changes) => {
if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) {
- dirtyRef.current = true;
+ markDirty();
}
onNodesChange(changes);
}}
onEdgesChange={(changes) => {
if (changes.some((c) => c.type !== "select")) {
- dirtyRef.current = true;
+ markDirty();
}
onEdgesChange(changes);
}}
onConnect={onConnect}
+ isValidConnection={isValidConnection}
onNodeClick={onNodeClick}
onEdgeClick={(_e, edge) => {
setCtxMenu(null);
@@ -1913,6 +2048,16 @@ export function TopologyPage() {
{t("topology.nodeMenu")}
+
+
+ {t("topology.renameNode")}
+
+
{t("topology.edgeStyle")}
{selectedEdgeResolved ? (
+
+