mirror of
https://github.com/hansjone/netx.git
synced 2026-10-13 07:20:44 +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
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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue