Add biz-state schedule UI; empty port map ignores iface and lists unchanged diffs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-17 18:39:33 +08:00
parent ea6e0041c1
commit 29b08f9807
6 changed files with 148 additions and 19 deletions

View file

@ -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,

View file

@ -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__":

View file

@ -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",

View file

@ -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: "名称",

View file

@ -747,6 +747,7 @@ export function BizComparePage() {
{!compact ? (
<div className="bs-cmp-mapping">
<h4 style={{ margin: "8px 0" }}>{t("bizCompare.mapping")}</h4>
<p className="muted">{t("bizCompare.mappingOptionalHint")}</p>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<FieldSelect
value={mappingId}

View file

@ -37,6 +37,7 @@ type TaskRow = {
collect_running: boolean;
last_error: string;
last_collect_ended_at?: string | null;
interval_sec?: number;
};
type Placeholder = { name: string };
@ -183,6 +184,8 @@ export function BizStatePage() {
const [profiles, setProfiles] = useState<Profile[]>([]);
const [batches, setBatches] = useState<BatchRow[]>([]);
const [taskTab, setTaskTab] = useState<TaskTab>("profiles");
const [intervalSec, setIntervalSec] = useState(300);
const [retentionBatches, setRetentionBatches] = useState(30);
// VRF bind (inside task modal)
const [bindItemId, setBindItemId] = useState("");
@ -292,6 +295,8 @@ export function BizStatePage() {
const loadTask = async (id: string) => {
const task = await bizStateGetTask(id);
setDetail(task);
setIntervalSec(Math.max(60, Number(task.interval_sec || 300)));
setRetentionBatches(Math.max(1, Number(task.retention_batches || 30)));
const b = await bizStateListBatches(id);
setBatches((b.items || []) as BatchRow[]);
const p = await bizStateListProfiles({
@ -361,6 +366,24 @@ export function BizStatePage() {
}
};
const saveSchedule = async () => {
if (!taskId) return;
setBusy(true);
try {
await bizStatePatchTask(taskId, {
interval_sec: Math.max(60, Number(intervalSec) || 300),
retention_batches: Math.max(1, Number(retentionBatches) || 30),
});
showOk(t("bizState.scheduleSaved"));
await loadTask(taskId);
await refreshTasks();
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
const collectNow = async () => {
if (!taskId) return;
setBusy(true);
@ -543,6 +566,7 @@ export function BizStatePage() {
<tr>
<th>{t("bizState.colNe")}</th>
<th>{t("bizState.colSource")}</th>
<th>{t("bizState.colInterval")}</th>
<th>{t("bizState.colStatus")}</th>
<th>{t("bizState.colLast")}</th>
<th>{t("bizState.colActions")}</th>
@ -563,6 +587,7 @@ export function BizStatePage() {
{row.source || "managed"}
</NmStatusChip>
</td>
<td className="pt-list-num">{row.interval_sec ?? 300}s</td>
<td>
<div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}>
<NmStatusChip color={jobChipColor(row.status)}>{row.status}</NmStatusChip>
@ -633,13 +658,13 @@ export function BizStatePage() {
</td>
</tr>
))}
{!filteredTasks.length ? (
<tr>
<td colSpan={5}>
<div className="pt-list-empty">{t("bizState.empty")}</div>
</td>
</tr>
) : null}
{!filteredTasks.length ? (
<tr>
<td colSpan={6}>
<div className="pt-list-empty">{t("bizState.empty")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
@ -767,9 +792,34 @@ export function BizStatePage() {
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{detail ? (
<p className="muted">
{detail.vendor || "—"} · {detail.ne_ip || "—"} · interval {detail.interval_sec}s
</p>
<div className="config-sync-policy-row bs-schedule-row">
<label className="config-sync-policy-field">
<span>{t("bizState.interval")}</span>
<Input
type="number"
min={60}
max={86400}
value={String(intervalSec)}
onChange={(e) => setIntervalSec(Math.max(60, Number(e.target.value) || 60))}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("bizState.retention")}</span>
<Input
type="number"
min={1}
max={200}
value={String(retentionBatches)}
onChange={(e) => setRetentionBatches(Math.max(1, Number(e.target.value) || 1))}
/>
</label>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void saveSchedule()}>
{t("bizState.saveSchedule")}
</Button>
<span className="muted">
{detail.vendor || "—"} · {detail.ne_ip || "—"}
</span>
</div>
) : null}
{detail?.last_error ? <p className="form-error">{detail.last_error}</p> : null}