mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Improve topology UX, traffic discover logs, and CLI wrap parsing.
Paginate LLDP job items, surface port-traffic raw logs with a 300s default, harden topology discover/edit/zoom/fullscreen, and join mid-token CLI wraps before TextFSM. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
66f26b738d
commit
c13ab48ba7
22 changed files with 693 additions and 75 deletions
|
|
@ -18,10 +18,16 @@ cli_templates/
|
||||||
|
|
||||||
## Lookup order
|
## Lookup order
|
||||||
|
|
||||||
1. **This tree** (`index` → `vendor/*.textfsm`)
|
1. **Wrap-line join** (`netx_api.cli_wrap`) — flatten last-column wraps for registered
|
||||||
2. Community `ntc-templates` package
|
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)
|
## 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**:
|
3. Register in root `index` with a **relative path**:
|
||||||
`zte/zte_zxros_show_xxx.textfsm, .*, zte_zxros, sh[[ow]] ...`
|
`zte/zte_zxros_show_xxx.textfsm, .*, zte_zxros, sh[[ow]] ...`
|
||||||
4. Align Value names with community templates when possible
|
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 already works, no file is required.
|
||||||
When community is wrong/incomplete for our lab, add an override under the vendor folder
|
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
|
## Concurrency
|
||||||
|
|
||||||
Template files are read-only and safe across processes. Parsing goes through
|
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).
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,13 @@ Value PROT (up|down)
|
||||||
Value DESCRIPTION (.*)
|
Value DESCRIPTION (.*)
|
||||||
|
|
||||||
Start
|
Start
|
||||||
|
^Interface\s+\S+\s+Mode\s+.*Admin -> Ports
|
||||||
^Interface\s+Attribute\s+Mode -> Ports
|
^Interface\s+Attribute\s+Mode -> Ports
|
||||||
^\s*$$
|
^\s*$$
|
||||||
^.* -> Start
|
^.* -> Start
|
||||||
|
|
||||||
Ports
|
Ports
|
||||||
^${INTERFACE}\s+${ATTRIBUTE}\s+${MODE}\s+${BW}\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
|
||||||
^${INTERFACE}\s+${ATTRIBUTE}\s+${MODE}\s+${ADMIN}\s+${PHY}\s+${PROT}(?:\s+${DESCRIPTION})?\s*$$ -> Record
|
|
||||||
^\s*$$
|
^\s*$$
|
||||||
^\S+\s*#\s*$$ -> End
|
^\S+\s*#\s*$$ -> End
|
||||||
^.*#\s*$$ -> End
|
^.*#\s*$$ -> End
|
||||||
|
|
|
||||||
124
netx_api/cli_wrap.py
Normal file
124
netx_api/cli_wrap.py
Normal file
|
|
@ -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<head>[A-Za-z][\w./:-]*)\s+\S+")
|
||||||
|
_ZTE_CONT = re.compile(r"^\s{8,}(?P<tail>\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
|
||||||
|
|
@ -53,5 +53,10 @@ def api_list_jobs(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/jobs/{job_id}")
|
@router.get("/jobs/{job_id}")
|
||||||
def api_get_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def api_get_job(
|
||||||
return get_job_detail(db, job_id)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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:
|
def get_job_detail(
|
||||||
return get_discover_job(db, job_id).model_dump()
|
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()
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class PortTrafficDevice(Base):
|
||||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||||
note: Mapped[str] = mapped_column(String(256), default="") # optional remark
|
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
|
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)
|
retention_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||||
concurrency: Mapped[int] = mapped_column(Integer, default=1)
|
concurrency: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
collect_running: Mapped[bool] = mapped_column(Boolean, default=False)
|
collect_running: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, TypeVar
|
from typing import Any, Callable, TypeVar
|
||||||
|
|
||||||
|
from .cli_wrap import apply_cli_wrap
|
||||||
|
|
||||||
_log = logging.getLogger("netx.ntc_parse")
|
_log = logging.getLogger("netx.ntc_parse")
|
||||||
|
|
||||||
_PARSE_LOCK = threading.Lock()
|
_PARSE_LOCK = threading.Lock()
|
||||||
|
|
@ -107,10 +109,10 @@ def parse_cli(
|
||||||
command: str,
|
command: str,
|
||||||
text: str,
|
text: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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()
|
plat = str(platform or "").strip()
|
||||||
cmd = str(command 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():
|
if not plat or not cmd or not raw.strip():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -481,30 +481,58 @@ def discover_ports(db: Session, body: DiscoverPortsRequest) -> DiscoverPortsResp
|
||||||
detail=f"vendor_not_supported_for_port_traffic: {vendor or 'unknown'}",
|
detail=f"vendor_not_supported_for_port_traffic: {vendor or 'unknown'}",
|
||||||
)
|
)
|
||||||
|
|
||||||
per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
|
command = str(cmds.brief or "")
|
||||||
conn = open_netmiko_connection(creds, session_timeout=per_cmd + 60)
|
base = DiscoverPortsResponse(
|
||||||
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(
|
|
||||||
source=body.source,
|
source=body.source,
|
||||||
id=body.id,
|
id=body.id,
|
||||||
ne_name=ne_name,
|
ne_name=ne_name,
|
||||||
ne_ip=ne_ip,
|
ne_ip=ne_ip,
|
||||||
vendor=vendor,
|
vendor=vendor,
|
||||||
vendor_key=cmds.vendor_key,
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class PortTrafficDeviceCreate(BaseModel):
|
||||||
ne_ip: str = ""
|
ne_ip: str = ""
|
||||||
vendor: str = ""
|
vendor: str = ""
|
||||||
note: str = Field(default="", max_length=256)
|
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)
|
retention_days: int = Field(default=7, ge=1, le=90)
|
||||||
concurrency: int = Field(default=1, ge=1, le=5)
|
concurrency: int = Field(default=1, ge=1, le=5)
|
||||||
interfaces: list[PortTrafficIfaceIn] = Field(default_factory=list)
|
interfaces: list[PortTrafficIfaceIn] = Field(default_factory=list)
|
||||||
|
|
@ -147,6 +147,10 @@ class DiscoverPortsResponse(BaseModel):
|
||||||
vendor: str = ""
|
vendor: str = ""
|
||||||
vendor_key: str = ""
|
vendor_key: str = ""
|
||||||
ports: list[DiscoverPortItem] = Field(default_factory=list)
|
ports: list[DiscoverPortItem] = Field(default_factory=list)
|
||||||
|
ok: bool = True
|
||||||
|
command: str = ""
|
||||||
|
raw_preview: str = ""
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
class PortTrafficSamplePoint(BaseModel):
|
class PortTrafficSamplePoint(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -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]"
|
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_out: list[FabricDiscoverJobItemOut] = []
|
||||||
|
items_total = 0
|
||||||
|
items_page = 1
|
||||||
|
items_page_size = 0
|
||||||
if include_items:
|
if include_items:
|
||||||
items = (
|
q = (
|
||||||
db.query(TopoDiscoverJobItem)
|
db.query(TopoDiscoverJobItem)
|
||||||
.filter(TopoDiscoverJobItem.job_id == job.id)
|
.filter(TopoDiscoverJobItem.job_id == job.id)
|
||||||
.order_by(TopoDiscoverJobItem.created_at.asc())
|
.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:
|
for it in items:
|
||||||
unmatched = [
|
unmatched = [
|
||||||
FabricDiscoverUnmatched.model_validate(x) for x in (it.unmatched_json or [])[:40]
|
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,
|
started_at=job.started_at,
|
||||||
ended_at=job.ended_at,
|
ended_at=job.ended_at,
|
||||||
items=items_out,
|
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())
|
job = db.get(TopoDiscoverJob, str(job_id or "").strip())
|
||||||
if job is None:
|
if job is None:
|
||||||
raise HTTPException(status_code=404, detail="discover_job_not_found")
|
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:
|
def _ume_target_dict(db: Session, uid: str, default_profile: Any) -> dict[str, str] | None:
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,9 @@ class FabricDiscoverJobOut(BaseModel):
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
ended_at: datetime | None = None
|
ended_at: datetime | None = None
|
||||||
items: list[FabricDiscoverJobItemOut] = Field(default_factory=list)
|
items: list[FabricDiscoverJobItemOut] = Field(default_factory=list)
|
||||||
|
items_total: int = 0
|
||||||
|
items_page: int = 1
|
||||||
|
items_page_size: int = 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
135
tests/test_cli_wrap.py
Normal file
135
tests/test_cli_wrap.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -66,7 +66,7 @@ export const queryKeys = {
|
||||||
lldpCollectJobsAll: ["lldpCollectJobs"] as const,
|
lldpCollectJobsAll: ["lldpCollectJobs"] as const,
|
||||||
lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,
|
lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const,
|
||||||
lldpCollectJobAll: ["lldpCollectJob"] 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,
|
fabricEdgesAll: ["fabricEdges"] as const,
|
||||||
fabricEdges: (status: string, keyword: string, page: number) =>
|
fabricEdges: (status: string, keyword: string, page: number) =>
|
||||||
["fabricEdges", status, keyword, page] as const,
|
["fabricEdges", status, keyword, page] as const,
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,11 @@ const en = {
|
||||||
selectedPorts: "{{count}} interface(s) selected",
|
selectedPorts: "{{count}} interface(s) selected",
|
||||||
pickNeFirst: "Select one NE first",
|
pickNeFirst: "Select one NE first",
|
||||||
discover: "Discover interfaces",
|
discover: "Discover interfaces",
|
||||||
|
discoverDetail: "Discover detail",
|
||||||
|
discoverCommand: "Command",
|
||||||
|
discoverRaw: "Raw log",
|
||||||
|
discoverOk: "Discover succeeded",
|
||||||
|
discoverFail: "Discover failed",
|
||||||
confirmCreate: "Start monitoring",
|
confirmCreate: "Start monitoring",
|
||||||
wallTask: "Task",
|
wallTask: "Task",
|
||||||
wallPort: "Interface",
|
wallPort: "Interface",
|
||||||
|
|
@ -1344,6 +1349,8 @@ const en = {
|
||||||
alignNeedSelection: "Select at least two nodes",
|
alignNeedSelection: "Select at least two nodes",
|
||||||
snapGrid: "Snap to grid",
|
snapGrid: "Snap to grid",
|
||||||
autoLayoutDiscover: "Auto-layout after discover",
|
autoLayoutDiscover: "Auto-layout after discover",
|
||||||
|
discoverAutoAddUnmatched: "Auto-add unmatched NEs to fabric on discover",
|
||||||
|
discoverProjectNeighbors: "Project direct neighbors onto canvas after discover",
|
||||||
undo: "Undo",
|
undo: "Undo",
|
||||||
redo: "Redo",
|
redo: "Redo",
|
||||||
discoverSelected: "Discover selected ({{count}})",
|
discoverSelected: "Discover selected ({{count}})",
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,11 @@ const zh = {
|
||||||
selectedPorts: "已选 {{count}} 个接口",
|
selectedPorts: "已选 {{count}} 个接口",
|
||||||
pickNeFirst: "请先选择一台网元",
|
pickNeFirst: "请先选择一台网元",
|
||||||
discover: "拉取接口",
|
discover: "拉取接口",
|
||||||
|
discoverDetail: "拉取详情",
|
||||||
|
discoverCommand: "命令",
|
||||||
|
discoverRaw: "原始日志",
|
||||||
|
discoverOk: "拉取成功",
|
||||||
|
discoverFail: "拉取失败",
|
||||||
confirmCreate: "开始监控",
|
confirmCreate: "开始监控",
|
||||||
wallTask: "任务",
|
wallTask: "任务",
|
||||||
wallDevice: "设备",
|
wallDevice: "设备",
|
||||||
|
|
@ -1338,6 +1343,8 @@ const zh = {
|
||||||
alignNeedSelection: "请至少选中两个节点",
|
alignNeedSelection: "请至少选中两个节点",
|
||||||
snapGrid: "网格吸附",
|
snapGrid: "网格吸附",
|
||||||
autoLayoutDiscover: "发现后自动布局",
|
autoLayoutDiscover: "发现后自动布局",
|
||||||
|
discoverAutoAddUnmatched: "发现时自动入库未匹配网元",
|
||||||
|
discoverProjectNeighbors: "发现后将直连邻居加入画布",
|
||||||
undo: "撤销",
|
undo: "撤销",
|
||||||
redo: "重做",
|
redo: "重做",
|
||||||
discoverSelected: "发现选中 ({{count}})",
|
discoverSelected: "发现选中 ({{count}})",
|
||||||
|
|
|
||||||
|
|
@ -6181,6 +6181,50 @@ pre {
|
||||||
max-height: 14px;
|
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 {
|
.topo-canvas__empty {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
|
||||||
|
|
@ -422,25 +422,35 @@ type EdgeDefaults = Record<EdgeSourceKind, EdgeDefaultStyle>;
|
||||||
|
|
||||||
const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults";
|
const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults";
|
||||||
const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover";
|
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 {
|
try {
|
||||||
const raw = localStorage.getItem(AUTO_LAYOUT_DISCOVER_KEY);
|
const raw = localStorage.getItem(key);
|
||||||
if (raw === null) return false;
|
if (raw === null) return defaultValue;
|
||||||
return raw === "1" || raw === "true";
|
return raw === "1" || raw === "true";
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistAutoLayoutAfterDiscover(value: boolean) {
|
function persistBoolFlag(key: string, value: boolean) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(AUTO_LAYOUT_DISCOVER_KEY, value ? "1" : "0");
|
localStorage.setItem(key, value ? "1" : "0");
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* 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 = {
|
const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = {
|
||||||
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
|
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
|
||||||
discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" },
|
discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" },
|
||||||
|
|
@ -592,11 +602,18 @@ function applyViewGraph(
|
||||||
defaults: EdgeDefaults,
|
defaults: EdgeDefaults,
|
||||||
setNodes: (ns: Node<NeNodeData>[]) => void,
|
setNodes: (ns: Node<NeNodeData>[]) => void,
|
||||||
setEdges: (es: Edge[]) => void,
|
setEdges: (es: Edge[]) => void,
|
||||||
|
localPositions?: Map<string, { x: number; y: number }>,
|
||||||
) {
|
) {
|
||||||
const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults);
|
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);
|
setEdges(rfEdges);
|
||||||
return { rfNodes, rfEdges };
|
return { rfNodes: merged, rfEdges };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopologyPage() {
|
export function TopologyPage() {
|
||||||
|
|
@ -636,6 +653,12 @@ export function TopologyPage() {
|
||||||
const [toolMode, setToolMode] = useState<ToolMode>("select");
|
const [toolMode, setToolMode] = useState<ToolMode>("select");
|
||||||
const [snapToGrid, setSnapToGrid] = useState(true);
|
const [snapToGrid, setSnapToGrid] = useState(true);
|
||||||
const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(loadAutoLayoutAfterDiscover);
|
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 [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const [addNeOpen, setAddNeOpen] = useState(false);
|
const [addNeOpen, setAddNeOpen] = useState(false);
|
||||||
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
||||||
|
|
@ -668,6 +691,7 @@ export function TopologyPage() {
|
||||||
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dirtyRef = useRef(false);
|
const dirtyRef = useRef(false);
|
||||||
|
const appliedMapIdRef = useRef("");
|
||||||
const historyRef = useRef<HistorySnap[]>([]);
|
const historyRef = useRef<HistorySnap[]>([]);
|
||||||
const redoRef = useRef<HistorySnap[]>([]);
|
const redoRef = useRef<HistorySnap[]>([]);
|
||||||
const historyLockRef = useRef(false);
|
const historyLockRef = useRef(false);
|
||||||
|
|
@ -889,9 +913,16 @@ export function TopologyPage() {
|
||||||
setSearchParams({}, { replace: true });
|
setSearchParams({}, { replace: true });
|
||||||
}, [regions, searchParams, setSearchParams]);
|
}, [regions, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
appliedMapIdRef.current = "";
|
||||||
|
}, [mapId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvasMode) return;
|
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);
|
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults);
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
setNodes(rfNodes);
|
setNodes(rfNodes);
|
||||||
|
|
@ -1199,7 +1230,7 @@ export function TopologyPage() {
|
||||||
scope: "ne_ids",
|
scope: "ne_ids",
|
||||||
ne_ids,
|
ne_ids,
|
||||||
concurrency: 4,
|
concurrency: 4,
|
||||||
auto_add_unmatched: true,
|
auto_add_unmatched: discoverAutoAddUnmatched,
|
||||||
trigger_mode: "topology",
|
trigger_mode: "topology",
|
||||||
});
|
});
|
||||||
let job: TopologyDiscoverJob = jobStart;
|
let job: TopologyDiscoverJob = jobStart;
|
||||||
|
|
@ -1221,9 +1252,18 @@ export function TopologyPage() {
|
||||||
if (job.status === "failed") {
|
if (job.status === "failed") {
|
||||||
throw new Error(job.error || "discover_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);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), projected);
|
||||||
|
appliedMapIdRef.current = mapId;
|
||||||
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
|
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;
|
let didAutoLayout = false;
|
||||||
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
||||||
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
rfNodes = layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
||||||
|
|
@ -1271,7 +1311,7 @@ export function TopologyPage() {
|
||||||
setDiscovering(false);
|
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
|
const discoverResults = discoverReport?.results?.length
|
||||||
|
|
@ -1580,10 +1620,16 @@ export function TopologyPage() {
|
||||||
pushHistory();
|
pushHistory();
|
||||||
try {
|
try {
|
||||||
if (nodeIds.length) {
|
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);
|
const graph = await removeTopologyViewNodes(mapId, nodeIds);
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||||
|
appliedMapIdRef.current = mapId;
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
clearDirty();
|
clearDirty();
|
||||||
} else if (edgeIds.size) {
|
} else if (edgeIds.size) {
|
||||||
|
|
@ -1670,10 +1716,16 @@ export function TopologyPage() {
|
||||||
pushHistory();
|
pushHistory();
|
||||||
closeCtxMenu();
|
closeCtxMenu();
|
||||||
try {
|
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]);
|
const graph = await removeTopologyViewNodes(mapId, [nodeId]);
|
||||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||||
|
appliedMapIdRef.current = mapId;
|
||||||
historyLockRef.current = true;
|
historyLockRef.current = true;
|
||||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
applyViewGraph(graph, edgeDefaults, setNodes, setEdges, localPos);
|
||||||
historyLockRef.current = false;
|
historyLockRef.current = false;
|
||||||
clearDirty();
|
clearDirty();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -2473,7 +2525,6 @@ export function TopologyPage() {
|
||||||
[
|
[
|
||||||
["select", t("topology.toolSelect"), "V"],
|
["select", t("topology.toolSelect"), "V"],
|
||||||
["pan", t("topology.toolPan"), "H"],
|
["pan", t("topology.toolPan"), "H"],
|
||||||
["drag", t("topology.toolDrag"), "A"],
|
|
||||||
["connect", t("topology.toolConnect"), "C"],
|
["connect", t("topology.toolConnect"), "C"],
|
||||||
] as const
|
] as const
|
||||||
).map(([mode, label, key]) => (
|
).map(([mode, label, key]) => (
|
||||||
|
|
@ -2544,6 +2595,30 @@ export function TopologyPage() {
|
||||||
/>
|
/>
|
||||||
{t("topology.autoLayoutDiscover")}
|
{t("topology.autoLayoutDiscover")}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={discoverAutoAddUnmatched}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
setDiscoverAutoAddUnmatched(next);
|
||||||
|
persistBoolFlag(DISCOVER_AUTO_ADD_KEY, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.discoverAutoAddUnmatched")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={discoverProjectNeighbors}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
setDiscoverProjectNeighbors(next);
|
||||||
|
persistBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.discoverProjectNeighbors")}
|
||||||
|
</label>
|
||||||
<div className="topo-display-defaults">
|
<div className="topo-display-defaults">
|
||||||
<div className="topo-display-defaults__head">
|
<div className="topo-display-defaults__head">
|
||||||
<strong>{t("topology.edgeDefaults")}</strong>
|
<strong>{t("topology.edgeDefaults")}</strong>
|
||||||
|
|
@ -2955,6 +3030,48 @@ export function TopologyPage() {
|
||||||
onDragOver={onCanvasDragOver}
|
onDragOver={onCanvasDragOver}
|
||||||
onDrop={onCanvasDrop}
|
onDrop={onCanvasDrop}
|
||||||
>
|
>
|
||||||
|
{fullscreen ? (
|
||||||
|
<div className="topo-fs-toolbar" role="toolbar" aria-label={t("topology.toolModes")}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["select", t("topology.toolSelect"), "V"],
|
||||||
|
["pan", t("topology.toolPan"), "H"],
|
||||||
|
["connect", t("topology.toolConnect"), "C"],
|
||||||
|
] as const
|
||||||
|
).map(([mode, label, key]) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
className={`topo-fs-toolbar__btn${toolMode === mode ? " is-active" : ""}`}
|
||||||
|
title={`${label} (${key})`}
|
||||||
|
aria-pressed={toolMode === mode}
|
||||||
|
onClick={() => {
|
||||||
|
setToolMode(mode);
|
||||||
|
connectClickRef.current = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<kbd>{key}</kbd>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-fs-toolbar__btn"
|
||||||
|
title={t("topology.fit")}
|
||||||
|
onClick={() => rfRef.current?.fitView({ padding: 0.2 })}
|
||||||
|
>
|
||||||
|
{t("topology.fit")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-fs-toolbar__btn"
|
||||||
|
title={t("topology.exitFullscreen")}
|
||||||
|
onClick={() => void toggleFullscreen()}
|
||||||
|
>
|
||||||
|
{t("topology.exitFullscreen")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{treeRoot ? (
|
{treeRoot ? (
|
||||||
<TopoDisplayContext.Provider value={displayOpts}>
|
<TopoDisplayContext.Provider value={displayOpts}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
|
|
@ -2968,6 +3085,8 @@ export function TopologyPage() {
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
defaultEdgeOptions={{ type: "straight" }}
|
defaultEdgeOptions={{ type: "straight" }}
|
||||||
proOptions={{ hideAttribution: true }}
|
proOptions={{ hideAttribution: true }}
|
||||||
|
minZoom={0.05}
|
||||||
|
maxZoom={4}
|
||||||
nodesDraggable={toolBehavior.nodesDraggable}
|
nodesDraggable={toolBehavior.nodesDraggable}
|
||||||
nodesConnectable={toolBehavior.nodesConnectable}
|
nodesConnectable={toolBehavior.nodesConnectable}
|
||||||
elementsSelectable={toolBehavior.elementsSelectable}
|
elementsSelectable={toolBehavior.elementsSelectable}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,9 @@ export function LldpLinksPage() {
|
||||||
|
|
||||||
const [jobPage, setJobPage] = useState(1);
|
const [jobPage, setJobPage] = useState(1);
|
||||||
const [expandedJobId, setExpandedJobId] = useState("");
|
const [expandedJobId, setExpandedJobId] = useState("");
|
||||||
|
const [itemPage, setItemPage] = useState(1);
|
||||||
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
|
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
|
||||||
|
const ITEM_PAGE_SIZE = 20;
|
||||||
|
|
||||||
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
|
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
|
||||||
const [edgeKeyword, setEdgeKeyword] = useState("");
|
const [edgeKeyword, setEdgeKeyword] = useState("");
|
||||||
|
|
@ -90,8 +92,8 @@ export function LldpLinksPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobDetailQuery = useQuery({
|
const jobDetailQuery = useQuery({
|
||||||
queryKey: queryKeys.lldpCollectJob(expandedJobId),
|
queryKey: queryKeys.lldpCollectJob(expandedJobId, itemPage),
|
||||||
queryFn: () => fetchLldpCollectJob(expandedJobId),
|
queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: ITEM_PAGE_SIZE }),
|
||||||
enabled: Boolean(expandedJobId),
|
enabled: Boolean(expandedJobId),
|
||||||
staleTime: 800,
|
staleTime: 800,
|
||||||
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
|
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
|
||||||
|
|
@ -193,6 +195,8 @@ export function LldpLinksPage() {
|
||||||
const jobPages = pageCount(jobTotal, 10);
|
const jobPages = pageCount(jobTotal, 10);
|
||||||
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
||||||
const detailItems = jobDetailQuery.data?.items ?? [];
|
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 edgeItems = edgesQuery.data?.items ?? [];
|
||||||
const edgeTotal = Number(edgesQuery.data?.total || 0);
|
const edgeTotal = Number(edgesQuery.data?.total || 0);
|
||||||
const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE);
|
const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE);
|
||||||
|
|
@ -546,6 +550,7 @@ export function LldpLinksPage() {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setItemDetail(null);
|
setItemDetail(null);
|
||||||
setExpandedJobId(open ? "" : job.id);
|
setExpandedJobId(open ? "" : job.id);
|
||||||
|
setItemPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{open ? "−" : "+"}
|
{open ? "−" : "+"}
|
||||||
|
|
@ -653,6 +658,25 @@ export function LldpLinksPage() {
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div className="pager">
|
||||||
|
<button type="button" disabled={itemPage <= 1} onClick={() => setItemPage((p) => p - 1)}>
|
||||||
|
{t("common.prevPage")}
|
||||||
|
</button>
|
||||||
|
<span className="muted">
|
||||||
|
{t("common.pagerMeta", {
|
||||||
|
total: String(itemTotal),
|
||||||
|
page: String(itemPage),
|
||||||
|
pages: String(itemPages),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={itemPage >= itemPages}
|
||||||
|
onClick={() => setItemPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
{t("common.nextPage")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ export function PortTrafficPage() {
|
||||||
// Wizard / edit
|
// Wizard / edit
|
||||||
const [editDeviceId, setEditDeviceId] = useState("");
|
const [editDeviceId, setEditDeviceId] = useState("");
|
||||||
const [editDeviceSnap, setEditDeviceSnap] = useState<PortTrafficDevice | null>(null);
|
const [editDeviceSnap, setEditDeviceSnap] = useState<PortTrafficDevice | null>(null);
|
||||||
const [intervalSec, setIntervalSec] = useState(60);
|
const [intervalSec, setIntervalSec] = useState(300);
|
||||||
const [retentionDays, setRetentionDays] = useState(7);
|
const [retentionDays, setRetentionDays] = useState(7);
|
||||||
const [concurrency, setConcurrency] = useState(1);
|
const [concurrency, setConcurrency] = useState(1);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
|
|
@ -137,6 +137,12 @@ export function PortTrafficPage() {
|
||||||
const [ports, setPorts] = useState<PortTrafficDiscoverPort[]>([]);
|
const [ports, setPorts] = useState<PortTrafficDiscoverPort[]>([]);
|
||||||
const [portsLoading, setPortsLoading] = useState(false);
|
const [portsLoading, setPortsLoading] = useState(false);
|
||||||
const [portsError, setPortsError] = useState("");
|
const [portsError, setPortsError] = useState("");
|
||||||
|
const [discoverMeta, setDiscoverMeta] = useState<{
|
||||||
|
ok: boolean;
|
||||||
|
command: string;
|
||||||
|
raw_preview: string;
|
||||||
|
error: string;
|
||||||
|
} | null>(null);
|
||||||
const [pickedIfnames, setPickedIfnames] = useState<Record<string, PortTrafficIfaceIn>>({});
|
const [pickedIfnames, setPickedIfnames] = useState<Record<string, PortTrafficIfaceIn>>({});
|
||||||
const [deepLinkHint, setDeepLinkHint] = useState("");
|
const [deepLinkHint, setDeepLinkHint] = useState("");
|
||||||
const [deepLinkSource, setDeepLinkSource] = useState<"managed" | "ume" | "">("");
|
const [deepLinkSource, setDeepLinkSource] = useState<"managed" | "ume" | "">("");
|
||||||
|
|
@ -366,7 +372,7 @@ export function PortTrafficPage() {
|
||||||
|
|
||||||
const resetWizard = () => {
|
const resetWizard = () => {
|
||||||
setWizardStep(1);
|
setWizardStep(1);
|
||||||
setIntervalSec(60);
|
setIntervalSec(300);
|
||||||
setRetentionDays(7);
|
setRetentionDays(7);
|
||||||
setConcurrency(1);
|
setConcurrency(1);
|
||||||
setNote("");
|
setNote("");
|
||||||
|
|
@ -374,6 +380,7 @@ export function PortTrafficPage() {
|
||||||
setSelectedNe(null);
|
setSelectedNe(null);
|
||||||
setPorts([]);
|
setPorts([]);
|
||||||
setPortsError("");
|
setPortsError("");
|
||||||
|
setDiscoverMeta(null);
|
||||||
setPickedIfnames({});
|
setPickedIfnames({});
|
||||||
setNeKeyword("");
|
setNeKeyword("");
|
||||||
setNePage(1);
|
setNePage(1);
|
||||||
|
|
@ -404,6 +411,7 @@ export function PortTrafficPage() {
|
||||||
setPickedIfnames({});
|
setPickedIfnames({});
|
||||||
setPorts([]);
|
setPorts([]);
|
||||||
setPortsError("");
|
setPortsError("");
|
||||||
|
setDiscoverMeta(null);
|
||||||
setView("edit");
|
setView("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -434,11 +442,23 @@ export function PortTrafficPage() {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setPortsLoading(true);
|
setPortsLoading(true);
|
||||||
setPortsError("");
|
setPortsError("");
|
||||||
|
setDiscoverMeta(null);
|
||||||
try {
|
try {
|
||||||
const res = await discoverPortTrafficPorts({ source, id });
|
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) {
|
if (deepLinkIfname) {
|
||||||
const hit = res.ports.find((p) => p.ifname === deepLinkIfname);
|
const hit = (res.ports || []).find((p) => p.ifname === deepLinkIfname);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
setPickedIfnames((prev) => ({
|
setPickedIfnames((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -455,6 +475,12 @@ export function PortTrafficPage() {
|
||||||
const msg = e instanceof Error ? e.message : "discover_failed";
|
const msg = e instanceof Error ? e.message : "discover_failed";
|
||||||
setPorts([]);
|
setPorts([]);
|
||||||
setPortsError(msg);
|
setPortsError(msg);
|
||||||
|
setDiscoverMeta({
|
||||||
|
ok: false,
|
||||||
|
command: "",
|
||||||
|
raw_preview: "",
|
||||||
|
error: msg,
|
||||||
|
});
|
||||||
showError(msg);
|
showError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setPortsLoading(false);
|
setPortsLoading(false);
|
||||||
|
|
@ -841,6 +867,29 @@ export function PortTrafficPage() {
|
||||||
</button>
|
</button>
|
||||||
{portsError ? <span className="muted">{portsError}</span> : null}
|
{portsError ? <span className="muted">{portsError}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{discoverMeta ? (
|
||||||
|
<details className="panel" open style={{ marginBottom: 12 }}>
|
||||||
|
<summary>
|
||||||
|
{t("portTraffic.discoverDetail")}
|
||||||
|
{" · "}
|
||||||
|
{discoverMeta.ok ? t("portTraffic.discoverOk") : t("portTraffic.discoverFail")}
|
||||||
|
</summary>
|
||||||
|
{discoverMeta.command ? (
|
||||||
|
<p className="muted">
|
||||||
|
{t("portTraffic.discoverCommand")}: <code>{discoverMeta.command}</code>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{discoverMeta.error ? (
|
||||||
|
<p className="muted" style={{ color: "var(--danger, #b00)" }}>
|
||||||
|
{discoverMeta.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="muted">{t("portTraffic.discoverRaw")}</p>
|
||||||
|
<pre className="topo-discover-modal__raw" style={{ maxHeight: 240, overflow: "auto" }}>
|
||||||
|
{discoverMeta.raw_preview || "—"}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
{ports.length ? (
|
{ports.length ? (
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -894,7 +943,7 @@ export function PortTrafficPage() {
|
||||||
min={15}
|
min={15}
|
||||||
max={3600}
|
max={3600}
|
||||||
value={intervalSec}
|
value={intervalSec}
|
||||||
onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
|
onChange={(e) => setIntervalSec(Number(e.target.value) || 300)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="config-sync-policy-field">
|
<label className="config-sync-policy-field">
|
||||||
|
|
@ -1005,7 +1054,7 @@ export function PortTrafficPage() {
|
||||||
min={15}
|
min={15}
|
||||||
max={3600}
|
max={3600}
|
||||||
value={intervalSec}
|
value={intervalSec}
|
||||||
onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
|
onChange={(e) => setIntervalSec(Number(e.target.value) || 300)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="config-sync-policy-field">
|
<label className="config-sync-policy-field">
|
||||||
|
|
@ -1040,6 +1089,29 @@ export function PortTrafficPage() {
|
||||||
<span className="muted">{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</span>
|
<span className="muted">{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</span>
|
||||||
</div>
|
</div>
|
||||||
{portsError ? <p className="muted">{portsError}</p> : null}
|
{portsError ? <p className="muted">{portsError}</p> : null}
|
||||||
|
{discoverMeta ? (
|
||||||
|
<details className="panel" open style={{ marginBottom: 12 }}>
|
||||||
|
<summary>
|
||||||
|
{t("portTraffic.discoverDetail")}
|
||||||
|
{" · "}
|
||||||
|
{discoverMeta.ok ? t("portTraffic.discoverOk") : t("portTraffic.discoverFail")}
|
||||||
|
</summary>
|
||||||
|
{discoverMeta.command ? (
|
||||||
|
<p className="muted">
|
||||||
|
{t("portTraffic.discoverCommand")}: <code>{discoverMeta.command}</code>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{discoverMeta.error ? (
|
||||||
|
<p className="muted" style={{ color: "var(--danger, #b00)" }}>
|
||||||
|
{discoverMeta.error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="muted">{t("portTraffic.discoverRaw")}</p>
|
||||||
|
<pre className="topo-discover-modal__raw" style={{ maxHeight: 240, overflow: "auto" }}>
|
||||||
|
{discoverMeta.raw_preview || "—"}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
{ports.length ? (
|
{ports.length ? (
|
||||||
<table className="data-table">
|
<table className="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/** Topology editor interaction modes (NMS-style). */
|
/** Topology editor interaction modes (NMS-style). */
|
||||||
|
|
||||||
export type ToolMode = "select" | "pan" | "drag" | "connect";
|
export type ToolMode = "select" | "pan" | "connect";
|
||||||
|
|
||||||
export type ToolModeBehavior = {
|
export type ToolModeBehavior = {
|
||||||
nodesDraggable: boolean;
|
nodesDraggable: boolean;
|
||||||
|
|
@ -23,15 +23,6 @@ export function behaviorForMode(mode: ToolMode): ToolModeBehavior {
|
||||||
selectionOnDrag: false,
|
selectionOnDrag: false,
|
||||||
panOnScroll: true,
|
panOnScroll: true,
|
||||||
};
|
};
|
||||||
case "drag":
|
|
||||||
return {
|
|
||||||
nodesDraggable: true,
|
|
||||||
nodesConnectable: false,
|
|
||||||
elementsSelectable: true,
|
|
||||||
panOnDrag: [1, 2],
|
|
||||||
selectionOnDrag: false,
|
|
||||||
panOnScroll: true,
|
|
||||||
};
|
|
||||||
case "connect":
|
case "connect":
|
||||||
return {
|
return {
|
||||||
nodesDraggable: false,
|
nodesDraggable: false,
|
||||||
|
|
@ -58,7 +49,6 @@ export function toolModeFromKey(key: string): ToolMode | null {
|
||||||
const k = key.toLowerCase();
|
const k = key.toLowerCase();
|
||||||
if (k === "v") return "select";
|
if (k === "v") return "select";
|
||||||
if (k === "h") return "pan";
|
if (k === "h") return "pan";
|
||||||
if (k === "a") return "drag";
|
|
||||||
if (k === "c") return "connect";
|
if (k === "c") return "connect";
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1153,8 +1153,17 @@ export const fetchLldpCollectJobs = (params: { page?: number; pageSize?: number
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchLldpCollectJob = (jobId: string) =>
|
export const fetchLldpCollectJob = (
|
||||||
apiGet<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}`);
|
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<TopologyDiscoverJob>(
|
||||||
|
`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}?${p.toString()}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const createFabricManualEdge = (body: {
|
export const createFabricManualEdge = (body: {
|
||||||
a_node_id: string;
|
a_node_id: string;
|
||||||
|
|
|
||||||
|
|
@ -590,6 +590,9 @@ export type TopologyDiscoverJob = {
|
||||||
ended_at?: string | null;
|
ended_at?: string | null;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
items: TopologyDiscoverJobItem[];
|
items: TopologyDiscoverJobItem[];
|
||||||
|
items_total?: number;
|
||||||
|
items_page?: number;
|
||||||
|
items_page_size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @deprecated alias — prefer TopologyViewItem */
|
/** @deprecated alias — prefer TopologyViewItem */
|
||||||
|
|
@ -887,6 +890,10 @@ export type PortTrafficDiscoverResponse = {
|
||||||
vendor: string;
|
vendor: string;
|
||||||
vendor_key: string;
|
vendor_key: string;
|
||||||
ports: PortTrafficDiscoverPort[];
|
ports: PortTrafficDiscoverPort[];
|
||||||
|
ok?: boolean;
|
||||||
|
command?: string;
|
||||||
|
raw_preview?: string;
|
||||||
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PortTrafficSamplePoint = {
|
export type PortTrafficSamplePoint = {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue