mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:43:17 +08:00
Dedupe if-intf/config_interface collects and allow idle manual collect.
Remap legacy if_intf items, skip duplicate CLI+metric work, hide aux command duplicates in the workbook, use profile titles for sheet tabs, sticky table headers, and let scheduled tasks trigger collect-now when idle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
18a0c41196
commit
99cec1c324
7 changed files with 241 additions and 42 deletions
|
|
@ -77,6 +77,25 @@ def _bindings_for_item(db, item_id: str) -> list[dict[str, str]]:
|
|||
]
|
||||
|
||||
|
||||
def _resolve_collect_profile(profile_id: str):
|
||||
"""Resolve task-item profile; remap disabled if_intf → config_interface."""
|
||||
pid = str(profile_id or "").strip()
|
||||
if not pid:
|
||||
return None
|
||||
profile = get_profile(pid)
|
||||
if profile is None:
|
||||
return None
|
||||
if profile.enabled:
|
||||
return profile
|
||||
# Legacy IF VRF check merged into Config Interface Intent — VRF is a subset.
|
||||
if profile.metric_id == "if_intf" or pid.endswith(".if_intf"):
|
||||
vk = str(profile.vendor_key or "zte").strip() or "zte"
|
||||
remapped = get_profile(f"{vk}.config_interface") or get_profile("zte.config_interface")
|
||||
if remapped and remapped.enabled:
|
||||
return remapped
|
||||
return None
|
||||
|
||||
|
||||
def _persist_lldp_rows(
|
||||
db,
|
||||
*,
|
||||
|
|
@ -199,8 +218,12 @@ def _finish_task(task_id: str, *, error: str = "") -> None:
|
|||
db.close()
|
||||
|
||||
|
||||
def dispatch_collect(task_id: str) -> None:
|
||||
"""Claim and run one collect round."""
|
||||
def dispatch_collect(task_id: str, *, manual: bool = False) -> None:
|
||||
"""Claim and run one collect round.
|
||||
|
||||
Scheduler calls with ``manual=False`` (only when task status is ``running``).
|
||||
Collect-now calls with ``manual=True`` (any status, as long as not already collecting).
|
||||
"""
|
||||
db = SessionLocal()
|
||||
batch_id = ""
|
||||
try:
|
||||
|
|
@ -209,8 +232,14 @@ def dispatch_collect(task_id: str) -> None:
|
|||
return
|
||||
if task.collect_running:
|
||||
return
|
||||
if str(task.status or "") not in ("running", "draft", "paused"):
|
||||
return
|
||||
st = str(task.status or "").strip()
|
||||
if manual:
|
||||
# Idle manual trigger: allow scheduled / paused / draft / stopped
|
||||
if st in ("", "deleted"):
|
||||
return
|
||||
else:
|
||||
if st != "running":
|
||||
return
|
||||
|
||||
items = (
|
||||
db.query(BizStateTaskItem)
|
||||
|
|
@ -338,19 +367,25 @@ def _run_collect_session(
|
|||
# Build work list before opening session
|
||||
work: list[tuple[str, dict[str, str], str, str, str]] = []
|
||||
# concrete, params, profile_id, item_id, mode
|
||||
# Dedupe same CLI → same metric (e.g. legacy if_intf + config_interface).
|
||||
seen_work: set[tuple[str, str]] = set()
|
||||
for item in items:
|
||||
if item.kind == "custom_raw":
|
||||
cmd = normalize_command(item.command_override)
|
||||
if cmd:
|
||||
key = (cmd, "__custom__")
|
||||
if key in seen_work:
|
||||
continue
|
||||
seen_work.add(key)
|
||||
work.append((cmd, {}, "", item.id, "custom"))
|
||||
continue
|
||||
profile = get_profile(item.source_profile_id)
|
||||
profile = _resolve_collect_profile(item.source_profile_id)
|
||||
if profile is None:
|
||||
_append_event(
|
||||
db,
|
||||
task_id=task_id,
|
||||
message=f"unknown profile {item.source_profile_id}",
|
||||
level="error",
|
||||
message=f"skip profile {item.source_profile_id} (missing or disabled)",
|
||||
level="info",
|
||||
)
|
||||
continue
|
||||
binds = _bindings_for_item(db, item.id)
|
||||
|
|
@ -364,7 +399,16 @@ def _run_collect_session(
|
|||
_append_event(db, task_id=task_id, message=str(exc), level="error")
|
||||
continue
|
||||
for concrete, params in pairs:
|
||||
work.append((concrete, params, profile.profile_id, item.id, "normal"))
|
||||
cmd = normalize_command(concrete)
|
||||
# Prefer match_command metric so remapped if_intf shares key with config_interface
|
||||
hit = match_command(vendor_key=vendor_key, command=cmd)
|
||||
mid = str((hit.profile.metric_id if hit else profile.metric_id) or "").strip()
|
||||
pid = str((hit.profile.profile_id if hit else profile.profile_id) or "").strip()
|
||||
key = (cmd, mid or pid)
|
||||
if key in seen_work:
|
||||
continue
|
||||
seen_work.add(key)
|
||||
work.append((cmd, params, pid or profile.profile_id, item.id, "normal"))
|
||||
|
||||
if not work:
|
||||
batch.status = "failed"
|
||||
|
|
@ -424,9 +468,15 @@ def _run_collect_session(
|
|||
params_json=dict(params or {}),
|
||||
created_at=_utcnow(),
|
||||
)
|
||||
cache_hit_primary = False
|
||||
try:
|
||||
raw = send_show_command(conn, concrete, read_timeout=per_cmd)
|
||||
cmd_row.raw_text = str(raw or "")
|
||||
cached = session.get_cached(concrete)
|
||||
if cached is not None and str(cached.raw or "").strip():
|
||||
cmd_row.raw_text = str(cached.raw or "")
|
||||
cache_hit_primary = True
|
||||
else:
|
||||
raw = send_show_command(conn, concrete, read_timeout=per_cmd)
|
||||
cmd_row.raw_text = str(raw or "")
|
||||
except Exception as exc:
|
||||
any_fail = True
|
||||
cmd_row.parse_status = "failed"
|
||||
|
|
@ -466,12 +516,16 @@ def _run_collect_session(
|
|||
sdb.commit()
|
||||
continue
|
||||
|
||||
session.remember(
|
||||
concrete,
|
||||
raw=cmd_row.raw_text or "",
|
||||
ok=True,
|
||||
cmd_row_id=cmd_row.id,
|
||||
)
|
||||
if cache_hit_primary:
|
||||
# Keep existing cache entry; primary will re-parse from shared raw.
|
||||
pass
|
||||
else:
|
||||
session.remember(
|
||||
concrete,
|
||||
raw=cmd_row.raw_text or "",
|
||||
ok=True,
|
||||
cmd_row_id=cmd_row.id,
|
||||
)
|
||||
|
||||
resolved_aux = []
|
||||
aux_results: dict[str, Any] = {}
|
||||
|
|
@ -684,5 +738,6 @@ def trigger_collect_now(task_id: str) -> dict[str, Any]:
|
|||
raise HTTPException(status_code=409, detail="collect already running")
|
||||
finally:
|
||||
db.close()
|
||||
dispatch_collect(task_id)
|
||||
# Manual: allow even when schedule is on (status=running) or paused/stopped.
|
||||
dispatch_collect(task_id, manual=True)
|
||||
return {"ok": True, "task_id": task_id}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from ..models import (
|
|||
ManagedNE,
|
||||
)
|
||||
from ..timeutil import utcnow_naive
|
||||
from .command_match import preview_task_item
|
||||
from .command_match import normalize_command, preview_task_item
|
||||
from .profiles import (
|
||||
all_profiles,
|
||||
get_profile,
|
||||
|
|
@ -583,18 +583,25 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
|
|||
cmd_payload: list[dict[str, Any]] = []
|
||||
sheets_order: list[str] = []
|
||||
sheet_cmds: dict[str, list[dict[str, Any]]] = {}
|
||||
sheet_titles: dict[str, str] = {}
|
||||
|
||||
def _push_sheet(mid: str, cmd_info: dict[str, Any] | None = None) -> None:
|
||||
def _push_sheet(mid: str, cmd_info: dict[str, Any] | None = None, title: str = "") -> None:
|
||||
id_ = str(mid or "").strip()
|
||||
if not id_ or id_ in ("vrf_list", "commands"):
|
||||
return
|
||||
if id_ not in sheets_order:
|
||||
sheets_order.append(id_)
|
||||
sheet_cmds.setdefault(id_, [])
|
||||
if title:
|
||||
sheet_titles[id_] = title
|
||||
if cmd_info is not None:
|
||||
# Prefer primary collect rows over aux / aux_cached for the same CLI
|
||||
sheet_cmds[id_].append(cmd_info)
|
||||
|
||||
primary_cmds_by_cli: dict[str, dict[str, Any]] = {}
|
||||
for c in cmds:
|
||||
status = str(c.parse_status or "").strip().lower()
|
||||
is_aux = status.startswith("aux")
|
||||
info = {
|
||||
"id": c.id,
|
||||
"profile_id": c.profile_id,
|
||||
|
|
@ -606,10 +613,28 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
|
|||
"row_count": c.row_count,
|
||||
"message": c.message,
|
||||
"has_raw": bool(str(c.raw_text or "").strip()),
|
||||
"is_aux": is_aux,
|
||||
}
|
||||
cmd_n = normalize_command(str(c.raw_command or ""))
|
||||
if not is_aux and cmd_n:
|
||||
primary_cmds_by_cli.setdefault(cmd_n, info)
|
||||
# Commands sheet: hide aux when the same CLI already has a primary row
|
||||
if is_aux and cmd_n and cmd_n in primary_cmds_by_cli:
|
||||
continue
|
||||
if is_aux and cmd_n:
|
||||
# aux may appear before primary in list — defer; second pass below
|
||||
continue
|
||||
cmd_payload.append(info)
|
||||
mid = str(c.metric_id or "").strip()
|
||||
if mid and mid not in ("", "vrf_list"):
|
||||
title = ""
|
||||
pid = str(c.profile_id or "").strip()
|
||||
if pid:
|
||||
prof = get_profile(pid)
|
||||
if prof:
|
||||
title = str(prof.title or "")
|
||||
if not mid:
|
||||
mid = str(prof.metric_id or "").strip()
|
||||
if mid and mid not in ("", "vrf_list") and not is_aux:
|
||||
_push_sheet(
|
||||
mid,
|
||||
{
|
||||
|
|
@ -621,17 +646,49 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
|
|||
"has_raw": info["has_raw"],
|
||||
"profile_id": c.profile_id,
|
||||
},
|
||||
title=title,
|
||||
)
|
||||
# Aux command rows may have empty metric_id — still attach by profile if needed later
|
||||
|
||||
# Include aux-only CLIs that had no primary counterpart
|
||||
for c in cmds:
|
||||
status = str(c.parse_status or "").strip().lower()
|
||||
if not status.startswith("aux"):
|
||||
continue
|
||||
cmd_n = normalize_command(str(c.raw_command or ""))
|
||||
if cmd_n and cmd_n in primary_cmds_by_cli:
|
||||
continue
|
||||
cmd_payload.append(
|
||||
{
|
||||
"id": c.id,
|
||||
"profile_id": c.profile_id,
|
||||
"parser_id": c.parser_id,
|
||||
"metric_id": c.metric_id,
|
||||
"raw_command": c.raw_command,
|
||||
"params": c.params_json or {},
|
||||
"parse_status": c.parse_status,
|
||||
"row_count": c.row_count,
|
||||
"message": c.message,
|
||||
"has_raw": bool(str(c.raw_text or "").strip()),
|
||||
"is_aux": True,
|
||||
}
|
||||
)
|
||||
|
||||
for mid in metric_counts:
|
||||
if mid not in sheets_order:
|
||||
sheets_order.append(mid)
|
||||
sheet_cmds.setdefault(mid, [])
|
||||
if mid not in sheet_titles:
|
||||
# Best-effort title from any profile with this metric_id
|
||||
for p in all_profiles():
|
||||
if p.metric_id == mid and p.enabled:
|
||||
sheet_titles[mid] = str(p.title or mid)
|
||||
break
|
||||
sheet_titles.setdefault(mid, mid)
|
||||
|
||||
sheets = [
|
||||
{
|
||||
"metric_id": mid,
|
||||
"title": sheet_titles.get(mid) or mid,
|
||||
"row_count": int(metric_counts.get(mid) or 0),
|
||||
"commands": list(sheet_cmds.get(mid) or []),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,10 +193,11 @@ def api_collect_now(
|
|||
raise HTTPException(status_code=404, detail="task not found")
|
||||
if bool(task.collect_running):
|
||||
return {"ok": True, "started": False, "reason": "already_collecting", "task_id": task_id}
|
||||
# Allow one-shot from draft/paused
|
||||
# Allow one-shot even when schedule is enabled (idle only).
|
||||
task.last_collect_ended_at = None
|
||||
db.commit()
|
||||
background_tasks.add_task(dispatch_collect, task_id)
|
||||
tid = task_id
|
||||
background_tasks.add_task(lambda: dispatch_collect(tid, manual=True))
|
||||
return {"ok": True, "started": True, "task_id": task_id}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
|
|||
self.assertEqual(out["sheets"][0]["metric_id"], "arp")
|
||||
self.assertEqual(out["sheets"][0]["row_count"], 2)
|
||||
self.assertEqual(out["sheets"][0]["commands"][0]["raw_command"], "show arp | one-line")
|
||||
self.assertTrue(out["sheets"][0].get("title"))
|
||||
|
||||
def test_list_metric_rows_rejects_commands_sheet(self) -> None:
|
||||
db = MagicMock()
|
||||
|
|
|
|||
76
tests/test_collect_dedupe.py
Normal file
76
tests/test_collect_dedupe.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Collect work-list dedupe / if_intf remap / manual collect."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from netx_api.biz_state.collect_runner import (
|
||||
_resolve_collect_profile,
|
||||
dispatch_collect,
|
||||
trigger_collect_now,
|
||||
)
|
||||
from netx_api.biz_state.profiles import get_profile, reload_profiles
|
||||
|
||||
|
||||
class CollectProfileResolveTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
reload_profiles()
|
||||
|
||||
def test_if_intf_remaps_to_config_interface(self) -> None:
|
||||
p = _resolve_collect_profile("zte.if_intf")
|
||||
self.assertIsNotNone(p)
|
||||
assert p is not None
|
||||
self.assertEqual(p.profile_id, "zte.config_interface")
|
||||
self.assertEqual(p.metric_id, "config_interface")
|
||||
|
||||
def test_enabled_profile_passthrough(self) -> None:
|
||||
p = _resolve_collect_profile("zte.arp")
|
||||
self.assertIsNotNone(p)
|
||||
assert p is not None
|
||||
self.assertEqual(p.profile_id, "zte.arp")
|
||||
|
||||
def test_if_intf_profile_disabled(self) -> None:
|
||||
raw = get_profile("zte.if_intf")
|
||||
self.assertIsNotNone(raw)
|
||||
assert raw is not None
|
||||
self.assertFalse(raw.enabled)
|
||||
|
||||
|
||||
class ManualCollectTests(unittest.TestCase):
|
||||
def test_trigger_collect_now_passes_manual(self) -> None:
|
||||
task = MagicMock()
|
||||
task.collect_running = False
|
||||
db = MagicMock()
|
||||
db.get.return_value = task
|
||||
with (
|
||||
patch("netx_api.biz_state.collect_runner.SessionLocal", return_value=db),
|
||||
patch("netx_api.biz_state.collect_runner.dispatch_collect") as dc,
|
||||
):
|
||||
out = trigger_collect_now("t1")
|
||||
self.assertTrue(out["ok"])
|
||||
dc.assert_called_once_with("t1", manual=True)
|
||||
|
||||
def test_dispatch_manual_allows_paused(self) -> None:
|
||||
task = MagicMock()
|
||||
task.collect_running = False
|
||||
task.status = "paused"
|
||||
task.id = "t1"
|
||||
task.source = "managed"
|
||||
task.ne_id = "n1"
|
||||
task.ne_name = "NE"
|
||||
task.vendor = "zte"
|
||||
db = MagicMock()
|
||||
db.get.return_value = task
|
||||
# items query → empty so it returns early after setting error
|
||||
q = MagicMock()
|
||||
q.filter.return_value.order_by.return_value.all.return_value = []
|
||||
db.query.return_value = q
|
||||
with patch("netx_api.biz_state.collect_runner.SessionLocal", return_value=db):
|
||||
dispatch_collect("t1", manual=True)
|
||||
self.assertEqual(task.last_error, "no enabled task items")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -10051,6 +10051,25 @@ html.login-page--paused .login-page__flare {
|
|||
flex: 1 1 auto;
|
||||
min-height: 240px;
|
||||
max-height: min(48vh, 520px);
|
||||
overflow: auto;
|
||||
border-collapse: separate;
|
||||
}
|
||||
|
||||
.bs-workbook-modal .bs-sheet-table {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.bs-workbook-modal .bs-sheet-table table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.bs-workbook-modal .bs-sheet-table thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: rgba(15, 23, 42, 0.98) !important;
|
||||
box-shadow: 0 1px 0 rgba(148, 163, 184, 0.22);
|
||||
}
|
||||
|
||||
.bs-cmp-result-table.bs-sheet-table {
|
||||
|
|
|
|||
|
|
@ -166,29 +166,13 @@ function columnsFromRows(rows: Record<string, unknown>[]): SheetCol[] {
|
|||
return keys.map((k) => ({ key: k, header: k }));
|
||||
}
|
||||
|
||||
function metricSheetTitle(metricId: string, t: (k: string) => string): string {
|
||||
const map: Record<string, string> = {
|
||||
lldp_neighbor: t("bizState.sheetLldp"),
|
||||
isis_adjacency: t("bizState.sheetIsis"),
|
||||
interface_brief: t("bizState.sheetIfaceBrief"),
|
||||
interface_detail: t("bizState.sheetIfaceBrief"),
|
||||
arp: t("bizState.sheetArp"),
|
||||
nd6_cache: t("bizState.sheetNd6"),
|
||||
bgp_peer: t("bizState.sheetBgpPeer"),
|
||||
config_interface: t("bizState.sheetConfigIface"),
|
||||
config_vrf: t("bizState.sheetConfigVrf"),
|
||||
config_bgp_peer: t("bizState.sheetConfigBgp"),
|
||||
};
|
||||
return map[metricId] || metricId;
|
||||
}
|
||||
|
||||
function buildSheetTabs(batch: any, t: (k: string) => string): SheetTab[] {
|
||||
const tabs: SheetTab[] = [];
|
||||
const cmds = (batch?.commands || []) as SheetCmd[];
|
||||
if (cmds.length) {
|
||||
tabs.push({
|
||||
id: "commands",
|
||||
title: t("bizState.sheetCommands"),
|
||||
title: "Commands",
|
||||
rowCount: cmds.length,
|
||||
commands: cmds,
|
||||
});
|
||||
|
|
@ -196,9 +180,10 @@ function buildSheetTabs(batch: any, t: (k: string) => string): SheetTab[] {
|
|||
for (const s of (batch?.sheets || []) as any[]) {
|
||||
const mid = String(s?.metric_id || "").trim();
|
||||
if (!mid || mid === "vrf_list") continue;
|
||||
const title = String(s?.title || "").trim() || mid;
|
||||
tabs.push({
|
||||
id: mid,
|
||||
title: metricSheetTitle(mid, t),
|
||||
title,
|
||||
rowCount: Number(s?.row_count || 0),
|
||||
commands: Array.isArray(s?.commands) ? (s.commands as SheetCmd[]) : [],
|
||||
});
|
||||
|
|
@ -1451,7 +1436,12 @@ export function BizStatePage() {
|
|||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void collectNow()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={busy || Boolean(detail?.collect_running)}
|
||||
onPress={() => void collectNow()}
|
||||
>
|
||||
{t("bizState.collectNow")}
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeTask(taskId)}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue