diff --git a/netx_api/biz_state/compare_engine.py b/netx_api/biz_state/compare_engine.py index 3249beb..8bf7ca5 100644 --- a/netx_api/biz_state/compare_engine.py +++ b/netx_api/biz_state/compare_engine.py @@ -75,10 +75,21 @@ def compare_rows( """Return summary + diffs list. Diff kinds: added | removed | changed | unchanged + + Empty ``port_map`` means ignore port/iface changes: matching keys exclude + ``iface_fields`` (same-device batches compare without a rename map). """ if not key_fields: raise ValueError("key_fields required") pmap = dict(port_map or {}) + iface_set = {str(f) for f in (iface_fields or []) if str(f).strip()} + # No map → ignore port renames: drop iface columns from the match key. + if not pmap and iface_set: + match_keys = [k for k in key_fields if k not in iface_set] + if not match_keys: + match_keys = list(key_fields) + else: + match_keys = list(key_fields) before_mapped: list[dict[str, Any]] = [ apply_port_map(r, iface_fields=iface_fields, port_map=pmap) for r in before_rows @@ -86,14 +97,17 @@ def compare_rows( after_index: dict[tuple[str, ...], dict[str, Any]] = {} for r in after_rows: - after_index[row_key(r, key_fields)] = r + after_index[row_key(r, match_keys)] = r before_keys: set[tuple[str, ...]] = set() diffs: list[dict[str, Any]] = [] added = removed = changed = unchanged = 0 + def _key_obj(row: dict[str, Any]) -> dict[str, Any]: + return {f: row.get(f, "") for f in key_fields} + for orig, mapped in zip(before_rows, before_mapped): - k = row_key(mapped, key_fields) + k = row_key(mapped, match_keys) before_keys.add(k) after = after_index.get(k) if after is None: @@ -101,7 +115,7 @@ def compare_rows( diffs.append( { "kind": "removed", - "key": {f: mapped.get(f, "") for f in key_fields}, + "key": _key_obj(mapped), "before": orig, "after": None, "mapped_before": mapped, @@ -123,7 +137,7 @@ def compare_rows( diffs.append( { "kind": "changed", - "key": {f: mapped.get(f, "") for f in key_fields}, + "key": _key_obj(mapped), "before": orig, "after": after, "mapped_before": mapped, @@ -132,6 +146,16 @@ def compare_rows( ) else: unchanged += 1 + diffs.append( + { + "kind": "unchanged", + "key": _key_obj(mapped), + "before": orig, + "after": after, + "mapped_before": mapped, + "changes": {}, + } + ) for k, after in after_index.items(): if k in before_keys: @@ -140,7 +164,7 @@ def compare_rows( diffs.append( { "kind": "added", - "key": {f: after.get(f, "") for f in key_fields}, + "key": _key_obj(after), "before": None, "after": after, "mapped_before": None, @@ -154,6 +178,8 @@ def compare_rows( iface_fields=iface_fields, port_map=pmap, ) + if not pmap: + stats = {**stats, "ok": True, "ignore_port_changes": True} return { "summary": { "before_count": len(before_rows), @@ -162,6 +188,7 @@ def compare_rows( "removed": removed, "changed": changed, "unchanged": unchanged, + "match_key_fields": match_keys, }, "diffs": diffs, "mapping_stats": stats, diff --git a/tests/test_biz_state_compare.py b/tests/test_biz_state_compare.py index 58be5a4..92e9750 100644 --- a/tests/test_biz_state_compare.py +++ b/tests/test_biz_state_compare.py @@ -85,6 +85,44 @@ class CompareEngineTests(unittest.TestCase): self.assertEqual(s["unchanged"], 1) self.assertEqual(s["removed"], 1) self.assertEqual(s["added"], 1) + kinds = {d["kind"] for d in out["diffs"]} + self.assertIn("unchanged", kinds) + + def test_empty_port_map_ignores_iface_in_key(self) -> None: + """No port map → ignore local_if when matching (same neighbor, renamed port).""" + before = [ + {"local_if": "old-1", "remote_sys": "Peer", "remote_if": "p1", "remote_ip": "1.1.1.1"}, + ] + after = [ + {"local_if": "new-1", "remote_sys": "Peer", "remote_if": "p1", "remote_ip": "1.1.1.1"}, + ] + out = compare_rows( + before_rows=before, + after_rows=after, + key_fields=["local_if", "remote_sys", "remote_if"], + iface_fields=["local_if"], + compare_fields=["remote_ip"], + port_map={}, + ) + self.assertEqual(out["summary"]["unchanged"], 1) + self.assertEqual(out["summary"]["added"], 0) + self.assertEqual(out["summary"]["removed"], 0) + self.assertTrue(out["mapping_stats"].get("ignore_port_changes")) + + def test_unchanged_rows_are_listed(self) -> None: + before = [{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"}] + after = [{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"}] + out = compare_rows( + before_rows=before, + after_rows=after, + key_fields=["local_if", "remote_sys", "remote_if"], + iface_fields=["local_if"], + compare_fields=["remote_ip"], + port_map={}, + ) + self.assertEqual(out["summary"]["unchanged"], 1) + self.assertEqual(len(out["diffs"]), 1) + self.assertEqual(out["diffs"][0]["kind"], "unchanged") if __name__ == "__main__": diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 762b601..2d133c4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -191,6 +191,11 @@ const en = { paused: "Paused", collecting: "Collecting", detail: "Details", + interval: "Interval (sec)", + retention: "Keep batches", + saveSchedule: "Save schedule", + scheduleSaved: "Schedule saved", + colInterval: "Interval", profiles: "Monitor items", enable: "On", command: "Command template", @@ -260,14 +265,16 @@ const en = { presenceShort: "presence", modeFields: "Mode: compare selected field values", modePresence: "Mode: presence-only (entry set match)", - mapping: "Port mapping", + mapping: "Port mapping (optional)", newMapping: "New mapping…", mapName: "Mapping name", saveMapping: "Save mapping", validateMapping: "Validate", validateNeed: "Select mapping and before/after batches first", - mapHint: "before_if,after_if one pair per line", + mapHint: + "Optional. Leave empty to ignore port renames (match on non-interface keys). Or one before_if,after_if pair per line", mappingSaved: "Mapping saved", + mappingOptionalHint: "No port map: ignore local port changes; match on non-interface keys.", createJob: "Create compare job", defaultJobName: "Cutover compare", jobName: "Name", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 02f4449..d140b7b 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -191,6 +191,11 @@ const zh = { paused: "已暂停", collecting: "采集中", detail: "详情", + interval: "采集周期(秒)", + retention: "保留批次数", + saveSchedule: "保存周期", + scheduleSaved: "周期配置已保存", + colInterval: "周期", profiles: "监控项", enable: "启用", command: "命令模板", @@ -260,14 +265,15 @@ const zh = { presenceShort: "仅条目", modeFields: "模式:按比对字段检查值", modePresence: "模式:仅校验前后条目一致(不比具体值)", - mapping: "端口映射", + mapping: "端口映射(可选)", newMapping: "新建映射…", mapName: "映射名称", saveMapping: "保存映射", validateMapping: "校验映射", validateNeed: "请先选择映射与前后批次", - mapHint: "before_if,after_if 每行一对", + mapHint: "可选。不填表示忽略端口变化(同设备比对按非接口 Key 对齐)。填写时每行 before_if,after_if", mappingSaved: "映射已保存", + mappingOptionalHint: "未填端口映射:忽略本端端口变化,仅按邻居等非接口 Key 对齐。", createJob: "建立比对任务", defaultJobName: "割接比对", jobName: "名称", diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index 2f9f41f..4691d6b 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -747,6 +747,7 @@ export function BizComparePage() { {!compact ? (
{t("bizCompare.mappingOptionalHint")}
- {detail.vendor || "—"} · {detail.ne_ip || "—"} · interval {detail.interval_sec}s -
+{detail.last_error}
: null}