Move port traffic to device-centric monitoring with a dedicated wall and collect logs.

Split the ops wall to its own route with fullscreen charts, persist collect errors as device events, and polish wall legend layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 15:45:20 +08:00
parent 5079343b2a
commit 214bc56774
24 changed files with 3309 additions and 1357 deletions

View file

@ -595,17 +595,25 @@ class NeConfigHistory(Base):
task_id: Mapped[str] = mapped_column(String(64), default="")
class PortTrafficTask(Base):
"""Port traffic monitoring job definition."""
class PortTrafficDevice(Base):
"""Per-NE port traffic monitoring config (device-centric)."""
__tablename__ = "port_traffic_task"
__tablename__ = "port_traffic_device"
__table_args__ = (
UniqueConstraint("source", "ne_id", name="uq_port_traffic_device_ne"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
title: Mapped[str] = mapped_column(String(256), default="")
source: Mapped[str] = mapped_column(String(32), default="managed", index=True)
ne_id: Mapped[str] = mapped_column(String(128), default="", 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="")
note: Mapped[str] = mapped_column(String(256), default="") # optional remark
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|running|paused|stopped
interval_sec: Mapped[int] = mapped_column(Integer, default=60)
retention_days: Mapped[int] = mapped_column(Integer, default=7)
concurrency: Mapped[int] = mapped_column(Integer, default=5)
concurrency: Mapped[int] = mapped_column(Integer, default=1)
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)
@ -614,32 +622,41 @@ class PortTrafficTask(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Back-compat alias while callers migrate.
PortTrafficTask = PortTrafficDevice
class PortTrafficSeries(Base):
"""Logical port (business link) that survives physical NE/if replacement."""
__tablename__ = "port_traffic_series"
__table_args__ = (UniqueConstraint("task_id", "title", name="uq_port_traffic_series_title"),)
__table_args__ = (UniqueConstraint("device_id", "title", name="uq_port_traffic_series_title"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
task_id: Mapped[str] = mapped_column(String(64), index=True)
device_id: Mapped[str] = mapped_column(String(64), default="", index=True)
title: Mapped[str] = mapped_column(String(256), default="")
status: Mapped[str] = mapped_column(String(32), default="active", index=True) # active|disabled
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
@property
def task_id(self) -> str:
return str(self.device_id or "")
@task_id.setter
def task_id(self, value: str) -> None:
self.device_id = str(value or "")
class PortTrafficTarget(Base):
"""Monitored interface under a port traffic task."""
"""Monitored interface under a device monitoring config."""
__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)
device_id: Mapped[str] = mapped_column(String(64), default="", index=True)
series_id: Mapped[str] = mapped_column(String(64), default="", index=True)
source: Mapped[str] = mapped_column(String(32), default="managed", index=True)
target_id: Mapped[str] = mapped_column(String(128), index=True)
target_id: Mapped[str] = mapped_column(String(128), index=True) # NE id
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="")
@ -651,6 +668,14 @@ class PortTrafficTarget(Base):
last_sample_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
@property
def task_id(self) -> str:
return str(self.device_id or "")
@task_id.setter
def task_id(self, value: str) -> None:
self.device_id = str(value or "")
class PortTrafficSample(Base):
"""Time-series sample for a monitored interface."""
@ -670,3 +695,17 @@ class PortTrafficSample(Base):
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="")
class PortTrafficEvent(Base):
"""Collect / ops log line for a monitored device (and optional interface)."""
__tablename__ = "port_traffic_event"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
device_id: Mapped[str] = mapped_column(String(64), default="", index=True)
target_row_id: Mapped[str] = mapped_column(String(64), default="", index=True)
ifname: Mapped[str] = mapped_column(String(128), default="")
level: Mapped[str] = mapped_column(String(16), default="error", index=True) # info|warn|error
message: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)

View file

@ -1,4 +1,4 @@
"""Startup migration / backfill for port traffic logical series."""
"""Startup migration / backfill for port traffic device-centric model."""
from __future__ import annotations
@ -8,49 +8,274 @@ from uuid import uuid4
from sqlalchemy import text
from sqlalchemy.orm import Session
from .models import PortTrafficSample, PortTrafficSeries, PortTrafficTarget
from .models import PortTrafficDevice, PortTrafficSample, PortTrafficSeries, PortTrafficTarget
_log = logging.getLogger("netx.port_traffic.migrate")
def ensure_port_traffic_series_schema(conn) -> None:
"""DDL for series + series_id columns (Postgres IF NOT EXISTS)."""
"""DDL for device + series + device_id columns (Postgres IF NOT EXISTS)."""
conn.exec_driver_sql(
"""
CREATE TABLE IF NOT EXISTS port_traffic_device (
id VARCHAR(64) PRIMARY KEY,
source VARCHAR(32) DEFAULT 'managed',
ne_id VARCHAR(128) DEFAULT '',
ne_name VARCHAR(256) DEFAULT '',
ne_ip VARCHAR(128) DEFAULT '',
vendor VARCHAR(64) DEFAULT '',
note VARCHAR(256) DEFAULT '',
status VARCHAR(32) DEFAULT 'draft',
interval_sec INTEGER DEFAULT 60,
retention_days INTEGER DEFAULT 7,
concurrency INTEGER DEFAULT 1,
collect_running BOOLEAN DEFAULT FALSE,
last_collect_started_at TIMESTAMP,
last_collect_ended_at TIMESTAMP,
last_error VARCHAR(1024) DEFAULT '',
created_at TIMESTAMP,
updated_at TIMESTAMP
)
"""
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_port_traffic_device_ne ON port_traffic_device (source, ne_id)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_device_status ON port_traffic_device (status)"
)
conn.exec_driver_sql(
"""
CREATE TABLE IF NOT EXISTS port_traffic_series (
id VARCHAR(64) PRIMARY KEY,
task_id VARCHAR(64),
device_id VARCHAR(64) DEFAULT '',
title VARCHAR(256) DEFAULT '',
status VARCHAR(32) DEFAULT 'active',
created_at TIMESTAMP
)
"""
)
# Legacy column may still exist as task_id
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_series_task_id ON port_traffic_series (task_id)"
"ALTER TABLE port_traffic_series ADD COLUMN IF NOT EXISTS device_id VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql(
"ALTER TABLE port_traffic_series ADD COLUMN IF NOT EXISTS task_id VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_series_device_id ON port_traffic_series (device_id)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_series_status ON port_traffic_series (status)"
)
conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_port_traffic_series_title ON port_traffic_series (task_id, title)"
)
conn.exec_driver_sql(
"ALTER TABLE port_traffic_target ADD COLUMN IF NOT EXISTS series_id VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql(
"ALTER TABLE port_traffic_target ADD COLUMN IF NOT EXISTS device_id VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql(
"ALTER TABLE port_traffic_target ADD COLUMN IF NOT EXISTS task_id VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_target_series_id ON port_traffic_target (series_id)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_target_device_id ON port_traffic_target (device_id)"
)
conn.exec_driver_sql(
"ALTER TABLE port_traffic_sample ADD COLUMN IF NOT EXISTS series_id VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_sample_series_id ON port_traffic_sample (series_id)"
)
# One active interface globally per physical port
conn.exec_driver_sql(
"""
CREATE UNIQUE INDEX IF NOT EXISTS uq_port_traffic_target_active_if
ON port_traffic_target (source, target_id, ifname)
WHERE status = 'active'
"""
)
conn.exec_driver_sql(
"""
CREATE TABLE IF NOT EXISTS port_traffic_event (
id VARCHAR(64) PRIMARY KEY,
device_id VARCHAR(64) DEFAULT '',
target_row_id VARCHAR(64) DEFAULT '',
ifname VARCHAR(128) DEFAULT '',
level VARCHAR(16) DEFAULT 'error',
message TEXT DEFAULT '',
created_at TIMESTAMP
)
"""
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_event_device_id ON port_traffic_event (device_id)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_event_created_at ON port_traffic_event (created_at)"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_event_level ON port_traffic_event (level)"
)
def migrate_tasks_to_devices(db: Session) -> int:
"""Collapse legacy port_traffic_task rows into per-NE devices; reassign targets/series."""
# Copy task_id → device_id where missing
try:
db.execute(
text(
"""
UPDATE port_traffic_target
SET device_id = task_id
WHERE COALESCE(device_id, '') = '' AND COALESCE(task_id, '') <> ''
"""
)
)
db.execute(
text(
"""
UPDATE port_traffic_series
SET device_id = task_id
WHERE COALESCE(device_id, '') = '' AND COALESCE(task_id, '') <> ''
"""
)
)
db.commit()
except Exception:
db.rollback()
# If legacy task table missing, nothing else to do
try:
db.execute(text("SELECT 1 FROM port_traffic_task LIMIT 1"))
except Exception:
db.rollback()
return 0
targets = db.query(PortTrafficTarget).all()
if not targets:
# Still create devices from empty? skip
return 0
# Group active+any targets by (source, ne_id)
groups: dict[tuple[str, str], list[PortTrafficTarget]] = {}
for t in targets:
src = str(t.source or "managed").strip().lower() or "managed"
ne = str(t.target_id or "").strip()
if not ne:
continue
groups.setdefault((src, ne), []).append(t)
created = 0
# Load legacy tasks for interval/status
legacy: dict[str, dict] = {}
try:
rows = db.execute(
text(
"""
SELECT id, title, status, interval_sec, retention_days, concurrency,
collect_running, last_collect_started_at, last_collect_ended_at,
last_error, created_at, updated_at
FROM port_traffic_task
"""
)
).mappings().all()
for r in rows:
legacy[str(r["id"])] = dict(r)
except Exception:
db.rollback()
legacy = {}
for (src, ne), members in groups.items():
existing = (
db.query(PortTrafficDevice)
.filter(PortTrafficDevice.source == src, PortTrafficDevice.ne_id == ne)
.first()
)
if existing:
device = existing
else:
# Pick policy from the richest legacy task among members
intervals: list[int] = []
retentions: list[int] = []
statuses: list[str] = []
note = ""
created_at = None
for m in members:
tid = str(getattr(m, "device_id", None) or getattr(m, "task_id", None) or "")
meta = legacy.get(tid) or {}
if meta.get("interval_sec"):
intervals.append(int(meta["interval_sec"]))
if meta.get("retention_days"):
retentions.append(int(meta["retention_days"]))
if meta.get("status"):
statuses.append(str(meta["status"]))
if not note and meta.get("title"):
note = str(meta["title"])[:256]
if created_at is None and meta.get("created_at"):
created_at = meta["created_at"]
status = "running" if "running" in statuses else (statuses[0] if statuses else "stopped")
sample = members[0]
device = PortTrafficDevice(
id=uuid4().hex,
source=src,
ne_id=ne,
ne_name=str(sample.ne_name or ""),
ne_ip=str(sample.ne_ip or ""),
vendor=str(sample.vendor or ""),
note=note,
status=status if status in ("running", "paused", "stopped", "draft") else "stopped",
interval_sec=min(intervals) if intervals else 60,
retention_days=max(retentions) if retentions else 7,
concurrency=1,
collect_running=False,
created_at=created_at,
)
db.add(device)
db.flush()
created += 1
# Deduplicate active ifnames: keep earliest active, retire the rest
seen_active: set[str] = set()
for m in sorted(members, key=lambda x: str(x.created_at or "")):
m.device_id = str(device.id)
if str(m.status) == "active":
key = str(m.ifname or "").strip()
if key in seen_active:
m.status = "retired"
else:
seen_active.add(key)
# Keep ne metadata in sync
if not device.ne_name and m.ne_name:
device.ne_name = str(m.ne_name)
if not device.ne_ip and m.ne_ip:
device.ne_ip = str(m.ne_ip)
if not device.vendor and m.vendor:
device.vendor = str(m.vendor)
# Re-point series
series_ids = {str(m.series_id) for m in members if m.series_id}
if series_ids:
db.query(PortTrafficSeries).filter(PortTrafficSeries.id.in_(list(series_ids))).update(
{PortTrafficSeries.device_id: str(device.id)},
synchronize_session=False,
)
db.commit()
if created:
_log.info("port_traffic migrated devices created=%s groups=%s", created, len(groups))
return created
def backfill_port_traffic_series(db: Session) -> int:
"""Create one series per target missing series_id; stamp samples."""
migrate_tasks_to_devices(db)
targets = (
db.query(PortTrafficTarget)
.filter((PortTrafficTarget.series_id == None) | (PortTrafficTarget.series_id == "")) # noqa: E711
@ -58,13 +283,14 @@ def backfill_port_traffic_series(db: Session) -> int:
)
created = 0
for t in targets:
device_id = str(t.device_id or "")
title = _default_series_title(t.ne_name or "", t.ifname or "")
title = _unique_series_title(db, str(t.task_id), title)
title = _unique_series_title(db, device_id, title)
sid = uuid4().hex
db.add(
PortTrafficSeries(
id=sid,
task_id=str(t.task_id),
device_id=device_id,
title=title,
status="active",
)
@ -103,8 +329,8 @@ def default_series_title(ne_name: str, ifname: str) -> str:
return _default_series_title(ne_name, ifname)
def unique_series_title(db: Session, task_id: str, base: str) -> str:
return _unique_series_title(db, task_id, base)
def unique_series_title(db: Session, device_id: str, base: str) -> str:
return _unique_series_title(db, device_id, base)
def _default_series_title(ne_name: str, ifname: str) -> str:
@ -113,11 +339,11 @@ def _default_series_title(ne_name: str, ifname: str) -> str:
return f"{ne}:{iface}"[:256]
def _unique_series_title(db: Session, task_id: str, base: str) -> str:
def _unique_series_title(db: Session, device_id: str, base: str) -> str:
title = base[:256]
exists = (
db.query(PortTrafficSeries.id)
.filter(PortTrafficSeries.task_id == task_id, PortTrafficSeries.title == title)
.filter(PortTrafficSeries.device_id == device_id, PortTrafficSeries.title == title)
.first()
)
if not exists:
@ -126,7 +352,7 @@ def _unique_series_title(db: Session, task_id: str, base: str) -> str:
candidate = f"{base[:240]}#{i}"
exists = (
db.query(PortTrafficSeries.id)
.filter(PortTrafficSeries.task_id == task_id, PortTrafficSeries.title == candidate)
.filter(PortTrafficSeries.device_id == device_id, PortTrafficSeries.title == candidate)
.first()
)
if not exists:

View file

@ -116,6 +116,18 @@ class DetailRates:
out_util_pct: float = 0.0
def resolve_util_pct(vendor_util: float, bps: float, bw_bps: int) -> float:
"""Prefer vendor-reported util; derive from rate/BW when util is missing but traffic exists."""
u = float(vendor_util or 0.0)
if u > 0:
return u
bw = int(bw_bps or 0)
rate = float(bps or 0.0)
if bw > 0 and rate > 0:
return rate / float(bw) * 100.0
return u
def parse_bw_to_bps(raw: str) -> int:
text = (raw or "").strip()
if not text or text.upper() == "N/A":

View file

@ -7,7 +7,7 @@ from datetime import datetime
from sqlalchemy.orm import Session
from .models import PortTrafficTask
from .models import PortTrafficDevice
_log = logging.getLogger("netx.port_traffic.recovery")
@ -15,17 +15,17 @@ _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()
rows = db.query(PortTrafficDevice).filter(PortTrafficDevice.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
for device in rows:
device.collect_running = False
if not device.last_collect_ended_at:
device.last_collect_ended_at = now
if not device.last_error:
device.last_error = "requeued_after_restart"
device.updated_at = now
n += 1
if n:
db.commit()
_log.info("port_traffic recovery cleared collect_running on %s task(s)", n)
_log.info("port_traffic recovery cleared collect_running on %s device(s)", n)
return n

View file

@ -1,4 +1,4 @@
"""HTTP API for port traffic monitoring."""
"""HTTP API for port traffic monitoring (device-centric)."""
from __future__ import annotations
@ -9,28 +9,30 @@ from sqlalchemy.orm import Session
from .auth_service import write_audit
from .db import get_db
from .models import PortTrafficDevice
from .port_traffic_schemas import (
DiscoverPortsRequest,
PortTrafficDeviceCreate,
PortTrafficDeviceUpdate,
PortTrafficInterfacesPut,
PortTrafficReplacePortRequest,
PortTrafficTaskCreate,
PortTrafficTaskUpdate,
PortTrafficTargetsPut,
)
from .port_traffic_service import (
compare_targets,
create_task,
create_device,
dashboard,
delete_task,
delete_device,
discover_ports,
get_device,
get_samples,
get_task,
list_device_events,
list_devices,
list_series,
list_targets,
list_tasks,
put_targets,
put_interfaces,
replace_series_port,
set_task_status,
update_task,
set_device_status,
update_device,
)
router = APIRouter(prefix="/v1/port-traffic", tags=["port-traffic"])
@ -48,23 +50,23 @@ def api_dashboard(db: Session = Depends(get_db)):
return dashboard(db).model_dump()
@router.get("/tasks")
def api_list_tasks(
@router.get("/devices")
def api_list_devices(
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)
return list_devices(db, page=page, page_size=page_size)
@router.post("/tasks")
def api_create_task(
body: PortTrafficTaskCreate,
@router.post("/devices")
def api_create_device(
body: PortTrafficDeviceCreate,
background_tasks: BackgroundTasks,
request: Request,
db: Session = Depends(get_db),
):
out = create_task(db, body)
out = create_device(db, body)
if body.start_now and out.status == "running":
from .port_traffic_runner import dispatch_collect
@ -72,179 +74,206 @@ def api_create_task(
uid, uname = _actor(request)
write_audit(
db,
action="port_traffic.task.create",
action="port_traffic.device.create",
actor_user_id=uid,
actor_username=uname,
method="POST",
path="/v1/port-traffic/tasks",
path="/v1/port-traffic/devices",
status_code=200,
detail={"id": out.id, "title": out.title, "start_now": body.start_now},
detail={"id": out.id, "ne_id": out.ne_id, "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.get("/devices/{device_id}")
def api_get_device(device_id: str, db: Session = Depends(get_db)):
return get_device(db, device_id).model_dump()
@router.patch("/tasks/{task_id}")
def api_patch_task(
task_id: str,
body: PortTrafficTaskUpdate,
@router.patch("/devices/{device_id}")
def api_patch_device(
device_id: str,
body: PortTrafficDeviceUpdate,
request: Request,
db: Session = Depends(get_db),
):
out = update_task(db, task_id, body)
out = update_device(db, device_id, body)
uid, uname = _actor(request)
write_audit(
db,
action="port_traffic.task.update",
action="port_traffic.device.update",
actor_user_id=uid,
actor_username=uname,
method="PATCH",
path=f"/v1/port-traffic/tasks/{task_id}",
path=f"/v1/port-traffic/devices/{device_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)
@router.delete("/devices/{device_id}")
def api_delete_device(device_id: str, request: Request, db: Session = Depends(get_db)):
out = delete_device(db, device_id)
uid, uname = _actor(request)
write_audit(
db,
action="port_traffic.task.delete",
action="port_traffic.device.delete",
actor_user_id=uid,
actor_username=uname,
method="DELETE",
path=f"/v1/port-traffic/tasks/{task_id}",
path=f"/v1/port-traffic/devices/{device_id}",
status_code=200,
detail={"id": task_id},
detail={"id": device_id},
)
return out
@router.post("/tasks/{task_id}/start")
def api_start_task(
task_id: str,
@router.post("/devices/{device_id}/start")
def api_start_device(
device_id: str,
background_tasks: BackgroundTasks,
request: Request,
db: Session = Depends(get_db),
):
out = set_task_status(db, task_id, "running")
out = set_device_status(db, device_id, "running")
from .port_traffic_runner import dispatch_collect
background_tasks.add_task(dispatch_collect, task_id)
background_tasks.add_task(dispatch_collect, device_id)
uid, uname = _actor(request)
write_audit(
db,
action="port_traffic.task.start",
action="port_traffic.device.start",
actor_user_id=uid,
actor_username=uname,
method="POST",
path=f"/v1/port-traffic/tasks/{task_id}/start",
path=f"/v1/port-traffic/devices/{device_id}/start",
status_code=200,
detail={"id": task_id},
detail={"id": device_id},
)
return out.model_dump()
@router.post("/tasks/{task_id}/collect-now")
@router.post("/devices/{device_id}/collect-now")
def api_collect_now(
task_id: str,
device_id: str,
background_tasks: BackgroundTasks,
request: Request,
db: Session = Depends(get_db),
):
task = get_task(db, task_id)
if task.status not in ("running", "paused", "draft", "stopped"):
raise HTTPException(status_code=400, detail="invalid_status")
# Force due: clear last end so claim accepts, ensure running for this round.
from .models import PortTrafficTask
device = get_device(db, device_id)
from .port_traffic_runner import dispatch_collect
row = db.get(PortTrafficTask, task_id)
row = db.get(PortTrafficDevice, device_id)
if not row:
raise HTTPException(status_code=404, detail="task_not_found")
raise HTTPException(status_code=404, detail="device_not_found")
if bool(row.collect_running):
return {"ok": True, "started": False, "reason": "already_collecting", **task.model_dump()}
return {"ok": True, "started": False, "reason": "already_collecting", **device.model_dump()}
if str(row.status) != "running":
row.status = "running"
row.last_collect_ended_at = None
row.updated_at = datetime.utcnow()
db.commit()
background_tasks.add_task(dispatch_collect, task_id)
background_tasks.add_task(dispatch_collect, device_id)
uid, uname = _actor(request)
write_audit(
db,
action="port_traffic.task.collect_now",
action="port_traffic.device.collect_now",
actor_user_id=uid,
actor_username=uname,
method="POST",
path=f"/v1/port-traffic/tasks/{task_id}/collect-now",
path=f"/v1/port-traffic/devices/{device_id}/collect-now",
status_code=200,
detail={"id": task_id},
detail={"id": device_id},
)
out = get_task(db, task_id)
out = get_device(db, device_id)
return {"ok": True, "started": True, **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")
@router.post("/devices/{device_id}/pause")
def api_pause_device(device_id: str, request: Request, db: Session = Depends(get_db)):
out = set_device_status(db, device_id, "paused")
uid, uname = _actor(request)
write_audit(
db,
action="port_traffic.task.pause",
action="port_traffic.device.pause",
actor_user_id=uid,
actor_username=uname,
method="POST",
path=f"/v1/port-traffic/tasks/{task_id}/pause",
path=f"/v1/port-traffic/devices/{device_id}/pause",
status_code=200,
detail={"id": task_id},
detail={"id": device_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")
@router.post("/devices/{device_id}/stop")
def api_stop_device(device_id: str, request: Request, db: Session = Depends(get_db)):
out = set_device_status(db, device_id, "stopped")
uid, uname = _actor(request)
write_audit(
db,
action="port_traffic.task.stop",
action="port_traffic.device.stop",
actor_user_id=uid,
actor_username=uname,
method="POST",
path=f"/v1/port-traffic/tasks/{task_id}/stop",
path=f"/v1/port-traffic/devices/{device_id}/stop",
status_code=200,
detail={"id": task_id},
detail={"id": device_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.get("/devices/{device_id}/targets")
def api_list_targets(device_id: str, db: Session = Depends(get_db)):
return {"items": [t.model_dump() for t in list_targets(db, device_id)]}
@router.get("/tasks/{task_id}/series")
def api_list_series(task_id: str, db: Session = Depends(get_db)):
return {"items": [s.model_dump() for s in list_series(db, task_id)]}
@router.get("/devices/{device_id}/events")
def api_list_device_events(
device_id: str,
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
):
return list_device_events(db, device_id, limit=limit).model_dump()
@router.post("/tasks/{task_id}/series/{series_id}/replace")
@router.put("/devices/{device_id}/interfaces")
def api_put_interfaces(
device_id: str,
body: PortTrafficInterfacesPut,
request: Request,
db: Session = Depends(get_db),
):
items = put_interfaces(db, device_id, body)
uid, uname = _actor(request)
write_audit(
db,
action="port_traffic.device.interfaces_put",
actor_user_id=uid,
actor_username=uname,
method="PUT",
path=f"/v1/port-traffic/devices/{device_id}/interfaces",
status_code=200,
detail={"id": device_id, "count": len(items)},
)
return {"items": [t.model_dump() for t in items]}
@router.get("/devices/{device_id}/series")
def api_list_series(device_id: str, db: Session = Depends(get_db)):
return {"items": [s.model_dump() for s in list_series(db, device_id)]}
@router.post("/devices/{device_id}/series/{series_id}/replace")
def api_replace_series_port(
task_id: str,
device_id: str,
series_id: str,
body: PortTrafficReplacePortRequest,
request: Request,
db: Session = Depends(get_db),
):
out = replace_series_port(db, task_id, series_id, body)
out = replace_series_port(db, device_id, series_id, body)
uid, uname = _actor(request)
write_audit(
db,
@ -252,35 +281,13 @@ def api_replace_series_port(
actor_user_id=uid,
actor_username=uname,
method="POST",
path=f"/v1/port-traffic/tasks/{task_id}/series/{series_id}/replace",
path=f"/v1/port-traffic/devices/{device_id}/series/{series_id}/replace",
status_code=200,
detail={"series_id": series_id, "ifname": body.ifname, "target_id": body.target_id},
detail={"series_id": series_id, "ifname": body.ifname},
)
return out.model_dump()
@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()
@ -304,7 +311,7 @@ def api_compare(
offset_hours: float | None = Query(default=None, ge=0.25, le=24 * 90),
baseline_target_id: str | None = Query(
default=None,
description="optional mapped interface for baseline overlay (any task)",
description="optional mapped interface for baseline overlay",
),
to_ts: datetime | None = Query(default=None, alias="to"),
db: Session = Depends(get_db),

View file

@ -1,4 +1,4 @@
"""Port traffic collection worker: claim task round, sample interfaces via CLI."""
"""Port traffic collection worker: claim device round, sample interfaces via one CLI session."""
from __future__ import annotations
@ -15,11 +15,11 @@ 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 .models import PortTrafficDevice, PortTrafficEvent, PortTrafficSample, PortTrafficTarget
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
from .ne_netmiko import send_show_command
from .port_traffic_commands import commands_for_vendor, detail_command
from .port_traffic_parsers import parse_interface_detail
from .port_traffic_parsers import parse_interface_detail, resolve_util_pct
_log = logging.getLogger("netx.port_traffic.runner")
_pools: dict[str, ThreadPoolExecutor] = {}
@ -34,26 +34,29 @@ 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 _append_event(
db,
*,
device_id: str,
message: str,
level: str = "error",
target_row_id: str = "",
ifname: str = "",
) -> None:
msg = str(message or "").strip()
if not msg or not device_id:
return
db.add(
PortTrafficEvent(
id=uuid4().hex,
device_id=device_id,
target_row_id=str(target_row_id or ""),
ifname=str(ifname or ""),
level=str(level or "error")[:16],
message=msg[:4000],
created_at=_utcnow(),
)
)
def _set_target_error(target_row_id: str, message: str) -> None:
@ -62,25 +65,72 @@ def _set_target_error(target_row_id: str, message: str) -> None:
row = db.get(PortTrafficTarget, target_row_id)
if row:
row.last_error = message[:1020]
_append_event(
db,
device_id=str(row.device_id or ""),
target_row_id=str(row.id),
ifname=str(row.ifname or ""),
message=message,
level="error",
)
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."""
def _finish_collect_round(device_id: str, *, error: str = "") -> None:
db = SessionLocal()
try:
device = db.get(PortTrafficDevice, device_id)
if not device:
return
device.collect_running = False
device.last_collect_ended_at = _utcnow()
if error:
device.last_error = error[:1020]
_append_event(db, device_id=device_id, message=error, level="error")
device.updated_at = _utcnow()
db.commit()
finally:
db.close()
_release_pool(device_id)
def _pool_for_device(device_id: str, concurrency: int) -> ThreadPoolExecutor:
with _pools_lock:
pool = _pools.get(device_id)
if pool is None:
workers = max(1, min(5, int(concurrency or 1)))
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix=f"pt-{device_id[:8]}")
_pools[device_id] = pool
return pool
def _release_pool(device_id: str) -> None:
with _pools_lock:
pool = _pools.pop(device_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 device=%s", device_id)
def _claim_collect_round(device_id: str) -> list[str] | None:
for attempt in range(8):
db = SessionLocal()
try:
task = db.get(PortTrafficTask, task_id)
if not task:
device = db.get(PortTrafficDevice, device_id)
if not device:
return None
if str(task.status or "") != "running":
if str(device.status or "") != "running":
return None
if bool(task.collect_running):
if bool(device.collect_running):
return None
ended = task.last_collect_ended_at
interval = max(15, int(task.interval_sec or 60))
ended = device.last_collect_ended_at
interval = max(15, int(device.interval_sec or 60))
if ended is not None:
elapsed = (_utcnow() - ended).total_seconds()
if elapsed < interval:
@ -88,142 +138,69 @@ def _claim_collect_round(task_id: str) -> list[str] | None:
targets = (
db.query(PortTrafficTarget)
.filter(
PortTrafficTarget.task_id == task_id,
PortTrafficTarget.device_id == device_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()
device.collect_running = True
device.last_collect_started_at = _utcnow()
device.last_error = ""
device.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)
_log.exception("port_traffic claim failed device=%s attempt=%s", device_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 send_show_command(conn, command, read_timeout=read_timeout)
finally:
close_netmiko_connection(conn)
def _sample_one_target(target_row_id: str) -> None:
creds: dict[str, Any] | None = None
cmd = ""
vendor_key = "zte"
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
vendor_key = cmds.vendor_key
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_interface_detail(raw, vendor_key)
if (
parsed.in_bps == 0
and parsed.out_bps == 0
and parsed.bw_bps == 0
and parsed.in_util_pct == 0
and parsed.out_util_pct == 0
and not parsed.ifname
):
_set_target_error(target_row_id, "parse_empty")
return
def _save_sample(target_row_id: str, parsed: Any, vendor_hint_bw: int = 0) -> None:
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)
bw = int(parsed.bw_bps or row.bw_bps or vendor_hint_bw or 0)
if bw and not row.bw_bps:
row.bw_bps = bw
in_bps = float(parsed.in_bps)
out_bps = float(parsed.out_bps)
if (
in_bps == 0
and out_bps == 0
and bw == 0
and float(parsed.in_util_pct or 0) == 0
and float(parsed.out_util_pct or 0) == 0
and not parsed.ifname
):
row.last_error = "parse_empty"
_append_event(
db,
device_id=str(row.device_id or ""),
target_row_id=str(row.id),
ifname=str(row.ifname or ""),
message="parse_empty",
level="warn",
)
db.commit()
return
db.add(
PortTrafficSample(
id=uuid4().hex,
target_row_id=target_row_id,
series_id=str(row.series_id or ""),
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),
in_bps=in_bps,
out_bps=out_bps,
in_util_pct=resolve_util_pct(float(parsed.in_util_pct), in_bps, bw),
out_util_pct=resolve_util_pct(float(parsed.out_util_pct), out_bps, bw),
bw_bps=bw,
rate_period_sec=int(parsed.rate_period_sec or 0),
raw_ok=True,
@ -240,30 +217,103 @@ def _sample_one_target(target_row_id: str) -> None:
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
def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int:
"""One CLI login for the device; run show per interface. Returns error count."""
per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
cap = int(settings.ne_collect_run_timeout_cap_sec or 600)
errors = 0
db = SessionLocal()
try:
task = db.get(PortTrafficTask, task_id)
concurrency = int(task.concurrency or 5) if task else 5
device = db.get(PortTrafficDevice, device_id)
if not device:
return len(target_ids)
source = str(device.source or "").strip().lower()
ne_id = str(device.ne_id or "").strip()
vendor_hint = str(device.vendor or "")
try:
if source == "managed":
creds, info = resolve_cli_target(db, managed_ne_id=ne_id)
elif source == "ume":
creds, info = resolve_cli_target(db, ume_ne_id=ne_id)
else:
for tid in target_ids:
_set_target_error(tid, "invalid_source")
return len(target_ids)
except HTTPException as exc:
msg = str(exc.detail or "resolve_failed")[:1020]
for tid in target_ids:
_set_target_error(tid, msg)
return len(target_ids)
except Exception as exc:
msg = _format_error(exc)
for tid in target_ids:
_set_target_error(tid, msg)
return len(target_ids)
vendor = str(info.get("vendor") or vendor_hint or "")
device_type = str(info.get("device_type") or "")
cmds = commands_for_vendor(vendor, device_type)
if cmds is None:
for tid in target_ids:
_set_target_error(tid, "unsupported_vendor")
return len(target_ids)
vendor_key = cmds.vendor_key
targets = (
db.query(PortTrafficTarget)
.filter(PortTrafficTarget.id.in_(target_ids), PortTrafficTarget.status == "active")
.all()
)
ifaces = [(str(t.id), str(t.ifname or "").strip()) for t in targets if t.ifname]
finally:
db.close()
pool = _pool_for_task(task_id, concurrency)
futures = [pool.submit(_sample_one_target, tid) for tid in target_ids]
errors = 0
if not ifaces:
return 0
budget = min(cap, per_cmd * max(1, len(ifaces)) + 90)
conn = None
try:
for fut in as_completed(futures):
conn = open_netmiko_connection(creds, session_timeout=budget)
for tid, ifname in ifaces:
try:
fut.result()
except Exception:
cmd = detail_command(cmds, ifname)
raw = send_show_command(conn, cmd, read_timeout=per_cmd)
parsed = parse_interface_detail(raw, vendor_key)
_save_sample(tid, parsed)
except Exception as exc:
errors += 1
_log.exception("port_traffic target worker failed task=%s", task_id)
_set_target_error(tid, _format_error(exc))
_log.exception("port_traffic iface sample failed device=%s if=%s", device_id, ifname)
except Exception as exc:
msg = _format_error(exc)
for tid, _ in ifaces:
_set_target_error(tid, msg)
errors = len(ifaces)
_log.exception("port_traffic session failed device=%s", device_id)
finally:
if conn is not None:
close_netmiko_connection(conn)
return errors
def dispatch_collect(device_id: str) -> int:
"""Claim and sample all active interfaces for a running device. Returns target count."""
target_ids = _claim_collect_round(device_id)
if not target_ids:
return 0
try:
errors = _sample_targets_shared_session(device_id, target_ids)
except Exception:
errors = len(target_ids)
_log.exception("port_traffic collect failed device=%s", device_id)
finally:
err_msg = f"{errors}_target_errors" if errors else ""
_finish_collect_round(task_id, error=err_msg)
_finish_collect_round(device_id, error=err_msg)
return len(target_ids)
# Alias used by older call sites
dispatch_collect_device = dispatch_collect

View file

@ -8,7 +8,7 @@ from datetime import datetime
from .config import settings
from .db import SessionLocal
from .models import PortTrafficTask
from .models import PortTrafficDevice
from .port_traffic_runner import dispatch_collect
from .port_traffic_service import purge_expired_samples
@ -22,40 +22,47 @@ def _utcnow() -> datetime:
return datetime.utcnow()
def try_dispatch_due_tasks() -> int:
"""Dispatch collect rounds for due running tasks. Returns number started."""
def try_dispatch_due_devices() -> int:
"""Dispatch collect rounds for due running devices. Returns number started."""
db = SessionLocal()
try:
tasks = (
db.query(PortTrafficTask)
.filter(PortTrafficTask.status == "running", PortTrafficTask.collect_running.is_(False))
devices = (
db.query(PortTrafficDevice)
.filter(
PortTrafficDevice.status == "running",
PortTrafficDevice.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
for device in devices:
interval = max(15, int(device.interval_sec or 60))
ended = device.last_collect_ended_at
if ended is None:
due_ids.append(str(task.id))
due_ids.append(str(device.id))
continue
if (now - ended).total_seconds() >= interval:
due_ids.append(str(task.id))
due_ids.append(str(device.id))
finally:
db.close()
started = 0
for tid in due_ids:
for did in due_ids:
try:
n = dispatch_collect(tid)
n = dispatch_collect(did)
if n:
started += 1
_log.info("port_traffic collect started task=%s targets=%s", tid, n)
_log.info("port_traffic collect started device=%s targets=%s", did, n)
except Exception:
_log.exception("port_traffic dispatch failed task=%s", tid)
_log.exception("port_traffic dispatch failed device=%s", did)
return started
def try_dispatch_due_tasks() -> int:
return try_dispatch_due_devices()
def _loop() -> None:
global _purge_counter
tick = max(5, int(settings.port_traffic_scheduler_tick_sec or 15))
@ -63,9 +70,8 @@ def _loop() -> None:
while not _stop.is_set():
try:
if bool(settings.port_traffic_scheduler_enabled):
try_dispatch_due_tasks()
try_dispatch_due_devices()
_purge_counter += 1
# Retention purge roughly every ~20 ticks
if _purge_counter >= 20:
_purge_counter = 0
db = SessionLocal()
@ -89,7 +95,6 @@ def start_port_traffic_scheduler() -> None:
_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:

View file

@ -1,4 +1,4 @@
"""Pydantic schemas for port traffic monitoring API."""
"""Pydantic schemas for port traffic monitoring API (device-centric)."""
from __future__ import annotations
@ -8,17 +8,17 @@ 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 PortTrafficIfaceIn(BaseModel):
ifname: str
if_description: str = ""
bw_bps: int = 0
class PortTrafficTargetIn(BaseModel):
source: Literal["managed", "ume"]
target_id: str
"""Legacy-shaped input; create/device APIs prefer PortTrafficIfaceIn under one NE."""
source: Literal["managed", "ume"] = "managed"
target_id: str = ""
ne_name: str = ""
ne_ip: str = ""
vendor: str = ""
@ -27,25 +27,38 @@ class PortTrafficTargetIn(BaseModel):
bw_bps: int = 0
class PortTrafficTaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=256)
class PortTrafficDeviceCreate(BaseModel):
source: Literal["managed", "ume"]
ne_id: str = Field(min_length=1, max_length=128)
ne_name: str = ""
ne_ip: str = ""
vendor: str = ""
note: str = Field(default="", 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)
concurrency: int = Field(default=1, ge=1, le=5)
interfaces: list[PortTrafficIfaceIn] = Field(default_factory=list)
start_now: bool = False
class PortTrafficTaskUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=256)
class PortTrafficDeviceUpdate(BaseModel):
note: str | None = Field(default=None, 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)
concurrency: int | None = Field(default=None, ge=1, le=5)
ne_name: str | None = None
ne_ip: str | None = None
vendor: str | None = None
class PortTrafficTaskOut(BaseModel):
class PortTrafficDeviceOut(BaseModel):
id: str
title: str
source: str
ne_id: str
ne_name: str
ne_ip: str
vendor: str
note: str = ""
status: str
interval_sec: int
retention_days: int
@ -62,7 +75,8 @@ class PortTrafficTaskOut(BaseModel):
class PortTrafficTargetOut(BaseModel):
id: str
task_id: str
device_id: str
task_id: str = "" # alias of device_id for older clients
series_id: str = ""
source: str
target_id: str
@ -80,7 +94,8 @@ class PortTrafficTargetOut(BaseModel):
class PortTrafficSeriesOut(BaseModel):
id: str
task_id: str
device_id: str
task_id: str = ""
title: str
status: str
active_target: PortTrafficTargetOut | None = None
@ -88,18 +103,13 @@ class PortTrafficSeriesOut(BaseModel):
created_at: datetime | None = None
class PortTrafficTargetsPut(BaseModel):
targets: list[PortTrafficTargetIn]
class PortTrafficInterfacesPut(BaseModel):
"""Replace active interface set for a device (keeps samples for retired/removed)."""
interfaces: list[PortTrafficIfaceIn]
class PortTrafficReplacePortRequest(BaseModel):
"""Replace the active interface under a logical series (keeps history)."""
source: Literal["managed", "ume"]
target_id: str
ne_name: str = ""
ne_ip: str = ""
vendor: str = ""
ifname: str
if_description: str = ""
bw_bps: int = 0
@ -141,7 +151,7 @@ class PortTrafficSamplePoint(BaseModel):
out_util_pct: float
bw_bps: int
rate_period_sec: int = 0
ts_raw: datetime | None = None # baseline original time when aligned
ts_raw: datetime | None = None
class PortTrafficSamplesOut(BaseModel):
@ -166,8 +176,26 @@ class PortTrafficCompareOut(BaseModel):
class PortTrafficDashboardOut(BaseModel):
task_count: int = 0
running_task_count: int = 0
device_count: int = 0
running_device_count: int = 0
active_target_count: int = 0
sample_count_24h: int = 0
last_sample_at: datetime | None = None
# Back-compat aliases for older UI
task_count: int = 0
running_task_count: int = 0
class PortTrafficEventOut(BaseModel):
id: str
device_id: str
target_row_id: str = ""
ifname: str = ""
level: str = "error"
message: str = ""
created_at: datetime | None = None
class PortTrafficEventsOut(BaseModel):
items: list[PortTrafficEventOut] = Field(default_factory=list)
total: int = 0

View file

@ -1,4 +1,4 @@
"""Port traffic monitoring service: CRUD, discover, samples, dashboard."""
"""Port traffic monitoring service: device-centric CRUD, discover, samples, dashboard."""
from __future__ import annotations
@ -13,12 +13,12 @@ from sqlalchemy.orm import Session
from .cli_resolve import resolve_cli_target
from .config import settings
from .models import PortTrafficSample, PortTrafficSeries, PortTrafficTarget, PortTrafficTask
from .models import PortTrafficDevice, PortTrafficEvent, PortTrafficSample, PortTrafficSeries, PortTrafficTarget
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
from .ne_netmiko import send_show_command
from .port_traffic_commands import commands_for_vendor
from .port_traffic_migrate import default_series_title, unique_series_title
from .port_traffic_parsers import brief_port_to_dict, parse_interface_brief
from .port_traffic_parsers import brief_port_to_dict, parse_interface_brief, resolve_util_pct
from .port_traffic_schemas import (
DiscoverPortItem,
DiscoverPortsRequest,
@ -26,16 +26,18 @@ from .port_traffic_schemas import (
PortTrafficCompareMeta,
PortTrafficCompareOut,
PortTrafficDashboardOut,
PortTrafficDeviceCreate,
PortTrafficDeviceOut,
PortTrafficDeviceUpdate,
PortTrafficEventOut,
PortTrafficEventsOut,
PortTrafficIfaceIn,
PortTrafficInterfacesPut,
PortTrafficReplacePortRequest,
PortTrafficSamplePoint,
PortTrafficSamplesOut,
PortTrafficSeriesOut,
PortTrafficTargetIn,
PortTrafficTargetOut,
PortTrafficTargetsPut,
PortTrafficTaskCreate,
PortTrafficTaskOut,
PortTrafficTaskUpdate,
)
_log = logging.getLogger("netx.port_traffic.service")
@ -45,36 +47,12 @@ 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:
did = str(row.device_id or "")
return PortTrafficTargetOut(
id=str(row.id),
task_id=str(row.task_id),
device_id=did,
task_id=did,
series_id=str(row.series_id or ""),
source=str(row.source or ""),
target_id=str(row.target_id or ""),
@ -91,19 +69,89 @@ def _target_out(row: PortTrafficTarget) -> PortTrafficTargetOut:
)
def _create_series_and_target(
def _device_out(db: Session, device: PortTrafficDevice) -> PortTrafficDeviceOut:
did = str(device.id)
total = db.query(PortTrafficTarget).filter(PortTrafficTarget.device_id == did).count()
active = (
db.query(PortTrafficTarget)
.filter(PortTrafficTarget.device_id == did, PortTrafficTarget.status == "active")
.count()
)
return PortTrafficDeviceOut(
id=did,
source=str(device.source or ""),
ne_id=str(device.ne_id or ""),
ne_name=str(device.ne_name or ""),
ne_ip=str(device.ne_ip or ""),
vendor=str(device.vendor or ""),
note=str(device.note or ""),
status=str(device.status or ""),
interval_sec=int(device.interval_sec or 60),
retention_days=int(device.retention_days or 7),
concurrency=int(device.concurrency or 1),
collect_running=bool(device.collect_running),
target_count=int(total),
active_target_count=int(active),
last_collect_started_at=device.last_collect_started_at,
last_collect_ended_at=device.last_collect_ended_at,
last_error=str(device.last_error or ""),
created_at=device.created_at,
updated_at=device.updated_at,
)
def _assert_vendor(vendor: str, label: str) -> None:
cmds = commands_for_vendor(vendor or "", "")
if cmds is None:
raise HTTPException(
status_code=400,
detail=f"vendor_not_supported_for_port_traffic: {vendor or 'unknown'} ({label})",
)
def _assert_ifaces_free(
db: Session,
*,
task_id: str,
t: PortTrafficTargetIn,
source: str,
ne_id: str,
ifnames: list[str],
exclude_device_id: str | None = None,
) -> None:
names = [str(x).strip() for x in ifnames if str(x).strip()]
if not names:
return
q = db.query(PortTrafficTarget).filter(
PortTrafficTarget.source == source,
PortTrafficTarget.target_id == ne_id,
PortTrafficTarget.ifname.in_(names),
PortTrafficTarget.status == "active",
)
if exclude_device_id:
q = q.filter(PortTrafficTarget.device_id != exclude_device_id)
hit = q.first()
if hit:
raise HTTPException(
status_code=409,
detail=f"interface_already_monitored: {hit.ifname} on device {hit.device_id}",
)
def _create_iface(
db: Session,
*,
device: PortTrafficDevice,
iface: PortTrafficIfaceIn,
now: datetime,
) -> PortTrafficTarget:
title = unique_series_title(db, task_id, default_series_title(t.ne_name or "", t.ifname.strip()))
ifname = iface.ifname.strip()
title = unique_series_title(
db, str(device.id), default_series_title(device.ne_name or "", ifname)
)
sid = uuid4().hex
db.add(
PortTrafficSeries(
id=sid,
task_id=task_id,
device_id=str(device.id),
title=title,
status="active",
created_at=now,
@ -111,16 +159,16 @@ def _create_series_and_target(
)
row = PortTrafficTarget(
id=uuid4().hex,
task_id=task_id,
device_id=str(device.id),
series_id=sid,
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),
source=str(device.source),
target_id=str(device.ne_id),
ne_name=str(device.ne_name or ""),
ne_ip=str(device.ne_ip or ""),
vendor=str(device.vendor or ""),
ifname=ifname,
if_description=iface.if_description or "",
bw_bps=int(iface.bw_bps or 0),
status="active",
created_at=now,
)
@ -128,44 +176,49 @@ def _create_series_and_target(
return row
def _assert_supported_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())
def list_devices(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
q = db.query(PortTrafficDevice).order_by(PortTrafficDevice.ne_name.asc(), PortTrafficDevice.ne_ip.asc())
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],
"items": [_device_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 get_device(db: Session, device_id: str) -> PortTrafficDeviceOut:
device = db.get(PortTrafficDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="device_not_found")
return _device_out(db, device)
def create_task(db: Session, body: PortTrafficTaskCreate) -> PortTrafficTaskOut:
_assert_supported_targets(body.targets)
def create_device(db: Session, body: PortTrafficDeviceCreate) -> PortTrafficDeviceOut:
source = body.source
ne_id = body.ne_id.strip()
_assert_vendor(body.vendor, body.ne_name or ne_id)
clash = (
db.query(PortTrafficDevice)
.filter(PortTrafficDevice.source == source, PortTrafficDevice.ne_id == ne_id)
.first()
)
if clash:
raise HTTPException(status_code=409, detail="device_already_monitored")
ifnames = [i.ifname for i in body.interfaces]
_assert_ifaces_free(db, source=source, ne_id=ne_id, ifnames=ifnames)
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(
status = "running" if body.start_now and body.interfaces else "draft"
device = PortTrafficDevice(
id=uuid4().hex,
title=body.title.strip(),
source=source,
ne_id=ne_id,
ne_name=(body.ne_name or "").strip(),
ne_ip=(body.ne_ip or "").strip(),
vendor=(body.vendor or "").strip(),
note=(body.note or "").strip(),
status=status,
interval_sec=int(body.interval_sec),
retention_days=int(body.retention_days),
@ -173,134 +226,166 @@ def create_task(db: Session, body: PortTrafficTaskCreate) -> PortTrafficTaskOut:
created_at=now,
updated_at=now,
)
db.add(task)
db.add(device)
db.flush()
for t in body.targets:
_create_series_and_target(db, task_id=task.id, t=t, now=now)
for iface in body.interfaces:
if not iface.ifname.strip():
continue
_create_iface(db, device=device, iface=iface, now=now)
db.commit()
db.refresh(task)
return _task_out(db, task)
db.refresh(device)
return _device_out(db, device)
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")
def update_device(db: Session, device_id: str, body: PortTrafficDeviceUpdate) -> PortTrafficDeviceOut:
device = db.get(PortTrafficDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="device_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()
for key in ("note", "ne_name", "ne_ip", "vendor"):
if key in data and data[key] is not None:
setattr(device, key, str(data[key]).strip())
if "interval_sec" in data and data["interval_sec"] is not None:
task.interval_sec = int(data["interval_sec"])
device.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"])
device.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()
device.concurrency = int(data["concurrency"])
device.updated_at = _utcnow()
db.commit()
db.refresh(task)
return _task_out(db, task)
db.refresh(device)
return _device_out(db, device)
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):
def delete_device(db: Session, device_id: str) -> dict[str, Any]:
device = db.get(PortTrafficDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="device_not_found")
if bool(device.collect_running):
raise HTTPException(status_code=409, detail="collect_running")
targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).all()
targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.device_id == device_id).all()
ids = [str(t.id) for t in targets]
series_ids = [str(t.series_id) for t in targets if t.series_id]
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(
db.query(PortTrafficTarget).filter(PortTrafficTarget.device_id == device_id).delete(
synchronize_session=False
)
db.query(PortTrafficEvent).filter(PortTrafficEvent.device_id == device_id).delete(
synchronize_session=False
)
if series_ids:
db.query(PortTrafficSeries).filter(PortTrafficSeries.id.in_(series_ids)).delete(
synchronize_session=False
)
else:
db.query(PortTrafficSeries).filter(PortTrafficSeries.task_id == task_id).delete(
db.query(PortTrafficSeries).filter(PortTrafficSeries.device_id == device_id).delete(
synchronize_session=False
)
db.delete(task)
db.delete(device)
db.commit()
return {"ok": True, "id": task_id}
return {"ok": True, "id": device_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")
def set_device_status(db: Session, device_id: str, status: str) -> PortTrafficDeviceOut:
device = db.get(PortTrafficDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="device_not_found")
if status == "running":
active = (
db.query(PortTrafficTarget)
.filter(PortTrafficTarget.task_id == task_id, PortTrafficTarget.status == "active")
.filter(PortTrafficTarget.device_id == device_id, PortTrafficTarget.status == "active")
.count()
)
if active <= 0:
raise HTTPException(status_code=400, detail="no_active_targets")
# Allow scheduler/collect-now to fire immediately after start/resume.
task.last_collect_ended_at = None
task.status = status
task.updated_at = _utcnow()
device.last_collect_ended_at = None
device.status = status
device.updated_at = _utcnow()
db.commit()
db.refresh(task)
return _task_out(db, task)
db.refresh(device)
return _device_out(db, device)
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):
def put_interfaces(db: Session, device_id: str, body: PortTrafficInterfacesPut) -> list[PortTrafficTargetOut]:
device = db.get(PortTrafficDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="device_not_found")
if bool(device.collect_running):
raise HTTPException(status_code=409, detail="collect_running")
_assert_supported_targets(body.targets)
old = db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).all()
old_ids = [str(t.id) for t in old]
old_series = list({str(t.series_id) for t in old if t.series_id})
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
)
if old_series:
db.query(PortTrafficSeries).filter(PortTrafficSeries.id.in_(old_series)).delete(
synchronize_session=False
)
wanted = {i.ifname.strip(): i for i in body.interfaces if i.ifname.strip()}
_assert_ifaces_free(
db,
source=str(device.source),
ne_id=str(device.ne_id),
ifnames=list(wanted.keys()),
exclude_device_id=device_id,
)
now = _utcnow()
rows: list[PortTrafficTarget] = []
for t in body.targets:
rows.append(_create_series_and_target(db, task_id=task_id, t=t, now=now))
task.updated_at = now
existing = (
db.query(PortTrafficTarget)
.filter(PortTrafficTarget.device_id == device_id)
.all()
)
by_if = {str(t.ifname): t for t in existing if str(t.status) == "active"}
for ifname, row in list(by_if.items()):
if ifname not in wanted:
row.status = "retired"
for ifname, iface in wanted.items():
if ifname in by_if:
row = by_if[ifname]
row.if_description = iface.if_description or row.if_description
if iface.bw_bps:
row.bw_bps = int(iface.bw_bps)
continue
# Reactivate retired same ifname if present
retired = next(
(
t
for t in existing
if str(t.ifname) == ifname and str(t.status) in ("retired", "disabled")
),
None,
)
if retired:
retired.status = "active"
retired.if_description = iface.if_description or retired.if_description
if iface.bw_bps:
retired.bw_bps = int(iface.bw_bps)
continue
_create_iface(db, device=device, iface=iface, now=now)
device.updated_at = now
db.commit()
return [_target_out(r) for r in rows]
return list_targets(db, device_id)
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")
def list_targets(db: Session, device_id: str) -> list[PortTrafficTargetOut]:
device = db.get(PortTrafficDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="device_not_found")
rows = (
db.query(PortTrafficTarget)
.filter(PortTrafficTarget.task_id == task_id)
.order_by(PortTrafficTarget.ne_name, PortTrafficTarget.ifname)
.filter(PortTrafficTarget.device_id == device_id)
.order_by(PortTrafficTarget.ifname)
.all()
)
return [_target_out(r) for r in rows]
def list_series(db: Session, task_id: str) -> list[PortTrafficSeriesOut]:
task = db.get(PortTrafficTask, task_id)
if not task:
raise HTTPException(status_code=404, detail="task_not_found")
def list_series(db: Session, device_id: str) -> list[PortTrafficSeriesOut]:
device = db.get(PortTrafficDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="device_not_found")
rows = (
db.query(PortTrafficSeries)
.filter(PortTrafficSeries.task_id == task_id)
.filter(PortTrafficSeries.device_id == device_id)
.order_by(PortTrafficSeries.title.asc())
.all()
)
@ -317,10 +402,12 @@ def list_series(db: Session, task_id: str) -> list[PortTrafficSeriesOut]:
.filter(PortTrafficTarget.series_id == s.id, PortTrafficTarget.status == "retired")
.count()
)
did = str(s.device_id or "")
out.append(
PortTrafficSeriesOut(
id=str(s.id),
task_id=str(s.task_id),
device_id=did,
task_id=did,
title=str(s.title or ""),
status=str(s.status or ""),
active_target=_target_out(active) if active else None,
@ -333,29 +420,26 @@ def list_series(db: Session, task_id: str) -> list[PortTrafficSeriesOut]:
def replace_series_port(
db: Session,
task_id: str,
device_id: str,
series_id: str,
body: PortTrafficReplacePortRequest,
) -> PortTrafficSeriesOut:
task = db.get(PortTrafficTask, task_id)
if not task:
raise HTTPException(status_code=404, detail="task_not_found")
if bool(task.collect_running):
device = db.get(PortTrafficDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="device_not_found")
if bool(device.collect_running):
raise HTTPException(status_code=409, detail="collect_running")
series = db.get(PortTrafficSeries, series_id)
if not series or str(series.task_id) != task_id:
if not series or str(series.device_id) != device_id:
raise HTTPException(status_code=404, detail="series_not_found")
tmp = PortTrafficTargetIn(
source=body.source,
target_id=body.target_id,
ne_name=body.ne_name,
ne_ip=body.ne_ip,
vendor=body.vendor,
ifname=body.ifname,
if_description=body.if_description,
bw_bps=body.bw_bps,
ifname = body.ifname.strip()
_assert_ifaces_free(
db,
source=str(device.source),
ne_id=str(device.ne_id),
ifnames=[ifname],
exclude_device_id=device_id,
)
_assert_supported_targets([tmp])
now = _utcnow()
actives = (
db.query(PortTrafficTarget)
@ -370,7 +454,7 @@ def replace_series_port(
clash = (
db.query(PortTrafficSeries.id)
.filter(
PortTrafficSeries.task_id == task_id,
PortTrafficSeries.device_id == device_id,
PortTrafficSeries.title == wanted,
PortTrafficSeries.id != series_id,
)
@ -381,24 +465,23 @@ def replace_series_port(
series.title = wanted
row = PortTrafficTarget(
id=uuid4().hex,
task_id=task_id,
device_id=device_id,
series_id=series_id,
source=body.source,
target_id=body.target_id,
ne_name=body.ne_name or "",
ne_ip=body.ne_ip or "",
vendor=body.vendor or "",
ifname=body.ifname.strip(),
source=str(device.source),
target_id=str(device.ne_id),
ne_name=str(device.ne_name or ""),
ne_ip=str(device.ne_ip or ""),
vendor=str(device.vendor or ""),
ifname=ifname,
if_description=body.if_description or "",
bw_bps=int(body.bw_bps or 0),
status="active",
created_at=now,
)
db.add(row)
task.updated_at = now
device.updated_at = now
db.commit()
items = list_series(db, task_id)
for item in items:
for item in list_series(db, device_id):
if item.id == series_id:
return item
raise HTTPException(status_code=500, detail="series_replace_failed")
@ -449,7 +532,6 @@ def discover_ports(db: Session, body: DiscoverPortsRequest) -> DiscoverPortsResp
def _as_naive_utc(value: datetime | None) -> datetime | None:
"""Normalize query bounds to naive UTC (DB columns are naive utcnow)."""
if value is None:
return None
if value.tzinfo is None:
@ -468,15 +550,18 @@ def _sample_points(
ts = ts_raw
if align_offset is not None and ts_raw is not None:
ts = ts_raw + align_offset
in_bps = float(r.in_bps or 0)
out_bps = float(r.out_bps or 0)
bw = int(r.bw_bps or 0)
points.append(
PortTrafficSamplePoint(
ts=ts,
ts_raw=ts_raw if align_offset is not None else None,
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),
in_bps=in_bps,
out_bps=out_bps,
in_util_pct=resolve_util_pct(float(r.in_util_pct or 0), in_bps, bw),
out_util_pct=resolve_util_pct(float(r.out_util_pct or 0), out_bps, bw),
bw_bps=bw,
rate_period_sec=int(r.rate_period_sec or 0),
)
)
@ -530,7 +615,6 @@ def compare_targets(
baseline_target_id: str | None = None,
to_ts: datetime | None = None,
) -> PortTrafficCompareOut:
"""Compare current interface samples vs period and/or manually mapped interface."""
target = db.get(PortTrafficTarget, target_row_id)
if not target:
raise HTTPException(status_code=404, detail="target_not_found")
@ -549,27 +633,25 @@ def compare_targets(
range_h = max(0.25, float(range_hours or 24))
from_ts = to_ts - timedelta(hours=range_h)
to_q = to_ts + timedelta(seconds=5)
current_rows = _query_target_samples(
db, target_row_id=str(target.id), from_ts=from_ts, to_ts=to_q
current = _sample_points(
_query_target_samples(db, target_row_id=str(target.id), from_ts=from_ts, to_ts=to_q)
)
current = _sample_points(current_rows)
off_h = baseline_offset_hours(baseline, range_h, offset_hours)
baseline_points: list[PortTrafficSamplePoint] = []
# Baseline source: mapped interface if set, else same interface (period compare only).
base_src = mapped if mapped is not None else target
want_baseline = off_h is not None or mapped is not None
if want_baseline:
if off_h is not None:
delta = timedelta(hours=off_h)
b_from = from_ts - delta
b_to = to_q - delta
base_rows = _query_target_samples(
db, target_row_id=str(base_src.id), from_ts=b_from, to_ts=b_to
db,
target_row_id=str(base_src.id),
from_ts=from_ts - delta,
to_ts=to_q - delta,
)
baseline_points = _sample_points(base_rows, align_offset=delta)
else:
# Mapped port, same window (no time shift) — cross-device overlay.
base_rows = _query_target_samples(
db, target_row_id=str(base_src.id), from_ts=from_ts, to_ts=to_q
)
@ -590,7 +672,6 @@ def compare_targets(
)
# Back-compat alias for older imports/tests.
def compare_series(db: Session, **kwargs: Any) -> PortTrafficCompareOut:
target_row_id = kwargs.pop("target_row_id", None) or kwargs.pop("target_id", None)
series_id = kwargs.pop("series_id", None)
@ -622,15 +703,14 @@ def get_samples(
now = _utcnow()
to_ts = _as_naive_utc(to_ts) or now
from_ts = _as_naive_utc(from_ts) or (to_ts - timedelta(hours=1))
# Slight skew so just-written samples are not clipped by client clock.
to_ts = to_ts + timedelta(seconds=5)
rows = _query_target_samples(db, target_row_id=target_row_id, from_ts=from_ts, to_ts=to_ts)
return PortTrafficSamplesOut(target=_target_out(target), points=_sample_points(rows))
def dashboard(db: Session) -> PortTrafficDashboardOut:
task_count = db.query(PortTrafficTask).count()
running = db.query(PortTrafficTask).filter(PortTrafficTask.status == "running").count()
device_count = db.query(PortTrafficDevice).count()
running = db.query(PortTrafficDevice).filter(PortTrafficDevice.status == "running").count()
active_targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.status == "active").count()
since = _utcnow() - timedelta(hours=24)
sample_count = (
@ -640,38 +720,130 @@ def dashboard(db: Session) -> PortTrafficDashboardOut:
)
last = db.query(func.max(PortTrafficSample.ts)).scalar()
return PortTrafficDashboardOut(
task_count=int(task_count),
running_task_count=int(running),
device_count=int(device_count),
running_device_count=int(running),
active_target_count=int(active_targets),
sample_count_24h=int(sample_count),
last_sample_at=last,
task_count=int(device_count),
running_task_count=int(running),
)
def list_device_events(
db: Session,
device_id: str,
*,
limit: int = 100,
) -> PortTrafficEventsOut:
device = db.get(PortTrafficDevice, device_id)
if not device:
raise HTTPException(status_code=404, detail="device_not_found")
lim = max(1, min(500, int(limit or 100)))
q = db.query(PortTrafficEvent).filter(PortTrafficEvent.device_id == device_id)
total = q.count()
# Seed once from current last_error snapshot so older failures still show in log UI.
if total == 0:
seeded = False
if str(device.last_error or "").strip():
append_device_event(
db,
device_id=device_id,
message=str(device.last_error),
level="error",
)
seeded = True
for t in (
db.query(PortTrafficTarget)
.filter(PortTrafficTarget.device_id == device_id)
.order_by(PortTrafficTarget.ifname)
.all()
):
err = str(t.last_error or "").strip()
if not err:
continue
append_device_event(
db,
device_id=device_id,
target_row_id=str(t.id),
ifname=str(t.ifname or ""),
message=err,
level="error",
)
seeded = True
if seeded:
db.commit()
total = db.query(PortTrafficEvent).filter(PortTrafficEvent.device_id == device_id).count()
q = db.query(PortTrafficEvent).filter(PortTrafficEvent.device_id == device_id)
rows = q.order_by(PortTrafficEvent.created_at.desc()).limit(lim).all()
items = [
PortTrafficEventOut(
id=str(r.id),
device_id=str(r.device_id or ""),
target_row_id=str(r.target_row_id or ""),
ifname=str(r.ifname or ""),
level=str(r.level or "error"),
message=str(r.message or ""),
created_at=r.created_at,
)
for r in rows
]
return PortTrafficEventsOut(items=items, total=int(total))
def append_device_event(
db: Session,
*,
device_id: str,
message: str,
level: str = "error",
target_row_id: str = "",
ifname: str = "",
) -> None:
msg = str(message or "").strip()
if not msg or not device_id:
return
db.add(
PortTrafficEvent(
id=uuid4().hex,
device_id=device_id,
target_row_id=str(target_row_id or ""),
ifname=str(ifname or ""),
level=str(level or "error")[:16],
message=msg[:4000],
created_at=_utcnow(),
)
)
def purge_expired_samples(db: Session) -> int:
"""Delete samples older than each task's retention_days."""
tasks = db.query(PortTrafficTask).all()
devices = db.query(PortTrafficDevice).all()
deleted = 0
now = _utcnow()
for task in tasks:
days = max(1, int(task.retention_days or 7))
for device in devices:
days = max(1, int(device.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()
for t in db.query(PortTrafficTarget.id)
.filter(PortTrafficTarget.device_id == device.id)
.all()
]
if not target_ids:
continue
n = (
db.query(PortTrafficSample)
.filter(
PortTrafficSample.target_row_id.in_(target_ids),
PortTrafficSample.ts < cutoff,
if target_ids:
n = (
db.query(PortTrafficSample)
.filter(
PortTrafficSample.target_row_id.in_(target_ids),
PortTrafficSample.ts < cutoff,
)
.delete(synchronize_session=False)
)
.delete(synchronize_session=False)
)
deleted += int(n or 0)
deleted += int(n or 0)
db.query(PortTrafficEvent).filter(
PortTrafficEvent.device_id == device.id,
PortTrafficEvent.created_at < cutoff,
).delete(synchronize_session=False)
db.commit()
if deleted:
db.commit()
_log.info("port_traffic retention purged samples=%s", deleted)
return deleted

View file

@ -11,7 +11,7 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from netx_api.db import Base
from netx_api.models import PortTrafficSample, PortTrafficSeries, PortTrafficTarget, PortTrafficTask
from netx_api.models import PortTrafficDevice, PortTrafficSample, PortTrafficSeries, PortTrafficTarget
from netx_api.port_traffic_service import baseline_offset_hours, compare_targets
@ -34,16 +34,32 @@ class CompareTargetsTests(unittest.TestCase):
Base.metadata.create_all(bind=engine)
self.Session = sessionmaker(bind=engine)
self.db = self.Session()
self.task_id = uuid4().hex
self.device_id = uuid4().hex
self.device2_id = uuid4().hex
self.series_id = uuid4().hex
self.target_id = uuid4().hex
self.mapped_id = uuid4().hex
now = datetime(2026, 7, 31, 12, 0, 0)
self.now = now
self.db.add(
PortTrafficTask(
id=self.task_id,
title="t1",
PortTrafficDevice(
id=self.device_id,
source="managed",
ne_id="ne1",
ne_name="R1",
vendor="Huawei",
status="running",
created_at=now,
updated_at=now,
)
)
self.db.add(
PortTrafficDevice(
id=self.device2_id,
source="managed",
ne_id="ne2",
ne_name="R2",
vendor="ZTE",
status="running",
created_at=now,
updated_at=now,
@ -52,7 +68,7 @@ class CompareTargetsTests(unittest.TestCase):
self.db.add(
PortTrafficSeries(
id=self.series_id,
task_id=self.task_id,
device_id=self.device_id,
title="uplink1",
status="active",
created_at=now,
@ -61,7 +77,7 @@ class CompareTargetsTests(unittest.TestCase):
self.db.add(
PortTrafficTarget(
id=self.target_id,
task_id=self.task_id,
device_id=self.device_id,
series_id=self.series_id,
source="managed",
target_id="ne1",
@ -75,7 +91,7 @@ class CompareTargetsTests(unittest.TestCase):
self.db.add(
PortTrafficTarget(
id=self.mapped_id,
task_id=self.task_id,
device_id=self.device2_id,
series_id="",
source="managed",
target_id="ne2",
@ -86,7 +102,6 @@ class CompareTargetsTests(unittest.TestCase):
created_at=now,
)
)
# current window points (last 2h) on primary interface
for i in range(3):
self.db.add(
PortTrafficSample(
@ -99,7 +114,6 @@ class CompareTargetsTests(unittest.TestCase):
raw_ok=True,
)
)
# baseline day-1 points on same port
for i in range(3):
self.db.add(
PortTrafficSample(
@ -112,7 +126,6 @@ class CompareTargetsTests(unittest.TestCase):
raw_ok=True,
)
)
# mapped port same-window points
for i in range(2):
self.db.add(
PortTrafficSample(
@ -175,7 +188,6 @@ class CompareTargetsTests(unittest.TestCase):
self.assertIsNone(out.baseline[0].ts_raw)
def test_mapped_port_with_day_offset(self):
# day-ago samples on mapped port
for i in range(2):
self.db.add(
PortTrafficSample(

View file

@ -15,6 +15,7 @@ from netx_api.port_traffic_parsers import (
parse_interface_detail,
parse_zte_interface_brief,
parse_zte_interface_detail,
resolve_util_pct,
)
SAMPLE_DETAIL = """\
@ -280,5 +281,20 @@ class CommandsTests(unittest.TestCase):
)
class ResolveUtilTests(unittest.TestCase):
def test_keeps_vendor_util(self):
self.assertEqual(resolve_util_pct(0.01, 0.0, 1_000_000_000), 0.01)
def test_derives_when_vendor_zero(self):
# 98 Kbit/s on 1 Gbit/s ≈ 0.0098%
self.assertAlmostEqual(resolve_util_pct(0.0, 98_000.0, 1_000_000_000), 0.0098)
def test_zero_when_no_traffic(self):
self.assertEqual(resolve_util_pct(0.0, 0.0, 1_000_000_000), 0.0)
def test_zero_when_no_bw(self):
self.assertEqual(resolve_util_pct(0.0, 98_000.0, 0), 0.0)
if __name__ == "__main__":
unittest.main()

View file

@ -42,7 +42,8 @@ src/
| `/network/topology` | redirect → `/topology` | — |
| `/network/tasks/collect` | 采集任务 | `network` |
| `/network/tasks/config-sync` | 配置同步 | `network` |
| `/network/tasks/port-traffic` | 端口流量监控(任务向导 + 大屏) | `network` |
| `/network/tasks/port-traffic` | 端口流量监控(设备管理) | `network` |
| `/network/tasks/port-traffic/wall` | 端口流量大屏 | `network` |
| `/topology` | 拓扑管理(模式化编辑器:选择/平移/拖动/连线、框选、自动布局、拖放添加) | `topology` |
| `/webcrt` | WebCRT 终端 | `webcrt` |
| `/collect` | redirect → `/network/tasks/collect` | — |
@ -107,15 +108,16 @@ src/
## 端口流量监控
- API:`/v1/port-traffic/*`(任务 CRUD、discover/ports、samples、compare、dashboard)
- 接口:大屏与取样均按 `port_traffic_target`(网元 + ifname,含各类接口);样点按 `target_row_id`
- 周期对比:`GET /v1/port-traffic/compare?target_id=&range_hours=&baseline=off|shift|day|week|custom`(同一接口时间平移叠图)
- 手工映射:可选 `baseline_target_id`(可跨任务),基线取自另一个接口;可与周期偏移叠加
- 厂商:ZTE / 华为 / 思科;解析速率 **bit/s**
- 调度:`NETX_PORT_TRAFFIC_SCHEDULER_ENABLED`(默认开),tick `NETX_PORT_TRAFFIC_SCHEDULER_TICK_SEC`(默认 15)
- 单飞:同一任务同时只允许一轮采集;崩溃启动清除 `collect_running`
- 保留:按任务 `retention_days` 清理过期 sample(周对比建议 ≥8 天)
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 大屏;接口选择 + 周期对比 + 跨任务映射基线接口)
- API:`/v1/port-traffic/devices/*`(设备 CRUD、interfaces、samples、compare、dashboard);`discover/ports` 仍按网元拉取接口
- **设备中心**:每台网元(`source`+`ne_id`)一份监控配置;周期/保留/启停挂在设备上;接口归属于该设备
- 唯一性:同一物理口 `(source, ne_id, ifname)` 全局仅允许一个 **active** 监控行,避免重复 CLI 采集
- 调度:按设备到期;一轮采集对该设备 **登录一次** 后批量 `show interface`
- 手工映射:大屏可选其它设备接口作基线叠图;可与周期偏移叠加
- 厂商:ZTE / 华为 / 思科;解析速率 **bit/s**;缺厂商 util 时按 bps/bw 回算
- 调度开关:`NETX_PORT_TRAFFIC_SCHEDULER_ENABLED`(默认开),tick `NETX_PORT_TRAFFIC_SCHEDULER_TICK_SEC`(默认 15)
- 保留:按设备 `retention_days` 清理过期 sample(周对比建议 ≥8 天)
- 前端:`/network/tasks/port-traffic`(设备列表 / 向导 / 编辑 / 采集日志);`/network/tasks/port-traffic/wall`(独立 uPlot 大屏,支持 `?device_id=&target_id=`)
- 采集日志:`GET /v1/port-traffic/devices/{id}/events`;失败写入 `port_traffic_event`,列表操作可查看
- 支持拓扑深链:`?ne_id=&source=managed|ume&ifname=` 打开向导并预填网元
## 拓扑管理

View file

@ -14,6 +14,7 @@ import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage";
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
import { PortTrafficPage } from "./pages/network/PortTrafficPage";
import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage";
import { LoginPage } from "./pages/LoginPage";
import { UsersPage } from "./pages/UsersPage";
import { AuditPage } from "./pages/AuditPage";
@ -95,6 +96,7 @@ function ProtectedApp() {
<Route path="webcrt" element={<NetworkWebcrtRedirect />} />
<Route path="tasks/collect" element={<CollectPage />} />
<Route path="tasks/config-sync" element={<ConfigSyncPage />} />
<Route path="tasks/port-traffic/wall" element={<PortTrafficWallPage />} />
<Route path="tasks/port-traffic" element={<PortTrafficPage />} />
</Route>
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />

View file

@ -50,6 +50,12 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [
labelKey: "network.nav.portTraffic",
group: "tasks",
},
{
id: "port-traffic-wall",
path: "/network/tasks/port-traffic/wall",
labelKey: "network.nav.portTrafficWall",
group: "tasks",
},
],
},
] as const;

View file

@ -60,9 +60,10 @@ export const queryKeys = {
["networkConfigs", page, keyword, source, vendor] 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,
portTrafficDevicesAll: ["portTrafficDevices"] as const,
portTrafficDevices: (page: number) => ["portTrafficDevices", page] as const,
portTrafficTargets: (deviceId: string) => ["portTrafficTargets", deviceId] as const,
portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const,
portTrafficSamples: (targetId: string, rangeHours: number) =>
["portTrafficSamples", targetId, rangeHours] as const,
portTrafficCompare: (

View file

@ -54,6 +54,7 @@ const en = {
collectTasks: "Collection tasks",
configSync: "Config sync",
portTraffic: "Port traffic",
portTrafficWall: "Traffic wall",
},
collapseNav: "Collapse sidebar",
expandNav: "Expand sidebar",
@ -64,21 +65,37 @@ const en = {
},
portTraffic: {
title: "Port traffic",
create: "New task",
create: "Monitor device",
backList: "Back to list",
created: "Task created",
created: "Device monitoring added",
createFailed: "Create failed",
updated: "Saved",
interfacesSaved: "Interfaces updated",
started: "Started",
paused: "Paused",
stopped: "Stopped",
statusRunning: "Running",
statusPaused: "Paused",
statusStopped: "Stopped",
collecting: "Collecting",
deleted: "Deleted",
confirmDelete: "Delete this task and its samples?",
empty: "No monitoring tasks yet. Create one to start.",
confirmDelete: "Delete this device monitoring and its samples?",
empty: "No devices monitored yet. Pick a NE and interfaces to start.",
wall: "Ops wall",
wallTitle: "Port traffic wall",
toDevices: "Manage devices",
wallEmpty: "No monitored devices yet. Add a device and pick interfaces first.",
fullscreen: "Fullscreen",
exitFullscreen: "Exit fullscreen",
edit: "Edit",
save: "Save",
start: "Start",
pause: "Pause",
stop: "Stop",
delete: "Delete",
wizStep1: "Device",
wizStep2: "Interfaces",
wizStep3: "Params",
step1: "Params",
step2: "NEs",
step3: "Interfaces",
@ -93,18 +110,22 @@ const en = {
retention: "Retention (days)",
retentionHintWizard: "Use ≥8 days for week-over-week compare",
concurrency: "Concurrency",
note: "Note",
notePh: "Optional",
startNow: "Start after create",
neKeywordPh: "Name / IP / vendor",
deepLinkHint: "Opened from topology: NE {{ne}}{{ifname}}. Confirm selection, then continue.",
deepLinkIfname: ", interface {{ifname}}",
selectedNe: "{{count}} NE(s) selected",
selectedOneNe: "Selected",
selectedPorts: "{{count}} interface(s) selected",
pickNeFirst: "Select NEs in the previous step first",
pickNeFirst: "Select one NE first",
discover: "Discover interfaces",
confirmCreate: "Create task",
confirmCreate: "Start monitoring",
wallTask: "Task",
wallPort: "Interface",
wallDevice: "Device",
wallDeviceLabel: "Device",
pickPort: "Select interface",
range: "Range",
compare: "Compare",
@ -130,21 +151,38 @@ const en = {
collectStarted: "Collect triggered",
collectBusy: "Collect already running",
targetError: "Last interface error",
waitSamples: "Samples appear after the task runs; or click Collect now. Chart auto-refreshes.",
waitAfterError: "Last collect failed — fix connectivity, then Collect now.",
waitSamples: "Samples appear after the device runs; or click Collect now. Chart auto-refreshes.",
waitAfterError: "Collect failed — open Logs under Traffic monitoring for details, then Collect now.",
log: "Logs",
logTitle: "Collect logs",
logEmpty: "No log entries yet.",
logDevice: "Device",
logLevel: "Level",
logTime: "Time",
logMessage: "Message",
logClose: "Close",
logHasError: "Has error",
kpiLatest: "latest",
kpiBw: "Bandwidth",
latestAt: "Latest sample",
hoverPoint: "Cursor",
wallLiveTitle: "Live traffic",
filterView: "View",
filterMap: "Mapped baseline",
yMode: "Y axis",
yModeAuto: "Auto (incl. baseline)",
yModeCurrent: "Fit current",
yModeUtil: "Utilization %",
kpi: {
tasks: "Tasks",
devices: "Devices",
running: "Running",
ports: "Interfaces",
samples24h: "Samples 24h",
},
col: {
title: "Task",
device: "Device",
status: "Status",
ports: "Interfaces",
interval: "Interval",

View file

@ -53,7 +53,8 @@ const zh = {
webcrt: "WebCRT",
collectTasks: "采集任务",
configSync: "配置同步",
portTraffic: "端口流量监控",
portTraffic: "流量监控",
portTrafficWall: "流量大屏",
},
collapseNav: "折叠侧栏",
expandNav: "展开侧栏",
@ -64,21 +65,37 @@ const zh = {
},
portTraffic: {
title: "端口流量监控",
create: "新建任务",
create: "添加设备监控",
backList: "返回列表",
created: "任务已创建",
created: "已添加设备监控",
createFailed: "创建失败",
updated: "已保存",
interfacesSaved: "接口已更新",
started: "已启动",
paused: "已暂停",
stopped: "已停止",
statusRunning: "运行中",
statusPaused: "已暂停",
statusStopped: "已停止",
collecting: "采集中",
deleted: "已删除",
confirmDelete: "删除该任务及其采样数据?",
empty: "暂无监控任务。请先创建任务。",
confirmDelete: "删除该设备监控及其采样数据?",
empty: "暂无设备监控。请先选择网元并勾选接口。",
wall: "监控大屏",
wallTitle: "端口流量大屏",
toDevices: "设备管理",
wallEmpty: "暂无监控设备。请先添加设备并勾选接口。",
fullscreen: "全屏",
exitFullscreen: "退出全屏",
edit: "编辑",
save: "保存",
start: "启动",
pause: "暂停",
stop: "停止",
delete: "删除",
wizStep1: "选设备",
wizStep2: "选接口",
wizStep3: "参数",
step1: "参数",
step2: "选网元",
step3: "选接口",
@ -93,18 +110,22 @@ const zh = {
retention: "保留(天)",
retentionHintWizard: "若需上周同日对比,建议 ≥8 天",
concurrency: "并发",
note: "备注",
notePh: "可选",
startNow: "创建后立即启动",
neKeywordPh: "名称 / IP / 厂商",
deepLinkHint: "已从拓扑跳转:网元 {{ne}}{{ifname}}。请确认选中后继续创建监控任务。",
deepLinkHint: "已从拓扑跳转:网元 {{ne}}{{ifname}}。请确认选中后继续。",
deepLinkIfname: ",接口 {{ifname}}",
selectedNe: "已选 {{count}} 台网元",
selectedOneNe: "已选",
selectedPorts: "已选 {{count}} 个接口",
pickNeFirst: "请先在上一步选择网元",
pickNeFirst: "请先选择一台网元",
discover: "拉取接口",
confirmCreate: "创建任务",
confirmCreate: "开始监控",
wallTask: "任务",
wallDevice: "设备",
wallPort: "接口",
wallDevice: "网元",
wallDeviceLabel: "网元",
pickPort: "选择接口",
range: "时间范围",
compare: "周期对比",
@ -125,26 +146,43 @@ const zh = {
mapBaselinePort: "映射基线接口",
mapBaselineTask: "映射基线任务",
mapBaselineNone: "不映射",
retentionHint: "周对比建议任务保留天数 ≥ 8;否则基线可能无数据。",
retentionHint: "周对比建议保留天数 ≥ 8;否则基线可能无数据。",
collectNow: "立即采集",
collectStarted: "已触发采集",
collectBusy: "采集进行中",
targetError: "接口最近错误",
waitSamples: "任务运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。",
waitAfterError: "采集失败,请检查连通性后点「立即采集」重试。",
waitSamples: "设备运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。",
waitAfterError: "采集失败,请到「流量监控」操作中的日志查看详情,修复后可点「立即采集」重试。",
log: "日志",
logTitle: "采集日志",
logEmpty: "暂无日志。",
logDevice: "设备",
logLevel: "级别",
logTime: "时间",
logMessage: "内容",
logClose: "关闭",
logHasError: "有错误",
kpiLatest: "最新",
kpiBw: "带宽",
latestAt: "最新样点",
hoverPoint: "光标位置",
wallLiveTitle: "实时流量",
filterView: "视图",
filterMap: "映射基线",
yMode: "纵轴",
yModeAuto: "自动(含基线)",
yModeCurrent: "按当前流量",
yModeUtil: "利用率 %",
kpi: {
tasks: "任务数",
devices: "监控设备",
running: "运行中",
ports: "监控接口",
samples24h: "24h 样点",
},
col: {
title: "任务",
device: "设备",
status: "状态",
ports: "接口",
interval: "周期",

View file

@ -3695,7 +3695,8 @@ pre {
}
.network-main:has(.topo-page),
.network-main:has(.webcrt-shell) {
.network-main:has(.webcrt-shell),
.network-main:has(.pt-wall-shell) {
padding: 0;
overflow: hidden;
}
@ -3714,6 +3715,12 @@ pre {
border: 0;
}
.network-main .pt-wall-shell {
height: 100%;
margin: 0;
min-height: 0;
}
.network-placeholder h2 {
margin: 0 0 8px;
}
@ -3763,58 +3770,578 @@ pre {
color: #334155;
}
.pt-wall-page__filters {
margin-bottom: 12px;
gap: 12px 18px;
align-items: flex-end;
.pt-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.pt-wall-page__filters label {
display: inline-flex;
.pt-list-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.pt-list-kpi {
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.pt-list-kpi--live {
background: #eff6ff;
border-color: #bfdbfe;
}
.pt-list-kpi__label {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
}
.pt-list-kpi__value {
font-size: 22px;
font-weight: 650;
font-variant-numeric: tabular-nums;
color: #0f172a;
line-height: 1.2;
}
.pt-list-kpi--live .pt-list-kpi__value {
color: #1d4ed8;
}
.pt-list-table-wrap {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: auto;
}
.pt-list-table {
margin: 0;
}
.pt-list-table thead th {
background: #f8fafc;
font-size: 12px;
font-weight: 600;
color: #64748b;
white-space: nowrap;
}
.pt-list-table tbody td {
vertical-align: middle;
}
.pt-list-table tbody tr:hover td {
background: #f8fafc;
}
.pt-list-task-name {
font-weight: 600;
color: #0f172a;
}
.pt-list-task-err {
margin-top: 2px;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #b91c1c;
}
.pt-list-task-err-btn {
display: inline-block;
margin-top: 4px;
padding: 0;
border: 0;
background: transparent;
color: #b91c1c;
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.pt-list-task-err-btn:hover {
color: #991b1b;
}
.pt-log-modal__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.pt-log-modal__head h3 {
margin: 0;
}
.pt-log-modal__sub {
margin: 4px 0 0;
font-size: 13px;
}
.pt-log-modal__list {
display: flex;
flex-direction: column;
gap: 4px;
gap: 10px;
max-height: min(60vh, 520px);
overflow: auto;
}
.pt-log-item {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
background: #f8fafc;
}
.pt-log-item--error {
border-color: #fecaca;
background: #fef2f2;
}
.pt-log-item--warn {
border-color: #fde68a;
background: #fffbeb;
}
.pt-log-item__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-bottom: 6px;
font-size: 12px;
color: #64748b;
}
.pt-wall-page__filters select {
min-width: 160px;
.pt-log-item__level {
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
color: #b91c1c;
}
.pt-log-item--warn .pt-log-item__level {
color: #b45309;
}
.pt-log-item--info .pt-log-item__level {
color: #0369a1;
}
.pt-log-item__if {
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
color: #0f172a;
}
.pt-log-item__msg {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.45;
color: #1e293b;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.pt-list-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
}
.pt-list-status + .pt-list-status {
margin-left: 6px;
}
.pt-list-status--running {
background: #dcfce7;
color: #166534;
}
.pt-list-status--running::before,
.pt-list-status--collect::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.pt-list-status--paused {
background: #fef9c3;
color: #854d0e;
}
.pt-list-status--stopped,
.pt-list-status--other {
background: #f1f5f9;
color: #475569;
}
.pt-list-status--collect {
background: #e0e7ff;
color: #3730a3;
}
.pt-list-num,
.pt-list-time {
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: #334155;
}
.pt-list-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.pt-list-actions .btn-primary,
.pt-list-actions .btn--danger,
.pt-list-actions button {
padding: 4px 10px;
font-size: 12px;
line-height: 1.4;
}
.pt-list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 200px;
padding: 32px 16px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
text-align: center;
}
.pt-list-empty p {
margin: 0;
}
.pt-list-pager {
justify-content: center;
}
@media (max-width: 900px) {
.pt-list-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.pt-wall-shell {
display: flex;
flex-direction: column;
min-height: 0;
color: #e2e8f0;
background:
radial-gradient(1200px 480px at 12% -10%, rgba(45, 212, 191, 0.08), transparent 55%),
radial-gradient(900px 420px at 88% 0%, rgba(56, 189, 248, 0.06), transparent 50%),
linear-gradient(180deg, #0a1220 0%, #070d18 100%);
}
.pt-wall-shell__empty {
flex: 1;
display: grid;
place-content: center;
gap: 12px;
color: #94a3b8;
}
.pt-wall-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 10px 14px 12px;
gap: 0;
}
.pt-wall-page__hint {
margin: 0 0 8px;
flex: 0 0 auto;
font-size: 12px;
color: #94a3b8;
}
.pt-wall-page__stage {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.pt-wall-page__stage:fullscreen,
.pt-wall-page__stage.is-fullscreen:fullscreen {
width: 100vw;
height: 100vh;
padding: 20px 24px;
box-sizing: border-box;
background:
radial-gradient(1200px 480px at 12% -10%, rgba(45, 212, 191, 0.08), transparent 55%),
linear-gradient(180deg, #0a1220 0%, #070d18 100%);
}
.pt-wall-page__stage .pt-wall {
flex: 1;
min-height: 0;
height: 100%;
}
.pt-wall-page__fs-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 11px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 6px;
background: transparent;
color: #cbd5e1;
font-size: 12px;
cursor: pointer;
}
.pt-wall-page__fs-btn--toolbar {
flex: 0 0 auto;
align-self: flex-end;
}
.pt-wall-page__fs-btn:hover {
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.45);
color: #f8fafc;
}
.app-main .pt-wall-page__fs-btn {
background: transparent;
color: #cbd5e1;
}
.app-main .pt-wall-page__fs-btn:hover {
background: rgba(148, 163, 184, 0.12);
color: #f8fafc;
}
.pt-wall-page__toolbar {
display: flex;
flex-direction: column;
gap: 8px;
flex: 0 0 auto;
margin-bottom: 10px;
padding: 0 0 10px;
background: transparent;
border: 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 0;
}
.pt-wall-page__row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px 10px;
}
.pt-wall-page__row--primary {
padding-bottom: 0;
border-bottom: 0;
}
.pt-wall-page__row--secondary {
align-items: flex-end;
}
.pt-wall-page__field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 11px;
color: #64748b;
min-width: 0;
}
.pt-wall-page__field select,
.pt-wall-page__field input {
height: 30px;
padding: 0 9px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 6px;
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
font-size: 12px;
}
.pt-wall-page__field select:hover,
.pt-wall-page__field input:hover,
.pt-wall-page__field select:focus,
.pt-wall-page__field input:focus {
border-color: rgba(45, 212, 191, 0.45);
outline: none;
}
.app-main .pt-wall-page__field select,
.app-main .pt-wall-page__field input {
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
border-color: rgba(148, 163, 184, 0.22);
}
.pt-wall-page__field--task {
flex: 0 1 160px;
min-width: 128px;
}
.pt-wall-page__field--port {
flex: 1 1 220px;
min-width: 180px;
}
.pt-wall-page__field--sm {
flex: 0 0 78px;
}
.pt-wall-page__field--md {
flex: 0 1 148px;
min-width: 120px;
}
.pt-wall-page__field--port select {
width: 100%;
}
.pt-wall-page__collect {
flex: 0 0 auto;
height: 30px;
margin-left: auto;
align-self: flex-end;
white-space: nowrap;
border-radius: 6px;
border: 1px solid rgba(45, 212, 191, 0.35);
background: rgba(45, 212, 191, 0.16);
color: #99f6e4;
font-size: 12px;
}
.app-main .pt-wall-page__collect.btn-primary {
background: rgba(45, 212, 191, 0.16);
border-color: rgba(45, 212, 191, 0.35);
color: #99f6e4;
}
.app-main .pt-wall-page__collect.btn-primary:hover:not(:disabled) {
background: rgba(45, 212, 191, 0.28);
color: #ecfdf5;
}
.pt-wall-page__cluster {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 8px 10px;
min-width: 0;
}
.pt-wall-page__cluster--map {
flex: 1 1 280px;
}
.pt-wall-page__cluster-label {
flex: 0 0 auto;
padding-bottom: 7px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #475569;
white-space: nowrap;
}
.pt-wall-page__cluster-fields {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px 10px;
}
.pt-wall-page__cluster-sep {
width: 1px;
align-self: stretch;
margin: 4px 6px 0;
background: rgba(148, 163, 184, 0.14);
flex: 0 0 1px;
}
@media (max-width: 900px) {
.pt-wall-page__cluster {
flex-direction: column;
align-items: stretch;
}
.pt-wall-page__cluster-label {
padding-bottom: 0;
}
.pt-wall-page__cluster-sep {
display: none;
}
.pt-wall-page__collect {
margin-left: 0;
}
.pt-wall-page__field--port {
flex-basis: 100%;
}
}
.pt-wall {
--pt-bg: #0b1220;
--pt-panel: #111827;
--pt-line: rgba(148, 163, 184, 0.22);
--pt-bg: #0a1220;
--pt-panel: transparent;
--pt-line: rgba(148, 163, 184, 0.16);
--pt-text: #e2e8f0;
--pt-muted: #94a3b8;
--pt-muted: #8b9bb0;
--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;
--pt-out: #fbbf24;
display: flex;
flex-direction: column;
min-height: 0;
background: transparent;
border: 0;
border-radius: 0;
padding: 4px 2px 0;
color: var(--pt-text);
}
.pt-wall__head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 14px;
align-items: flex-end;
gap: 16px;
margin-bottom: 12px;
flex: 0 0 auto;
}
.pt-wall__head-title {
@ -3832,51 +4359,71 @@ pre {
}
.pt-wall__ne {
font-size: 13px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--pt-muted);
}
.pt-wall__if {
font-size: 20px;
font-weight: 650;
letter-spacing: 0.02em;
font-size: 22px;
font-weight: 600;
letter-spacing: 0.01em;
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
color: #f8fafc;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: min(720px, 72vw);
}
.pt-wall__range {
font-size: 12px;
color: var(--pt-muted);
white-space: nowrap;
padding-bottom: 3px;
font-variant-numeric: tabular-nums;
}
.pt-wall__kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
gap: 0;
margin-bottom: 14px;
flex: 0 0 auto;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 8px;
background: rgba(15, 23, 42, 0.35);
overflow: hidden;
}
.pt-wall__kpi {
background: rgba(17, 24, 39, 0.72);
border: 1px solid var(--pt-line);
border-radius: 8px;
padding: 10px 12px;
background: transparent;
border: 0;
border-radius: 0;
border-right: 1px solid rgba(148, 163, 184, 0.1);
padding: 12px 16px;
}
.pt-wall__kpi:last-child {
border-right: 0;
}
.pt-wall__kpi-label {
font-size: 11px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--pt-muted);
margin-bottom: 4px;
letter-spacing: 0.08em;
color: #64748b;
margin-bottom: 6px;
}
.pt-wall__kpi-value {
font-size: 22px;
font-weight: 650;
font-size: 20px;
font-weight: 600;
font-variant-numeric: tabular-nums;
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
line-height: 1.2;
line-height: 1.15;
color: #f1f5f9;
}
.pt-wall__kpi--in .pt-wall__kpi-value {
@ -3887,16 +4434,33 @@ pre {
color: var(--pt-out);
}
.pt-wall__kpi--util .pt-wall__kpi-value {
color: #7dd3fc;
}
.pt-wall__kpi-unit,
.pt-wall__kpi-sep {
margin-left: 4px;
font-size: 12px;
font-size: 11px;
color: var(--pt-muted);
font-weight: 500;
}
.pt-wall__chart-wrap {
position: relative;
flex: 1;
min-height: 180px;
width: 100%;
border-radius: 8px;
background: rgba(8, 14, 26, 0.45);
border: 1px solid rgba(148, 163, 184, 0.1);
padding: 8px 8px 4px;
box-sizing: border-box;
}
.pt-wall__chart {
min-height: 260px;
min-height: 0;
height: 100%;
width: 100%;
}
@ -3905,12 +4469,6 @@ pre {
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
}
.pt-wall__chart-wrap {
position: relative;
min-height: 260px;
width: 100%;
}
.pt-wall__tip {
position: absolute;
top: 0;
@ -3919,13 +4477,14 @@ pre {
min-width: 150px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.94);
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(8, 14, 26, 0.94);
color: #e2e8f0;
font-size: 12px;
line-height: 1.45;
pointer-events: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(6px);
}
.pt-wall__tip-time {
@ -3946,45 +4505,99 @@ pre {
}
.pt-wall__tip-row--out strong {
color: #f59e0b;
color: #fbbf24;
}
.pt-wall__chart .u-axis {
border: 0 !important;
}
.pt-wall__chart .u-legend {
color: var(--pt-muted);
}
.pt-wall__chart .u-legend .u-value {
display: none;
display: none !important;
}
.pt-wall__foot {
display: flex;
flex-wrap: wrap;
gap: 10px 28px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 8px 20px;
margin-top: 8px;
padding-top: 0;
border-top: 0;
flex: 0 0 auto;
min-height: 28px;
}
.pt-wall__legend {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
color: var(--pt-muted);
font-size: 12px;
line-height: 1.2;
}
.pt-wall__legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.pt-wall__legend-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
flex: 0 0 10px;
}
.pt-wall__legend-item--in .pt-wall__legend-swatch {
background: #2dd4bf;
}
.pt-wall__legend-item--out .pt-wall__legend-swatch {
background: #fbbf24;
}
.pt-wall__legend-item--base-in .pt-wall__legend-swatch {
background: rgba(45, 212, 191, 0.45);
box-shadow: inset 0 0 0 1px rgba(45, 212, 191, 0.7);
}
.pt-wall__legend-item--base-out .pt-wall__legend-swatch {
background: rgba(251, 191, 36, 0.4);
box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.65);
}
.pt-wall__foot-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 20px;
margin-left: auto;
}
.pt-wall__foot-ne,
.pt-wall__foot-if {
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
gap: 6px 10px;
min-width: 0;
}
.pt-wall__foot-label {
font-size: 11px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--pt-muted);
letter-spacing: 0.08em;
color: #64748b;
}
.pt-wall__foot-value {
font-size: 15px;
font-size: 13px;
font-weight: 600;
color: #e2e8f0;
}
@ -4002,7 +4615,7 @@ pre {
.pt-wall__empty {
position: absolute;
inset: 0;
inset: 8px;
display: flex;
align-items: center;
justify-content: center;
@ -4010,9 +4623,9 @@ pre {
text-align: center;
line-height: 1.5;
color: var(--pt-muted);
background: rgba(11, 18, 32, 0.72);
border: 1px dashed rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: rgba(8, 14, 26, 0.55);
border: 1px dashed rgba(148, 163, 184, 0.22);
border-radius: 6px;
pointer-events: none;
}
@ -4020,4 +4633,12 @@ pre {
.pt-wall__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pt-wall__kpi:nth-child(2n) {
border-right: 0;
}
.pt-wall__kpi:nth-child(-n + 2) {
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
}

View file

@ -104,7 +104,10 @@ export function NetworkLayout() {
className={({ isActive }) =>
`network-nav__link${isActive ? " is-active" : ""}`
}
end={item.path === "/network/devices"}
end={
item.path === "/network/devices" ||
item.path === "/network/tasks/port-traffic"
}
>
{t(item.labelKey)}
</NavLink>
@ -127,7 +130,10 @@ export function NetworkLayout() {
className={({ isActive }) =>
`network-nav__rail-link${isActive ? " is-active" : ""}`
}
end={item.path === "/network/devices"}
end={
item.path === "/network/devices" ||
item.path === "/network/tasks/port-traffic"
}
>
{t(item.labelKey).slice(0, 1)}
</NavLink>

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,8 @@ import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types";
import { formatSystemTime, parseApiTime } from "../../utils/time";
import { useI18n } from "../../i18n";
export type WallYMode = "auto" | "current" | "util";
function formatBps(n: number): string {
const v = Math.abs(n);
if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`;
@ -18,13 +20,59 @@ function formatBwLabel(bps: number): string {
return `${formatBps(bps)}bit/s`;
}
function formatUtilPct(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0%";
if (n < 0.01) return "<0.01%";
if (n < 1) return `${n.toFixed(2)}%`;
return `${n.toFixed(1)}%`;
}
function resolveUtil(vendorUtil: number, bps: number, bw: number): number {
if (vendorUtil > 0) return vendorUtil;
if (bw > 0 && bps > 0) return (bps / bw) * 100;
return vendorUtil || 0;
}
function formatHoverTime(sec: number): string {
return formatSystemTime(new Date(sec * 1000));
}
function yPadRange(min: number, max: number): [number, number] {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
if (min === max) {
const pad = Math.max(max * 0.25 || 0.1, max > 0 && max < 1 ? max * 0.5 : 10);
return [Math.max(0, min - pad * 0.1), max + pad];
}
return [Math.max(0, min * 0.95), max * 1.05];
}
function finiteMax(vals: number[]): number {
let m = Number.NEGATIVE_INFINITY;
for (const v of vals) {
if (Number.isFinite(v) && v > m) m = v;
}
return Number.isFinite(m) ? m : 0;
}
function pointValue(
p: PortTrafficSamplePoint,
dir: "in" | "out",
mode: WallYMode,
bwFallback: number,
): number {
const bps = dir === "in" ? Number(p.in_bps) || 0 : Number(p.out_bps) || 0;
if (mode !== "util") return bps;
const bw = Number(p.bw_bps) || bwFallback || 0;
const vendor = dir === "in" ? Number(p.in_util_pct) || 0 : Number(p.out_util_pct) || 0;
return resolveUtil(vendor, bps, bw);
}
function toAlignedSeries(
current: PortTrafficSamplePoint[],
baseline: PortTrafficSamplePoint[],
mode: WallYMode,
bwFallback: number,
baseBwFallback: number,
): [number[], number[], number[], number[], number[]] {
type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number };
const map = new Map<number, Row>();
@ -33,8 +81,8 @@ function toAlignedSeries(
if (!d) continue;
const x = Math.floor(d.getTime() / 1000);
const row = map.get(x) || { x };
row.inC = Number(p.in_bps) || 0;
row.outC = Number(p.out_bps) || 0;
row.inC = pointValue(p, "in", mode, bwFallback);
row.outC = pointValue(p, "out", mode, bwFallback);
map.set(x, row);
}
for (const p of baseline) {
@ -42,8 +90,8 @@ function toAlignedSeries(
if (!d) continue;
const x = Math.floor(d.getTime() / 1000);
const row = map.get(x) || { x };
row.inB = Number(p.in_bps) || 0;
row.outB = Number(p.out_bps) || 0;
row.inB = pointValue(p, "in", mode, baseBwFallback || bwFallback);
row.outB = pointValue(p, "out", mode, baseBwFallback || bwFallback);
map.set(x, row);
}
const rows = [...map.values()].sort((a, b) => a.x - b.x);
@ -61,7 +109,12 @@ function toAlignedSeries(
function chartSize(el: HTMLElement): { width: number; height: number } {
const width = Math.max(320, el.clientWidth || el.parentElement?.clientWidth || 800);
const height = Math.max(260, Math.min(440, Math.floor(width * 0.34)));
const parentH = el.clientHeight || el.parentElement?.clientHeight || 0;
// Fill staged layout / fullscreen; fall back to aspect ratio in compact panels.
const height =
parentH >= 180
? Math.max(180, parentH)
: Math.max(260, Math.min(440, Math.floor(width * 0.34)));
return { width, height };
}
@ -73,6 +126,7 @@ type Props = {
points: PortTrafficSamplePoint[];
baselinePoints?: PortTrafficSamplePoint[];
rangeLabel: string;
yMode?: WallYMode;
loading?: boolean;
hint?: string;
};
@ -83,6 +137,7 @@ export function PortTrafficWall({
points,
baselinePoints = EMPTY_POINTS,
rangeLabel,
yMode = "auto",
loading,
hint,
}: Props) {
@ -92,21 +147,27 @@ export function PortTrafficWall({
const tipRef = useRef<HTMLDivElement | null>(null);
const pointsRef = useRef(points);
const baselineRef = useRef(baselinePoints);
const yModeRef = useRef(yMode);
const bwRef = useRef(0);
pointsRef.current = points;
baselineRef.current = baselinePoints;
yModeRef.current = yMode;
const latest = points.length ? points[points.length - 1] : null;
const kpi = useMemo(() => {
const bw = latest?.bw_bps || target?.bw_bps || 0;
const inBps = latest?.in_bps ?? 0;
const outBps = latest?.out_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,
inBps,
outBps,
inUtil: resolveUtil(latest?.in_util_pct ?? 0, inBps, bw),
outUtil: resolveUtil(latest?.out_util_pct ?? 0, outBps, bw),
ts: latest?.ts ?? null,
};
}, [latest, target]);
bwRef.current = kpi.bw;
useEffect(() => {
const el = mountRef.current;
@ -139,16 +200,16 @@ export function PortTrafficWall({
label: t("portTraffic.seriesCurrentIn"),
stroke: "#2dd4bf",
width: 2,
fill: "rgba(45, 212, 191, 0.12)",
points: { show: true, size: 5, fill: "#2dd4bf" },
fill: "rgba(45, 212, 191, 0.08)",
points: { show: false },
spanGaps: false,
},
{
label: t("portTraffic.seriesCurrentOut"),
stroke: "#f59e0b",
stroke: "#fbbf24",
width: 2,
fill: "rgba(245, 158, 11, 0.10)",
points: { show: true, size: 5, fill: "#f59e0b" },
fill: "rgba(251, 191, 36, 0.07)",
points: { show: false },
spanGaps: false,
},
{
@ -162,7 +223,7 @@ export function PortTrafficWall({
},
{
label: t("portTraffic.seriesBaselineOut"),
stroke: "rgba(245, 158, 11, 0.45)",
stroke: "rgba(251, 191, 36, 0.4)",
width: 1.5,
dash: [6, 4],
points: { show: false },
@ -173,16 +234,20 @@ export function PortTrafficWall({
axes: [
{
stroke: "#94a3b8",
grid: { stroke: "rgba(148,163,184,0.18)", width: 1 },
ticks: { stroke: "rgba(148,163,184,0.35)" },
grid: { show: false },
ticks: { stroke: "rgba(148,163,184,0.35)", size: 4 },
border: { show: false },
gap: 6,
},
{
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)),
border: { show: false },
values: (_u, splits) =>
splits.map((v) => (yModeRef.current === "util" ? `${v}` : formatBps(v))),
size: 64,
label: "bit/s",
label: yMode === "util" ? "%" : "bit/s",
labelFont: "12px sans-serif",
labelSize: 14,
},
@ -191,17 +256,19 @@ export function PortTrafficWall({
x: { time: true },
y: {
auto: true,
range: (_u, min, max) => {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
if (min === max) {
const pad = Math.max(10, Math.abs(max) * 0.25 || 10);
return [Math.max(0, min - pad), max + pad];
range: (u, min, max) => {
if (yModeRef.current === "current") {
const vals = [...(u.data[1] || []), ...(u.data[2] || [])].filter((v) =>
Number.isFinite(v),
) as number[];
if (!vals.length) return yPadRange(min, max);
return yPadRange(0, finiteMax(vals));
}
return [Math.max(0, min * 0.95), max * 1.05];
return yPadRange(min, max);
},
},
},
legend: { show: true, live: false },
legend: { show: false },
cursor: {
drag: { x: true, y: false },
focus: { prox: 32 },
@ -221,10 +288,6 @@ export function PortTrafficWall({
}
const tsSec = Number(u.data[0][idx]);
const inBps = Number(u.data[1]?.[idx]);
const outBps = Number(u.data[2]?.[idx]);
const inBase = Number(u.data[3]?.[idx]);
const outBase = Number(u.data[4]?.[idx]);
const sample = pointsRef.current.find((p) => {
const d = parseApiTime(p.ts);
return d && Math.floor(d.getTime() / 1000) === tsSec;
@ -233,26 +296,39 @@ export function PortTrafficWall({
const d = parseApiTime(p.ts);
return d && Math.floor(d.getTime() / 1000) === tsSec;
});
const mode = yModeRef.current;
const yIn = Number(u.data[1]?.[idx]);
const yOut = Number(u.data[2]?.[idx]);
const yInB = Number(u.data[3]?.[idx]);
const yOutB = Number(u.data[4]?.[idx]);
const unit = mode === "util" ? "%" : "bit/s";
const fmtY = (v: number) =>
mode === "util" ? formatUtilPct(v).replace(/%$/, "") : formatBps(v);
if (!tip) return;
tip.style.display = "block";
const rows = [
`<div class="pt-wall__tip-time">${formatHoverTime(tsSec)}</div>`,
Number.isFinite(inBps)
? `<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${formatBps(inBps)} bit/s</strong></div>`
Number.isFinite(yIn)
? `<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${fmtY(yIn)} ${unit}</strong></div>`
: "",
Number.isFinite(outBps)
? `<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${formatBps(outBps)} bit/s</strong></div>`
: "",
sample
? `<div class="pt-wall__tip-row"><span>Util</span><strong>${Number(sample.in_util_pct || 0).toFixed(1)}% / ${Number(sample.out_util_pct || 0).toFixed(1)}%</strong></div>`
Number.isFinite(yOut)
? `<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${fmtY(yOut)} ${unit}</strong></div>`
: "",
];
if (Number.isFinite(inBase) || Number.isFinite(outBase)) {
if (sample) {
const bw = Number(sample.bw_bps) || bwRef.current || 0;
const inU = resolveUtil(Number(sample.in_util_pct) || 0, Number(sample.in_bps) || 0, bw);
const outU = resolveUtil(Number(sample.out_util_pct) || 0, Number(sample.out_bps) || 0, bw);
rows.push(
`<div class="pt-wall__tip-row"><span>Util</span><strong>${formatUtilPct(inU)} / ${formatUtilPct(outU)}</strong></div>`,
);
}
if (Number.isFinite(yInB) || Number.isFinite(yOutB)) {
rows.push(
`<div class="pt-wall__tip-row"><span>${t("portTraffic.baseline")}</span><strong>${
Number.isFinite(inBase) ? formatBps(inBase) : "—"
} / ${Number.isFinite(outBase) ? formatBps(outBase) : "—"} bit/s</strong></div>`,
Number.isFinite(yInB) ? fmtY(yInB) : "—"
} / ${Number.isFinite(yOutB) ? fmtY(yOutB) : "—"} ${unit}</strong></div>`,
);
if (baseSample?.ts_raw) {
rows.push(
@ -279,8 +355,16 @@ export function PortTrafficWall({
plotRef.current.setSize(chartSize(mountRef.current));
};
window.addEventListener("resize", onResize);
const ro =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => onResize())
: null;
ro?.observe(el);
const wrap = el.parentElement;
if (wrap) ro?.observe(wrap);
return () => {
window.removeEventListener("resize", onResize);
ro?.disconnect();
plotRef.current?.over.removeEventListener("mouseleave", onLeave);
plotRef.current?.destroy();
plotRef.current = null;
@ -291,9 +375,9 @@ export function PortTrafficWall({
useEffect(() => {
const plot = plotRef.current;
if (!plot) return;
// Always setData with resetScales; toggling series.show before setData can leave
// uPlot stuck on a blank [0,1] y-range until a later update.
const data = toAlignedSeries(points, baselinePoints);
const bw = kpi.bw || target?.bw_bps || 0;
const baseBw = baselineTarget?.bw_bps || bw;
const data = toAlignedSeries(points, baselinePoints, yMode, bw, baseBw);
const showBaseline = baselinePoints.length > 0;
plot.setData(data, true);
const s3 = Boolean(plot.series[3]?.show);
@ -302,15 +386,22 @@ export function PortTrafficWall({
plot.setSeries(3, { show: showBaseline }, false);
plot.setSeries(4, { show: showBaseline }, false);
}
const yAxis = plot.axes[1];
if (yAxis) {
const nextLabel = yMode === "util" ? "%" : "bit/s";
if (yAxis.label !== nextLabel) {
yAxis.label = nextLabel;
plot.redraw(false, false);
}
}
const el = mountRef.current;
if (el) {
// Legend show/hide changes layout; size after paint.
requestAnimationFrame(() => {
if (!plotRef.current || !mountRef.current) return;
plotRef.current.setSize(chartSize(mountRef.current));
});
}
}, [points, baselinePoints]);
}, [points, baselinePoints, yMode, kpi.bw, target?.bw_bps, baselineTarget?.bw_bps]);
const empty = points.length === 0 && baselinePoints.length === 0;
const neLabel = target ? target.ne_name || target.ne_ip || "—" : "—";
@ -326,7 +417,15 @@ export function PortTrafficWall({
return (
<div className="pt-wall">
<div className="pt-wall__head">
<div className="pt-wall__head-title">{t("portTraffic.wallLiveTitle")}</div>
<div className="pt-wall__title">
<div className="pt-wall__ne">
{neLabel}
{ipLabel ? ` · ${ipLabel}` : ""}
</div>
<div className="pt-wall__if" title={ifLabel}>
{ifLabel}
</div>
</div>
<div className="pt-wall__range">
{rangeLabel}
{kpi.ts ? ` · ${t("portTraffic.latestAt")} ${formatSystemTime(kpi.ts)}` : ""}
@ -334,37 +433,29 @@ export function PortTrafficWall({
</div>
<div className="pt-wall__kpis">
<div className="pt-wall__kpi">
<div className="pt-wall__kpi-label">
{t("portTraffic.kpiBw")} · {t("portTraffic.kpiLatest")}
</div>
<div className="pt-wall__kpi-label">{t("portTraffic.kpiBw")}</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 · {t("portTraffic.kpiLatest")}
</div>
<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 · {t("portTraffic.kpiLatest")}
</div>
<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 · {t("portTraffic.kpiLatest")}
</div>
<div className="pt-wall__kpi pt-wall__kpi--util">
<div className="pt-wall__kpi-label">Util</div>
<div className="pt-wall__kpi-value">
{kpi.inUtil.toFixed(1)}%
{formatUtilPct(kpi.inUtil)}
<span className="pt-wall__kpi-sep">/</span>
{kpi.outUtil.toFixed(1)}%
{formatUtilPct(kpi.outUtil)}
</div>
</div>
</div>
@ -378,17 +469,30 @@ export function PortTrafficWall({
) : null}
</div>
<div className="pt-wall__foot">
<div className="pt-wall__foot-ne" title={neLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.wallDevice")}</span>
<span className="pt-wall__foot-value">{neLabel}</span>
{ipLabel ? <span className="pt-wall__foot-ip">{ipLabel}</span> : null}
</div>
<div className="pt-wall__foot-if" title={ifLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.wallPort")}</span>
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{ifLabel}</span>
<div className="pt-wall__legend" aria-hidden={!points.length && !baselinePoints.length}>
<span className="pt-wall__legend-item pt-wall__legend-item--in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentOut")}
</span>
{showBaselineMeta ? (
<>
<span className="pt-wall__legend-item pt-wall__legend-item--base-in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--base-out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineOut")}
</span>
</>
) : null}
</div>
{showBaselineMeta ? (
<>
<div className="pt-wall__foot-meta">
<div className="pt-wall__foot-ne" title={baseNeLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.baselineDevice")}</span>
<span className="pt-wall__foot-value">{baseNeLabel}</span>
@ -398,7 +502,7 @@ export function PortTrafficWall({
<span className="pt-wall__foot-label">{t("portTraffic.baselinePort")}</span>
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{baseIfLabel}</span>
</div>
</>
</div>
) : null}
</div>
</div>

View file

@ -0,0 +1,389 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import {
collectPortTrafficNow,
fetchPortTrafficCompare,
fetchPortTrafficDevices,
fetchPortTrafficTargets,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import type { PortTrafficDevice, PortTrafficSamplePoint } from "../../types";
import { PortTrafficWall, type WallYMode } from "./PortTrafficWall";
const POLL_MS = 5000;
const EMPTY_COMPARE_POINTS: PortTrafficSamplePoint[] = [];
type BaselineMode = "off" | "shift" | "day" | "week" | "custom";
function deviceLabel(d: PortTrafficDevice) {
return d.ne_name || d.ne_ip || d.ne_id || "—";
}
function FullscreenIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
/>
</svg>
);
}
export function PortTrafficWallPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkAppliedRef = useRef(false);
const stageRef = useRef<HTMLDivElement | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const [wallDeviceId, setWallDeviceId] = useState(() => (searchParams.get("device_id") || "").trim());
const [wallTargetId, setWallTargetId] = useState(() => (searchParams.get("target_id") || "").trim());
const [mapBaselineDeviceId, setMapBaselineDeviceId] = useState("");
const [mapBaselineTargetId, setMapBaselineTargetId] = useState("");
const [rangeHours, setRangeHours] = useState(24);
const [baseline, setBaseline] = useState<BaselineMode>("off");
const [customOffsetHours, setCustomOffsetHours] = useState(48);
const [yMode, setYMode] = useState<WallYMode>("auto");
const devicesQuery = useQuery({
queryKey: queryKeys.portTrafficDevices(0),
queryFn: () => fetchPortTrafficDevices({ page: 1, pageSize: 100 }),
staleTime: 5000,
refetchInterval: POLL_MS,
});
const wallTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(wallDeviceId),
queryFn: () => fetchPortTrafficTargets(wallDeviceId),
enabled: Boolean(wallDeviceId),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const mapBaselineTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(mapBaselineDeviceId),
queryFn: () => fetchPortTrafficTargets(mapBaselineDeviceId),
enabled: Boolean(mapBaselineDeviceId),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const compareQuery = useQuery({
queryKey: queryKeys.portTrafficCompare(
wallTargetId,
rangeHours,
baseline,
baseline === "custom" ? customOffsetHours : 0,
mapBaselineTargetId,
),
queryFn: () =>
fetchPortTrafficCompare({
targetId: wallTargetId,
rangeHours,
baseline,
offsetHours: baseline === "custom" ? customOffsetHours : undefined,
baselineTargetId: mapBaselineTargetId || undefined,
}),
enabled: Boolean(wallTargetId),
staleTime: 1000,
placeholderData: keepPreviousData,
refetchInterval: (q) => {
const n = q.state.data?.current?.length ?? 0;
return n === 0 ? 2500 : POLL_MS;
},
});
const devices = devicesQuery.data?.items || [];
const wallTargets = useMemo(
() => (wallTargetsQuery.data?.items || []).filter((x) => x.status === "active"),
[wallTargetsQuery.data?.items],
);
const selectedWallTarget = wallTargets.find((x) => x.id === wallTargetId) || null;
const mapBaselineOptions = useMemo(
() =>
(mapBaselineTargetsQuery.data?.items || []).filter(
(x) => x.status === "active" && x.id !== wallTargetId,
),
[mapBaselineTargetsQuery.data?.items, wallTargetId],
);
const wallPoints = compareQuery.data?.current ?? EMPTY_COMPARE_POINTS;
const wallBaselinePoints =
baseline === "off" && !mapBaselineTargetId
? EMPTY_COMPARE_POINTS
: (compareQuery.data?.baseline ?? EMPTY_COMPARE_POINTS);
useEffect(() => {
if (deepLinkAppliedRef.current) return;
const did = (searchParams.get("device_id") || "").trim();
const tid = (searchParams.get("target_id") || "").trim();
deepLinkAppliedRef.current = true;
if (did) setWallDeviceId(did);
if (tid) setWallTargetId(tid);
if (did || tid) setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]);
useEffect(() => {
if (wallDeviceId || !devices.length) return;
setWallDeviceId(devices[0].id);
}, [wallDeviceId, devices]);
useEffect(() => {
if (!wallDeviceId || wallTargetId || !wallTargets.length) return;
setWallTargetId(wallTargets[0].id);
}, [wallDeviceId, wallTargetId, wallTargets]);
useEffect(() => {
if (!mapBaselineTargetId) return;
if (mapBaselineTargetId === wallTargetId) {
setMapBaselineTargetId("");
return;
}
if (
mapBaselineDeviceId &&
mapBaselineTargetsQuery.isFetched &&
!mapBaselineOptions.some((x) => x.id === mapBaselineTargetId)
) {
setMapBaselineTargetId("");
}
}, [
mapBaselineTargetId,
wallTargetId,
mapBaselineDeviceId,
mapBaselineOptions,
mapBaselineTargetsQuery.isFetched,
]);
useEffect(() => {
const syncFs = () => {
const el = stageRef.current;
setFullscreen(Boolean(el && document.fullscreenElement === el));
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
};
document.addEventListener("fullscreenchange", syncFs);
return () => document.removeEventListener("fullscreenchange", syncFs);
}, []);
const toggleFullscreen = async () => {
const el = stageRef.current;
if (!el) return;
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
} catch (err) {
showError(String(err));
}
};
const collectMut = useMutation({
mutationFn: collectPortTrafficNow,
onSuccess: (res) => {
showOk(res.started ? t("portTraffic.collectStarted") : t("portTraffic.collectBusy"));
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficDevicesAll });
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTargets(wallDeviceId) });
void queryClient.invalidateQueries({ queryKey: ["portTrafficCompare"] });
},
onError: (e: Error) => showError(e.message),
});
return (
<section className="pt-wall-shell">
{!devices.length && !devicesQuery.isLoading ? (
<div className="pt-list-empty pt-wall-shell__empty">
<p>{t("portTraffic.wallEmpty")}</p>
<Link to="/network/tasks/port-traffic" className="btn-primary" style={{ textDecoration: "none" }}>
{t("portTraffic.create")}
</Link>
</div>
) : (
<div className="pt-wall-page">
<div className="pt-wall-page__toolbar">
<div className="pt-wall-page__row pt-wall-page__row--primary">
<label className="pt-wall-page__field pt-wall-page__field--task">
{t("portTraffic.wallDevice")}
<select
value={wallDeviceId}
onChange={(e) => {
setWallDeviceId(e.target.value);
setWallTargetId("");
}}
>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{deviceLabel(d)}
{d.ne_ip ? ` (${d.ne_ip})` : ""}
</option>
))}
</select>
</label>
<label className="pt-wall-page__field pt-wall-page__field--port">
{t("portTraffic.wallPort")}
<select value={wallTargetId} onChange={(e) => setWallTargetId(e.target.value)}>
<option value="">{t("portTraffic.pickPort")}</option>
{wallTargets.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
<button
type="button"
className="btn-primary pt-wall-page__collect"
disabled={!wallDeviceId || collectMut.isPending}
onClick={() => collectMut.mutate(wallDeviceId)}
>
{t("portTraffic.collectNow")}
</button>
<button
type="button"
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
onClick={() => void toggleFullscreen()}
title={t("portTraffic.fullscreen")}
aria-label={t("portTraffic.fullscreen")}
>
<FullscreenIcon />
<span>{t("portTraffic.fullscreen")}</span>
</button>
</div>
<div className="pt-wall-page__row pt-wall-page__row--secondary">
<div className="pt-wall-page__cluster">
<span className="pt-wall-page__cluster-label">{t("portTraffic.filterView")}</span>
<div className="pt-wall-page__cluster-fields">
<label className="pt-wall-page__field pt-wall-page__field--sm">
{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>
<label className="pt-wall-page__field pt-wall-page__field--md">
{t("portTraffic.compare")}
<select value={baseline} onChange={(e) => setBaseline(e.target.value as BaselineMode)}>
<option value="off">{t("portTraffic.compareOff")}</option>
<option value="day">{t("portTraffic.compareDay")}</option>
<option value="week">{t("portTraffic.compareWeek")}</option>
<option value="shift">{t("portTraffic.compareShift")}</option>
<option value="custom">{t("portTraffic.compareCustom")}</option>
</select>
</label>
{baseline === "custom" ? (
<label className="pt-wall-page__field pt-wall-page__field--sm">
{t("portTraffic.offsetHours")}
<input
type="number"
min={1}
max={24 * 90}
value={customOffsetHours}
onChange={(e) => setCustomOffsetHours(Number(e.target.value) || 24)}
/>
</label>
) : null}
<label className="pt-wall-page__field pt-wall-page__field--md">
{t("portTraffic.yMode")}
<select value={yMode} onChange={(e) => setYMode(e.target.value as WallYMode)}>
<option value="auto">{t("portTraffic.yModeAuto")}</option>
<option value="current">{t("portTraffic.yModeCurrent")}</option>
<option value="util">{t("portTraffic.yModeUtil")}</option>
</select>
</label>
</div>
</div>
<div className="pt-wall-page__cluster-sep" aria-hidden />
<div className="pt-wall-page__cluster pt-wall-page__cluster--map">
<span className="pt-wall-page__cluster-label">{t("portTraffic.filterMap")}</span>
<div className="pt-wall-page__cluster-fields">
<label className="pt-wall-page__field pt-wall-page__field--task">
{t("portTraffic.wallDevice")}
<select
value={mapBaselineDeviceId}
onChange={(e) => {
setMapBaselineDeviceId(e.target.value);
setMapBaselineTargetId("");
}}
disabled={!wallTargetId}
>
<option value="">{t("portTraffic.mapBaselineNone")}</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{deviceLabel(d)}
</option>
))}
</select>
</label>
<label className="pt-wall-page__field pt-wall-page__field--port">
{t("portTraffic.wallPort")}
<select
value={mapBaselineTargetId}
onChange={(e) => setMapBaselineTargetId(e.target.value)}
disabled={!mapBaselineDeviceId}
>
<option value="">{t("portTraffic.pickPort")}</option>
{mapBaselineOptions.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
</div>
</div>
</div>
</div>
{baseline === "week" ? (
<p className="muted pt-wall-page__hint">{t("portTraffic.retentionHint")}</p>
) : null}
<div
ref={stageRef}
className={`pt-wall-page__stage${fullscreen ? " is-fullscreen" : ""}`}
>
<PortTrafficWall
target={selectedWallTarget}
baselineTarget={
compareQuery.data?.meta?.baseline_target ||
(baseline !== "off" && wallBaselinePoints.length ? selectedWallTarget : null)
}
points={wallPoints}
baselinePoints={wallBaselinePoints}
yMode={yMode}
rangeLabel={`${rangeHours}h${
baseline === "off"
? ""
: baseline === "day"
? ` · ${t("portTraffic.compareDay")}`
: baseline === "week"
? ` · ${t("portTraffic.compareWeek")}`
: baseline === "shift"
? ` · ${t("portTraffic.compareShift")}`
: ` · ${t("portTraffic.compareCustom")}`
}${mapBaselineTargetId ? ` · ${t("portTraffic.mapBaselinePort")}` : ""}`}
loading={compareQuery.isLoading || compareQuery.isFetching}
hint={
!wallTargetId
? t("portTraffic.pickPort")
: selectedWallTarget?.last_error
? t("portTraffic.waitAfterError")
: t("portTraffic.waitSamples")
}
/>
</div>
</div>
)}
</section>
);
}

View file

@ -31,13 +31,15 @@ import type {
NeConfigSnapshotMeta,
PortTrafficCompare,
PortTrafficDashboard,
PortTrafficDevice,
PortTrafficDiscoverResponse,
PortTrafficEvent,
PortTrafficIfaceIn,
PortTrafficSamples,
PortTrafficTarget,
PortTrafficTargetIn,
PortTrafficTask,
} from "../types";
export const AUTH_TOKEN_KEY = "netx_access_token";
export const getAuthToken = (): string | null => {
@ -797,45 +799,77 @@ export const downloadNeConfigSnapshot = async (
export const fetchPortTrafficDashboard = () =>
apiGet<PortTrafficDashboard>("/v1/port-traffic/dashboard");
export const fetchPortTrafficTasks = (params: { page?: number; pageSize?: number }) => {
export const fetchPortTrafficDevices = (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()}`,
return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>(
`/v1/port-traffic/devices?${p.toString()}`,
);
};
export const createPortTrafficTask = (body: {
title: string;
/** @deprecated */
export const fetchPortTrafficTasks = fetchPortTrafficDevices;
export const createPortTrafficDevice = (body: {
source: "managed" | "ume";
ne_id: string;
ne_name?: string;
ne_ip?: string;
vendor?: string;
note?: string;
interval_sec?: number;
retention_days?: number;
concurrency?: number;
targets?: PortTrafficTargetIn[];
interfaces?: PortTrafficIfaceIn[];
start_now?: boolean;
}) => apiPost<PortTrafficTask>("/v1/port-traffic/tasks", body);
}) => apiPost<PortTrafficDevice>("/v1/port-traffic/devices", body);
export const startPortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/start`, {});
export const updatePortTrafficDevice = (
deviceId: string,
body: {
note?: string;
interval_sec?: number;
retention_days?: number;
concurrency?: number;
ne_name?: string;
ne_ip?: string;
vendor?: string;
},
) => apiPatch<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`, body);
export const pausePortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/pause`, {});
export const startPortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/start`, {});
export const stopPortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/stop`, {});
export const pausePortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/pause`, {});
export const collectPortTrafficNow = (taskId: string) =>
apiPost<PortTrafficTask & { ok: boolean; started: boolean; reason?: string }>(
`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/collect-now`,
export const stopPortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/stop`, {});
export const collectPortTrafficNow = (deviceId: string) =>
apiPost<PortTrafficDevice & { ok: boolean; started: boolean; reason?: string }>(
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/collect-now`,
{},
);
export const deletePortTrafficTask = (taskId: string) =>
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}`);
export const deletePortTrafficDevice = (deviceId: string) =>
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`);
export const fetchPortTrafficTargets = (taskId: string) =>
export const fetchPortTrafficTargets = (deviceId: string) =>
apiGet<{ items: PortTrafficTarget[] }>(
`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/targets`,
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/targets`,
);
export const fetchPortTrafficEvents = (deviceId: string, limit = 100) =>
apiGet<{ items: PortTrafficEvent[]; total: number }>(
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/events?limit=${limit}`,
);
export const putPortTrafficInterfaces = (deviceId: string, interfaces: PortTrafficIfaceIn[]) =>
apiPut<{ items: PortTrafficTarget[] }>(
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/interfaces`,
{ interfaces },
);
export const discoverPortTrafficPorts = (body: { source: "managed" | "ume"; id: string }) =>

View file

@ -533,9 +533,14 @@ export type NeConfigSnapshotDetail = NeConfigSnapshotMeta & {
config_alt_text: string;
};
export type PortTrafficTask = {
export type PortTrafficDevice = {
id: string;
title: string;
source: string;
ne_id: string;
ne_name: string;
ne_ip: string;
vendor: string;
note: string;
status: string;
interval_sec: number;
retention_days: number;
@ -550,9 +555,13 @@ export type PortTrafficTask = {
updated_at?: string | null;
};
/** @deprecated use PortTrafficDevice */
export type PortTrafficTask = PortTrafficDevice;
export type PortTrafficTarget = {
id: string;
task_id: string;
device_id: string;
task_id?: string;
series_id?: string;
source: string;
target_id: string;
@ -568,9 +577,20 @@ export type PortTrafficTarget = {
created_at?: string | null;
};
export type PortTrafficEvent = {
id: string;
device_id: string;
target_row_id: string;
ifname: string;
level: string;
message: string;
created_at?: string | null;
};
export type PortTrafficSeries = {
id: string;
task_id: string;
device_id: string;
task_id?: string;
title: string;
status: string;
active_target: PortTrafficTarget | null;
@ -578,17 +598,20 @@ export type PortTrafficSeries = {
created_at?: string | null;
};
export type PortTrafficTargetIn = {
source: "managed" | "ume";
target_id: string;
ne_name?: string;
ne_ip?: string;
vendor?: string;
export type PortTrafficIfaceIn = {
ifname: string;
if_description?: string;
bw_bps?: number;
};
export type PortTrafficTargetIn = PortTrafficIfaceIn & {
source?: "managed" | "ume";
target_id?: string;
ne_name?: string;
ne_ip?: string;
vendor?: string;
};
export type PortTrafficDiscoverPort = {
ifname: string;
attribute: string;
@ -642,10 +665,12 @@ export type PortTrafficCompare = {
};
export type PortTrafficDashboard = {
task_count: number;
running_task_count: number;
device_count: number;
running_device_count: number;
active_target_count: number;
sample_count_24h: number;
last_sample_at?: string | null;
task_count?: number;
running_task_count?: number;
};