mirror of
https://github.com/hansjone/netx.git
synced 2026-10-19 01:53:18 +08:00
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:
parent
ea6e0041c1
commit
29b08f9807
6 changed files with 148 additions and 19 deletions
|
|
@ -75,10 +75,21 @@ def compare_rows(
|
||||||
"""Return summary + diffs list.
|
"""Return summary + diffs list.
|
||||||
|
|
||||||
Diff kinds: added | removed | changed | unchanged
|
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:
|
if not key_fields:
|
||||||
raise ValueError("key_fields required")
|
raise ValueError("key_fields required")
|
||||||
pmap = dict(port_map or {})
|
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]] = [
|
before_mapped: list[dict[str, Any]] = [
|
||||||
apply_port_map(r, iface_fields=iface_fields, port_map=pmap) for r in before_rows
|
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]] = {}
|
after_index: dict[tuple[str, ...], dict[str, Any]] = {}
|
||||||
for r in after_rows:
|
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()
|
before_keys: set[tuple[str, ...]] = set()
|
||||||
diffs: list[dict[str, Any]] = []
|
diffs: list[dict[str, Any]] = []
|
||||||
added = removed = changed = unchanged = 0
|
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):
|
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)
|
before_keys.add(k)
|
||||||
after = after_index.get(k)
|
after = after_index.get(k)
|
||||||
if after is None:
|
if after is None:
|
||||||
|
|
@ -101,7 +115,7 @@ def compare_rows(
|
||||||
diffs.append(
|
diffs.append(
|
||||||
{
|
{
|
||||||
"kind": "removed",
|
"kind": "removed",
|
||||||
"key": {f: mapped.get(f, "") for f in key_fields},
|
"key": _key_obj(mapped),
|
||||||
"before": orig,
|
"before": orig,
|
||||||
"after": None,
|
"after": None,
|
||||||
"mapped_before": mapped,
|
"mapped_before": mapped,
|
||||||
|
|
@ -123,7 +137,7 @@ def compare_rows(
|
||||||
diffs.append(
|
diffs.append(
|
||||||
{
|
{
|
||||||
"kind": "changed",
|
"kind": "changed",
|
||||||
"key": {f: mapped.get(f, "") for f in key_fields},
|
"key": _key_obj(mapped),
|
||||||
"before": orig,
|
"before": orig,
|
||||||
"after": after,
|
"after": after,
|
||||||
"mapped_before": mapped,
|
"mapped_before": mapped,
|
||||||
|
|
@ -132,6 +146,16 @@ def compare_rows(
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
unchanged += 1
|
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():
|
for k, after in after_index.items():
|
||||||
if k in before_keys:
|
if k in before_keys:
|
||||||
|
|
@ -140,7 +164,7 @@ def compare_rows(
|
||||||
diffs.append(
|
diffs.append(
|
||||||
{
|
{
|
||||||
"kind": "added",
|
"kind": "added",
|
||||||
"key": {f: after.get(f, "") for f in key_fields},
|
"key": _key_obj(after),
|
||||||
"before": None,
|
"before": None,
|
||||||
"after": after,
|
"after": after,
|
||||||
"mapped_before": None,
|
"mapped_before": None,
|
||||||
|
|
@ -154,6 +178,8 @@ def compare_rows(
|
||||||
iface_fields=iface_fields,
|
iface_fields=iface_fields,
|
||||||
port_map=pmap,
|
port_map=pmap,
|
||||||
)
|
)
|
||||||
|
if not pmap:
|
||||||
|
stats = {**stats, "ok": True, "ignore_port_changes": True}
|
||||||
return {
|
return {
|
||||||
"summary": {
|
"summary": {
|
||||||
"before_count": len(before_rows),
|
"before_count": len(before_rows),
|
||||||
|
|
@ -162,6 +188,7 @@ def compare_rows(
|
||||||
"removed": removed,
|
"removed": removed,
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"unchanged": unchanged,
|
"unchanged": unchanged,
|
||||||
|
"match_key_fields": match_keys,
|
||||||
},
|
},
|
||||||
"diffs": diffs,
|
"diffs": diffs,
|
||||||
"mapping_stats": stats,
|
"mapping_stats": stats,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,44 @@ class CompareEngineTests(unittest.TestCase):
|
||||||
self.assertEqual(s["unchanged"], 1)
|
self.assertEqual(s["unchanged"], 1)
|
||||||
self.assertEqual(s["removed"], 1)
|
self.assertEqual(s["removed"], 1)
|
||||||
self.assertEqual(s["added"], 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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,11 @@ const en = {
|
||||||
paused: "Paused",
|
paused: "Paused",
|
||||||
collecting: "Collecting",
|
collecting: "Collecting",
|
||||||
detail: "Details",
|
detail: "Details",
|
||||||
|
interval: "Interval (sec)",
|
||||||
|
retention: "Keep batches",
|
||||||
|
saveSchedule: "Save schedule",
|
||||||
|
scheduleSaved: "Schedule saved",
|
||||||
|
colInterval: "Interval",
|
||||||
profiles: "Monitor items",
|
profiles: "Monitor items",
|
||||||
enable: "On",
|
enable: "On",
|
||||||
command: "Command template",
|
command: "Command template",
|
||||||
|
|
@ -260,14 +265,16 @@ const en = {
|
||||||
presenceShort: "presence",
|
presenceShort: "presence",
|
||||||
modeFields: "Mode: compare selected field values",
|
modeFields: "Mode: compare selected field values",
|
||||||
modePresence: "Mode: presence-only (entry set match)",
|
modePresence: "Mode: presence-only (entry set match)",
|
||||||
mapping: "Port mapping",
|
mapping: "Port mapping (optional)",
|
||||||
newMapping: "New mapping…",
|
newMapping: "New mapping…",
|
||||||
mapName: "Mapping name",
|
mapName: "Mapping name",
|
||||||
saveMapping: "Save mapping",
|
saveMapping: "Save mapping",
|
||||||
validateMapping: "Validate",
|
validateMapping: "Validate",
|
||||||
validateNeed: "Select mapping and before/after batches first",
|
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",
|
mappingSaved: "Mapping saved",
|
||||||
|
mappingOptionalHint: "No port map: ignore local port changes; match on non-interface keys.",
|
||||||
createJob: "Create compare job",
|
createJob: "Create compare job",
|
||||||
defaultJobName: "Cutover compare",
|
defaultJobName: "Cutover compare",
|
||||||
jobName: "Name",
|
jobName: "Name",
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,11 @@ const zh = {
|
||||||
paused: "已暂停",
|
paused: "已暂停",
|
||||||
collecting: "采集中",
|
collecting: "采集中",
|
||||||
detail: "详情",
|
detail: "详情",
|
||||||
|
interval: "采集周期(秒)",
|
||||||
|
retention: "保留批次数",
|
||||||
|
saveSchedule: "保存周期",
|
||||||
|
scheduleSaved: "周期配置已保存",
|
||||||
|
colInterval: "周期",
|
||||||
profiles: "监控项",
|
profiles: "监控项",
|
||||||
enable: "启用",
|
enable: "启用",
|
||||||
command: "命令模板",
|
command: "命令模板",
|
||||||
|
|
@ -260,14 +265,15 @@ const zh = {
|
||||||
presenceShort: "仅条目",
|
presenceShort: "仅条目",
|
||||||
modeFields: "模式:按比对字段检查值",
|
modeFields: "模式:按比对字段检查值",
|
||||||
modePresence: "模式:仅校验前后条目一致(不比具体值)",
|
modePresence: "模式:仅校验前后条目一致(不比具体值)",
|
||||||
mapping: "端口映射",
|
mapping: "端口映射(可选)",
|
||||||
newMapping: "新建映射…",
|
newMapping: "新建映射…",
|
||||||
mapName: "映射名称",
|
mapName: "映射名称",
|
||||||
saveMapping: "保存映射",
|
saveMapping: "保存映射",
|
||||||
validateMapping: "校验映射",
|
validateMapping: "校验映射",
|
||||||
validateNeed: "请先选择映射与前后批次",
|
validateNeed: "请先选择映射与前后批次",
|
||||||
mapHint: "before_if,after_if 每行一对",
|
mapHint: "可选。不填表示忽略端口变化(同设备比对按非接口 Key 对齐)。填写时每行 before_if,after_if",
|
||||||
mappingSaved: "映射已保存",
|
mappingSaved: "映射已保存",
|
||||||
|
mappingOptionalHint: "未填端口映射:忽略本端端口变化,仅按邻居等非接口 Key 对齐。",
|
||||||
createJob: "建立比对任务",
|
createJob: "建立比对任务",
|
||||||
defaultJobName: "割接比对",
|
defaultJobName: "割接比对",
|
||||||
jobName: "名称",
|
jobName: "名称",
|
||||||
|
|
|
||||||
|
|
@ -747,6 +747,7 @@ export function BizComparePage() {
|
||||||
{!compact ? (
|
{!compact ? (
|
||||||
<div className="bs-cmp-mapping">
|
<div className="bs-cmp-mapping">
|
||||||
<h4 style={{ margin: "8px 0" }}>{t("bizCompare.mapping")}</h4>
|
<h4 style={{ margin: "8px 0" }}>{t("bizCompare.mapping")}</h4>
|
||||||
|
<p className="muted">{t("bizCompare.mappingOptionalHint")}</p>
|
||||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
value={mappingId}
|
value={mappingId}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ type TaskRow = {
|
||||||
collect_running: boolean;
|
collect_running: boolean;
|
||||||
last_error: string;
|
last_error: string;
|
||||||
last_collect_ended_at?: string | null;
|
last_collect_ended_at?: string | null;
|
||||||
|
interval_sec?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Placeholder = { name: string };
|
type Placeholder = { name: string };
|
||||||
|
|
@ -183,6 +184,8 @@ export function BizStatePage() {
|
||||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
const [batches, setBatches] = useState<BatchRow[]>([]);
|
const [batches, setBatches] = useState<BatchRow[]>([]);
|
||||||
const [taskTab, setTaskTab] = useState<TaskTab>("profiles");
|
const [taskTab, setTaskTab] = useState<TaskTab>("profiles");
|
||||||
|
const [intervalSec, setIntervalSec] = useState(300);
|
||||||
|
const [retentionBatches, setRetentionBatches] = useState(30);
|
||||||
|
|
||||||
// VRF bind (inside task modal)
|
// VRF bind (inside task modal)
|
||||||
const [bindItemId, setBindItemId] = useState("");
|
const [bindItemId, setBindItemId] = useState("");
|
||||||
|
|
@ -292,6 +295,8 @@ export function BizStatePage() {
|
||||||
const loadTask = async (id: string) => {
|
const loadTask = async (id: string) => {
|
||||||
const task = await bizStateGetTask(id);
|
const task = await bizStateGetTask(id);
|
||||||
setDetail(task);
|
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);
|
const b = await bizStateListBatches(id);
|
||||||
setBatches((b.items || []) as BatchRow[]);
|
setBatches((b.items || []) as BatchRow[]);
|
||||||
const p = await bizStateListProfiles({
|
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 () => {
|
const collectNow = async () => {
|
||||||
if (!taskId) return;
|
if (!taskId) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|
@ -543,6 +566,7 @@ export function BizStatePage() {
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("bizState.colNe")}</th>
|
<th>{t("bizState.colNe")}</th>
|
||||||
<th>{t("bizState.colSource")}</th>
|
<th>{t("bizState.colSource")}</th>
|
||||||
|
<th>{t("bizState.colInterval")}</th>
|
||||||
<th>{t("bizState.colStatus")}</th>
|
<th>{t("bizState.colStatus")}</th>
|
||||||
<th>{t("bizState.colLast")}</th>
|
<th>{t("bizState.colLast")}</th>
|
||||||
<th>{t("bizState.colActions")}</th>
|
<th>{t("bizState.colActions")}</th>
|
||||||
|
|
@ -563,6 +587,7 @@ export function BizStatePage() {
|
||||||
{row.source || "managed"}
|
{row.source || "managed"}
|
||||||
</NmStatusChip>
|
</NmStatusChip>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="pt-list-num">{row.interval_sec ?? 300}s</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}>
|
<div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}>
|
||||||
<NmStatusChip color={jobChipColor(row.status)}>{row.status}</NmStatusChip>
|
<NmStatusChip color={jobChipColor(row.status)}>{row.status}</NmStatusChip>
|
||||||
|
|
@ -633,13 +658,13 @@ export function BizStatePage() {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!filteredTasks.length ? (
|
{!filteredTasks.length ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5}>
|
<td colSpan={6}>
|
||||||
<div className="pt-list-empty">{t("bizState.empty")}</div>
|
<div className="pt-list-empty">{t("bizState.empty")}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -767,9 +792,34 @@ export function BizStatePage() {
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body className="flex flex-col gap-3">
|
<Modal.Body className="flex flex-col gap-3">
|
||||||
{detail ? (
|
{detail ? (
|
||||||
<p className="muted">
|
<div className="config-sync-policy-row bs-schedule-row">
|
||||||
{detail.vendor || "—"} · {detail.ne_ip || "—"} · interval {detail.interval_sec}s
|
<label className="config-sync-policy-field">
|
||||||
</p>
|
<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}
|
) : null}
|
||||||
{detail?.last_error ? <p className="form-error">{detail.last_error}</p> : null}
|
{detail?.last_error ? <p className="form-error">{detail.last_error}</p> : null}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue