mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
Add ZTE-first port traffic monitoring with task wizard and ops wall.
Collect interface bit/s via CLI on a schedule, store samples in Postgres, and chart trends with uPlot. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a95c6598bb
commit
efe8f58cd2
25 changed files with 2880 additions and 36 deletions
|
|
@ -77,6 +77,9 @@ class Settings(BaseSettings):
|
||||||
config_sync_scheduler_tick_sec: int = 60
|
config_sync_scheduler_tick_sec: int = 60
|
||||||
# After process start / unexpected restart, wait before any scheduled sync.
|
# After process start / unexpected restart, wait before any scheduled sync.
|
||||||
config_sync_startup_grace_sec: int = 3600
|
config_sync_startup_grace_sec: int = 3600
|
||||||
|
# Port traffic monitoring (CLI rate bit/s samples)
|
||||||
|
port_traffic_scheduler_enabled: bool = True
|
||||||
|
port_traffic_scheduler_tick_sec: int = 15
|
||||||
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).
|
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).
|
||||||
ne_exec_max_commands: int = 5
|
ne_exec_max_commands: int = 5
|
||||||
# WebCRT interactive terminal sessions
|
# WebCRT interactive terminal sessions
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from .db import Base, SessionLocal, engine, get_db
|
||||||
from .collection_router import router as collection_router
|
from .collection_router import router as collection_router
|
||||||
from .cli_router import router as cli_router
|
from .cli_router import router as cli_router
|
||||||
from .config_sync_router import router as config_sync_router
|
from .config_sync_router import router as config_sync_router
|
||||||
|
from .port_traffic_router import router as port_traffic_router
|
||||||
from .managed_ne_router import router as managed_ne_router
|
from .managed_ne_router import router as managed_ne_router
|
||||||
from .webcrt_router import router as webcrt_router
|
from .webcrt_router import router as webcrt_router
|
||||||
from .topology_router import router as topology_router
|
from .topology_router import router as topology_router
|
||||||
|
|
@ -135,6 +136,7 @@ app.include_router(managed_ne_router)
|
||||||
app.include_router(cli_router)
|
app.include_router(cli_router)
|
||||||
app.include_router(collection_router)
|
app.include_router(collection_router)
|
||||||
app.include_router(config_sync_router)
|
app.include_router(config_sync_router)
|
||||||
|
app.include_router(port_traffic_router)
|
||||||
app.include_router(webcrt_router)
|
app.include_router(webcrt_router)
|
||||||
app.include_router(topology_router)
|
app.include_router(topology_router)
|
||||||
parser_cfg = load_parser_config()
|
parser_cfg = load_parser_config()
|
||||||
|
|
@ -836,11 +838,15 @@ def on_startup() -> None:
|
||||||
_schedule_log.info("startup: resumed %s pending ne collection runs", resumed)
|
_schedule_log.info("startup: resumed %s pending ne collection runs", resumed)
|
||||||
from .config_sync_recovery import recover_config_sync_on_startup
|
from .config_sync_recovery import recover_config_sync_on_startup
|
||||||
from .config_sync_service import ensure_policy
|
from .config_sync_service import ensure_policy
|
||||||
|
from .port_traffic_recovery import recover_port_traffic_on_startup
|
||||||
|
|
||||||
ensure_policy(db)
|
ensure_policy(db)
|
||||||
cfg_resumed = recover_config_sync_on_startup(db)
|
cfg_resumed = recover_config_sync_on_startup(db)
|
||||||
if cfg_resumed:
|
if cfg_resumed:
|
||||||
_schedule_log.info("startup: resumed %s config_sync task(s) from interrupted cycle", cfg_resumed)
|
_schedule_log.info("startup: resumed %s config_sync task(s) from interrupted cycle", cfg_resumed)
|
||||||
|
pt_cleared = recover_port_traffic_on_startup(db)
|
||||||
|
if pt_cleared:
|
||||||
|
_schedule_log.info("startup: cleared %s port_traffic stuck collect_running flag(s)", pt_cleared)
|
||||||
except Exception:
|
except Exception:
|
||||||
_schedule_log.exception("startup: ne collection / config_sync recovery failed")
|
_schedule_log.exception("startup: ne collection / config_sync recovery failed")
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -851,6 +857,12 @@ def on_startup() -> None:
|
||||||
start_config_sync_scheduler()
|
start_config_sync_scheduler()
|
||||||
except Exception:
|
except Exception:
|
||||||
_schedule_log.exception("startup: config_sync scheduler init failed")
|
_schedule_log.exception("startup: config_sync scheduler init failed")
|
||||||
|
try:
|
||||||
|
from .port_traffic_scheduler import start_port_traffic_scheduler
|
||||||
|
|
||||||
|
start_port_traffic_scheduler()
|
||||||
|
except Exception:
|
||||||
|
_schedule_log.exception("startup: port_traffic scheduler init failed")
|
||||||
# Best-effort schema evolution for new columns (no migrations framework).
|
# Best-effort schema evolution for new columns (no migrations framework).
|
||||||
# Safe for Postgres (IF NOT EXISTS); ignored on failure.
|
# Safe for Postgres (IF NOT EXISTS); ignored on failure.
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
|
from sqlalchemy import BigInteger, Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from sqlalchemy.types import JSON
|
from sqlalchemy.types import JSON
|
||||||
|
|
@ -588,3 +588,65 @@ class NeConfigHistory(Base):
|
||||||
collected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
collected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
cycle_id: Mapped[str] = mapped_column(String(64), default="")
|
cycle_id: Mapped[str] = mapped_column(String(64), default="")
|
||||||
task_id: Mapped[str] = mapped_column(String(64), default="")
|
task_id: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficTask(Base):
|
||||||
|
"""Port traffic monitoring job definition."""
|
||||||
|
|
||||||
|
__tablename__ = "port_traffic_task"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||||
|
title: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|running|paused|stopped
|
||||||
|
interval_sec: Mapped[int] = mapped_column(Integer, default=60)
|
||||||
|
retention_days: Mapped[int] = mapped_column(Integer, default=7)
|
||||||
|
concurrency: Mapped[int] = mapped_column(Integer, default=5)
|
||||||
|
collect_running: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
last_collect_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
last_collect_ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
last_error: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficTarget(Base):
|
||||||
|
"""Monitored interface under a port traffic task."""
|
||||||
|
|
||||||
|
__tablename__ = "port_traffic_target"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("task_id", "source", "target_id", "ifname", name="uq_port_traffic_target_if"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||||
|
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), default="managed", index=True)
|
||||||
|
target_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||||
|
ne_name: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
ne_ip: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
ifname: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
if_description: Mapped[str] = mapped_column(String(512), default="")
|
||||||
|
bw_bps: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="active", index=True) # active|disabled
|
||||||
|
last_error: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
|
last_sample_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficSample(Base):
|
||||||
|
"""Time-series sample for a monitored interface."""
|
||||||
|
|
||||||
|
__tablename__ = "port_traffic_sample"
|
||||||
|
__table_args__ = (UniqueConstraint("target_row_id", "ts", name="uq_port_traffic_sample_ts"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||||
|
target_row_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
in_bps: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
out_bps: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
in_util_pct: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
out_util_pct: Mapped[float] = mapped_column(Float, default=0.0)
|
||||||
|
bw_bps: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||||
|
rate_period_sec: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
raw_ok: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
message: Mapped[str] = mapped_column(String(512), default="")
|
||||||
|
|
|
||||||
29
netx_api/port_traffic_commands.py
Normal file
29
netx_api/port_traffic_commands.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""Vendor → port traffic CLI command matrix (ZTE first)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .config_sync_commands import normalize_vendor_key
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PortTrafficCommands:
|
||||||
|
brief: str
|
||||||
|
detail_template: str # format with ifname=
|
||||||
|
vendor_key: str = "other"
|
||||||
|
|
||||||
|
|
||||||
|
def commands_for_vendor(vendor: str, device_type: str = "") -> PortTrafficCommands | None:
|
||||||
|
key = normalize_vendor_key(vendor, device_type)
|
||||||
|
if key == "zte":
|
||||||
|
return PortTrafficCommands(
|
||||||
|
brief="show interface brief",
|
||||||
|
detail_template="show interface {ifname}",
|
||||||
|
vendor_key=key,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def detail_command(cmds: PortTrafficCommands, ifname: str) -> str:
|
||||||
|
return cmds.detail_template.format(ifname=ifname)
|
||||||
237
netx_api/port_traffic_parsers.py
Normal file
237
netx_api/port_traffic_parsers.py
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
"""Parsers for ZTE show interface brief / detail (rate bit/s)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
_BW_UNIT = {
|
||||||
|
"k": 1_000,
|
||||||
|
"m": 1_000_000,
|
||||||
|
"g": 1_000_000_000,
|
||||||
|
"t": 1_000_000_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
_RE_BW_COMPACT = re.compile(r"^(\d+(?:\.\d+)?)\s*([kKmMgGtT])(?:bit)?s?$", re.I)
|
||||||
|
_RE_BW_DETAIL = re.compile(
|
||||||
|
r"\bBW\s+(\d+(?:\.\d+)?)\s*([kKmMgGtT])?\s*(?:G?bit|bit)/s\b",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_RE_RATE_PERIOD = re.compile(r"Rate\s+period\s*:\s*(\d+)\s*s", re.I)
|
||||||
|
_RE_INPUT_BPS = re.compile(r"^\s*Input\s*:\s*([\d.]+)\s*bit/s", re.I | re.M)
|
||||||
|
_RE_OUTPUT_BPS = re.compile(r"^\s*Output\s*:\s*([\d.]+)\s*bit/s", re.I | re.M)
|
||||||
|
_RE_UTIL = re.compile(
|
||||||
|
r"Intf\s+utilization\s*:\s*input\s*([\d.]+)%\s*output\s*([\d.]+)%",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_RE_IF_UP = re.compile(r"^(\S+)\s+is\s+(up|down)\b", re.I | re.M)
|
||||||
|
_RE_DESC = re.compile(r"^\s*Description:\s*(.+?)\s*$", re.I | re.M)
|
||||||
|
|
||||||
|
# Fixed-width columns from ZTE `show interface brief` header.
|
||||||
|
_COL_IF = (0, 24)
|
||||||
|
_COL_ATTR = (24, 35)
|
||||||
|
_COL_MODE = (35, 48)
|
||||||
|
_COL_BW = (48, 54)
|
||||||
|
_COL_ADMIN = (54, 60)
|
||||||
|
_COL_PHY = (60, 66)
|
||||||
|
_COL_PROT = (66, 72)
|
||||||
|
_COL_DESC = (72, None)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BriefPort:
|
||||||
|
ifname: str
|
||||||
|
attribute: str = ""
|
||||||
|
mode: str = ""
|
||||||
|
bw_raw: str = ""
|
||||||
|
bw_bps: int = 0
|
||||||
|
admin: str = ""
|
||||||
|
phy: str = ""
|
||||||
|
prot: str = ""
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DetailRates:
|
||||||
|
ifname: str = ""
|
||||||
|
admin_oper: str = ""
|
||||||
|
description: str = ""
|
||||||
|
bw_bps: int = 0
|
||||||
|
rate_period_sec: int = 0
|
||||||
|
in_bps: float = 0.0
|
||||||
|
out_bps: float = 0.0
|
||||||
|
in_util_pct: float = 0.0
|
||||||
|
out_util_pct: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bw_to_bps(raw: str) -> int:
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text or text.upper() == "N/A":
|
||||||
|
return 0
|
||||||
|
m = _RE_BW_COMPACT.match(text.replace(" ", ""))
|
||||||
|
if m:
|
||||||
|
value = float(m.group(1))
|
||||||
|
mult = _BW_UNIT[m.group(2).lower()]
|
||||||
|
return int(value * mult)
|
||||||
|
m2 = _RE_BW_DETAIL.search(text)
|
||||||
|
if m2:
|
||||||
|
value = float(m2.group(1))
|
||||||
|
unit = (m2.group(2) or "g").lower()
|
||||||
|
# "BW 1 Gbit/s" — unit letter may be in "Gbit" when group2 empty after "1 "
|
||||||
|
if m2.group(2) is None and "gbit" in text.lower():
|
||||||
|
unit = "g"
|
||||||
|
elif m2.group(2) is None and "mbit" in text.lower():
|
||||||
|
unit = "m"
|
||||||
|
elif m2.group(2) is None and "kbit" in text.lower():
|
||||||
|
unit = "k"
|
||||||
|
mult = _BW_UNIT.get(unit, 1_000_000_000)
|
||||||
|
return int(value * mult)
|
||||||
|
# Plain "BW 1000000000" unlikely; try digits only
|
||||||
|
digits = re.sub(r"[^\d.]", "", text)
|
||||||
|
if digits:
|
||||||
|
try:
|
||||||
|
return int(float(digits))
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _slice(line: str, start: int, end: int | None) -> str:
|
||||||
|
if end is None:
|
||||||
|
return line[start:].rstrip() if len(line) > start else ""
|
||||||
|
if len(line) <= start:
|
||||||
|
return ""
|
||||||
|
return line[start:end].strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_zte_interface_brief(text: str) -> list[BriefPort]:
|
||||||
|
"""Parse ZTE `show interface brief` into port rows."""
|
||||||
|
lines = (text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||||
|
out: list[BriefPort] = []
|
||||||
|
started = False
|
||||||
|
for raw in lines:
|
||||||
|
line = raw.rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
if line.lstrip().startswith("Interface") and "Admin" in line and "Description" in line:
|
||||||
|
started = True
|
||||||
|
continue
|
||||||
|
if not started:
|
||||||
|
continue
|
||||||
|
if " is " in line and "ifindex" in line.lower():
|
||||||
|
break
|
||||||
|
ifname = _slice(line, *_COL_IF).split()[0] if _slice(line, *_COL_IF) else ""
|
||||||
|
if not ifname or ifname.lower() == "interface":
|
||||||
|
continue
|
||||||
|
bw_raw = _slice(line, *_COL_BW)
|
||||||
|
out.append(
|
||||||
|
BriefPort(
|
||||||
|
ifname=ifname,
|
||||||
|
attribute=_slice(line, *_COL_ATTR),
|
||||||
|
mode=_slice(line, *_COL_MODE),
|
||||||
|
bw_raw=bw_raw,
|
||||||
|
bw_bps=parse_bw_to_bps(bw_raw),
|
||||||
|
admin=_slice(line, *_COL_ADMIN).lower(),
|
||||||
|
phy=_slice(line, *_COL_PHY).lower(),
|
||||||
|
prot=_slice(line, *_COL_PROT).lower(),
|
||||||
|
description=_slice(line, *_COL_DESC).strip(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_zte_interface_detail(text: str) -> DetailRates:
|
||||||
|
"""Parse ZTE `show interface {ifname}` rate / util / BW."""
|
||||||
|
blob = text or ""
|
||||||
|
ifname = ""
|
||||||
|
admin_oper = ""
|
||||||
|
m_if = _RE_IF_UP.search(blob)
|
||||||
|
if m_if:
|
||||||
|
ifname = m_if.group(1)
|
||||||
|
admin_oper = m_if.group(2).lower()
|
||||||
|
desc = ""
|
||||||
|
m_desc = _RE_DESC.search(blob)
|
||||||
|
if m_desc:
|
||||||
|
desc = m_desc.group(1).strip()
|
||||||
|
|
||||||
|
bw_bps = 0
|
||||||
|
m_bw = _RE_BW_DETAIL.search(blob)
|
||||||
|
if m_bw:
|
||||||
|
bw_bps = parse_bw_to_bps(m_bw.group(0))
|
||||||
|
else:
|
||||||
|
# Fallback line scan
|
||||||
|
for line in blob.splitlines():
|
||||||
|
if re.search(r"\bBW\b", line, re.I):
|
||||||
|
bw_bps = parse_bw_to_bps(line)
|
||||||
|
if bw_bps:
|
||||||
|
break
|
||||||
|
|
||||||
|
rate_period = 0
|
||||||
|
m_rp = _RE_RATE_PERIOD.search(blob)
|
||||||
|
if m_rp:
|
||||||
|
rate_period = int(m_rp.group(1))
|
||||||
|
|
||||||
|
# Prefer Rate period block: first Input/Output after "Rate period"
|
||||||
|
in_bps = 0.0
|
||||||
|
out_bps = 0.0
|
||||||
|
rp_idx = blob.lower().find("rate period")
|
||||||
|
rate_blob = blob[rp_idx:] if rp_idx >= 0 else blob
|
||||||
|
# Stop before Peak rate to avoid peak Input/Output
|
||||||
|
peak_idx = rate_blob.lower().find("peak rate")
|
||||||
|
if peak_idx >= 0:
|
||||||
|
rate_blob = rate_blob[:peak_idx]
|
||||||
|
m_in = _RE_INPUT_BPS.search(rate_blob)
|
||||||
|
m_out = _RE_OUTPUT_BPS.search(rate_blob)
|
||||||
|
if m_in:
|
||||||
|
in_bps = float(m_in.group(1))
|
||||||
|
if m_out:
|
||||||
|
out_bps = float(m_out.group(1))
|
||||||
|
|
||||||
|
in_util = 0.0
|
||||||
|
out_util = 0.0
|
||||||
|
m_util = _RE_UTIL.search(blob)
|
||||||
|
if m_util:
|
||||||
|
in_util = float(m_util.group(1))
|
||||||
|
out_util = float(m_util.group(2))
|
||||||
|
|
||||||
|
return DetailRates(
|
||||||
|
ifname=ifname,
|
||||||
|
admin_oper=admin_oper,
|
||||||
|
description=desc,
|
||||||
|
bw_bps=bw_bps,
|
||||||
|
rate_period_sec=rate_period,
|
||||||
|
in_bps=in_bps,
|
||||||
|
out_bps=out_bps,
|
||||||
|
in_util_pct=in_util,
|
||||||
|
out_util_pct=out_util,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def brief_port_to_dict(row: BriefPort) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ifname": row.ifname,
|
||||||
|
"attribute": row.attribute,
|
||||||
|
"mode": row.mode,
|
||||||
|
"bw_raw": row.bw_raw,
|
||||||
|
"bw_bps": row.bw_bps,
|
||||||
|
"admin": row.admin,
|
||||||
|
"phy": row.phy,
|
||||||
|
"prot": row.prot,
|
||||||
|
"description": row.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detail_to_dict(row: DetailRates) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ifname": row.ifname,
|
||||||
|
"admin_oper": row.admin_oper,
|
||||||
|
"description": row.description,
|
||||||
|
"bw_bps": row.bw_bps,
|
||||||
|
"rate_period_sec": row.rate_period_sec,
|
||||||
|
"in_bps": row.in_bps,
|
||||||
|
"out_bps": row.out_bps,
|
||||||
|
"in_util_pct": row.in_util_pct,
|
||||||
|
"out_util_pct": row.out_util_pct,
|
||||||
|
}
|
||||||
31
netx_api/port_traffic_recovery.py
Normal file
31
netx_api/port_traffic_recovery.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Startup recovery for interrupted port traffic collect rounds."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import PortTrafficTask
|
||||||
|
|
||||||
|
_log = logging.getLogger("netx.port_traffic.recovery")
|
||||||
|
|
||||||
|
|
||||||
|
def recover_port_traffic_on_startup(db: Session) -> int:
|
||||||
|
"""Clear stuck collect_running flags so scheduler can resume."""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
rows = db.query(PortTrafficTask).filter(PortTrafficTask.collect_running.is_(True)).all()
|
||||||
|
n = 0
|
||||||
|
for task in rows:
|
||||||
|
task.collect_running = False
|
||||||
|
if not task.last_collect_ended_at:
|
||||||
|
task.last_collect_ended_at = now
|
||||||
|
if not task.last_error:
|
||||||
|
task.last_error = "requeued_after_restart"
|
||||||
|
task.updated_at = now
|
||||||
|
n += 1
|
||||||
|
if n:
|
||||||
|
db.commit()
|
||||||
|
_log.info("port_traffic recovery cleared collect_running on %s task(s)", n)
|
||||||
|
return n
|
||||||
211
netx_api/port_traffic_router.py
Normal file
211
netx_api/port_traffic_router.py
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
"""HTTP API for port traffic monitoring."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .auth_service import write_audit
|
||||||
|
from .db import get_db
|
||||||
|
from .port_traffic_schemas import (
|
||||||
|
DiscoverPortsRequest,
|
||||||
|
PortTrafficTaskCreate,
|
||||||
|
PortTrafficTaskUpdate,
|
||||||
|
PortTrafficTargetsPut,
|
||||||
|
)
|
||||||
|
from .port_traffic_service import (
|
||||||
|
create_task,
|
||||||
|
dashboard,
|
||||||
|
delete_task,
|
||||||
|
discover_ports,
|
||||||
|
get_samples,
|
||||||
|
get_task,
|
||||||
|
list_targets,
|
||||||
|
list_tasks,
|
||||||
|
put_targets,
|
||||||
|
set_task_status,
|
||||||
|
update_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/v1/port-traffic", tags=["port-traffic"])
|
||||||
|
|
||||||
|
|
||||||
|
def _actor(request: Request) -> tuple[str, str]:
|
||||||
|
user = getattr(request.state, "auth_user", None)
|
||||||
|
if not user:
|
||||||
|
return "", ""
|
||||||
|
return str(getattr(user, "id", "") or ""), str(getattr(user, "username", "") or "")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard")
|
||||||
|
def api_dashboard(db: Session = Depends(get_db)):
|
||||||
|
return dashboard(db).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks")
|
||||||
|
def api_list_tasks(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=100),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
return list_tasks(db, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks")
|
||||||
|
def api_create_task(
|
||||||
|
body: PortTrafficTaskCreate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
out = create_task(db, body)
|
||||||
|
uid, uname = _actor(request)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
action="port_traffic.task.create",
|
||||||
|
actor_user_id=uid,
|
||||||
|
actor_username=uname,
|
||||||
|
method="POST",
|
||||||
|
path="/v1/port-traffic/tasks",
|
||||||
|
status_code=200,
|
||||||
|
detail={"id": out.id, "title": out.title, "start_now": body.start_now},
|
||||||
|
)
|
||||||
|
return out.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}")
|
||||||
|
def api_get_task(task_id: str, db: Session = Depends(get_db)):
|
||||||
|
return get_task(db, task_id).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/tasks/{task_id}")
|
||||||
|
def api_patch_task(
|
||||||
|
task_id: str,
|
||||||
|
body: PortTrafficTaskUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
out = update_task(db, task_id, body)
|
||||||
|
uid, uname = _actor(request)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
action="port_traffic.task.update",
|
||||||
|
actor_user_id=uid,
|
||||||
|
actor_username=uname,
|
||||||
|
method="PATCH",
|
||||||
|
path=f"/v1/port-traffic/tasks/{task_id}",
|
||||||
|
status_code=200,
|
||||||
|
detail=body.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return out.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tasks/{task_id}")
|
||||||
|
def api_delete_task(task_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
out = delete_task(db, task_id)
|
||||||
|
uid, uname = _actor(request)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
action="port_traffic.task.delete",
|
||||||
|
actor_user_id=uid,
|
||||||
|
actor_username=uname,
|
||||||
|
method="DELETE",
|
||||||
|
path=f"/v1/port-traffic/tasks/{task_id}",
|
||||||
|
status_code=200,
|
||||||
|
detail={"id": task_id},
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/start")
|
||||||
|
def api_start_task(task_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
out = set_task_status(db, task_id, "running")
|
||||||
|
uid, uname = _actor(request)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
action="port_traffic.task.start",
|
||||||
|
actor_user_id=uid,
|
||||||
|
actor_username=uname,
|
||||||
|
method="POST",
|
||||||
|
path=f"/v1/port-traffic/tasks/{task_id}/start",
|
||||||
|
status_code=200,
|
||||||
|
detail={"id": task_id},
|
||||||
|
)
|
||||||
|
return out.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/pause")
|
||||||
|
def api_pause_task(task_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
out = set_task_status(db, task_id, "paused")
|
||||||
|
uid, uname = _actor(request)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
action="port_traffic.task.pause",
|
||||||
|
actor_user_id=uid,
|
||||||
|
actor_username=uname,
|
||||||
|
method="POST",
|
||||||
|
path=f"/v1/port-traffic/tasks/{task_id}/pause",
|
||||||
|
status_code=200,
|
||||||
|
detail={"id": task_id},
|
||||||
|
)
|
||||||
|
return out.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/stop")
|
||||||
|
def api_stop_task(task_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
out = set_task_status(db, task_id, "stopped")
|
||||||
|
uid, uname = _actor(request)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
action="port_traffic.task.stop",
|
||||||
|
actor_user_id=uid,
|
||||||
|
actor_username=uname,
|
||||||
|
method="POST",
|
||||||
|
path=f"/v1/port-traffic/tasks/{task_id}/stop",
|
||||||
|
status_code=200,
|
||||||
|
detail={"id": task_id},
|
||||||
|
)
|
||||||
|
return out.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks/{task_id}/targets")
|
||||||
|
def api_list_targets(task_id: str, db: Session = Depends(get_db)):
|
||||||
|
return {"items": [t.model_dump() for t in list_targets(db, task_id)]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/tasks/{task_id}/targets")
|
||||||
|
def api_put_targets(
|
||||||
|
task_id: str,
|
||||||
|
body: PortTrafficTargetsPut,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
items = put_targets(db, task_id, body)
|
||||||
|
uid, uname = _actor(request)
|
||||||
|
write_audit(
|
||||||
|
db,
|
||||||
|
action="port_traffic.targets.put",
|
||||||
|
actor_user_id=uid,
|
||||||
|
actor_username=uname,
|
||||||
|
method="PUT",
|
||||||
|
path=f"/v1/port-traffic/tasks/{task_id}/targets",
|
||||||
|
status_code=200,
|
||||||
|
detail={"id": task_id, "count": len(items)},
|
||||||
|
)
|
||||||
|
return {"items": [t.model_dump() for t in items]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/discover/ports")
|
||||||
|
def api_discover_ports(body: DiscoverPortsRequest, db: Session = Depends(get_db)):
|
||||||
|
return discover_ports(db, body).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/samples")
|
||||||
|
def api_samples(
|
||||||
|
target_id: str = Query(..., description="port_traffic_target row id"),
|
||||||
|
from_ts: datetime | None = Query(default=None, alias="from"),
|
||||||
|
to_ts: datetime | None = Query(default=None, alias="to"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
return get_samples(db, target_row_id=target_id, from_ts=from_ts, to_ts=to_ts).model_dump()
|
||||||
258
netx_api/port_traffic_runner.py
Normal file
258
netx_api/port_traffic_runner.py
Normal file
|
|
@ -0,0 +1,258 @@
|
||||||
|
"""Port traffic collection worker: claim task round, sample interfaces via CLI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from datetime import datetime
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from .cli_resolve import resolve_cli_target
|
||||||
|
from .config import settings
|
||||||
|
from .db import SessionLocal
|
||||||
|
from .models import PortTrafficSample, PortTrafficTarget, PortTrafficTask
|
||||||
|
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
|
||||||
|
from .port_traffic_commands import commands_for_vendor, detail_command
|
||||||
|
from .port_traffic_parsers import parse_zte_interface_detail
|
||||||
|
|
||||||
|
_log = logging.getLogger("netx.port_traffic.runner")
|
||||||
|
_pools: dict[str, ThreadPoolExecutor] = {}
|
||||||
|
_pools_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_error(exc: BaseException) -> str:
|
||||||
|
return f"{type(exc).__name__}: {exc}"[:1020]
|
||||||
|
|
||||||
|
|
||||||
|
def _pool_for_task(task_id: str, concurrency: int) -> ThreadPoolExecutor:
|
||||||
|
with _pools_lock:
|
||||||
|
pool = _pools.get(task_id)
|
||||||
|
if pool is None:
|
||||||
|
workers = max(1, min(20, int(concurrency or 5)))
|
||||||
|
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix=f"pt-{task_id[:8]}")
|
||||||
|
_pools[task_id] = pool
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
def _release_pool(task_id: str) -> None:
|
||||||
|
with _pools_lock:
|
||||||
|
pool = _pools.pop(task_id, None)
|
||||||
|
if pool is not None:
|
||||||
|
try:
|
||||||
|
pool.shutdown(wait=False, cancel_futures=False)
|
||||||
|
except TypeError:
|
||||||
|
pool.shutdown(wait=False)
|
||||||
|
except Exception:
|
||||||
|
_log.exception("port_traffic pool shutdown failed task=%s", task_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_target_error(target_row_id: str, message: str) -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.get(PortTrafficTarget, target_row_id)
|
||||||
|
if row:
|
||||||
|
row.last_error = message[:1020]
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_collect_round(task_id: str) -> list[str] | None:
|
||||||
|
"""Mark task collect_running and return active target row ids, or None if skip."""
|
||||||
|
for attempt in range(8):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
task = db.get(PortTrafficTask, task_id)
|
||||||
|
if not task:
|
||||||
|
return None
|
||||||
|
if str(task.status or "") != "running":
|
||||||
|
return None
|
||||||
|
if bool(task.collect_running):
|
||||||
|
return None
|
||||||
|
ended = task.last_collect_ended_at
|
||||||
|
interval = max(15, int(task.interval_sec or 60))
|
||||||
|
if ended is not None:
|
||||||
|
elapsed = (_utcnow() - ended).total_seconds()
|
||||||
|
if elapsed < interval:
|
||||||
|
return None
|
||||||
|
targets = (
|
||||||
|
db.query(PortTrafficTarget)
|
||||||
|
.filter(
|
||||||
|
PortTrafficTarget.task_id == task_id,
|
||||||
|
PortTrafficTarget.status == "active",
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not targets:
|
||||||
|
return None
|
||||||
|
task.collect_running = True
|
||||||
|
task.last_collect_started_at = _utcnow()
|
||||||
|
task.last_error = ""
|
||||||
|
task.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
return [str(t.id) for t in targets]
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
_log.exception("port_traffic claim failed task=%s attempt=%s", task_id, attempt)
|
||||||
|
time.sleep(0.05 * (attempt + 1))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _finish_collect_round(task_id: str, *, error: str = "") -> None:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
task = db.get(PortTrafficTask, task_id)
|
||||||
|
if not task:
|
||||||
|
return
|
||||||
|
task.collect_running = False
|
||||||
|
task.last_collect_ended_at = _utcnow()
|
||||||
|
if error:
|
||||||
|
task.last_error = error[:1020]
|
||||||
|
task.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
_release_pool(task_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_show(creds: dict[str, Any], command: str, read_timeout: int) -> str:
|
||||||
|
conn = open_netmiko_connection(creds, session_timeout=read_timeout + 60)
|
||||||
|
try:
|
||||||
|
return str(conn.send_command(command_string=command, read_timeout=read_timeout) or "")
|
||||||
|
finally:
|
||||||
|
close_netmiko_connection(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_one_target(target_row_id: str) -> None:
|
||||||
|
creds: dict[str, Any] | None = None
|
||||||
|
cmd = ""
|
||||||
|
per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
|
||||||
|
cap = int(settings.ne_collect_run_timeout_cap_sec or 600)
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.get(PortTrafficTarget, target_row_id)
|
||||||
|
if not row or str(row.status or "") != "active":
|
||||||
|
return
|
||||||
|
source = str(row.source or "").strip().lower()
|
||||||
|
target_id = str(row.target_id or "").strip()
|
||||||
|
ifname = str(row.ifname or "").strip()
|
||||||
|
vendor_hint = str(row.vendor or "")
|
||||||
|
try:
|
||||||
|
if source == "managed":
|
||||||
|
creds, device = resolve_cli_target(db, managed_ne_id=target_id)
|
||||||
|
elif source == "ume":
|
||||||
|
creds, device = resolve_cli_target(db, ume_ne_id=target_id)
|
||||||
|
else:
|
||||||
|
row.last_error = "invalid_source"
|
||||||
|
db.commit()
|
||||||
|
return
|
||||||
|
except HTTPException as exc:
|
||||||
|
row.last_error = str(exc.detail or "resolve_failed")[:1020]
|
||||||
|
db.commit()
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
row.last_error = _format_error(exc)
|
||||||
|
db.commit()
|
||||||
|
return
|
||||||
|
|
||||||
|
vendor = str(device.get("vendor") or vendor_hint or "")
|
||||||
|
device_type = str(device.get("device_type") or "")
|
||||||
|
cmds = commands_for_vendor(vendor, device_type)
|
||||||
|
if cmds is None:
|
||||||
|
row.last_error = "unsupported_vendor"
|
||||||
|
db.commit()
|
||||||
|
return
|
||||||
|
cmd = detail_command(cmds, ifname)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if not creds or not cmd:
|
||||||
|
return
|
||||||
|
|
||||||
|
budget = min(cap, per_cmd + 90)
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
|
fut = pool.submit(_run_show, creds, cmd, per_cmd)
|
||||||
|
raw = fut.result(timeout=budget)
|
||||||
|
except Exception as exc:
|
||||||
|
_set_target_error(target_row_id, _format_error(exc))
|
||||||
|
return
|
||||||
|
|
||||||
|
parsed = parse_zte_interface_detail(raw)
|
||||||
|
if parsed.in_bps == 0 and parsed.out_bps == 0 and parsed.bw_bps == 0 and not parsed.ifname:
|
||||||
|
_set_target_error(target_row_id, "parse_empty")
|
||||||
|
return
|
||||||
|
|
||||||
|
now = _utcnow()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
row = db.get(PortTrafficTarget, target_row_id)
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
bw = int(parsed.bw_bps or row.bw_bps or 0)
|
||||||
|
if bw and not row.bw_bps:
|
||||||
|
row.bw_bps = bw
|
||||||
|
db.add(
|
||||||
|
PortTrafficSample(
|
||||||
|
id=uuid4().hex,
|
||||||
|
target_row_id=target_row_id,
|
||||||
|
ts=now,
|
||||||
|
in_bps=float(parsed.in_bps),
|
||||||
|
out_bps=float(parsed.out_bps),
|
||||||
|
in_util_pct=float(parsed.in_util_pct),
|
||||||
|
out_util_pct=float(parsed.out_util_pct),
|
||||||
|
bw_bps=bw,
|
||||||
|
rate_period_sec=int(parsed.rate_period_sec or 0),
|
||||||
|
raw_ok=True,
|
||||||
|
message="",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row.last_error = ""
|
||||||
|
row.last_sample_at = now
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
_log.exception("port_traffic sample save failed target=%s", target_row_id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_collect(task_id: str) -> int:
|
||||||
|
"""Claim and sample all active targets for a running task. Returns target count."""
|
||||||
|
target_ids = _claim_collect_round(task_id)
|
||||||
|
if not target_ids:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
task = db.get(PortTrafficTask, task_id)
|
||||||
|
concurrency = int(task.concurrency or 5) if task else 5
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
pool = _pool_for_task(task_id, concurrency)
|
||||||
|
futures = [pool.submit(_sample_one_target, tid) for tid in target_ids]
|
||||||
|
errors = 0
|
||||||
|
try:
|
||||||
|
for fut in as_completed(futures):
|
||||||
|
try:
|
||||||
|
fut.result()
|
||||||
|
except Exception:
|
||||||
|
errors += 1
|
||||||
|
_log.exception("port_traffic target worker failed task=%s", task_id)
|
||||||
|
finally:
|
||||||
|
err_msg = f"{errors}_target_errors" if errors else ""
|
||||||
|
_finish_collect_round(task_id, error=err_msg)
|
||||||
|
return len(target_ids)
|
||||||
96
netx_api/port_traffic_scheduler.py
Normal file
96
netx_api/port_traffic_scheduler.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Background scheduler for port traffic collection + retention purge."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .db import SessionLocal
|
||||||
|
from .models import PortTrafficTask
|
||||||
|
from .port_traffic_runner import dispatch_collect
|
||||||
|
from .port_traffic_service import purge_expired_samples
|
||||||
|
|
||||||
|
_log = logging.getLogger("netx.port_traffic.scheduler")
|
||||||
|
_stop = threading.Event()
|
||||||
|
_thread: threading.Thread | None = None
|
||||||
|
_purge_counter = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def try_dispatch_due_tasks() -> int:
|
||||||
|
"""Dispatch collect rounds for due running tasks. Returns number started."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
tasks = (
|
||||||
|
db.query(PortTrafficTask)
|
||||||
|
.filter(PortTrafficTask.status == "running", PortTrafficTask.collect_running.is_(False))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
due_ids: list[str] = []
|
||||||
|
now = _utcnow()
|
||||||
|
for task in tasks:
|
||||||
|
interval = max(15, int(task.interval_sec or 60))
|
||||||
|
ended = task.last_collect_ended_at
|
||||||
|
if ended is None:
|
||||||
|
due_ids.append(str(task.id))
|
||||||
|
continue
|
||||||
|
if (now - ended).total_seconds() >= interval:
|
||||||
|
due_ids.append(str(task.id))
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
started = 0
|
||||||
|
for tid in due_ids:
|
||||||
|
try:
|
||||||
|
n = dispatch_collect(tid)
|
||||||
|
if n:
|
||||||
|
started += 1
|
||||||
|
_log.info("port_traffic collect started task=%s targets=%s", tid, n)
|
||||||
|
except Exception:
|
||||||
|
_log.exception("port_traffic dispatch failed task=%s", tid)
|
||||||
|
return started
|
||||||
|
|
||||||
|
|
||||||
|
def _loop() -> None:
|
||||||
|
global _purge_counter
|
||||||
|
tick = max(5, int(settings.port_traffic_scheduler_tick_sec or 15))
|
||||||
|
_log.info("port_traffic scheduler started tick=%ss", tick)
|
||||||
|
while not _stop.is_set():
|
||||||
|
try:
|
||||||
|
if bool(settings.port_traffic_scheduler_enabled):
|
||||||
|
try_dispatch_due_tasks()
|
||||||
|
_purge_counter += 1
|
||||||
|
# Retention purge roughly every ~20 ticks
|
||||||
|
if _purge_counter >= 20:
|
||||||
|
_purge_counter = 0
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
purge_expired_samples(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
_log.exception("port_traffic scheduler tick failed")
|
||||||
|
_stop.wait(tick)
|
||||||
|
_log.info("port_traffic scheduler stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def start_port_traffic_scheduler() -> None:
|
||||||
|
global _thread
|
||||||
|
if not bool(settings.port_traffic_scheduler_enabled):
|
||||||
|
_log.info("port_traffic scheduler disabled by settings")
|
||||||
|
return
|
||||||
|
if _thread and _thread.is_alive():
|
||||||
|
return
|
||||||
|
_stop.clear()
|
||||||
|
_thread = threading.Thread(target=_loop, name="port-traffic-scheduler", daemon=True)
|
||||||
|
_thread.start()
|
||||||
|
_log.info("started thread %s alive=%s", _thread.name, _thread.is_alive())
|
||||||
|
|
||||||
|
|
||||||
|
def stop_port_traffic_scheduler() -> None:
|
||||||
|
_stop.set()
|
||||||
131
netx_api/port_traffic_schemas.py
Normal file
131
netx_api/port_traffic_schemas.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""Pydantic schemas for port traffic monitoring API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficNeRef(BaseModel):
|
||||||
|
source: Literal["managed", "ume"]
|
||||||
|
id: str
|
||||||
|
ne_name: str = ""
|
||||||
|
ne_ip: str = ""
|
||||||
|
vendor: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficTargetIn(BaseModel):
|
||||||
|
source: Literal["managed", "ume"]
|
||||||
|
target_id: str
|
||||||
|
ne_name: str = ""
|
||||||
|
ne_ip: str = ""
|
||||||
|
vendor: str = ""
|
||||||
|
ifname: str
|
||||||
|
if_description: str = ""
|
||||||
|
bw_bps: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficTaskCreate(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=256)
|
||||||
|
interval_sec: int = Field(default=60, ge=15, le=3600)
|
||||||
|
retention_days: int = Field(default=7, ge=1, le=90)
|
||||||
|
concurrency: int = Field(default=5, ge=1, le=20)
|
||||||
|
targets: list[PortTrafficTargetIn] = Field(default_factory=list)
|
||||||
|
start_now: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficTaskUpdate(BaseModel):
|
||||||
|
title: str | None = Field(default=None, min_length=1, max_length=256)
|
||||||
|
interval_sec: int | None = Field(default=None, ge=15, le=3600)
|
||||||
|
retention_days: int | None = Field(default=None, ge=1, le=90)
|
||||||
|
concurrency: int | None = Field(default=None, ge=1, le=20)
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficTaskOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
status: str
|
||||||
|
interval_sec: int
|
||||||
|
retention_days: int
|
||||||
|
concurrency: int
|
||||||
|
collect_running: bool = False
|
||||||
|
target_count: int = 0
|
||||||
|
active_target_count: int = 0
|
||||||
|
last_collect_started_at: datetime | None = None
|
||||||
|
last_collect_ended_at: datetime | None = None
|
||||||
|
last_error: str = ""
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficTargetOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
task_id: str
|
||||||
|
source: str
|
||||||
|
target_id: str
|
||||||
|
ne_name: str
|
||||||
|
ne_ip: str
|
||||||
|
vendor: str
|
||||||
|
ifname: str
|
||||||
|
if_description: str
|
||||||
|
bw_bps: int
|
||||||
|
status: str
|
||||||
|
last_error: str = ""
|
||||||
|
last_sample_at: datetime | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficTargetsPut(BaseModel):
|
||||||
|
targets: list[PortTrafficTargetIn]
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverPortsRequest(BaseModel):
|
||||||
|
source: Literal["managed", "ume"]
|
||||||
|
id: str
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverPortItem(BaseModel):
|
||||||
|
ifname: str
|
||||||
|
attribute: str = ""
|
||||||
|
mode: str = ""
|
||||||
|
bw_raw: str = ""
|
||||||
|
bw_bps: int = 0
|
||||||
|
admin: str = ""
|
||||||
|
phy: str = ""
|
||||||
|
prot: str = ""
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DiscoverPortsResponse(BaseModel):
|
||||||
|
source: str
|
||||||
|
id: str
|
||||||
|
ne_name: str = ""
|
||||||
|
ne_ip: str = ""
|
||||||
|
vendor: str = ""
|
||||||
|
vendor_key: str = ""
|
||||||
|
ports: list[DiscoverPortItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficSamplePoint(BaseModel):
|
||||||
|
ts: datetime
|
||||||
|
in_bps: float
|
||||||
|
out_bps: float
|
||||||
|
in_util_pct: float
|
||||||
|
out_util_pct: float
|
||||||
|
bw_bps: int
|
||||||
|
rate_period_sec: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficSamplesOut(BaseModel):
|
||||||
|
target: PortTrafficTargetOut
|
||||||
|
points: list[PortTrafficSamplePoint] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficDashboardOut(BaseModel):
|
||||||
|
task_count: int = 0
|
||||||
|
running_task_count: int = 0
|
||||||
|
active_target_count: int = 0
|
||||||
|
sample_count_24h: int = 0
|
||||||
|
last_sample_at: datetime | None = None
|
||||||
398
netx_api/port_traffic_service.py
Normal file
398
netx_api/port_traffic_service.py
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""Port traffic monitoring service: CRUD, discover, samples, dashboard."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .cli_resolve import resolve_cli_target
|
||||||
|
from .config import settings
|
||||||
|
from .models import PortTrafficSample, PortTrafficTarget, PortTrafficTask
|
||||||
|
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
|
||||||
|
from .port_traffic_commands import commands_for_vendor
|
||||||
|
from .port_traffic_parsers import brief_port_to_dict, parse_zte_interface_brief
|
||||||
|
from .port_traffic_schemas import (
|
||||||
|
DiscoverPortItem,
|
||||||
|
DiscoverPortsRequest,
|
||||||
|
DiscoverPortsResponse,
|
||||||
|
PortTrafficDashboardOut,
|
||||||
|
PortTrafficSamplePoint,
|
||||||
|
PortTrafficSamplesOut,
|
||||||
|
PortTrafficTargetIn,
|
||||||
|
PortTrafficTargetOut,
|
||||||
|
PortTrafficTargetsPut,
|
||||||
|
PortTrafficTaskCreate,
|
||||||
|
PortTrafficTaskOut,
|
||||||
|
PortTrafficTaskUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
_log = logging.getLogger("netx.port_traffic.service")
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def _task_out(db: Session, task: PortTrafficTask) -> PortTrafficTaskOut:
|
||||||
|
tid = str(task.id)
|
||||||
|
total = db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == tid).count()
|
||||||
|
active = (
|
||||||
|
db.query(PortTrafficTarget)
|
||||||
|
.filter(PortTrafficTarget.task_id == tid, PortTrafficTarget.status == "active")
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
return PortTrafficTaskOut(
|
||||||
|
id=tid,
|
||||||
|
title=str(task.title or ""),
|
||||||
|
status=str(task.status or ""),
|
||||||
|
interval_sec=int(task.interval_sec or 60),
|
||||||
|
retention_days=int(task.retention_days or 7),
|
||||||
|
concurrency=int(task.concurrency or 5),
|
||||||
|
collect_running=bool(task.collect_running),
|
||||||
|
target_count=int(total),
|
||||||
|
active_target_count=int(active),
|
||||||
|
last_collect_started_at=task.last_collect_started_at,
|
||||||
|
last_collect_ended_at=task.last_collect_ended_at,
|
||||||
|
last_error=str(task.last_error or ""),
|
||||||
|
created_at=task.created_at,
|
||||||
|
updated_at=task.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _target_out(row: PortTrafficTarget) -> PortTrafficTargetOut:
|
||||||
|
return PortTrafficTargetOut(
|
||||||
|
id=str(row.id),
|
||||||
|
task_id=str(row.task_id),
|
||||||
|
source=str(row.source or ""),
|
||||||
|
target_id=str(row.target_id or ""),
|
||||||
|
ne_name=str(row.ne_name or ""),
|
||||||
|
ne_ip=str(row.ne_ip or ""),
|
||||||
|
vendor=str(row.vendor or ""),
|
||||||
|
ifname=str(row.ifname or ""),
|
||||||
|
if_description=str(row.if_description or ""),
|
||||||
|
bw_bps=int(row.bw_bps or 0),
|
||||||
|
status=str(row.status or ""),
|
||||||
|
last_error=str(row.last_error or ""),
|
||||||
|
last_sample_at=row.last_sample_at,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_zte_targets(targets: list[PortTrafficTargetIn]) -> None:
|
||||||
|
for t in targets:
|
||||||
|
cmds = commands_for_vendor(t.vendor or "", "")
|
||||||
|
if cmds is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"vendor_not_supported_for_port_traffic: {t.vendor or 'unknown'} ({t.ne_name or t.target_id})",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_tasks(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
|
||||||
|
q = db.query(PortTrafficTask).order_by(PortTrafficTask.created_at.desc())
|
||||||
|
total = q.count()
|
||||||
|
rows = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"items": [_task_out(db, r).model_dump() for r in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_task(db: Session, task_id: str) -> PortTrafficTaskOut:
|
||||||
|
task = db.get(PortTrafficTask, task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="task_not_found")
|
||||||
|
return _task_out(db, task)
|
||||||
|
|
||||||
|
|
||||||
|
def create_task(db: Session, body: PortTrafficTaskCreate) -> PortTrafficTaskOut:
|
||||||
|
_assert_zte_targets(body.targets)
|
||||||
|
now = _utcnow()
|
||||||
|
status = "running" if body.start_now and body.targets else "draft"
|
||||||
|
if body.start_now and not body.targets:
|
||||||
|
status = "draft"
|
||||||
|
task = PortTrafficTask(
|
||||||
|
id=uuid4().hex,
|
||||||
|
title=body.title.strip(),
|
||||||
|
status=status,
|
||||||
|
interval_sec=int(body.interval_sec),
|
||||||
|
retention_days=int(body.retention_days),
|
||||||
|
concurrency=int(body.concurrency),
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.flush()
|
||||||
|
for t in body.targets:
|
||||||
|
db.add(
|
||||||
|
PortTrafficTarget(
|
||||||
|
id=uuid4().hex,
|
||||||
|
task_id=task.id,
|
||||||
|
source=t.source,
|
||||||
|
target_id=t.target_id,
|
||||||
|
ne_name=t.ne_name or "",
|
||||||
|
ne_ip=t.ne_ip or "",
|
||||||
|
vendor=t.vendor or "",
|
||||||
|
ifname=t.ifname.strip(),
|
||||||
|
if_description=t.if_description or "",
|
||||||
|
bw_bps=int(t.bw_bps or 0),
|
||||||
|
status="active",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
return _task_out(db, task)
|
||||||
|
|
||||||
|
|
||||||
|
def update_task(db: Session, task_id: str, body: PortTrafficTaskUpdate) -> PortTrafficTaskOut:
|
||||||
|
task = db.get(PortTrafficTask, task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="task_not_found")
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
if "title" in data and data["title"] is not None:
|
||||||
|
task.title = str(data["title"]).strip()
|
||||||
|
if "interval_sec" in data and data["interval_sec"] is not None:
|
||||||
|
task.interval_sec = int(data["interval_sec"])
|
||||||
|
if "retention_days" in data and data["retention_days"] is not None:
|
||||||
|
task.retention_days = int(data["retention_days"])
|
||||||
|
if "concurrency" in data and data["concurrency"] is not None:
|
||||||
|
task.concurrency = int(data["concurrency"])
|
||||||
|
task.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
return _task_out(db, task)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_task(db: Session, task_id: str) -> dict[str, Any]:
|
||||||
|
task = db.get(PortTrafficTask, task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="task_not_found")
|
||||||
|
if bool(task.collect_running):
|
||||||
|
raise HTTPException(status_code=409, detail="collect_running")
|
||||||
|
targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).all()
|
||||||
|
ids = [str(t.id) for t in targets]
|
||||||
|
if ids:
|
||||||
|
db.query(PortTrafficSample).filter(PortTrafficSample.target_row_id.in_(ids)).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.delete(task)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "id": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
def set_task_status(db: Session, task_id: str, status: str) -> PortTrafficTaskOut:
|
||||||
|
task = db.get(PortTrafficTask, task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="task_not_found")
|
||||||
|
if status == "running":
|
||||||
|
active = (
|
||||||
|
db.query(PortTrafficTarget)
|
||||||
|
.filter(PortTrafficTarget.task_id == task_id, PortTrafficTarget.status == "active")
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if active <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="no_active_targets")
|
||||||
|
task.status = status
|
||||||
|
task.updated_at = _utcnow()
|
||||||
|
if status in ("stopped", "paused"):
|
||||||
|
# leave collect_running for runner to finish; recovery clears stuck flags
|
||||||
|
pass
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
return _task_out(db, task)
|
||||||
|
|
||||||
|
|
||||||
|
def put_targets(db: Session, task_id: str, body: PortTrafficTargetsPut) -> list[PortTrafficTargetOut]:
|
||||||
|
task = db.get(PortTrafficTask, task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="task_not_found")
|
||||||
|
if bool(task.collect_running):
|
||||||
|
raise HTTPException(status_code=409, detail="collect_running")
|
||||||
|
_assert_zte_targets(body.targets)
|
||||||
|
old = db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).all()
|
||||||
|
old_ids = [str(t.id) for t in old]
|
||||||
|
if old_ids:
|
||||||
|
db.query(PortTrafficSample).filter(PortTrafficSample.target_row_id.in_(old_ids)).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).delete(
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
now = _utcnow()
|
||||||
|
rows: list[PortTrafficTarget] = []
|
||||||
|
for t in body.targets:
|
||||||
|
row = PortTrafficTarget(
|
||||||
|
id=uuid4().hex,
|
||||||
|
task_id=task_id,
|
||||||
|
source=t.source,
|
||||||
|
target_id=t.target_id,
|
||||||
|
ne_name=t.ne_name or "",
|
||||||
|
ne_ip=t.ne_ip or "",
|
||||||
|
vendor=t.vendor or "",
|
||||||
|
ifname=t.ifname.strip(),
|
||||||
|
if_description=t.if_description or "",
|
||||||
|
bw_bps=int(t.bw_bps or 0),
|
||||||
|
status="active",
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
rows.append(row)
|
||||||
|
task.updated_at = now
|
||||||
|
db.commit()
|
||||||
|
return [_target_out(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def list_targets(db: Session, task_id: str) -> list[PortTrafficTargetOut]:
|
||||||
|
task = db.get(PortTrafficTask, task_id)
|
||||||
|
if not task:
|
||||||
|
raise HTTPException(status_code=404, detail="task_not_found")
|
||||||
|
rows = (
|
||||||
|
db.query(PortTrafficTarget)
|
||||||
|
.filter(PortTrafficTarget.task_id == task_id)
|
||||||
|
.order_by(PortTrafficTarget.ne_name, PortTrafficTarget.ifname)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_target_out(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def discover_ports(db: Session, body: DiscoverPortsRequest) -> DiscoverPortsResponse:
|
||||||
|
try:
|
||||||
|
if body.source == "managed":
|
||||||
|
creds, device = resolve_cli_target(db, managed_ne_id=body.id)
|
||||||
|
else:
|
||||||
|
creds, device = resolve_cli_target(db, ume_ne_id=body.id)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=f"resolve_failed: {exc}") from exc
|
||||||
|
|
||||||
|
vendor = str(device.get("vendor") or creds.get("vendor") or "")
|
||||||
|
device_type = str(device.get("device_type") or creds.get("device_type") or "")
|
||||||
|
ne_name = str(device.get("name") or creds.get("host") or "")
|
||||||
|
ne_ip = str(device.get("ip_address") or creds.get("host") or "")
|
||||||
|
cmds = commands_for_vendor(vendor, device_type)
|
||||||
|
if cmds is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
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 = str(conn.send_command(command_string=cmds.brief, read_timeout=per_cmd) or "")
|
||||||
|
finally:
|
||||||
|
close_netmiko_connection(conn)
|
||||||
|
|
||||||
|
ports = [DiscoverPortItem(**brief_port_to_dict(p)) for p in parse_zte_interface_brief(raw)]
|
||||||
|
return DiscoverPortsResponse(
|
||||||
|
source=body.source,
|
||||||
|
id=body.id,
|
||||||
|
ne_name=ne_name,
|
||||||
|
ne_ip=ne_ip,
|
||||||
|
vendor=vendor,
|
||||||
|
vendor_key=cmds.vendor_key,
|
||||||
|
ports=ports,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_samples(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
target_row_id: str,
|
||||||
|
from_ts: datetime | None = None,
|
||||||
|
to_ts: datetime | None = None,
|
||||||
|
) -> PortTrafficSamplesOut:
|
||||||
|
target = db.get(PortTrafficTarget, target_row_id)
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=404, detail="target_not_found")
|
||||||
|
now = _utcnow()
|
||||||
|
if to_ts is None:
|
||||||
|
to_ts = now
|
||||||
|
if from_ts is None:
|
||||||
|
from_ts = to_ts - timedelta(hours=1)
|
||||||
|
rows = (
|
||||||
|
db.query(PortTrafficSample)
|
||||||
|
.filter(
|
||||||
|
PortTrafficSample.target_row_id == target_row_id,
|
||||||
|
PortTrafficSample.ts >= from_ts,
|
||||||
|
PortTrafficSample.ts <= to_ts,
|
||||||
|
PortTrafficSample.raw_ok.is_(True),
|
||||||
|
)
|
||||||
|
.order_by(PortTrafficSample.ts.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
points = [
|
||||||
|
PortTrafficSamplePoint(
|
||||||
|
ts=r.ts,
|
||||||
|
in_bps=float(r.in_bps or 0),
|
||||||
|
out_bps=float(r.out_bps or 0),
|
||||||
|
in_util_pct=float(r.in_util_pct or 0),
|
||||||
|
out_util_pct=float(r.out_util_pct or 0),
|
||||||
|
bw_bps=int(r.bw_bps or 0),
|
||||||
|
rate_period_sec=int(r.rate_period_sec or 0),
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return PortTrafficSamplesOut(target=_target_out(target), points=points)
|
||||||
|
|
||||||
|
|
||||||
|
def dashboard(db: Session) -> PortTrafficDashboardOut:
|
||||||
|
task_count = db.query(PortTrafficTask).count()
|
||||||
|
running = db.query(PortTrafficTask).filter(PortTrafficTask.status == "running").count()
|
||||||
|
active_targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.status == "active").count()
|
||||||
|
since = _utcnow() - timedelta(hours=24)
|
||||||
|
sample_count = (
|
||||||
|
db.query(PortTrafficSample)
|
||||||
|
.filter(PortTrafficSample.ts >= since, PortTrafficSample.raw_ok.is_(True))
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
last = db.query(func.max(PortTrafficSample.ts)).scalar()
|
||||||
|
return PortTrafficDashboardOut(
|
||||||
|
task_count=int(task_count),
|
||||||
|
running_task_count=int(running),
|
||||||
|
active_target_count=int(active_targets),
|
||||||
|
sample_count_24h=int(sample_count),
|
||||||
|
last_sample_at=last,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def purge_expired_samples(db: Session) -> int:
|
||||||
|
"""Delete samples older than each task's retention_days."""
|
||||||
|
tasks = db.query(PortTrafficTask).all()
|
||||||
|
deleted = 0
|
||||||
|
now = _utcnow()
|
||||||
|
for task in tasks:
|
||||||
|
days = max(1, int(task.retention_days or 7))
|
||||||
|
cutoff = now - timedelta(days=days)
|
||||||
|
target_ids = [
|
||||||
|
str(t.id)
|
||||||
|
for t in db.query(PortTrafficTarget.id).filter(PortTrafficTarget.task_id == task.id).all()
|
||||||
|
]
|
||||||
|
if not target_ids:
|
||||||
|
continue
|
||||||
|
n = (
|
||||||
|
db.query(PortTrafficSample)
|
||||||
|
.filter(
|
||||||
|
PortTrafficSample.target_row_id.in_(target_ids),
|
||||||
|
PortTrafficSample.ts < cutoff,
|
||||||
|
)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
deleted += int(n or 0)
|
||||||
|
if deleted:
|
||||||
|
db.commit()
|
||||||
|
_log.info("port_traffic retention purged samples=%s", deleted)
|
||||||
|
return deleted
|
||||||
116
tests/test_port_traffic_parsers.py
Normal file
116
tests/test_port_traffic_parsers.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Unit tests for ZTE port traffic brief/detail parsers (sample from oclaw Untitled-1.ps1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from netx_api.port_traffic_commands import commands_for_vendor, detail_command
|
||||||
|
from netx_api.port_traffic_parsers import (
|
||||||
|
parse_bw_to_bps,
|
||||||
|
parse_zte_interface_brief,
|
||||||
|
parse_zte_interface_detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
SAMPLE_DETAIL = """\
|
||||||
|
AL5458-ACC-6120HS#show interface xgei-1/1/0/1
|
||||||
|
13:47:08 Africa/Algiers Fri Jul 31 2026
|
||||||
|
xgei-1/1/0/1 is up, ifindex: 8194
|
||||||
|
Description: C2930L100-EQ2
|
||||||
|
Line protocol is up, IPv4 protocol is up, IPv6 protocol is down,
|
||||||
|
detected status is RX-OK/TX-OK
|
||||||
|
Last line protocol up time : 2026-05-26 15:14:49
|
||||||
|
Hardware is XGigabit Ethernet, address is 00d0.0000.088f
|
||||||
|
Internet address is 192.168.1.1/30
|
||||||
|
BW 1 Gbit/s
|
||||||
|
IP MTU 1500 bytes
|
||||||
|
MTU 1562 bytes
|
||||||
|
MPLS MTU 1548 bytes
|
||||||
|
|
||||||
|
Fec-eth : N/A
|
||||||
|
Fec-bypass : N/A
|
||||||
|
ARP type ARP
|
||||||
|
ARP Timeout 04:00:00
|
||||||
|
Last Clear Time : 2026-05-26 15:13:48 Last Refresh Time: 2026-07-31 13:47:00
|
||||||
|
Rate period : 30 s
|
||||||
|
Input : 824 bit/s 1 packet/s
|
||||||
|
Output : 824 bit/s 1 packet/s
|
||||||
|
Peak rate:
|
||||||
|
Input : 3536 bit/s peak time 2026-05-26 15:17:10
|
||||||
|
Output : 6896 bit/s peak time 2026-05-26 15:17:10
|
||||||
|
Intf utilization: input 0% output 0%
|
||||||
|
HardwareCounters:
|
||||||
|
In_Bytes 462427066 In_Packets 6529765
|
||||||
|
"""
|
||||||
|
|
||||||
|
SAMPLE_BRIEF = """\
|
||||||
|
AL5458-ACC-6120HS#show interface brief
|
||||||
|
13:43:00 Africa/Algiers Fri Jul 31 2026
|
||||||
|
Interface Attribute Mode BW Admin Phy Prot Description
|
||||||
|
xgei-1/1/0/1 optical Duplex/full 1G up up up C2930L100-EQ2
|
||||||
|
xgei-1/1/0/2 optical Duplex/full 1G up down down
|
||||||
|
xgei-1/1/0/4 optical Duplex/full 10G up down down 123456test11255
|
||||||
|
cgei-1/1/0/33 optical Duplex/full 100G up down down
|
||||||
|
xxvgei-1/1/0/11 optical Duplex/full 25G up down down
|
||||||
|
smartgroup1 N/A N/A up up down
|
||||||
|
smartgroup10 N/A N/A 1G up up up
|
||||||
|
bvi2 N/A N/A up up up
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BwParseTests(unittest.TestCase):
|
||||||
|
def test_compact(self):
|
||||||
|
self.assertEqual(parse_bw_to_bps("1G"), 1_000_000_000)
|
||||||
|
self.assertEqual(parse_bw_to_bps("10G"), 10_000_000_000)
|
||||||
|
self.assertEqual(parse_bw_to_bps("100G"), 100_000_000_000)
|
||||||
|
self.assertEqual(parse_bw_to_bps("25G"), 25_000_000_000)
|
||||||
|
self.assertEqual(parse_bw_to_bps("1M"), 1_000_000)
|
||||||
|
|
||||||
|
def test_detail_line(self):
|
||||||
|
self.assertEqual(parse_bw_to_bps("BW 1 Gbit/s"), 1_000_000_000)
|
||||||
|
|
||||||
|
|
||||||
|
class BriefParserTests(unittest.TestCase):
|
||||||
|
def test_sample_brief(self):
|
||||||
|
rows = parse_zte_interface_brief(SAMPLE_BRIEF)
|
||||||
|
by_name = {r.ifname: r for r in rows}
|
||||||
|
self.assertIn("xgei-1/1/0/1", by_name)
|
||||||
|
r1 = by_name["xgei-1/1/0/1"]
|
||||||
|
self.assertEqual(r1.bw_bps, 1_000_000_000)
|
||||||
|
self.assertEqual(r1.admin, "up")
|
||||||
|
self.assertEqual(r1.phy, "up")
|
||||||
|
self.assertEqual(r1.prot, "up")
|
||||||
|
self.assertEqual(r1.description, "C2930L100-EQ2")
|
||||||
|
self.assertEqual(by_name["xgei-1/1/0/4"].bw_bps, 10_000_000_000)
|
||||||
|
self.assertEqual(by_name["cgei-1/1/0/33"].bw_bps, 100_000_000_000)
|
||||||
|
self.assertEqual(by_name["xxvgei-1/1/0/11"].bw_bps, 25_000_000_000)
|
||||||
|
self.assertEqual(by_name["smartgroup1"].bw_bps, 0)
|
||||||
|
self.assertEqual(by_name["smartgroup10"].bw_bps, 1_000_000_000)
|
||||||
|
self.assertEqual(by_name["xgei-1/1/0/2"].phy, "down")
|
||||||
|
|
||||||
|
|
||||||
|
class DetailParserTests(unittest.TestCase):
|
||||||
|
def test_sample_detail(self):
|
||||||
|
d = parse_zte_interface_detail(SAMPLE_DETAIL)
|
||||||
|
self.assertEqual(d.ifname, "xgei-1/1/0/1")
|
||||||
|
self.assertEqual(d.bw_bps, 1_000_000_000)
|
||||||
|
self.assertEqual(d.rate_period_sec, 30)
|
||||||
|
self.assertEqual(d.in_bps, 824.0)
|
||||||
|
self.assertEqual(d.out_bps, 824.0)
|
||||||
|
self.assertEqual(d.in_util_pct, 0.0)
|
||||||
|
self.assertEqual(d.out_util_pct, 0.0)
|
||||||
|
self.assertEqual(d.description, "C2930L100-EQ2")
|
||||||
|
# Peak rates must not override Rate period values
|
||||||
|
self.assertNotEqual(d.in_bps, 3536.0)
|
||||||
|
|
||||||
|
|
||||||
|
class CommandsTests(unittest.TestCase):
|
||||||
|
def test_zte_matrix(self):
|
||||||
|
cmds = commands_for_vendor("ZTE", "zxros")
|
||||||
|
assert cmds is not None
|
||||||
|
self.assertEqual(cmds.brief, "show interface brief")
|
||||||
|
self.assertEqual(detail_command(cmds, "xgei-1/1/0/1"), "show interface xgei-1/1/0/1")
|
||||||
|
self.assertIsNone(commands_for_vendor("Cisco", "ios"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
11
web/WEB.md
11
web/WEB.md
|
|
@ -42,7 +42,7 @@ src/
|
||||||
| `/network/topology` | redirect → `/topology` | — |
|
| `/network/topology` | redirect → `/topology` | — |
|
||||||
| `/network/tasks/collect` | 采集任务 | `network` |
|
| `/network/tasks/collect` | 采集任务 | `network` |
|
||||||
| `/network/tasks/config-sync` | 配置同步 | `network` |
|
| `/network/tasks/config-sync` | 配置同步 | `network` |
|
||||||
| `/network/tasks/port-traffic` | 端口流量(占位) | `network` |
|
| `/network/tasks/port-traffic` | 端口流量监控(任务向导 + 大屏) | `network` |
|
||||||
| `/topology` | 拓扑管理 | `topology` |
|
| `/topology` | 拓扑管理 | `topology` |
|
||||||
| `/webcrt` | WebCRT 终端 | `webcrt` |
|
| `/webcrt` | WebCRT 终端 | `webcrt` |
|
||||||
| `/collect` | redirect → `/network/tasks/collect` | — |
|
| `/collect` | redirect → `/network/tasks/collect` | — |
|
||||||
|
|
@ -105,6 +105,15 @@ src/
|
||||||
- 进程启动宽限:`NETX_CONFIG_SYNC_STARTUP_GRACE_SEC`(默认 3600)仅约束**新建**自动周期,不影响续跑
|
- 进程启动宽限:`NETX_CONFIG_SYNC_STARTUP_GRACE_SEC`(默认 3600)仅约束**新建**自动周期,不影响续跑
|
||||||
- 前端:`/network/tasks/config-sync`(看板)+ `/network/configs`(查看)
|
- 前端:`/network/tasks/config-sync`(看板)+ `/network/configs`(查看)
|
||||||
|
|
||||||
|
## 端口流量监控
|
||||||
|
|
||||||
|
- API:`/v1/port-traffic/*`(任务 CRUD、discover/ports、samples、dashboard)
|
||||||
|
- 厂商:本期仅 ZTE(`show interface brief` / `show interface {if}`),解析 Rate period **bit/s**
|
||||||
|
- 调度:`NETX_PORT_TRAFFIC_SCHEDULER_ENABLED`(默认开),tick `NETX_PORT_TRAFFIC_SCHEDULER_TICK_SEC`(默认 15)
|
||||||
|
- 单飞:同一任务同时只允许一轮采集;崩溃启动清除 `collect_running`
|
||||||
|
- 保留:按任务 `retention_days` 清理过期 sample
|
||||||
|
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 监控大屏)
|
||||||
|
|
||||||
## WebCRT
|
## WebCRT
|
||||||
|
|
||||||
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
|
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
|
||||||
|
|
|
||||||
9
web/package-lock.json
generated
9
web/package-lock.json
generated
|
|
@ -14,7 +14,8 @@
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router-dom": "^7.14.2"
|
"react-router-dom": "^7.14.2",
|
||||||
|
"uplot": "^1.6.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|
@ -2889,6 +2890,12 @@
|
||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uplot": {
|
||||||
|
"version": "1.6.32",
|
||||||
|
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
|
||||||
|
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/uri-js": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,8 @@
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router-dom": "^7.14.2"
|
"react-router-dom": "^7.14.2",
|
||||||
|
"uplot": "^1.6.32"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { NetworkLayout } from "./pages/network/NetworkLayout";
|
||||||
import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage";
|
import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage";
|
||||||
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
|
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
|
||||||
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
|
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
|
||||||
import { NetworkPlaceholderPage } from "./pages/network/NetworkPlaceholderPage";
|
import { PortTrafficPage } from "./pages/network/PortTrafficPage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { UsersPage } from "./pages/UsersPage";
|
import { UsersPage } from "./pages/UsersPage";
|
||||||
import { AuditPage } from "./pages/AuditPage";
|
import { AuditPage } from "./pages/AuditPage";
|
||||||
|
|
@ -95,7 +95,7 @@ function ProtectedApp() {
|
||||||
<Route path="webcrt" element={<NetworkWebcrtRedirect />} />
|
<Route path="webcrt" element={<NetworkWebcrtRedirect />} />
|
||||||
<Route path="tasks/collect" element={<CollectPage />} />
|
<Route path="tasks/collect" element={<CollectPage />} />
|
||||||
<Route path="tasks/config-sync" element={<ConfigSyncPage />} />
|
<Route path="tasks/config-sync" element={<ConfigSyncPage />} />
|
||||||
<Route path="tasks/port-traffic" element={<NetworkPlaceholderPage kind="port-traffic" />} />
|
<Route path="tasks/port-traffic" element={<PortTrafficPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />
|
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
|
|
||||||
|
|
@ -59,4 +59,10 @@ export const queryKeys = {
|
||||||
networkConfigs: (page: number, keyword: string, source: string, vendor: string) =>
|
networkConfigs: (page: number, keyword: string, source: string, vendor: string) =>
|
||||||
["networkConfigs", page, keyword, source, vendor] as const,
|
["networkConfigs", page, keyword, source, vendor] as const,
|
||||||
networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const,
|
networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const,
|
||||||
|
portTrafficDashboard: ["portTrafficDashboard"] as const,
|
||||||
|
portTrafficTasksAll: ["portTrafficTasks"] as const,
|
||||||
|
portTrafficTasks: (page: number) => ["portTrafficTasks", page] as const,
|
||||||
|
portTrafficTargets: (taskId: string) => ["portTrafficTargets", taskId] as const,
|
||||||
|
portTrafficSamples: (targetId: string, rangeHours: number) =>
|
||||||
|
["portTrafficSamples", targetId, rangeHours] as const,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,65 @@ const en = {
|
||||||
placeholder: {
|
placeholder: {
|
||||||
title: "Coming soon",
|
title: "Coming soon",
|
||||||
body: "This capability is not available yet.",
|
body: "This capability is not available yet.",
|
||||||
portTrafficTitle: "Port traffic monitoring",
|
},
|
||||||
portTrafficBody: "Planned: collect per-port traffic on a schedule and show trends. Suggested path:",
|
},
|
||||||
portTrafficStep1: "Task definition: NE/port scope, interval, retention",
|
portTraffic: {
|
||||||
portTrafficStep2: "Collection: prefer CLI (display/show interface); SNMP later at scale",
|
title: "Port traffic",
|
||||||
portTrafficStep3: "Storage: time series or hourly rollups (in/out bps, errors)",
|
create: "New task",
|
||||||
portTrafficStep4: "UI: task list + per-port trend charts",
|
backList: "Back to list",
|
||||||
|
created: "Task created",
|
||||||
|
createFailed: "Create failed",
|
||||||
|
started: "Started",
|
||||||
|
paused: "Paused",
|
||||||
|
stopped: "Stopped",
|
||||||
|
deleted: "Deleted",
|
||||||
|
confirmDelete: "Delete this task and its samples?",
|
||||||
|
empty: "No monitoring tasks yet. Create one to start.",
|
||||||
|
wall: "Ops wall",
|
||||||
|
start: "Start",
|
||||||
|
pause: "Pause",
|
||||||
|
stop: "Stop",
|
||||||
|
delete: "Delete",
|
||||||
|
step1: "Params",
|
||||||
|
step2: "NEs",
|
||||||
|
step3: "Ports",
|
||||||
|
step4: "Confirm",
|
||||||
|
prev: "Back",
|
||||||
|
next: "Next",
|
||||||
|
fieldTitle: "Task name",
|
||||||
|
titlePh: "e.g. Access uplinks",
|
||||||
|
titleRequired: "Task name is required",
|
||||||
|
portsRequired: "Select at least one port",
|
||||||
|
interval: "Interval (sec)",
|
||||||
|
retention: "Retention (days)",
|
||||||
|
concurrency: "Concurrency",
|
||||||
|
startNow: "Start after create",
|
||||||
|
neKeywordPh: "Name / IP / vendor",
|
||||||
|
selectedNe: "{{count}} NE(s) selected",
|
||||||
|
selectedPorts: "{{count}} port(s) selected",
|
||||||
|
pickNeFirst: "Select NEs in the previous step first",
|
||||||
|
discover: "Discover ports",
|
||||||
|
confirmCreate: "Create task",
|
||||||
|
wallTask: "Task",
|
||||||
|
wallPort: "Interface",
|
||||||
|
pickPort: "Select interface",
|
||||||
|
range: "Range",
|
||||||
|
kpi: {
|
||||||
|
tasks: "Tasks",
|
||||||
|
running: "Running",
|
||||||
|
ports: "Ports",
|
||||||
|
samples24h: "Samples 24h",
|
||||||
|
},
|
||||||
|
col: {
|
||||||
|
title: "Task",
|
||||||
|
status: "Status",
|
||||||
|
ports: "Ports",
|
||||||
|
interval: "Interval",
|
||||||
|
last: "Last collect",
|
||||||
|
actions: "Actions",
|
||||||
|
source: "Source",
|
||||||
|
name: "Device",
|
||||||
|
vendor: "Vendor",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
configSync: {
|
configSync: {
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,65 @@ const zh = {
|
||||||
placeholder: {
|
placeholder: {
|
||||||
title: "功能规划中",
|
title: "功能规划中",
|
||||||
body: "该能力尚未开放。",
|
body: "该能力尚未开放。",
|
||||||
portTrafficTitle: "端口流量监控",
|
},
|
||||||
portTrafficBody: "规划中:按任务采集设备端口流量并展示趋势。建议路径如下。",
|
},
|
||||||
portTrafficStep1: "任务定义:选择网元/端口范围、采集周期与保留策略",
|
portTraffic: {
|
||||||
portTrafficStep2: "采集:优先 CLI(display/show interface),规模上来后再 SNMP",
|
title: "端口流量监控",
|
||||||
portTrafficStep3: "存储:端口 in/out bps、error 等时序或按小时汇总",
|
create: "新建任务",
|
||||||
portTrafficStep4: "界面:任务列表 + 单端口趋势图",
|
backList: "返回列表",
|
||||||
|
created: "任务已创建",
|
||||||
|
createFailed: "创建失败",
|
||||||
|
started: "已启动",
|
||||||
|
paused: "已暂停",
|
||||||
|
stopped: "已停止",
|
||||||
|
deleted: "已删除",
|
||||||
|
confirmDelete: "删除该任务及其采样数据?",
|
||||||
|
empty: "暂无监控任务。请先创建任务。",
|
||||||
|
wall: "监控大屏",
|
||||||
|
start: "启动",
|
||||||
|
pause: "暂停",
|
||||||
|
stop: "停止",
|
||||||
|
delete: "删除",
|
||||||
|
step1: "参数",
|
||||||
|
step2: "选网元",
|
||||||
|
step3: "选端口",
|
||||||
|
step4: "确认",
|
||||||
|
prev: "上一步",
|
||||||
|
next: "下一步",
|
||||||
|
fieldTitle: "任务名称",
|
||||||
|
titlePh: "例如:接入侧上联",
|
||||||
|
titleRequired: "请填写任务名称",
|
||||||
|
portsRequired: "请至少选择一个端口",
|
||||||
|
interval: "周期(秒)",
|
||||||
|
retention: "保留(天)",
|
||||||
|
concurrency: "并发",
|
||||||
|
startNow: "创建后立即启动",
|
||||||
|
neKeywordPh: "名称 / IP / 厂商",
|
||||||
|
selectedNe: "已选 {{count}} 台网元",
|
||||||
|
selectedPorts: "已选 {{count}} 个端口",
|
||||||
|
pickNeFirst: "请先在上一步选择网元",
|
||||||
|
discover: "拉取端口",
|
||||||
|
confirmCreate: "创建任务",
|
||||||
|
wallTask: "任务",
|
||||||
|
wallPort: "接口",
|
||||||
|
pickPort: "选择接口",
|
||||||
|
range: "时间范围",
|
||||||
|
kpi: {
|
||||||
|
tasks: "任务数",
|
||||||
|
running: "运行中",
|
||||||
|
ports: "监控端口",
|
||||||
|
samples24h: "24h 样点",
|
||||||
|
},
|
||||||
|
col: {
|
||||||
|
title: "任务",
|
||||||
|
status: "状态",
|
||||||
|
ports: "端口",
|
||||||
|
interval: "周期",
|
||||||
|
last: "最近采集",
|
||||||
|
actions: "操作",
|
||||||
|
source: "来源",
|
||||||
|
name: "设备",
|
||||||
|
vendor: "厂商",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
configSync: {
|
configSync: {
|
||||||
|
|
|
||||||
|
|
@ -2918,3 +2918,196 @@ pre {
|
||||||
color: #475569;
|
color: #475569;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Port traffic monitoring */
|
||||||
|
.pt-wizard__steps {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wizard__step {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wizard__step.is-active {
|
||||||
|
border-color: #0f766e;
|
||||||
|
background: #ccfbf1;
|
||||||
|
color: #115e59;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wizard__ne-block {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wizard__confirm-list {
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall-page__filters {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
gap: 12px 18px;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall-page__filters label {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall-page__filters select {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall {
|
||||||
|
--pt-bg: #0b1220;
|
||||||
|
--pt-panel: #111827;
|
||||||
|
--pt-line: rgba(148, 163, 184, 0.22);
|
||||||
|
--pt-text: #e2e8f0;
|
||||||
|
--pt-muted: #94a3b8;
|
||||||
|
--pt-in: #2dd4bf;
|
||||||
|
--pt-out: #f59e0b;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(11, 18, 32, 0.98)),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent,
|
||||||
|
transparent 23px,
|
||||||
|
rgba(148, 163, 184, 0.05) 24px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent,
|
||||||
|
transparent 23px,
|
||||||
|
rgba(148, 163, 184, 0.05) 24px
|
||||||
|
);
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px 12px;
|
||||||
|
color: var(--pt-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__ne {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--pt-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__if {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__range {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--pt-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__kpi {
|
||||||
|
background: rgba(17, 24, 39, 0.72);
|
||||||
|
border: 1px solid var(--pt-line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--pt-muted);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__kpi-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 650;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__kpi--in .pt-wall__kpi-value {
|
||||||
|
color: var(--pt-in);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__kpi--out .pt-wall__kpi-value {
|
||||||
|
color: var(--pt-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__kpi-unit,
|
||||||
|
.pt-wall__kpi-sep {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--pt-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__chart {
|
||||||
|
min-height: 240px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__chart .uplot,
|
||||||
|
.pt-wall__chart .u-wrap {
|
||||||
|
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__chart .u-legend {
|
||||||
|
color: var(--pt-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 240px;
|
||||||
|
color: var(--pt-muted);
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.pt-wall__kpis {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,13 @@
|
||||||
import { useI18n } from "../../i18n";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
kind: "port-traffic";
|
kind?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NetworkPlaceholderPage({ kind }: Props) {
|
/** Generic placeholder for unfinished network sub-pages. */
|
||||||
const { t } = useI18n();
|
export function NetworkPlaceholderPage(_props: Props) {
|
||||||
const titleKey = kind === "port-traffic" ? "network.placeholder.portTrafficTitle" : "network.placeholder.title";
|
|
||||||
const bodyKey = kind === "port-traffic" ? "network.placeholder.portTrafficBody" : "network.placeholder.body";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel network-placeholder">
|
<section className="panel network-placeholder">
|
||||||
<h2>{t(titleKey)}</h2>
|
<h2>Coming soon</h2>
|
||||||
<p className="muted">{t(bodyKey)}</p>
|
<p className="muted">This capability is not available yet.</p>
|
||||||
{kind === "port-traffic" ? (
|
|
||||||
<ul className="network-placeholder__list">
|
|
||||||
<li>{t("network.placeholder.portTrafficStep1")}</li>
|
|
||||||
<li>{t("network.placeholder.portTrafficStep2")}</li>
|
|
||||||
<li>{t("network.placeholder.portTrafficStep3")}</li>
|
|
||||||
<li>{t("network.placeholder.portTrafficStep4")}</li>
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
645
web/src/pages/network/PortTrafficPage.tsx
Normal file
645
web/src/pages/network/PortTrafficPage.tsx
Normal file
|
|
@ -0,0 +1,645 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createPortTrafficTask,
|
||||||
|
deletePortTrafficTask,
|
||||||
|
discoverPortTrafficPorts,
|
||||||
|
fetchCliTargets,
|
||||||
|
fetchPortTrafficDashboard,
|
||||||
|
fetchPortTrafficSamples,
|
||||||
|
fetchPortTrafficTargets,
|
||||||
|
fetchPortTrafficTasks,
|
||||||
|
pausePortTrafficTask,
|
||||||
|
startPortTrafficTask,
|
||||||
|
stopPortTrafficTask,
|
||||||
|
} from "../../services/api";
|
||||||
|
import { queryKeys } from "../../constants/queryKeys";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import { useToast } from "../../hooks/useToast";
|
||||||
|
import type { CliTargetItem, PortTrafficDiscoverPort, PortTrafficTargetIn } from "../../types";
|
||||||
|
import { pageCount } from "../../utils/display";
|
||||||
|
import { formatSystemTime } from "../../utils/time";
|
||||||
|
import { PortTrafficWall } from "./PortTrafficWall";
|
||||||
|
|
||||||
|
const POLL_MS = 5000;
|
||||||
|
const TARGET_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
type PortPick = PortTrafficTargetIn & { key: string };
|
||||||
|
|
||||||
|
function neKey(source: string, id: string) {
|
||||||
|
return `${source}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function portKey(source: string, id: string, ifname: string) {
|
||||||
|
return `${source}:${id}:${ifname}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBw(bps: number) {
|
||||||
|
if (!bps) return "—";
|
||||||
|
if (bps >= 1e9) return `${bps / 1e9}G`;
|
||||||
|
if (bps >= 1e6) return `${bps / 1e6}M`;
|
||||||
|
return `${bps}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortTrafficPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { showOk, showError } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [view, setView] = useState<"list" | "wizard" | "wall">("list");
|
||||||
|
const [taskPage, setTaskPage] = useState(1);
|
||||||
|
const [wallTaskId, setWallTaskId] = useState("");
|
||||||
|
const [wallTargetId, setWallTargetId] = useState("");
|
||||||
|
const [rangeHours, setRangeHours] = useState(1);
|
||||||
|
|
||||||
|
// Wizard state
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [intervalSec, setIntervalSec] = useState(60);
|
||||||
|
const [retentionDays, setRetentionDays] = useState(7);
|
||||||
|
const [concurrency, setConcurrency] = useState(5);
|
||||||
|
const [startNow, setStartNow] = useState(true);
|
||||||
|
const [neKeyword, setNeKeyword] = useState("");
|
||||||
|
const [nePage, setNePage] = useState(1);
|
||||||
|
const [selectedNes, setSelectedNes] = useState<Record<string, CliTargetItem>>({});
|
||||||
|
const [portsByNe, setPortsByNe] = useState<
|
||||||
|
Record<string, { loading?: boolean; error?: string; ports: PortTrafficDiscoverPort[]; meta?: { ne_name: string; ne_ip: string; vendor: string } }>
|
||||||
|
>({});
|
||||||
|
const [pickedPorts, setPickedPorts] = useState<Record<string, PortPick>>({});
|
||||||
|
|
||||||
|
const dashQuery = useQuery({
|
||||||
|
queryKey: queryKeys.portTrafficDashboard,
|
||||||
|
queryFn: fetchPortTrafficDashboard,
|
||||||
|
staleTime: 2000,
|
||||||
|
refetchInterval: POLL_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tasksQuery = useQuery({
|
||||||
|
queryKey: queryKeys.portTrafficTasks(taskPage),
|
||||||
|
queryFn: () => fetchPortTrafficTasks({ page: taskPage, pageSize: 10 }),
|
||||||
|
staleTime: 2000,
|
||||||
|
refetchInterval: POLL_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wallTasksQuery = useQuery({
|
||||||
|
queryKey: queryKeys.portTrafficTasks(0),
|
||||||
|
queryFn: () => fetchPortTrafficTasks({ page: 1, pageSize: 100 }),
|
||||||
|
enabled: view === "wall",
|
||||||
|
staleTime: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nesQuery = useQuery({
|
||||||
|
queryKey: queryKeys.cliTargets(neKeyword, nePage, TARGET_PAGE_SIZE),
|
||||||
|
queryFn: () =>
|
||||||
|
fetchCliTargets({ source: "all", keyword: neKeyword, page: nePage, pageSize: TARGET_PAGE_SIZE }),
|
||||||
|
enabled: view === "wizard" && step === 2,
|
||||||
|
staleTime: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wallTargetsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.portTrafficTargets(wallTaskId),
|
||||||
|
queryFn: () => fetchPortTrafficTargets(wallTaskId),
|
||||||
|
enabled: view === "wall" && Boolean(wallTaskId),
|
||||||
|
staleTime: 3000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const samplesQuery = useQuery({
|
||||||
|
queryKey: queryKeys.portTrafficSamples(wallTargetId, rangeHours),
|
||||||
|
queryFn: () => {
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date(to.getTime() - rangeHours * 3600 * 1000);
|
||||||
|
return fetchPortTrafficSamples({
|
||||||
|
targetId: wallTargetId,
|
||||||
|
from: from.toISOString(),
|
||||||
|
to: to.toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: view === "wall" && Boolean(wallTargetId),
|
||||||
|
staleTime: 2000,
|
||||||
|
refetchInterval: POLL_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateAll = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTasksAll });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficDashboard });
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMut = useMutation({
|
||||||
|
mutationFn: createPortTrafficTask,
|
||||||
|
onSuccess: () => {
|
||||||
|
showOk(t("portTraffic.created"));
|
||||||
|
invalidateAll();
|
||||||
|
resetWizard();
|
||||||
|
setView("list");
|
||||||
|
},
|
||||||
|
onError: (e: Error) => showError(e.message || t("portTraffic.createFailed")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const startMut = useMutation({
|
||||||
|
mutationFn: startPortTrafficTask,
|
||||||
|
onSuccess: () => {
|
||||||
|
showOk(t("portTraffic.started"));
|
||||||
|
invalidateAll();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => showError(e.message),
|
||||||
|
});
|
||||||
|
const pauseMut = useMutation({
|
||||||
|
mutationFn: pausePortTrafficTask,
|
||||||
|
onSuccess: () => {
|
||||||
|
showOk(t("portTraffic.paused"));
|
||||||
|
invalidateAll();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => showError(e.message),
|
||||||
|
});
|
||||||
|
const stopMut = useMutation({
|
||||||
|
mutationFn: stopPortTrafficTask,
|
||||||
|
onSuccess: () => {
|
||||||
|
showOk(t("portTraffic.stopped"));
|
||||||
|
invalidateAll();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => showError(e.message),
|
||||||
|
});
|
||||||
|
const deleteMut = useMutation({
|
||||||
|
mutationFn: deletePortTrafficTask,
|
||||||
|
onSuccess: () => {
|
||||||
|
showOk(t("portTraffic.deleted"));
|
||||||
|
invalidateAll();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => showError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetWizard = () => {
|
||||||
|
setStep(1);
|
||||||
|
setTitle("");
|
||||||
|
setIntervalSec(60);
|
||||||
|
setRetentionDays(7);
|
||||||
|
setConcurrency(5);
|
||||||
|
setStartNow(true);
|
||||||
|
setSelectedNes({});
|
||||||
|
setPortsByNe({});
|
||||||
|
setPickedPorts({});
|
||||||
|
setNeKeyword("");
|
||||||
|
setNePage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openWizard = () => {
|
||||||
|
resetWizard();
|
||||||
|
setView("wizard");
|
||||||
|
};
|
||||||
|
|
||||||
|
const openWall = (taskId: string) => {
|
||||||
|
setWallTaskId(taskId);
|
||||||
|
setWallTargetId("");
|
||||||
|
setView("wall");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleNe = (row: CliTargetItem) => {
|
||||||
|
const k = neKey(row.source, row.id);
|
||||||
|
setSelectedNes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (next[k]) delete next[k];
|
||||||
|
else next[k] = row;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const discoverNe = async (row: CliTargetItem) => {
|
||||||
|
const k = neKey(row.source, row.id);
|
||||||
|
setPortsByNe((prev) => ({ ...prev, [k]: { loading: true, ports: prev[k]?.ports || [] } }));
|
||||||
|
try {
|
||||||
|
const source = row.source === "ume" ? "ume" : "managed";
|
||||||
|
const res = await discoverPortTrafficPorts({ source, id: row.id });
|
||||||
|
setPortsByNe((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[k]: {
|
||||||
|
ports: res.ports,
|
||||||
|
meta: { ne_name: res.ne_name || row.name, ne_ip: res.ne_ip || row.ip_address, vendor: res.vendor || row.vendor || "" },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : "discover_failed";
|
||||||
|
setPortsByNe((prev) => ({ ...prev, [k]: { ports: [], error: msg } }));
|
||||||
|
showError(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePort = (ne: CliTargetItem, port: PortTrafficDiscoverPort, meta?: { ne_name: string; ne_ip: string; vendor: string }) => {
|
||||||
|
const source = (ne.source === "ume" ? "ume" : "managed") as "managed" | "ume";
|
||||||
|
const k = portKey(source, ne.id, port.ifname);
|
||||||
|
setPickedPorts((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
if (next[k]) {
|
||||||
|
delete next[k];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
next[k] = {
|
||||||
|
key: k,
|
||||||
|
source,
|
||||||
|
target_id: ne.id,
|
||||||
|
ne_name: meta?.ne_name || ne.name,
|
||||||
|
ne_ip: meta?.ne_ip || ne.ip_address,
|
||||||
|
vendor: meta?.vendor || ne.vendor || "",
|
||||||
|
ifname: port.ifname,
|
||||||
|
if_description: port.description,
|
||||||
|
bw_bps: port.bw_bps,
|
||||||
|
};
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedNeList = useMemo(() => Object.values(selectedNes), [selectedNes]);
|
||||||
|
const pickedList = useMemo(() => Object.values(pickedPorts), [pickedPorts]);
|
||||||
|
const wallTargets = wallTargetsQuery.data?.items || [];
|
||||||
|
const selectedWallTarget = wallTargets.find((x) => x.id === wallTargetId) || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (view !== "wall" || wallTargetId || !wallTargets.length) return;
|
||||||
|
setWallTargetId(wallTargets[0].id);
|
||||||
|
}, [view, wallTargetId, wallTargets]);
|
||||||
|
|
||||||
|
const submitWizard = () => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
showError(t("portTraffic.titleRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!pickedList.length) {
|
||||||
|
showError(t("portTraffic.portsRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createMut.mutate({
|
||||||
|
title: title.trim(),
|
||||||
|
interval_sec: intervalSec,
|
||||||
|
retention_days: retentionDays,
|
||||||
|
concurrency,
|
||||||
|
start_now: startNow,
|
||||||
|
targets: pickedList.map(({ key: _k, ...rest }) => rest),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const dash = dashQuery.data;
|
||||||
|
const tasks = tasksQuery.data?.items || [];
|
||||||
|
const taskPages = pageCount(tasksQuery.data?.total || 0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel__toolbar">
|
||||||
|
<h2>{t("portTraffic.title")}</h2>
|
||||||
|
<div className="btn-row">
|
||||||
|
{view !== "list" ? (
|
||||||
|
<button type="button" onClick={() => setView("list")}>
|
||||||
|
{t("portTraffic.backList")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="btn-primary" onClick={openWizard}>
|
||||||
|
{t("portTraffic.create")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === "list" ? (
|
||||||
|
<>
|
||||||
|
<div className="stat-grid" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="muted">{t("portTraffic.kpi.tasks")}</div>
|
||||||
|
<div>{dash?.task_count ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="muted">{t("portTraffic.kpi.running")}</div>
|
||||||
|
<div>{dash?.running_task_count ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="muted">{t("portTraffic.kpi.ports")}</div>
|
||||||
|
<div>{dash?.active_target_count ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="stat-card">
|
||||||
|
<div className="muted">{t("portTraffic.kpi.samples24h")}</div>
|
||||||
|
<div>{dash?.sample_count_24h ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("portTraffic.col.title")}</th>
|
||||||
|
<th>{t("portTraffic.col.status")}</th>
|
||||||
|
<th>{t("portTraffic.col.ports")}</th>
|
||||||
|
<th>{t("portTraffic.col.interval")}</th>
|
||||||
|
<th>{t("portTraffic.col.last")}</th>
|
||||||
|
<th>{t("portTraffic.col.actions")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{!tasks.length ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="muted">
|
||||||
|
{t("portTraffic.empty")}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
tasks.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td>{row.title}</td>
|
||||||
|
<td>
|
||||||
|
{row.status}
|
||||||
|
{row.collect_running ? " · collecting" : ""}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{row.active_target_count}/{row.target_count}
|
||||||
|
</td>
|
||||||
|
<td>{row.interval_sec}s</td>
|
||||||
|
<td>{formatSystemTime(row.last_collect_ended_at) || "—"}</td>
|
||||||
|
<td>
|
||||||
|
<div className="btn-row">
|
||||||
|
<button type="button" onClick={() => openWall(row.id)}>
|
||||||
|
{t("portTraffic.wall")}
|
||||||
|
</button>
|
||||||
|
{row.status !== "running" ? (
|
||||||
|
<button type="button" disabled={startMut.isPending} onClick={() => startMut.mutate(row.id)}>
|
||||||
|
{t("portTraffic.start")}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" disabled={pauseMut.isPending} onClick={() => pauseMut.mutate(row.id)}>
|
||||||
|
{t("portTraffic.pause")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{row.status !== "stopped" ? (
|
||||||
|
<button type="button" disabled={stopMut.isPending} onClick={() => stopMut.mutate(row.id)}>
|
||||||
|
{t("portTraffic.stop")}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={deleteMut.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(t("portTraffic.confirmDelete"))) deleteMut.mutate(row.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("portTraffic.delete")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="pager">
|
||||||
|
<button type="button" className="pager__btn" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span className="muted">
|
||||||
|
{taskPage}/{Math.max(1, taskPages)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pager__btn"
|
||||||
|
disabled={taskPage >= taskPages}
|
||||||
|
onClick={() => setTaskPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{view === "wizard" ? (
|
||||||
|
<div className="pt-wizard">
|
||||||
|
<div className="pt-wizard__steps">
|
||||||
|
{[1, 2, 3, 4].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
className={`pt-wizard__step${step === n ? " is-active" : ""}`}
|
||||||
|
onClick={() => setStep(n)}
|
||||||
|
>
|
||||||
|
{n}. {t(`portTraffic.step${n}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 ? (
|
||||||
|
<div className="config-sync-policy-row">
|
||||||
|
<label className="config-sync-policy-field">
|
||||||
|
{t("portTraffic.fieldTitle")}
|
||||||
|
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t("portTraffic.titlePh")} />
|
||||||
|
</label>
|
||||||
|
<label className="config-sync-policy-field">
|
||||||
|
{t("portTraffic.interval")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={15}
|
||||||
|
max={3600}
|
||||||
|
value={intervalSec}
|
||||||
|
onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="config-sync-policy-field">
|
||||||
|
{t("portTraffic.retention")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={90}
|
||||||
|
value={retentionDays}
|
||||||
|
onChange={(e) => setRetentionDays(Number(e.target.value) || 7)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="config-sync-policy-field">
|
||||||
|
{t("portTraffic.concurrency")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={concurrency}
|
||||||
|
onChange={(e) => setConcurrency(Number(e.target.value) || 5)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="config-sync-policy-check">
|
||||||
|
<input type="checkbox" checked={startNow} onChange={(e) => setStartNow(e.target.checked)} />
|
||||||
|
{t("portTraffic.startNow")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 2 ? (
|
||||||
|
<>
|
||||||
|
<div className="filter-inline">
|
||||||
|
<input
|
||||||
|
value={neKeyword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNeKeyword(e.target.value);
|
||||||
|
setNePage(1);
|
||||||
|
}}
|
||||||
|
placeholder={t("portTraffic.neKeywordPh")}
|
||||||
|
/>
|
||||||
|
<span className="muted">{t("portTraffic.selectedNe", { count: selectedNeList.length })}</span>
|
||||||
|
</div>
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th>{t("portTraffic.col.source")}</th>
|
||||||
|
<th>{t("portTraffic.col.name")}</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>{t("portTraffic.col.vendor")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(nesQuery.data?.items || []).map((row) => {
|
||||||
|
const k = neKey(row.source, row.id);
|
||||||
|
return (
|
||||||
|
<tr key={k}>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" checked={Boolean(selectedNes[k])} onChange={() => toggleNe(row)} />
|
||||||
|
</td>
|
||||||
|
<td>{row.source}</td>
|
||||||
|
<td>{row.name}</td>
|
||||||
|
<td>{row.ip_address}</td>
|
||||||
|
<td>{row.vendor || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 3 ? (
|
||||||
|
<div className="pt-wizard__ports">
|
||||||
|
{!selectedNeList.length ? <p>{t("portTraffic.pickNeFirst")}</p> : null}
|
||||||
|
{selectedNeList.map((ne) => {
|
||||||
|
const k = neKey(ne.source, ne.id);
|
||||||
|
const bucket = portsByNe[k];
|
||||||
|
return (
|
||||||
|
<div key={k} className="pt-wizard__ne-block">
|
||||||
|
<div className="btn-row" style={{ marginBottom: 8 }}>
|
||||||
|
<strong>
|
||||||
|
{ne.name} ({ne.ip_address})
|
||||||
|
</strong>
|
||||||
|
<button type="button" disabled={bucket?.loading} onClick={() => void discoverNe(ne)}>
|
||||||
|
{bucket?.loading ? "…" : t("portTraffic.discover")}
|
||||||
|
</button>
|
||||||
|
{bucket?.error ? <span className="muted">{bucket.error}</span> : null}
|
||||||
|
</div>
|
||||||
|
{bucket?.ports?.length ? (
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th />
|
||||||
|
<th>Interface</th>
|
||||||
|
<th>BW</th>
|
||||||
|
<th>Admin/Phy/Prot</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bucket.ports.map((p) => {
|
||||||
|
const pk = portKey(ne.source === "ume" ? "ume" : "managed", ne.id, p.ifname);
|
||||||
|
return (
|
||||||
|
<tr key={pk}>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(pickedPorts[pk])}
|
||||||
|
onChange={() => togglePort(ne, p, bucket.meta)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>{p.ifname}</td>
|
||||||
|
<td>{p.bw_raw || formatBw(p.bw_bps)}</td>
|
||||||
|
<td>
|
||||||
|
{p.admin}/{p.phy}/{p.prot}
|
||||||
|
</td>
|
||||||
|
<td>{p.description || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<p>{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{step === 4 ? (
|
||||||
|
<div className="pt-wizard__confirm">
|
||||||
|
<p>
|
||||||
|
<strong>{title || "—"}</strong> · {intervalSec}s · {retentionDays}d · ×{concurrency}
|
||||||
|
</p>
|
||||||
|
<p>{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</p>
|
||||||
|
<ul className="pt-wizard__confirm-list">
|
||||||
|
{pickedList.slice(0, 40).map((p) => (
|
||||||
|
<li key={p.key}>
|
||||||
|
{p.ne_name} / {p.ifname} ({formatBw(p.bw_bps || 0)})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{pickedList.length > 40 ? <li>… +{pickedList.length - 40}</li> : null}
|
||||||
|
</ul>
|
||||||
|
<button type="button" className="btn-primary" disabled={createMut.isPending} onClick={submitWizard}>
|
||||||
|
{t("portTraffic.confirmCreate")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="btn-row" style={{ marginTop: 12 }}>
|
||||||
|
<button type="button" disabled={step <= 1} onClick={() => setStep((s) => s - 1)}>
|
||||||
|
{t("portTraffic.prev")}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={step >= 4} onClick={() => setStep((s) => s + 1)}>
|
||||||
|
{t("portTraffic.next")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{view === "wall" ? (
|
||||||
|
<div className="pt-wall-page">
|
||||||
|
<div className="filter-inline pt-wall-page__filters">
|
||||||
|
<label>
|
||||||
|
{t("portTraffic.wallTask")}
|
||||||
|
<select
|
||||||
|
value={wallTaskId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setWallTaskId(e.target.value);
|
||||||
|
setWallTargetId("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(wallTasksQuery.data?.items || tasksQuery.data?.items || []).map((task) => (
|
||||||
|
<option key={task.id} value={task.id}>
|
||||||
|
{task.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("portTraffic.wallPort")}
|
||||||
|
<select value={wallTargetId} onChange={(e) => setWallTargetId(e.target.value)}>
|
||||||
|
<option value="">{t("portTraffic.pickPort")}</option>
|
||||||
|
{wallTargets.map((tg) => (
|
||||||
|
<option key={tg.id} value={tg.id}>
|
||||||
|
{tg.ne_name} / {tg.ifname}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("portTraffic.range")}
|
||||||
|
<select value={rangeHours} onChange={(e) => setRangeHours(Number(e.target.value))}>
|
||||||
|
<option value={1}>1h</option>
|
||||||
|
<option value={6}>6h</option>
|
||||||
|
<option value={24}>24h</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<PortTrafficWall
|
||||||
|
target={selectedWallTarget}
|
||||||
|
points={samplesQuery.data?.points || []}
|
||||||
|
rangeLabel={`${rangeHours}h`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
web/src/pages/network/PortTrafficWall.tsx
Normal file
157
web/src/pages/network/PortTrafficWall.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import uPlot from "uplot";
|
||||||
|
import "uplot/dist/uPlot.min.css";
|
||||||
|
import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types";
|
||||||
|
|
||||||
|
function formatBps(n: number): string {
|
||||||
|
const v = Math.abs(n);
|
||||||
|
if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`;
|
||||||
|
if (v >= 1e6) return `${(n / 1e6).toFixed(2)} M`;
|
||||||
|
if (v >= 1e3) return `${(n / 1e3).toFixed(1)} K`;
|
||||||
|
return `${n.toFixed(0)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBwLabel(bps: number): string {
|
||||||
|
if (!bps) return "—";
|
||||||
|
return `${formatBps(bps)}bit/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
target: PortTrafficTarget | null;
|
||||||
|
points: PortTrafficSamplePoint[];
|
||||||
|
rangeLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PortTrafficWall({ target, points, rangeLabel }: Props) {
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const plotRef = useRef<uPlot | null>(null);
|
||||||
|
|
||||||
|
const latest = points.length ? points[points.length - 1] : null;
|
||||||
|
const kpi = useMemo(() => {
|
||||||
|
const bw = latest?.bw_bps || target?.bw_bps || 0;
|
||||||
|
return {
|
||||||
|
bw,
|
||||||
|
inBps: latest?.in_bps ?? 0,
|
||||||
|
outBps: latest?.out_bps ?? 0,
|
||||||
|
inUtil: latest?.in_util_pct ?? 0,
|
||||||
|
outUtil: latest?.out_util_pct ?? 0,
|
||||||
|
};
|
||||||
|
}, [latest, target]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = rootRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const xs = points.map((p) => Math.floor(new Date(p.ts).getTime() / 1000));
|
||||||
|
const inSeries = points.map((p) => p.in_bps);
|
||||||
|
const outSeries = points.map((p) => p.out_bps);
|
||||||
|
|
||||||
|
const opts: uPlot.Options = {
|
||||||
|
width: Math.max(320, el.clientWidth || 800),
|
||||||
|
height: Math.max(220, Math.min(420, Math.floor((el.clientWidth || 800) * 0.32))),
|
||||||
|
series: [
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
label: "In",
|
||||||
|
stroke: "#2dd4bf",
|
||||||
|
width: 2,
|
||||||
|
points: { show: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Out",
|
||||||
|
stroke: "#f59e0b",
|
||||||
|
width: 2,
|
||||||
|
points: { show: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
axes: [
|
||||||
|
{
|
||||||
|
stroke: "#94a3b8",
|
||||||
|
grid: { stroke: "rgba(148,163,184,0.18)", width: 1 },
|
||||||
|
ticks: { stroke: "rgba(148,163,184,0.35)" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
stroke: "#94a3b8",
|
||||||
|
grid: { stroke: "rgba(148,163,184,0.12)", width: 1 },
|
||||||
|
ticks: { stroke: "rgba(148,163,184,0.35)" },
|
||||||
|
values: (_u, splits) => splits.map((v) => formatBps(v)),
|
||||||
|
size: 56,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scales: {
|
||||||
|
x: { time: true },
|
||||||
|
},
|
||||||
|
legend: { show: true },
|
||||||
|
cursor: {
|
||||||
|
drag: { x: true, y: false },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
plotRef.current?.destroy();
|
||||||
|
plotRef.current = null;
|
||||||
|
|
||||||
|
if (xs.length === 0) {
|
||||||
|
el.replaceChildren();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plotRef.current = new uPlot(opts, [xs, inSeries, outSeries], el);
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
if (!plotRef.current || !rootRef.current) return;
|
||||||
|
plotRef.current.setSize({
|
||||||
|
width: Math.max(320, rootRef.current.clientWidth),
|
||||||
|
height: Math.max(220, Math.min(420, Math.floor(rootRef.current.clientWidth * 0.32))),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
plotRef.current?.destroy();
|
||||||
|
plotRef.current = null;
|
||||||
|
};
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pt-wall">
|
||||||
|
<div className="pt-wall__head">
|
||||||
|
<div className="pt-wall__title">
|
||||||
|
<span className="pt-wall__ne">{target ? target.ne_name || target.ne_ip || "—" : "—"}</span>
|
||||||
|
<span className="pt-wall__if">{target?.ifname || "Select interface"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-wall__range">{rangeLabel}</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-wall__kpis">
|
||||||
|
<div className="pt-wall__kpi">
|
||||||
|
<div className="pt-wall__kpi-label">Bandwidth</div>
|
||||||
|
<div className="pt-wall__kpi-value">{formatBwLabel(kpi.bw)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-wall__kpi pt-wall__kpi--in">
|
||||||
|
<div className="pt-wall__kpi-label">In</div>
|
||||||
|
<div className="pt-wall__kpi-value">
|
||||||
|
{formatBps(kpi.inBps)}
|
||||||
|
<span className="pt-wall__kpi-unit">bit/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-wall__kpi pt-wall__kpi--out">
|
||||||
|
<div className="pt-wall__kpi-label">Out</div>
|
||||||
|
<div className="pt-wall__kpi-value">
|
||||||
|
{formatBps(kpi.outBps)}
|
||||||
|
<span className="pt-wall__kpi-unit">bit/s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-wall__kpi">
|
||||||
|
<div className="pt-wall__kpi-label">Util In / Out</div>
|
||||||
|
<div className="pt-wall__kpi-value">
|
||||||
|
{kpi.inUtil.toFixed(1)}%
|
||||||
|
<span className="pt-wall__kpi-sep">/</span>
|
||||||
|
{kpi.outUtil.toFixed(1)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-wall__chart" ref={rootRef}>
|
||||||
|
{!points.length ? <div className="pt-wall__empty">No samples in range</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,12 @@ import type {
|
||||||
ConfigSyncTask,
|
ConfigSyncTask,
|
||||||
NeConfigSnapshotDetail,
|
NeConfigSnapshotDetail,
|
||||||
NeConfigSnapshotMeta,
|
NeConfigSnapshotMeta,
|
||||||
|
PortTrafficDashboard,
|
||||||
|
PortTrafficDiscoverResponse,
|
||||||
|
PortTrafficSamples,
|
||||||
|
PortTrafficTarget,
|
||||||
|
PortTrafficTargetIn,
|
||||||
|
PortTrafficTask,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
export const AUTH_TOKEN_KEY = "netx_access_token";
|
export const AUTH_TOKEN_KEY = "netx_access_token";
|
||||||
|
|
@ -779,3 +785,56 @@ export const downloadNeConfigSnapshot = async (
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchPortTrafficDashboard = () =>
|
||||||
|
apiGet<PortTrafficDashboard>("/v1/port-traffic/dashboard");
|
||||||
|
|
||||||
|
export const fetchPortTrafficTasks = (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<{ total: number; page: number; page_size: number; items: PortTrafficTask[] }>(
|
||||||
|
`/v1/port-traffic/tasks?${p.toString()}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPortTrafficTask = (body: {
|
||||||
|
title: string;
|
||||||
|
interval_sec?: number;
|
||||||
|
retention_days?: number;
|
||||||
|
concurrency?: number;
|
||||||
|
targets?: PortTrafficTargetIn[];
|
||||||
|
start_now?: boolean;
|
||||||
|
}) => apiPost<PortTrafficTask>("/v1/port-traffic/tasks", body);
|
||||||
|
|
||||||
|
export const startPortTrafficTask = (taskId: string) =>
|
||||||
|
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/start`, {});
|
||||||
|
|
||||||
|
export const pausePortTrafficTask = (taskId: string) =>
|
||||||
|
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/pause`, {});
|
||||||
|
|
||||||
|
export const stopPortTrafficTask = (taskId: string) =>
|
||||||
|
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/stop`, {});
|
||||||
|
|
||||||
|
export const deletePortTrafficTask = (taskId: string) =>
|
||||||
|
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}`);
|
||||||
|
|
||||||
|
export const fetchPortTrafficTargets = (taskId: string) =>
|
||||||
|
apiGet<{ items: PortTrafficTarget[] }>(
|
||||||
|
`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/targets`,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const discoverPortTrafficPorts = (body: { source: "managed" | "ume"; id: string }) =>
|
||||||
|
apiPost<PortTrafficDiscoverResponse>("/v1/port-traffic/discover/ports", body);
|
||||||
|
|
||||||
|
export const fetchPortTrafficSamples = (params: {
|
||||||
|
targetId: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
}) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set("target_id", params.targetId);
|
||||||
|
if (params.from) p.set("from", params.from);
|
||||||
|
if (params.to) p.set("to", params.to);
|
||||||
|
return apiGet<PortTrafficSamples>(`/v1/port-traffic/samples?${p.toString()}`);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -507,3 +507,93 @@ export type NeConfigSnapshotDetail = NeConfigSnapshotMeta & {
|
||||||
config_alt_text: string;
|
config_alt_text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PortTrafficTask = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
interval_sec: number;
|
||||||
|
retention_days: number;
|
||||||
|
concurrency: number;
|
||||||
|
collect_running: boolean;
|
||||||
|
target_count: number;
|
||||||
|
active_target_count: number;
|
||||||
|
last_collect_started_at?: string | null;
|
||||||
|
last_collect_ended_at?: string | null;
|
||||||
|
last_error: string;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortTrafficTarget = {
|
||||||
|
id: string;
|
||||||
|
task_id: string;
|
||||||
|
source: string;
|
||||||
|
target_id: string;
|
||||||
|
ne_name: string;
|
||||||
|
ne_ip: string;
|
||||||
|
vendor: string;
|
||||||
|
ifname: string;
|
||||||
|
if_description: string;
|
||||||
|
bw_bps: number;
|
||||||
|
status: string;
|
||||||
|
last_error: string;
|
||||||
|
last_sample_at?: string | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortTrafficTargetIn = {
|
||||||
|
source: "managed" | "ume";
|
||||||
|
target_id: string;
|
||||||
|
ne_name?: string;
|
||||||
|
ne_ip?: string;
|
||||||
|
vendor?: string;
|
||||||
|
ifname: string;
|
||||||
|
if_description?: string;
|
||||||
|
bw_bps?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortTrafficDiscoverPort = {
|
||||||
|
ifname: string;
|
||||||
|
attribute: string;
|
||||||
|
mode: string;
|
||||||
|
bw_raw: string;
|
||||||
|
bw_bps: number;
|
||||||
|
admin: string;
|
||||||
|
phy: string;
|
||||||
|
prot: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortTrafficDiscoverResponse = {
|
||||||
|
source: string;
|
||||||
|
id: string;
|
||||||
|
ne_name: string;
|
||||||
|
ne_ip: string;
|
||||||
|
vendor: string;
|
||||||
|
vendor_key: string;
|
||||||
|
ports: PortTrafficDiscoverPort[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortTrafficSamplePoint = {
|
||||||
|
ts: string;
|
||||||
|
in_bps: number;
|
||||||
|
out_bps: number;
|
||||||
|
in_util_pct: number;
|
||||||
|
out_util_pct: number;
|
||||||
|
bw_bps: number;
|
||||||
|
rate_period_sec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortTrafficSamples = {
|
||||||
|
target: PortTrafficTarget;
|
||||||
|
points: PortTrafficSamplePoint[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PortTrafficDashboard = {
|
||||||
|
task_count: number;
|
||||||
|
running_task_count: number;
|
||||||
|
active_target_count: number;
|
||||||
|
sample_count_24h: number;
|
||||||
|
last_sample_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue