diff --git a/netx_api/cli_templates/README.md b/netx_api/cli_templates/README.md
index a34f9dc..034bad9 100644
--- a/netx_api/cli_templates/README.md
+++ b/netx_api/cli_templates/README.md
@@ -18,10 +18,16 @@ cli_templates/
## Lookup order
-1. **This tree** (`index` → `vendor/*.textfsm`)
-2. Community `ntc-templates` package
+1. **Wrap-line join** (`netx_api.cli_wrap`) — flatten last-column wraps for registered
+ platform+command pairs (ZTE interface brief / LLDP brief today). Other vendors can
+ register rules the same way; do **not** apply blindly (Huawei LLDP detail uses real
+ indented fields).
+2. **This tree** (`index` → `vendor/*.textfsm`)
+3. Community `ntc-templates` package
-No regex CLI parsers. If both miss, the call returns empty — fix or add a template.
+No regex CLI parsers for field extraction. If both template trees miss, the call returns
+empty — fix or add a template. Wrap joining is the supported escape hatch when TextFSM
+alone cannot merge mid-token column wraps.
## Vendor coverage (blind fill from community + NetX)
@@ -45,7 +51,10 @@ No regex CLI parsers. If both miss, the call returns empty — fix or add a temp
3. Register in root `index` with a **relative path**:
`zte/zte_zxros_show_xxx.textfsm, .*, zte_zxros, sh[[ow]] ...`
4. Align Value names with community templates when possible
-5. Add unit tests under `tests/`
+5. If the CLI wraps the last column onto the next line, add a `CliWrapRule` in
+ `netx_api/cli_wrap.py` (platform + command scoped) — do not try to solve mid-token
+ wraps in TextFSM alone
+6. Add unit tests under `tests/`
When community already works, no file is required.
When community is wrong/incomplete for our lab, add an override under the vendor folder
@@ -54,4 +63,4 @@ with the same Platform+Command so NetX wins.
## Concurrency
Template files are read-only and safe across processes. Parsing goes through
-`netx_api.ntc_parse` (fresh `CliTable` + process lock; never share instances across threads).
+`netx_api.ntc_parse` (wrap-join → fresh `CliTable` + process lock; never share instances across threads).
diff --git a/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm b/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm
index c3f1b71..4fd23b7 100644
--- a/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm
+++ b/netx_api/cli_templates/zte/zte_zxros_show_interface_brief.textfsm
@@ -8,13 +8,13 @@ Value PROT (up|down)
Value DESCRIPTION (.*)
Start
+ ^Interface\s+\S+\s+Mode\s+.*Admin -> Ports
^Interface\s+Attribute\s+Mode -> Ports
^\s*$$
^.* -> Start
Ports
- ^${INTERFACE}\s+${ATTRIBUTE}\s+${MODE}\s+${BW}\s+${ADMIN}\s+${PHY}\s+${PROT}(?:\s+${DESCRIPTION})?\s*$$ -> Record
- ^${INTERFACE}\s+${ATTRIBUTE}\s+${MODE}\s+${ADMIN}\s+${PHY}\s+${PROT}(?:\s+${DESCRIPTION})?\s*$$ -> Record
+ ^${INTERFACE}\s+${ATTRIBUTE}\s+${MODE}(?:\s+${BW})?\s+${ADMIN}\s+${PHY}\s+${PROT}(?:\s+${DESCRIPTION})?\s*$$ -> Record
^\s*$$
^\S+\s*#\s*$$ -> End
^.*#\s*$$ -> End
diff --git a/netx_api/cli_wrap.py b/netx_api/cli_wrap.py
new file mode 100644
index 0000000..d16a0ab
--- /dev/null
+++ b/netx_api/cli_wrap.py
@@ -0,0 +1,124 @@
+"""Pre-TextFSM CLI wrap-line joiner (vendor-agnostic).
+
+Many vendor CLIs wrap the last column (description / system name) onto the next
+line with leading spaces. Pure TextFSM cannot reliably merge those mid-token wraps
+without creating duplicate or incomplete records — especially when the wrap
+breaks inside a hostname (``AL5458-ACC-612`` + ``0HS``).
+
+This module flattens matching wrap lines **before** TextFSM runs. Rules are
+registered per platform + command so indented multi-field blocks (e.g. Huawei
+LLDP detail) are left alone.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class CliWrapRule:
+ """Join indented continuation lines into the previous table row."""
+
+ id: str
+ platforms: frozenset[str]
+ # Match ntc / NetX command string (already the resolved show/display command).
+ command: re.Pattern[str]
+ # A data row that may be continued (typically starts with interface name).
+ record_line: re.Pattern[str]
+ # Continuation fragment: leading whitespace + non-empty payload.
+ cont_line: re.Pattern[str]
+ # ZTE column wraps usually split mid-token → join with "" ; use " " if needed.
+ join_sep: str = ""
+ # Lines that must never be treated as records or continuations.
+ skip_line: re.Pattern[str] | None = None
+
+
+# ZTE ZXROS table rows: interface-like token at column 0.
+_ZTE_RECORD = re.compile(r"^(?P
[A-Za-z][\w./:-]*)\s+\S+")
+_ZTE_CONT = re.compile(r"^\s{8,}(?P\S.*?)\s*$")
+_ZTE_SKIP = re.compile(
+ r"^(?:"
+ r"-{3,}"
+ r"|Local\s+Interface\b"
+ r"|Interface\s+\S+\s+Mode\b"
+ r"|Interface\s+Attribute\b"
+ r"|Scope\s+codes\s*:"
+ r"|Total\s+neighbors\b"
+ r"|NB\s+=\s+"
+ r"|NC\s+=\s+"
+ r"|NTPMR\s+=\s+"
+ r"|PHY:\s*"
+ r"|.*#\s*$"
+ r")",
+ re.I,
+)
+
+CLI_WRAP_RULES: tuple[CliWrapRule, ...] = (
+ CliWrapRule(
+ id="zte_zxros_show_interface_brief",
+ platforms=frozenset({"zte_zxros"}),
+ command=re.compile(r"^show\s+interface\s+brief\b", re.I),
+ record_line=_ZTE_RECORD,
+ cont_line=_ZTE_CONT,
+ join_sep="",
+ skip_line=_ZTE_SKIP,
+ ),
+ CliWrapRule(
+ id="zte_zxros_show_lldp_neighbor_brief",
+ platforms=frozenset({"zte_zxros"}),
+ command=re.compile(r"^show\s+lldp\s+neighbor(?:s)?\s+brief\b", re.I),
+ record_line=_ZTE_RECORD,
+ cont_line=_ZTE_CONT,
+ join_sep="",
+ skip_line=_ZTE_SKIP,
+ ),
+)
+
+
+def _rules_for(*, platform: str, command: str) -> list[CliWrapRule]:
+ plat = str(platform or "").strip().lower()
+ cmd = str(command or "").strip()
+ if not plat or not cmd:
+ return []
+ return [
+ rule
+ for rule in CLI_WRAP_RULES
+ if plat in rule.platforms and rule.command.search(cmd)
+ ]
+
+
+def _is_skip(line: str, rule: CliWrapRule) -> bool:
+ if not rule.skip_line:
+ return False
+ if rule.skip_line.match(line):
+ return True
+ stripped = line.strip()
+ return bool(stripped and rule.skip_line.match(stripped))
+
+
+def _apply_rule(text: str, rule: CliWrapRule) -> str:
+ lines = str(text or "").splitlines()
+ if not lines:
+ return str(text or "")
+ out: list[str] = []
+ for ln in lines:
+ if _is_skip(ln, rule):
+ out.append(ln)
+ continue
+ cont = rule.cont_line.match(ln)
+ if cont and out:
+ prev = out[-1]
+ if rule.record_line.match(prev) and not _is_skip(prev, rule):
+ out[-1] = f"{prev.rstrip()}{rule.join_sep}{cont.group('tail')}"
+ continue
+ out.append(ln)
+ return "\n".join(out)
+
+
+def apply_cli_wrap(text: str, *, platform: str = "", command: str = "") -> str:
+ """Return text with registered wrap continuations flattened for TextFSM."""
+ raw = str(text or "")
+ for rule in _rules_for(platform=platform, command=command):
+ raw = _apply_rule(raw, rule)
+ return raw
diff --git a/netx_api/lldp_collect_router.py b/netx_api/lldp_collect_router.py
index ba6887c..2811084 100644
--- a/netx_api/lldp_collect_router.py
+++ b/netx_api/lldp_collect_router.py
@@ -53,5 +53,10 @@ def api_list_jobs(
@router.get("/jobs/{job_id}")
-def api_get_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
- return get_job_detail(db, job_id)
+def api_get_job(
+ job_id: str,
+ page: int = Query(default=1, ge=1),
+ page_size: int = Query(default=20, ge=1, le=100),
+ db: Session = Depends(get_db),
+) -> dict[str, Any]:
+ return get_job_detail(db, job_id, page=page, page_size=page_size)
diff --git a/netx_api/lldp_collect_service.py b/netx_api/lldp_collect_service.py
index 99330bd..d58f35d 100644
--- a/netx_api/lldp_collect_service.py
+++ b/netx_api/lldp_collect_service.py
@@ -287,5 +287,7 @@ def list_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict:
}
-def get_job_detail(db: Session, job_id: str) -> dict:
- return get_discover_job(db, job_id).model_dump()
+def get_job_detail(
+ db: Session, job_id: str, *, page: int | None = None, page_size: int | None = None
+) -> dict:
+ return get_discover_job(db, job_id, page=page, page_size=page_size).model_dump()
diff --git a/netx_api/models/port_traffic.py b/netx_api/models/port_traffic.py
index 9161a5b..98e2ab2 100644
--- a/netx_api/models/port_traffic.py
+++ b/netx_api/models/port_traffic.py
@@ -33,7 +33,7 @@ class PortTrafficDevice(Base):
vendor: Mapped[str] = mapped_column(String(64), default="")
note: Mapped[str] = mapped_column(String(256), default="") # optional remark
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|running|paused|stopped
- interval_sec: Mapped[int] = mapped_column(Integer, default=60)
+ interval_sec: Mapped[int] = mapped_column(Integer, default=300)
retention_days: Mapped[int] = mapped_column(Integer, default=7)
concurrency: Mapped[int] = mapped_column(Integer, default=1)
collect_running: Mapped[bool] = mapped_column(Boolean, default=False)
diff --git a/netx_api/ntc_parse.py b/netx_api/ntc_parse.py
index b1001cd..6fa35d3 100644
--- a/netx_api/ntc_parse.py
+++ b/netx_api/ntc_parse.py
@@ -13,6 +13,8 @@ from functools import lru_cache
from pathlib import Path
from typing import Any, Callable, TypeVar
+from .cli_wrap import apply_cli_wrap
+
_log = logging.getLogger("netx.ntc_parse")
_PARSE_LOCK = threading.Lock()
@@ -107,10 +109,10 @@ def parse_cli(
command: str,
text: str,
) -> list[dict[str, Any]]:
- """Parse CLI text: custom templates first, then community ntc-templates."""
+ """Parse CLI text: wrap-join → custom templates → community ntc-templates."""
plat = str(platform or "").strip()
cmd = str(command or "").strip()
- raw = str(text or "")
+ raw = apply_cli_wrap(str(text or ""), platform=plat, command=cmd)
if not plat or not cmd or not raw.strip():
return []
diff --git a/netx_api/port_traffic_devices.py b/netx_api/port_traffic_devices.py
index d2146d0..7310f3a 100644
--- a/netx_api/port_traffic_devices.py
+++ b/netx_api/port_traffic_devices.py
@@ -481,30 +481,58 @@ def discover_ports(db: Session, body: DiscoverPortsRequest) -> DiscoverPortsResp
detail=f"vendor_not_supported_for_port_traffic: {vendor or 'unknown'}",
)
- per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
- conn = open_netmiko_connection(creds, session_timeout=per_cmd + 60)
- try:
- raw = send_show_command(conn, cmds.brief, read_timeout=per_cmd)
- finally:
- close_netmiko_connection(conn)
-
- ports = [
- DiscoverPortItem(**brief_port_to_dict(p))
- for p in parse_interface_brief(
- raw,
- cmds.vendor_key,
- command=cmds.brief,
- device_type=device_type,
- )
- ]
- return DiscoverPortsResponse(
+ command = str(cmds.brief or "")
+ base = DiscoverPortsResponse(
source=body.source,
id=body.id,
ne_name=ne_name,
ne_ip=ne_ip,
vendor=vendor,
vendor_key=cmds.vendor_key,
- ports=ports,
+ command=command,
)
+ per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
+ raw = ""
+ try:
+ conn = open_netmiko_connection(creds, session_timeout=per_cmd + 60)
+ try:
+ raw = send_show_command(conn, command, read_timeout=per_cmd)
+ finally:
+ close_netmiko_connection(conn)
+ except Exception as exc:
+ preview = str(raw or "")
+ if len(preview) > 12_000:
+ preview = f"{preview[:12_000]}\n...[truncated preview 12000/{len(raw)} chars]"
+ base.ok = False
+ base.error = f"cli_failed: {exc}"
+ base.raw_preview = preview
+ return base
+
+ preview = str(raw or "")
+ if len(preview) > 12_000:
+ preview = f"{preview[:12_000]}\n...[truncated preview 12000/{len(raw)} chars]"
+ base.raw_preview = preview
+
+ try:
+ ports = [
+ DiscoverPortItem(**brief_port_to_dict(p))
+ for p in parse_interface_brief(
+ raw,
+ cmds.vendor_key,
+ command=command,
+ device_type=device_type,
+ )
+ ]
+ except Exception as exc:
+ base.ok = False
+ base.error = f"parse_failed: {exc}"
+ return base
+
+ base.ports = ports
+ if not ports:
+ base.ok = False
+ base.error = "no_ports_parsed"
+ return base
+
diff --git a/netx_api/port_traffic_schemas.py b/netx_api/port_traffic_schemas.py
index 20e1568..3c6d208 100644
--- a/netx_api/port_traffic_schemas.py
+++ b/netx_api/port_traffic_schemas.py
@@ -34,7 +34,7 @@ class PortTrafficDeviceCreate(BaseModel):
ne_ip: str = ""
vendor: str = ""
note: str = Field(default="", max_length=256)
- interval_sec: int = Field(default=60, ge=15, le=3600)
+ interval_sec: int = Field(default=300, ge=15, le=3600)
retention_days: int = Field(default=7, ge=1, le=90)
concurrency: int = Field(default=1, ge=1, le=5)
interfaces: list[PortTrafficIfaceIn] = Field(default_factory=list)
@@ -147,6 +147,10 @@ class DiscoverPortsResponse(BaseModel):
vendor: str = ""
vendor_key: str = ""
ports: list[DiscoverPortItem] = Field(default_factory=list)
+ ok: bool = True
+ command: str = ""
+ raw_preview: str = ""
+ error: str = ""
class PortTrafficSamplePoint(BaseModel):
diff --git a/netx_api/topology_discover_common.py b/netx_api/topology_discover_common.py
index 2b4f501..02cf107 100644
--- a/netx_api/topology_discover_common.py
+++ b/netx_api/topology_discover_common.py
@@ -24,15 +24,33 @@ def _raw_preview(raw: str, *, limit: int = _RAW_PREVIEW_MAX) -> str:
return f"{text[:limit]}\n...[truncated preview {limit}/{len(text)} chars]"
-def _job_out(db: Session, job: TopoDiscoverJob, *, include_items: bool = True) -> FabricDiscoverJobOut:
+def _job_out(
+ db: Session,
+ job: TopoDiscoverJob,
+ *,
+ include_items: bool = True,
+ page: int | None = None,
+ page_size: int | None = None,
+) -> FabricDiscoverJobOut:
items_out: list[FabricDiscoverJobItemOut] = []
+ items_total = 0
+ items_page = 1
+ items_page_size = 0
if include_items:
- items = (
+ q = (
db.query(TopoDiscoverJobItem)
.filter(TopoDiscoverJobItem.job_id == job.id)
.order_by(TopoDiscoverJobItem.created_at.asc())
- .all()
)
+ items_total = int(q.count())
+ if page is not None and page_size is not None:
+ items_page = max(1, int(page or 1))
+ items_page_size = max(1, min(100, int(page_size or 20)))
+ items = q.offset((items_page - 1) * items_page_size).limit(items_page_size).all()
+ else:
+ items = q.all()
+ items_page = 1
+ items_page_size = items_total
for it in items:
unmatched = [
FabricDiscoverUnmatched.model_validate(x) for x in (it.unmatched_json or [])[:40]
@@ -74,14 +92,23 @@ def _job_out(db: Session, job: TopoDiscoverJob, *, include_items: bool = True) -
started_at=job.started_at,
ended_at=job.ended_at,
items=items_out,
+ items_total=items_total,
+ items_page=items_page,
+ items_page_size=items_page_size,
)
-def get_discover_job(db: Session, job_id: str) -> FabricDiscoverJobOut:
+def get_discover_job(
+ db: Session,
+ job_id: str,
+ *,
+ page: int | None = None,
+ page_size: int | None = None,
+) -> FabricDiscoverJobOut:
job = db.get(TopoDiscoverJob, str(job_id or "").strip())
if job is None:
raise HTTPException(status_code=404, detail="discover_job_not_found")
- return _job_out(db, job)
+ return _job_out(db, job, page=page, page_size=page_size)
def _ume_target_dict(db: Session, uid: str, default_profile: Any) -> dict[str, str] | None:
diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py
index a564b3a..b9576d9 100644
--- a/netx_api/topology_schemas.py
+++ b/netx_api/topology_schemas.py
@@ -132,6 +132,9 @@ class FabricDiscoverJobOut(BaseModel):
started_at: datetime | None = None
ended_at: datetime | None = None
items: list[FabricDiscoverJobItemOut] = Field(default_factory=list)
+ items_total: int = 0
+ items_page: int = 1
+ items_page_size: int = 0
# ---------------------------------------------------------------------------
diff --git a/tests/test_cli_wrap.py b/tests/test_cli_wrap.py
new file mode 100644
index 0000000..5f8003e
--- /dev/null
+++ b/tests/test_cli_wrap.py
@@ -0,0 +1,135 @@
+"""Tests for pre-TextFSM wrap-line joining and ZTE wrapped CLI samples."""
+
+from __future__ import annotations
+
+import unittest
+
+from netx_api.cli_wrap import apply_cli_wrap
+from netx_api.ntc_parse import parse_cli
+from netx_api.port_traffic_parsers import parse_interface_brief
+from netx_api import topology_lldp as lldp
+
+
+ZTE_BRIEF_PORTATTRIBUTE_WRAP = """\
+CSR3_6120WA#show interface brief
+Interface Portattribute Mode BW(Mbps) Admin Phy Prot Description
+gei-1/1/0/1 electric Duplex/full 100 up up up
+xgei-1/1/0/28 optical Duplex/full 100 up up up BJX_Xinert-8/9
+xgei-1/1/0/31 optical Duplex/full 100 up up up css-5/5-4
+smartgroup101 N/A N/A 100 up up up yangzhen_CSR3_IX
+ IA
+CSR3_6120WA#
+"""
+
+ZTE_LLDP_WRAP_NAME = """\
+MER1#show ll n b
+11:21:03 Beijing Mon Aug 3 2026
+Scope codes:
+ NB = Nearest Bridge
+ NC = Nearest Customer Bridge
+ NTPMR = Nearest non-TPMR Bridge
+
+Total neighbors: 1
+Local Interface Scope Chassis ID Port ID Holdtime System Name
+--------------------------------------------------------------------------------
+mgmt_eth NB fc44.9f67.4214 gei-0/1/1/16 99 NJ-NZ-N8-F3-AC
+ C.R&D-3H3-6
+MER1#
+"""
+
+ZTE_LLDP_WRAP_MIDWORD = """\
+AL5458-ACC-6120HS#show ll n b
+10:47:56 Africa/Algiers Mon Aug 3 2026
+Scope codes:
+ NB = Nearest Bridge
+ NC = Nearest Customer Bridge
+ NTPMR = Nearest non-TPMR Bridge
+
+Total neighbors: 2
+Local Interface Scope Chassis ID Port ID Holdtime System Name
+--------------------------------------------------------------------------------
+xgei-1/1/0/1 NB 00d0.0000.088f xgei-1/1/0/1 93 AL5458-ACC-612
+ 0HS
+xgei-1/1/0/6 NB 00d0.0000.088f xgei-1/1/0/6 93 AL5458-ACC-612
+ 0HS
+AL5458-ACC-6120HS#
+"""
+
+ZTE_LLDP_NO_WRAP = """\
+CSR3_6120WA#show ll n b
+Scope codes:
+ NB = Nearest Bridge
+ NC = Nearest Customer Bridge
+ NTPMR = Nearest non-TPMR Bridge
+
+Total neighbors: 4
+Local Interface Scope Chassis ID Port ID Holdtime System Name
+--------------------------------------------------------------------------------
+gei-1/1/0/1 NB 00d0.0000.081f gei-1/1/0/1 115 OLT/CPE_6180H
+gei-1/1/0/2 NB 00d0.0000.081f gei-1/1/0/2 103 OLT/CPE_6180H
+xgei-1/1/0/27 NB d80a.e69d.6e30 xgei-1/1/0/28 110 CSR4_6120HSC
+xgei-1/1/0/30 NB 0247.8a3e.f910 xgei-1/1/0/2 96 PAG3_6120HS
+CSR3_6120WA#
+"""
+
+
+class CliWrapUnitTests(unittest.TestCase):
+ def test_join_interface_brief_description(self) -> None:
+ flat = apply_cli_wrap(
+ ZTE_BRIEF_PORTATTRIBUTE_WRAP,
+ platform="zte_zxros",
+ command="show interface brief",
+ )
+ self.assertIn("yangzhen_CSR3_IXIA", flat)
+ self.assertNotIn("\n IA", flat)
+
+ def test_join_lldp_system_name_midword(self) -> None:
+ flat = apply_cli_wrap(
+ ZTE_LLDP_WRAP_MIDWORD,
+ platform="zte_zxros",
+ command="show lldp neighbor brief",
+ )
+ self.assertIn("xgei-1/1/0/1 NB 00d0.0000.088f xgei-1/1/0/1 93 AL5458-ACC-6120HS", flat)
+ self.assertNotRegex(flat, r"AL5458-ACC-612\s*\n")
+
+ def test_no_rule_for_unrelated_command(self) -> None:
+ raw = "iface\n cont"
+ self.assertEqual(
+ apply_cli_wrap(raw, platform="zte_zxros", command="show interface xgei-1/1/0/1"),
+ raw,
+ )
+
+
+class ZteWrapParseTests(unittest.TestCase):
+ def test_brief_portattribute_header_and_wrap(self) -> None:
+ ports = parse_interface_brief(ZTE_BRIEF_PORTATTRIBUTE_WRAP, "zte")
+ by_name = {p.ifname: p for p in ports}
+ self.assertIn("smartgroup101", by_name)
+ self.assertEqual(by_name["smartgroup101"].description, "yangzhen_CSR3_IXIA")
+ self.assertEqual(by_name["xgei-1/1/0/28"].description, "BJX_Xinert-8/9")
+ self.assertEqual(by_name["gei-1/1/0/1"].admin, "up")
+
+ def test_lldp_wrapped_system_name(self) -> None:
+ hits = lldp.parse_zte_lldp(ZTE_LLDP_WRAP_NAME)
+ self.assertEqual(len(hits), 1)
+ self.assertEqual(hits[0].local_port, "mgmt_eth")
+ self.assertEqual(hits[0].remote_name, "NJ-NZ-N8-F3-ACC.R&D-3H3-6")
+ self.assertEqual(hits[0].remote_port, "gei-0/1/1/16")
+
+ def test_lldp_wrapped_midword_hostname(self) -> None:
+ hits = lldp.parse_zte_lldp(ZTE_LLDP_WRAP_MIDWORD)
+ self.assertEqual(len(hits), 2)
+ self.assertTrue(all(h.remote_name == "AL5458-ACC-6120HS" for h in hits))
+
+ def test_lldp_unwrapped_still_works(self) -> None:
+ rows = parse_cli(
+ platform="zte_zxros",
+ command="show lldp neighbor brief",
+ text=ZTE_LLDP_NO_WRAP,
+ )
+ self.assertEqual(len(rows), 4)
+ self.assertEqual(rows[0].get("neighbor_name"), "OLT/CPE_6180H")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts
index 1ac3f82..ee964fe 100644
--- a/web/src/constants/queryKeys.ts
+++ b/web/src/constants/queryKeys.ts
@@ -66,7 +66,7 @@ export const queryKeys = {
lldpCollectJobsAll: ["lldpCollectJobs"] as const,
lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,
lldpCollectJobAll: ["lldpCollectJob"] as const,
- lldpCollectJob: (jobId: string) => ["lldpCollectJob", jobId] as const,
+ lldpCollectJob: (jobId: string, page = 1) => ["lldpCollectJob", jobId, page] as const,
fabricEdgesAll: ["fabricEdges"] as const,
fabricEdges: (status: string, keyword: string, page: number) =>
["fabricEdges", status, keyword, page] as const,
diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts
index eb855dc..3ebc152 100644
--- a/web/src/i18n/en.ts
+++ b/web/src/i18n/en.ts
@@ -159,6 +159,11 @@ const en = {
selectedPorts: "{{count}} interface(s) selected",
pickNeFirst: "Select one NE first",
discover: "Discover interfaces",
+ discoverDetail: "Discover detail",
+ discoverCommand: "Command",
+ discoverRaw: "Raw log",
+ discoverOk: "Discover succeeded",
+ discoverFail: "Discover failed",
confirmCreate: "Start monitoring",
wallTask: "Task",
wallPort: "Interface",
@@ -1344,6 +1349,8 @@ const en = {
alignNeedSelection: "Select at least two nodes",
snapGrid: "Snap to grid",
autoLayoutDiscover: "Auto-layout after discover",
+ discoverAutoAddUnmatched: "Auto-add unmatched NEs to fabric on discover",
+ discoverProjectNeighbors: "Project direct neighbors onto canvas after discover",
undo: "Undo",
redo: "Redo",
discoverSelected: "Discover selected ({{count}})",
diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts
index fbab604..29b58eb 100644
--- a/web/src/i18n/zh.ts
+++ b/web/src/i18n/zh.ts
@@ -159,6 +159,11 @@ const zh = {
selectedPorts: "已选 {{count}} 个接口",
pickNeFirst: "请先选择一台网元",
discover: "拉取接口",
+ discoverDetail: "拉取详情",
+ discoverCommand: "命令",
+ discoverRaw: "原始日志",
+ discoverOk: "拉取成功",
+ discoverFail: "拉取失败",
confirmCreate: "开始监控",
wallTask: "任务",
wallDevice: "设备",
@@ -1338,6 +1343,8 @@ const zh = {
alignNeedSelection: "请至少选中两个节点",
snapGrid: "网格吸附",
autoLayoutDiscover: "发现后自动布局",
+ discoverAutoAddUnmatched: "发现时自动入库未匹配网元",
+ discoverProjectNeighbors: "发现后将直连邻居加入画布",
undo: "撤销",
redo: "重做",
discoverSelected: "发现选中 ({{count}})",
diff --git a/web/src/index.css b/web/src/index.css
index d2d8474..d0559e1 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -6181,6 +6181,50 @@ pre {
max-height: 14px;
}
+.topo-fs-toolbar {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ z-index: 20;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ padding: 8px;
+ border-radius: 10px;
+ background: rgba(255, 255, 255, 0.92);
+ border: 1px solid rgba(15, 23, 42, 0.12);
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
+ pointer-events: auto;
+}
+
+.topo-fs-toolbar__btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 10px;
+ border-radius: 8px;
+ border: 1px solid rgba(15, 23, 42, 0.12);
+ background: #fff;
+ color: #0f172a;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.topo-fs-toolbar__btn:hover {
+ background: #f8fafc;
+}
+
+.topo-fs-toolbar__btn.is-active {
+ border-color: #0ea5e9;
+ background: #e0f2fe;
+ color: #075985;
+}
+
+.topo-fs-toolbar__btn kbd {
+ font-size: 10px;
+ opacity: 0.7;
+}
+
.topo-canvas__empty {
display: grid;
place-items: center;
diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx
index 4c9694e..e2f67a6 100644
--- a/web/src/pages/TopologyPage.tsx
+++ b/web/src/pages/TopologyPage.tsx
@@ -422,25 +422,35 @@ type EdgeDefaults = Record;
const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults";
const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover";
+const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched";
+const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors";
-function loadAutoLayoutAfterDiscover(): boolean {
+function loadBoolFlag(key: string, defaultValue: boolean): boolean {
try {
- const raw = localStorage.getItem(AUTO_LAYOUT_DISCOVER_KEY);
- if (raw === null) return false;
+ const raw = localStorage.getItem(key);
+ if (raw === null) return defaultValue;
return raw === "1" || raw === "true";
} catch {
- return false;
+ return defaultValue;
}
}
-function persistAutoLayoutAfterDiscover(value: boolean) {
+function persistBoolFlag(key: string, value: boolean) {
try {
- localStorage.setItem(AUTO_LAYOUT_DISCOVER_KEY, value ? "1" : "0");
+ localStorage.setItem(key, value ? "1" : "0");
} catch {
/* ignore */
}
}
+function loadAutoLayoutAfterDiscover(): boolean {
+ return loadBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, false);
+}
+
+function persistAutoLayoutAfterDiscover(value: boolean) {
+ persistBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, value);
+}
+
const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = {
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" },
@@ -592,11 +602,18 @@ function applyViewGraph(
defaults: EdgeDefaults,
setNodes: (ns: Node[]) => void,
setEdges: (es: Edge[]) => void,
+ localPositions?: Map,
) {
const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults);
- setNodes(rfNodes);
+ const merged = localPositions?.size
+ ? rfNodes.map((n) => {
+ const p = localPositions.get(n.id);
+ return p ? { ...n, position: { ...p } } : n;
+ })
+ : rfNodes;
+ setNodes(merged);
setEdges(rfEdges);
- return { rfNodes, rfEdges };
+ return { rfNodes: merged, rfEdges };
}
export function TopologyPage() {
@@ -636,6 +653,12 @@ export function TopologyPage() {
const [toolMode, setToolMode] = useState("select");
const [snapToGrid, setSnapToGrid] = useState(true);
const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(loadAutoLayoutAfterDiscover);
+ const [discoverAutoAddUnmatched, setDiscoverAutoAddUnmatched] = useState(() =>
+ loadBoolFlag(DISCOVER_AUTO_ADD_KEY, false),
+ );
+ const [discoverProjectNeighbors, setDiscoverProjectNeighbors] = useState(() =>
+ loadBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, false),
+ );
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [addNeOpen, setAddNeOpen] = useState(false);
const [paletteSource, setPaletteSource] = useState("managed");
@@ -668,6 +691,7 @@ export function TopologyPage() {
const rfRef = useRef, Edge> | null>(null);
const canvasRef = useRef(null);
const dirtyRef = useRef(false);
+ const appliedMapIdRef = useRef("");
const historyRef = useRef([]);
const redoRef = useRef([]);
const historyLockRef = useRef(false);
@@ -889,9 +913,16 @@ export function TopologyPage() {
setSearchParams({}, { replace: true });
}, [regions, searchParams, setSearchParams]);
+ useEffect(() => {
+ appliedMapIdRef.current = "";
+ }, [mapId]);
+
useEffect(() => {
if (!canvasMode) return;
- if (!graphQuery.data) return;
+ if (!mapId || !graphQuery.data) return;
+ // Only hydrate React Flow from server when entering a map — never clobber unsaved local positions.
+ if (appliedMapIdRef.current === mapId) return;
+ appliedMapIdRef.current = mapId;
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults);
historyLockRef.current = true;
setNodes(rfNodes);
@@ -1199,7 +1230,7 @@ export function TopologyPage() {
scope: "ne_ids",
ne_ids,
concurrency: 4,
- auto_add_unmatched: true,
+ auto_add_unmatched: discoverAutoAddUnmatched,
trigger_mode: "topology",
});
let job: TopologyDiscoverJob = jobStart;
@@ -1221,9 +1252,18 @@ export function TopologyPage() {
if (job.status === "failed") {
throw new Error(job.error || "discover_failed");
}
- const projected = await projectTopologyNeighbors(mapId);
+ const projected = discoverProjectNeighbors
+ ? await projectTopologyNeighbors(mapId)
+ : await fetchTopologyGraph(mapId);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
+ appliedMapIdRef.current = mapId;
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
+ // Keep existing node positions when we did not auto-layout.
+ const localPos = new Map(nodes.map((n) => [n.id, n.position]));
+ rfNodes = rfNodes.map((n) => {
+ const p = localPos.get(n.id);
+ return p ? { ...n, position: { ...p } } : n;
+ });
let didAutoLayout = false;
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
@@ -1271,7 +1311,7 @@ export function TopologyPage() {
setDiscovering(false);
}
},
- [mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, edgeDefaults, clearDirty, markDirty],
+ [mapId, discovering, nodes, queryClient, setNodes, setEdges, showOk, showError, t, autoLayoutAfterDiscover, discoverAutoAddUnmatched, discoverProjectNeighbors, edgeDefaults, clearDirty, markDirty],
);
const discoverResults = discoverReport?.results?.length
@@ -1580,10 +1620,16 @@ export function TopologyPage() {
pushHistory();
try {
if (nodeIds.length) {
+ if (dirtyRef.current) {
+ await patchTopologyPositions(mapId, flowToPositions(nodes));
+ clearDirty();
+ }
+ const localPos = new Map(nodes.map((n) => [n.id, n.position]));
const graph = await removeTopologyViewNodes(mapId, nodeIds);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
+ appliedMapIdRef.current = mapId;
historyLockRef.current = true;
- applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
+ applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
clearDirty();
} else if (edgeIds.size) {
@@ -1670,10 +1716,16 @@ export function TopologyPage() {
pushHistory();
closeCtxMenu();
try {
+ if (dirtyRef.current) {
+ await patchTopologyPositions(mapId, flowToPositions(nodes));
+ clearDirty();
+ }
+ const localPos = new Map(nodes.map((n) => [n.id, n.position]));
const graph = await removeTopologyViewNodes(mapId, [nodeId]);
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
+ appliedMapIdRef.current = mapId;
historyLockRef.current = true;
- applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
+ applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
historyLockRef.current = false;
clearDirty();
} catch (err) {
@@ -2473,7 +2525,6 @@ export function TopologyPage() {
[
["select", t("topology.toolSelect"), "V"],
["pan", t("topology.toolPan"), "H"],
- ["drag", t("topology.toolDrag"), "A"],
["connect", t("topology.toolConnect"), "C"],
] as const
).map(([mode, label, key]) => (
@@ -2544,6 +2595,30 @@ export function TopologyPage() {
/>
{t("topology.autoLayoutDiscover")}
+
+ {
+ const next = e.target.checked;
+ setDiscoverAutoAddUnmatched(next);
+ persistBoolFlag(DISCOVER_AUTO_ADD_KEY, next);
+ }}
+ />
+ {t("topology.discoverAutoAddUnmatched")}
+
+
+ {
+ const next = e.target.checked;
+ setDiscoverProjectNeighbors(next);
+ persistBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, next);
+ }}
+ />
+ {t("topology.discoverProjectNeighbors")}
+
{t("topology.edgeDefaults")}
@@ -2955,6 +3030,48 @@ export function TopologyPage() {
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}
>
+ {fullscreen ? (
+
+ {(
+ [
+ ["select", t("topology.toolSelect"), "V"],
+ ["pan", t("topology.toolPan"), "H"],
+ ["connect", t("topology.toolConnect"), "C"],
+ ] as const
+ ).map(([mode, label, key]) => (
+ {
+ setToolMode(mode);
+ connectClickRef.current = null;
+ }}
+ >
+ {label}
+ {key}
+
+ ))}
+ rfRef.current?.fitView({ padding: 0.2 })}
+ >
+ {t("topology.fit")}
+
+ void toggleFullscreen()}
+ >
+ {t("topology.exitFullscreen")}
+
+
+ ) : null}
{treeRoot ? (
(null);
+ const ITEM_PAGE_SIZE = 20;
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
const [edgeKeyword, setEdgeKeyword] = useState("");
@@ -90,8 +92,8 @@ export function LldpLinksPage() {
});
const jobDetailQuery = useQuery({
- queryKey: queryKeys.lldpCollectJob(expandedJobId),
- queryFn: () => fetchLldpCollectJob(expandedJobId),
+ queryKey: queryKeys.lldpCollectJob(expandedJobId, itemPage),
+ queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: ITEM_PAGE_SIZE }),
enabled: Boolean(expandedJobId),
staleTime: 800,
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
@@ -193,6 +195,8 @@ export function LldpLinksPage() {
const jobPages = pageCount(jobTotal, 10);
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
const detailItems = jobDetailQuery.data?.items ?? [];
+ const itemTotal = Number(jobDetailQuery.data?.items_total ?? detailItems.length);
+ const itemPages = pageCount(itemTotal, ITEM_PAGE_SIZE);
const edgeItems = edgesQuery.data?.items ?? [];
const edgeTotal = Number(edgesQuery.data?.total || 0);
const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE);
@@ -546,6 +550,7 @@ export function LldpLinksPage() {
onClick={() => {
setItemDetail(null);
setExpandedJobId(open ? "" : job.id);
+ setItemPage(1);
}}
>
{open ? "−" : "+"}
@@ -653,6 +658,25 @@ export function LldpLinksPage() {
) : null}
+
+ setItemPage((p) => p - 1)}>
+ {t("common.prevPage")}
+
+
+ {t("common.pagerMeta", {
+ total: String(itemTotal),
+ page: String(itemPage),
+ pages: String(itemPages),
+ })}
+
+ = itemPages}
+ onClick={() => setItemPage((p) => p + 1)}
+ >
+ {t("common.nextPage")}
+
+
) : null}
diff --git a/web/src/pages/network/PortTrafficPage.tsx b/web/src/pages/network/PortTrafficPage.tsx
index 7b0e8a0..9c6294d 100644
--- a/web/src/pages/network/PortTrafficPage.tsx
+++ b/web/src/pages/network/PortTrafficPage.tsx
@@ -126,7 +126,7 @@ export function PortTrafficPage() {
// Wizard / edit
const [editDeviceId, setEditDeviceId] = useState("");
const [editDeviceSnap, setEditDeviceSnap] = useState
(null);
- const [intervalSec, setIntervalSec] = useState(60);
+ const [intervalSec, setIntervalSec] = useState(300);
const [retentionDays, setRetentionDays] = useState(7);
const [concurrency, setConcurrency] = useState(1);
const [note, setNote] = useState("");
@@ -137,6 +137,12 @@ export function PortTrafficPage() {
const [ports, setPorts] = useState([]);
const [portsLoading, setPortsLoading] = useState(false);
const [portsError, setPortsError] = useState("");
+ const [discoverMeta, setDiscoverMeta] = useState<{
+ ok: boolean;
+ command: string;
+ raw_preview: string;
+ error: string;
+ } | null>(null);
const [pickedIfnames, setPickedIfnames] = useState>({});
const [deepLinkHint, setDeepLinkHint] = useState("");
const [deepLinkSource, setDeepLinkSource] = useState<"managed" | "ume" | "">("");
@@ -366,7 +372,7 @@ export function PortTrafficPage() {
const resetWizard = () => {
setWizardStep(1);
- setIntervalSec(60);
+ setIntervalSec(300);
setRetentionDays(7);
setConcurrency(1);
setNote("");
@@ -374,6 +380,7 @@ export function PortTrafficPage() {
setSelectedNe(null);
setPorts([]);
setPortsError("");
+ setDiscoverMeta(null);
setPickedIfnames({});
setNeKeyword("");
setNePage(1);
@@ -404,6 +411,7 @@ export function PortTrafficPage() {
setPickedIfnames({});
setPorts([]);
setPortsError("");
+ setDiscoverMeta(null);
setView("edit");
};
@@ -434,11 +442,23 @@ export function PortTrafficPage() {
if (!id) return;
setPortsLoading(true);
setPortsError("");
+ setDiscoverMeta(null);
try {
const res = await discoverPortTrafficPorts({ source, id });
- setPorts(res.ports);
+ setPorts(res.ports || []);
+ setDiscoverMeta({
+ ok: res.ok !== false,
+ command: res.command || "",
+ raw_preview: res.raw_preview || "",
+ error: res.error || "",
+ });
+ if (res.ok === false) {
+ const msg = res.error || t("portTraffic.discoverFail");
+ setPortsError(msg);
+ showError(msg);
+ }
if (deepLinkIfname) {
- const hit = res.ports.find((p) => p.ifname === deepLinkIfname);
+ const hit = (res.ports || []).find((p) => p.ifname === deepLinkIfname);
if (hit) {
setPickedIfnames((prev) => ({
...prev,
@@ -455,6 +475,12 @@ export function PortTrafficPage() {
const msg = e instanceof Error ? e.message : "discover_failed";
setPorts([]);
setPortsError(msg);
+ setDiscoverMeta({
+ ok: false,
+ command: "",
+ raw_preview: "",
+ error: msg,
+ });
showError(msg);
} finally {
setPortsLoading(false);
@@ -841,6 +867,29 @@ export function PortTrafficPage() {
{portsError ? {portsError} : null}
+ {discoverMeta ? (
+
+
+ {t("portTraffic.discoverDetail")}
+ {" · "}
+ {discoverMeta.ok ? t("portTraffic.discoverOk") : t("portTraffic.discoverFail")}
+
+ {discoverMeta.command ? (
+
+ {t("portTraffic.discoverCommand")}: {discoverMeta.command}
+
+ ) : null}
+ {discoverMeta.error ? (
+
+ {discoverMeta.error}
+
+ ) : null}
+ {t("portTraffic.discoverRaw")}
+
+ {discoverMeta.raw_preview || "—"}
+
+
+ ) : null}
{ports.length ? (
@@ -894,7 +943,7 @@ export function PortTrafficPage() {
min={15}
max={3600}
value={intervalSec}
- onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
+ onChange={(e) => setIntervalSec(Number(e.target.value) || 300)}
/>
@@ -1005,7 +1054,7 @@ export function PortTrafficPage() {
min={15}
max={3600}
value={intervalSec}
- onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
+ onChange={(e) => setIntervalSec(Number(e.target.value) || 300)}
/>
@@ -1040,6 +1089,29 @@ export function PortTrafficPage() {
{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}
{portsError ? {portsError}
: null}
+ {discoverMeta ? (
+
+
+ {t("portTraffic.discoverDetail")}
+ {" · "}
+ {discoverMeta.ok ? t("portTraffic.discoverOk") : t("portTraffic.discoverFail")}
+
+ {discoverMeta.command ? (
+
+ {t("portTraffic.discoverCommand")}: {discoverMeta.command}
+
+ ) : null}
+ {discoverMeta.error ? (
+
+ {discoverMeta.error}
+
+ ) : null}
+ {t("portTraffic.discoverRaw")}
+
+ {discoverMeta.raw_preview || "—"}
+
+
+ ) : null}
{ports.length ? (
diff --git a/web/src/pages/topology/toolMode.ts b/web/src/pages/topology/toolMode.ts
index 9187241..62bb214 100644
--- a/web/src/pages/topology/toolMode.ts
+++ b/web/src/pages/topology/toolMode.ts
@@ -1,6 +1,6 @@
/** Topology editor interaction modes (NMS-style). */
-export type ToolMode = "select" | "pan" | "drag" | "connect";
+export type ToolMode = "select" | "pan" | "connect";
export type ToolModeBehavior = {
nodesDraggable: boolean;
@@ -23,15 +23,6 @@ export function behaviorForMode(mode: ToolMode): ToolModeBehavior {
selectionOnDrag: false,
panOnScroll: true,
};
- case "drag":
- return {
- nodesDraggable: true,
- nodesConnectable: false,
- elementsSelectable: true,
- panOnDrag: [1, 2],
- selectionOnDrag: false,
- panOnScroll: true,
- };
case "connect":
return {
nodesDraggable: false,
@@ -58,7 +49,6 @@ export function toolModeFromKey(key: string): ToolMode | null {
const k = key.toLowerCase();
if (k === "v") return "select";
if (k === "h") return "pan";
- if (k === "a") return "drag";
if (k === "c") return "connect";
return null;
}
diff --git a/web/src/services/api.ts b/web/src/services/api.ts
index dea11ce..77ee68c 100644
--- a/web/src/services/api.ts
+++ b/web/src/services/api.ts
@@ -1153,8 +1153,17 @@ export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number
);
};
-export const fetchLldpCollectJob = (jobId: string) =>
- apiGet(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}`);
+export const fetchLldpCollectJob = (
+ jobId: string,
+ params?: { page?: number; pageSize?: number },
+) => {
+ const p = new URLSearchParams();
+ p.set("page", String(Math.max(1, Number(params?.page || 1))));
+ p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20)))));
+ return apiGet(
+ `/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`,
+ );
+};
export const createFabricManualEdge = (body: {
a_node_id: string;
diff --git a/web/src/types.ts b/web/src/types.ts
index 3b90aad..3c3a491 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -590,6 +590,9 @@ export type TopologyDiscoverJob = {
ended_at?: string | null;
created_at?: string | null;
items: TopologyDiscoverJobItem[];
+ items_total?: number;
+ items_page?: number;
+ items_page_size?: number;
};
/** @deprecated alias — prefer TopologyViewItem */
@@ -887,6 +890,10 @@ export type PortTrafficDiscoverResponse = {
vendor: string;
vendor_key: string;
ports: PortTrafficDiscoverPort[];
+ ok?: boolean;
+ command?: string;
+ raw_preview?: string;
+ error?: string;
};
export type PortTrafficSamplePoint = {