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="") task_id: Mapped[str] = mapped_column(String(64), default="")
class PortTrafficTask(Base): class PortTrafficDevice(Base):
"""Port traffic monitoring job definition.""" """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) 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 status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|running|paused|stopped
interval_sec: Mapped[int] = mapped_column(Integer, default=60) interval_sec: Mapped[int] = mapped_column(Integer, default=60)
retention_days: Mapped[int] = mapped_column(Integer, default=7) retention_days: Mapped[int] = mapped_column(Integer, default=7)
concurrency: Mapped[int] = mapped_column(Integer, default=5) concurrency: Mapped[int] = mapped_column(Integer, default=1)
collect_running: Mapped[bool] = mapped_column(Boolean, default=False) collect_running: Mapped[bool] = mapped_column(Boolean, default=False)
last_collect_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_collect_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_collect_ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_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) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Back-compat alias while callers migrate.
PortTrafficTask = PortTrafficDevice
class PortTrafficSeries(Base): class PortTrafficSeries(Base):
"""Logical port (business link) that survives physical NE/if replacement.""" """Logical port (business link) that survives physical NE/if replacement."""
__tablename__ = "port_traffic_series" __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) 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="") title: Mapped[str] = mapped_column(String(256), default="")
status: Mapped[str] = mapped_column(String(32), default="active", index=True) # active|disabled status: Mapped[str] = mapped_column(String(32), default="active", index=True) # active|disabled
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 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): class PortTrafficTarget(Base):
"""Monitored interface under a port traffic task.""" """Monitored interface under a device monitoring config."""
__tablename__ = "port_traffic_target" __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) 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) series_id: Mapped[str] = mapped_column(String(64), default="", index=True)
source: Mapped[str] = mapped_column(String(32), default="managed", 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_name: Mapped[str] = mapped_column(String(256), default="")
ne_ip: Mapped[str] = mapped_column(String(128), default="") ne_ip: Mapped[str] = mapped_column(String(128), default="")
vendor: Mapped[str] = mapped_column(String(64), 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) last_sample_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 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): class PortTrafficSample(Base):
"""Time-series sample for a monitored interface.""" """Time-series sample for a monitored interface."""
@ -670,3 +695,17 @@ class PortTrafficSample(Base):
rate_period_sec: Mapped[int] = mapped_column(Integer, default=0) rate_period_sec: Mapped[int] = mapped_column(Integer, default=0)
raw_ok: Mapped[bool] = mapped_column(Boolean, default=True) raw_ok: Mapped[bool] = mapped_column(Boolean, default=True)
message: Mapped[str] = mapped_column(String(512), default="") 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 from __future__ import annotations
@ -8,49 +8,274 @@ from uuid import uuid4
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session 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") _log = logging.getLogger("netx.port_traffic.migrate")
def ensure_port_traffic_series_schema(conn) -> None: 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( conn.exec_driver_sql(
""" """
CREATE TABLE IF NOT EXISTS port_traffic_series ( CREATE TABLE IF NOT EXISTS port_traffic_series (
id VARCHAR(64) PRIMARY KEY, id VARCHAR(64) PRIMARY KEY,
task_id VARCHAR(64), device_id VARCHAR(64) DEFAULT '',
title VARCHAR(256) DEFAULT '', title VARCHAR(256) DEFAULT '',
status VARCHAR(32) DEFAULT 'active', status VARCHAR(32) DEFAULT 'active',
created_at TIMESTAMP created_at TIMESTAMP
) )
""" """
) )
# Legacy column may still exist as task_id
conn.exec_driver_sql( 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( conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_series_status ON port_traffic_series (status)" "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( conn.exec_driver_sql(
"ALTER TABLE port_traffic_target ADD COLUMN IF NOT EXISTS series_id VARCHAR(64) DEFAULT ''" "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( conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_target_series_id ON port_traffic_target (series_id)" "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( conn.exec_driver_sql(
"ALTER TABLE port_traffic_sample ADD COLUMN IF NOT EXISTS series_id VARCHAR(64) DEFAULT ''" "ALTER TABLE port_traffic_sample ADD COLUMN IF NOT EXISTS series_id VARCHAR(64) DEFAULT ''"
) )
conn.exec_driver_sql( conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_port_traffic_sample_series_id ON port_traffic_sample (series_id)" "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: def backfill_port_traffic_series(db: Session) -> int:
"""Create one series per target missing series_id; stamp samples.""" """Create one series per target missing series_id; stamp samples."""
migrate_tasks_to_devices(db)
targets = ( targets = (
db.query(PortTrafficTarget) db.query(PortTrafficTarget)
.filter((PortTrafficTarget.series_id == None) | (PortTrafficTarget.series_id == "")) # noqa: E711 .filter((PortTrafficTarget.series_id == None) | (PortTrafficTarget.series_id == "")) # noqa: E711
@ -58,13 +283,14 @@ def backfill_port_traffic_series(db: Session) -> int:
) )
created = 0 created = 0
for t in targets: for t in targets:
device_id = str(t.device_id or "")
title = _default_series_title(t.ne_name or "", t.ifname 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 sid = uuid4().hex
db.add( db.add(
PortTrafficSeries( PortTrafficSeries(
id=sid, id=sid,
task_id=str(t.task_id), device_id=device_id,
title=title, title=title,
status="active", status="active",
) )
@ -103,8 +329,8 @@ def default_series_title(ne_name: str, ifname: str) -> str:
return _default_series_title(ne_name, ifname) return _default_series_title(ne_name, ifname)
def unique_series_title(db: Session, task_id: str, base: str) -> str: def unique_series_title(db: Session, device_id: str, base: str) -> str:
return _unique_series_title(db, task_id, base) return _unique_series_title(db, device_id, base)
def _default_series_title(ne_name: str, ifname: str) -> str: 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] 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] title = base[:256]
exists = ( exists = (
db.query(PortTrafficSeries.id) db.query(PortTrafficSeries.id)
.filter(PortTrafficSeries.task_id == task_id, PortTrafficSeries.title == title) .filter(PortTrafficSeries.device_id == device_id, PortTrafficSeries.title == title)
.first() .first()
) )
if not exists: if not exists:
@ -126,7 +352,7 @@ def _unique_series_title(db: Session, task_id: str, base: str) -> str:
candidate = f"{base[:240]}#{i}" candidate = f"{base[:240]}#{i}"
exists = ( exists = (
db.query(PortTrafficSeries.id) db.query(PortTrafficSeries.id)
.filter(PortTrafficSeries.task_id == task_id, PortTrafficSeries.title == candidate) .filter(PortTrafficSeries.device_id == device_id, PortTrafficSeries.title == candidate)
.first() .first()
) )
if not exists: if not exists:

View file

@ -116,6 +116,18 @@ class DetailRates:
out_util_pct: float = 0.0 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: def parse_bw_to_bps(raw: str) -> int:
text = (raw or "").strip() text = (raw or "").strip()
if not text or text.upper() == "N/A": if not text or text.upper() == "N/A":

View file

@ -7,7 +7,7 @@ from datetime import datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import PortTrafficTask from .models import PortTrafficDevice
_log = logging.getLogger("netx.port_traffic.recovery") _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: def recover_port_traffic_on_startup(db: Session) -> int:
"""Clear stuck collect_running flags so scheduler can resume.""" """Clear stuck collect_running flags so scheduler can resume."""
now = datetime.utcnow() 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 n = 0
for task in rows: for device in rows:
task.collect_running = False device.collect_running = False
if not task.last_collect_ended_at: if not device.last_collect_ended_at:
task.last_collect_ended_at = now device.last_collect_ended_at = now
if not task.last_error: if not device.last_error:
task.last_error = "requeued_after_restart" device.last_error = "requeued_after_restart"
task.updated_at = now device.updated_at = now
n += 1 n += 1
if n: if n:
db.commit() 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 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 from __future__ import annotations
@ -9,28 +9,30 @@ from sqlalchemy.orm import Session
from .auth_service import write_audit from .auth_service import write_audit
from .db import get_db from .db import get_db
from .models import PortTrafficDevice
from .port_traffic_schemas import ( from .port_traffic_schemas import (
DiscoverPortsRequest, DiscoverPortsRequest,
PortTrafficDeviceCreate,
PortTrafficDeviceUpdate,
PortTrafficInterfacesPut,
PortTrafficReplacePortRequest, PortTrafficReplacePortRequest,
PortTrafficTaskCreate,
PortTrafficTaskUpdate,
PortTrafficTargetsPut,
) )
from .port_traffic_service import ( from .port_traffic_service import (
compare_targets, compare_targets,
create_task, create_device,
dashboard, dashboard,
delete_task, delete_device,
discover_ports, discover_ports,
get_device,
get_samples, get_samples,
get_task, list_device_events,
list_devices,
list_series, list_series,
list_targets, list_targets,
list_tasks, put_interfaces,
put_targets,
replace_series_port, replace_series_port,
set_task_status, set_device_status,
update_task, update_device,
) )
router = APIRouter(prefix="/v1/port-traffic", tags=["port-traffic"]) 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() return dashboard(db).model_dump()
@router.get("/tasks") @router.get("/devices")
def api_list_tasks( def api_list_devices(
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db), 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") @router.post("/devices")
def api_create_task( def api_create_device(
body: PortTrafficTaskCreate, body: PortTrafficDeviceCreate,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
out = create_task(db, body) out = create_device(db, body)
if body.start_now and out.status == "running": if body.start_now and out.status == "running":
from .port_traffic_runner import dispatch_collect from .port_traffic_runner import dispatch_collect
@ -72,179 +74,206 @@ def api_create_task(
uid, uname = _actor(request) uid, uname = _actor(request)
write_audit( write_audit(
db, db,
action="port_traffic.task.create", action="port_traffic.device.create",
actor_user_id=uid, actor_user_id=uid,
actor_username=uname, actor_username=uname,
method="POST", method="POST",
path="/v1/port-traffic/tasks", path="/v1/port-traffic/devices",
status_code=200, 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() return out.model_dump()
@router.get("/tasks/{task_id}") @router.get("/devices/{device_id}")
def api_get_task(task_id: str, db: Session = Depends(get_db)): def api_get_device(device_id: str, db: Session = Depends(get_db)):
return get_task(db, task_id).model_dump() return get_device(db, device_id).model_dump()
@router.patch("/tasks/{task_id}") @router.patch("/devices/{device_id}")
def api_patch_task( def api_patch_device(
task_id: str, device_id: str,
body: PortTrafficTaskUpdate, body: PortTrafficDeviceUpdate,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
out = update_task(db, task_id, body) out = update_device(db, device_id, body)
uid, uname = _actor(request) uid, uname = _actor(request)
write_audit( write_audit(
db, db,
action="port_traffic.task.update", action="port_traffic.device.update",
actor_user_id=uid, actor_user_id=uid,
actor_username=uname, actor_username=uname,
method="PATCH", method="PATCH",
path=f"/v1/port-traffic/tasks/{task_id}", path=f"/v1/port-traffic/devices/{device_id}",
status_code=200, status_code=200,
detail=body.model_dump(exclude_unset=True), detail=body.model_dump(exclude_unset=True),
) )
return out.model_dump() return out.model_dump()
@router.delete("/tasks/{task_id}") @router.delete("/devices/{device_id}")
def api_delete_task(task_id: str, request: Request, db: Session = Depends(get_db)): def api_delete_device(device_id: str, request: Request, db: Session = Depends(get_db)):
out = delete_task(db, task_id) out = delete_device(db, device_id)
uid, uname = _actor(request) uid, uname = _actor(request)
write_audit( write_audit(
db, db,
action="port_traffic.task.delete", action="port_traffic.device.delete",
actor_user_id=uid, actor_user_id=uid,
actor_username=uname, actor_username=uname,
method="DELETE", method="DELETE",
path=f"/v1/port-traffic/tasks/{task_id}", path=f"/v1/port-traffic/devices/{device_id}",
status_code=200, status_code=200,
detail={"id": task_id}, detail={"id": device_id},
) )
return out return out
@router.post("/tasks/{task_id}/start") @router.post("/devices/{device_id}/start")
def api_start_task( def api_start_device(
task_id: str, device_id: str,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
request: Request, request: Request,
db: Session = Depends(get_db), 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 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) uid, uname = _actor(request)
write_audit( write_audit(
db, db,
action="port_traffic.task.start", action="port_traffic.device.start",
actor_user_id=uid, actor_user_id=uid,
actor_username=uname, actor_username=uname,
method="POST", method="POST",
path=f"/v1/port-traffic/tasks/{task_id}/start", path=f"/v1/port-traffic/devices/{device_id}/start",
status_code=200, status_code=200,
detail={"id": task_id}, detail={"id": device_id},
) )
return out.model_dump() return out.model_dump()
@router.post("/tasks/{task_id}/collect-now") @router.post("/devices/{device_id}/collect-now")
def api_collect_now( def api_collect_now(
task_id: str, device_id: str,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
request: Request, request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
task = get_task(db, task_id) device = get_device(db, device_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
from .port_traffic_runner import dispatch_collect from .port_traffic_runner import dispatch_collect
row = db.get(PortTrafficTask, task_id) row = db.get(PortTrafficDevice, device_id)
if not row: 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): 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": if str(row.status) != "running":
row.status = "running" row.status = "running"
row.last_collect_ended_at = None row.last_collect_ended_at = None
row.updated_at = datetime.utcnow() row.updated_at = datetime.utcnow()
db.commit() db.commit()
background_tasks.add_task(dispatch_collect, task_id) background_tasks.add_task(dispatch_collect, device_id)
uid, uname = _actor(request) uid, uname = _actor(request)
write_audit( write_audit(
db, db,
action="port_traffic.task.collect_now", action="port_traffic.device.collect_now",
actor_user_id=uid, actor_user_id=uid,
actor_username=uname, actor_username=uname,
method="POST", 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, 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()} return {"ok": True, "started": True, **out.model_dump()}
@router.post("/tasks/{task_id}/pause") @router.post("/devices/{device_id}/pause")
def api_pause_task(task_id: str, request: Request, db: Session = Depends(get_db)): def api_pause_device(device_id: str, request: Request, db: Session = Depends(get_db)):
out = set_task_status(db, task_id, "paused") out = set_device_status(db, device_id, "paused")
uid, uname = _actor(request) uid, uname = _actor(request)
write_audit( write_audit(
db, db,
action="port_traffic.task.pause", action="port_traffic.device.pause",
actor_user_id=uid, actor_user_id=uid,
actor_username=uname, actor_username=uname,
method="POST", method="POST",
path=f"/v1/port-traffic/tasks/{task_id}/pause", path=f"/v1/port-traffic/devices/{device_id}/pause",
status_code=200, status_code=200,
detail={"id": task_id}, detail={"id": device_id},
) )
return out.model_dump() return out.model_dump()
@router.post("/tasks/{task_id}/stop") @router.post("/devices/{device_id}/stop")
def api_stop_task(task_id: str, request: Request, db: Session = Depends(get_db)): def api_stop_device(device_id: str, request: Request, db: Session = Depends(get_db)):
out = set_task_status(db, task_id, "stopped") out = set_device_status(db, device_id, "stopped")
uid, uname = _actor(request) uid, uname = _actor(request)
write_audit( write_audit(
db, db,
action="port_traffic.task.stop", action="port_traffic.device.stop",
actor_user_id=uid, actor_user_id=uid,
actor_username=uname, actor_username=uname,
method="POST", method="POST",
path=f"/v1/port-traffic/tasks/{task_id}/stop", path=f"/v1/port-traffic/devices/{device_id}/stop",
status_code=200, status_code=200,
detail={"id": task_id}, detail={"id": device_id},
) )
return out.model_dump() return out.model_dump()
@router.get("/tasks/{task_id}/targets") @router.get("/devices/{device_id}/targets")
def api_list_targets(task_id: str, db: Session = Depends(get_db)): def api_list_targets(device_id: str, db: Session = Depends(get_db)):
return {"items": [t.model_dump() for t in list_targets(db, task_id)]} return {"items": [t.model_dump() for t in list_targets(db, device_id)]}
@router.get("/tasks/{task_id}/series") @router.get("/devices/{device_id}/events")
def api_list_series(task_id: str, db: Session = Depends(get_db)): def api_list_device_events(
return {"items": [s.model_dump() for s in list_series(db, task_id)]} 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( def api_replace_series_port(
task_id: str, device_id: str,
series_id: str, series_id: str,
body: PortTrafficReplacePortRequest, body: PortTrafficReplacePortRequest,
request: Request, request: Request,
db: Session = Depends(get_db), 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) uid, uname = _actor(request)
write_audit( write_audit(
db, db,
@ -252,35 +281,13 @@ def api_replace_series_port(
actor_user_id=uid, actor_user_id=uid,
actor_username=uname, actor_username=uname,
method="POST", 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, 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() 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") @router.post("/discover/ports")
def api_discover_ports(body: DiscoverPortsRequest, db: Session = Depends(get_db)): def api_discover_ports(body: DiscoverPortsRequest, db: Session = Depends(get_db)):
return discover_ports(db, body).model_dump() 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), offset_hours: float | None = Query(default=None, ge=0.25, le=24 * 90),
baseline_target_id: str | None = Query( baseline_target_id: str | None = Query(
default=None, 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"), to_ts: datetime | None = Query(default=None, alias="to"),
db: Session = Depends(get_db), 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 from __future__ import annotations
@ -15,11 +15,11 @@ from fastapi import HTTPException
from .cli_resolve import resolve_cli_target from .cli_resolve import resolve_cli_target
from .config import settings from .config import settings
from .db import SessionLocal 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_session_factory import close_netmiko_connection, open_netmiko_connection
from .ne_netmiko import send_show_command from .ne_netmiko import send_show_command
from .port_traffic_commands import commands_for_vendor, detail_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") _log = logging.getLogger("netx.port_traffic.runner")
_pools: dict[str, ThreadPoolExecutor] = {} _pools: dict[str, ThreadPoolExecutor] = {}
@ -34,26 +34,29 @@ def _format_error(exc: BaseException) -> str:
return f"{type(exc).__name__}: {exc}"[:1020] return f"{type(exc).__name__}: {exc}"[:1020]
def _pool_for_task(task_id: str, concurrency: int) -> ThreadPoolExecutor: def _append_event(
with _pools_lock: db,
pool = _pools.get(task_id) *,
if pool is None: device_id: str,
workers = max(1, min(20, int(concurrency or 5))) message: str,
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix=f"pt-{task_id[:8]}") level: str = "error",
_pools[task_id] = pool target_row_id: str = "",
return pool ifname: str = "",
) -> None:
msg = str(message or "").strip()
def _release_pool(task_id: str) -> None: if not msg or not device_id:
with _pools_lock: return
pool = _pools.pop(task_id, None) db.add(
if pool is not None: PortTrafficEvent(
try: id=uuid4().hex,
pool.shutdown(wait=False, cancel_futures=False) device_id=device_id,
except TypeError: target_row_id=str(target_row_id or ""),
pool.shutdown(wait=False) ifname=str(ifname or ""),
except Exception: level=str(level or "error")[:16],
_log.exception("port_traffic pool shutdown failed task=%s", task_id) message=msg[:4000],
created_at=_utcnow(),
)
)
def _set_target_error(target_row_id: str, message: str) -> None: 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) row = db.get(PortTrafficTarget, target_row_id)
if row: if row:
row.last_error = message[:1020] 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() db.commit()
finally: finally:
db.close() db.close()
def _claim_collect_round(task_id: str) -> list[str] | None: def _finish_collect_round(device_id: str, *, error: str = "") -> None:
"""Mark task collect_running and return active target row ids, or None if skip.""" 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): for attempt in range(8):
db = SessionLocal() db = SessionLocal()
try: try:
task = db.get(PortTrafficTask, task_id) device = db.get(PortTrafficDevice, device_id)
if not task: if not device:
return None return None
if str(task.status or "") != "running": if str(device.status or "") != "running":
return None return None
if bool(task.collect_running): if bool(device.collect_running):
return None return None
ended = task.last_collect_ended_at ended = device.last_collect_ended_at
interval = max(15, int(task.interval_sec or 60)) interval = max(15, int(device.interval_sec or 60))
if ended is not None: if ended is not None:
elapsed = (_utcnow() - ended).total_seconds() elapsed = (_utcnow() - ended).total_seconds()
if elapsed < interval: if elapsed < interval:
@ -88,142 +138,69 @@ def _claim_collect_round(task_id: str) -> list[str] | None:
targets = ( targets = (
db.query(PortTrafficTarget) db.query(PortTrafficTarget)
.filter( .filter(
PortTrafficTarget.task_id == task_id, PortTrafficTarget.device_id == device_id,
PortTrafficTarget.status == "active", PortTrafficTarget.status == "active",
) )
.all() .all()
) )
if not targets: if not targets:
return None return None
task.collect_running = True device.collect_running = True
task.last_collect_started_at = _utcnow() device.last_collect_started_at = _utcnow()
task.last_error = "" device.last_error = ""
task.updated_at = _utcnow() device.updated_at = _utcnow()
db.commit() db.commit()
return [str(t.id) for t in targets] return [str(t.id) for t in targets]
except Exception: except Exception:
db.rollback() 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)) time.sleep(0.05 * (attempt + 1))
finally: finally:
db.close() db.close()
return None return None
def _finish_collect_round(task_id: str, *, error: str = "") -> None: def _save_sample(target_row_id: str, parsed: Any, vendor_hint_bw: int = 0) -> 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
now = _utcnow() now = _utcnow()
db = SessionLocal() db = SessionLocal()
try: try:
row = db.get(PortTrafficTarget, target_row_id) row = db.get(PortTrafficTarget, target_row_id)
if not row: if not row:
return 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: if bw and not row.bw_bps:
row.bw_bps = bw 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( db.add(
PortTrafficSample( PortTrafficSample(
id=uuid4().hex, id=uuid4().hex,
target_row_id=target_row_id, target_row_id=target_row_id,
series_id=str(row.series_id or ""), series_id=str(row.series_id or ""),
ts=now, ts=now,
in_bps=float(parsed.in_bps), in_bps=in_bps,
out_bps=float(parsed.out_bps), out_bps=out_bps,
in_util_pct=float(parsed.in_util_pct), in_util_pct=resolve_util_pct(float(parsed.in_util_pct), in_bps, bw),
out_util_pct=float(parsed.out_util_pct), out_util_pct=resolve_util_pct(float(parsed.out_util_pct), out_bps, bw),
bw_bps=bw, bw_bps=bw,
rate_period_sec=int(parsed.rate_period_sec or 0), rate_period_sec=int(parsed.rate_period_sec or 0),
raw_ok=True, raw_ok=True,
@ -240,30 +217,103 @@ def _sample_one_target(target_row_id: str) -> None:
db.close() db.close()
def dispatch_collect(task_id: str) -> int: def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int:
"""Claim and sample all active targets for a running task. Returns target count.""" """One CLI login for the device; run show per interface. Returns error count."""
target_ids = _claim_collect_round(task_id) per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
if not target_ids: cap = int(settings.ne_collect_run_timeout_cap_sec or 600)
return 0 errors = 0
db = SessionLocal() db = SessionLocal()
try: try:
task = db.get(PortTrafficTask, task_id) device = db.get(PortTrafficDevice, device_id)
concurrency = int(task.concurrency or 5) if task else 5 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: finally:
db.close() db.close()
pool = _pool_for_task(task_id, concurrency) if not ifaces:
futures = [pool.submit(_sample_one_target, tid) for tid in target_ids] return 0
errors = 0
budget = min(cap, per_cmd * max(1, len(ifaces)) + 90)
conn = None
try: try:
for fut in as_completed(futures): conn = open_netmiko_connection(creds, session_timeout=budget)
for tid, ifname in ifaces:
try: try:
fut.result() cmd = detail_command(cmds, ifname)
except Exception: 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 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: finally:
err_msg = f"{errors}_target_errors" if errors else "" 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) 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 .config import settings
from .db import SessionLocal from .db import SessionLocal
from .models import PortTrafficTask from .models import PortTrafficDevice
from .port_traffic_runner import dispatch_collect from .port_traffic_runner import dispatch_collect
from .port_traffic_service import purge_expired_samples from .port_traffic_service import purge_expired_samples
@ -22,40 +22,47 @@ def _utcnow() -> datetime:
return datetime.utcnow() return datetime.utcnow()
def try_dispatch_due_tasks() -> int: def try_dispatch_due_devices() -> int:
"""Dispatch collect rounds for due running tasks. Returns number started.""" """Dispatch collect rounds for due running devices. Returns number started."""
db = SessionLocal() db = SessionLocal()
try: try:
tasks = ( devices = (
db.query(PortTrafficTask) db.query(PortTrafficDevice)
.filter(PortTrafficTask.status == "running", PortTrafficTask.collect_running.is_(False)) .filter(
PortTrafficDevice.status == "running",
PortTrafficDevice.collect_running.is_(False),
)
.all() .all()
) )
due_ids: list[str] = [] due_ids: list[str] = []
now = _utcnow() now = _utcnow()
for task in tasks: for device in devices:
interval = max(15, int(task.interval_sec or 60)) interval = max(15, int(device.interval_sec or 60))
ended = task.last_collect_ended_at ended = device.last_collect_ended_at
if ended is None: if ended is None:
due_ids.append(str(task.id)) due_ids.append(str(device.id))
continue continue
if (now - ended).total_seconds() >= interval: if (now - ended).total_seconds() >= interval:
due_ids.append(str(task.id)) due_ids.append(str(device.id))
finally: finally:
db.close() db.close()
started = 0 started = 0
for tid in due_ids: for did in due_ids:
try: try:
n = dispatch_collect(tid) n = dispatch_collect(did)
if n: if n:
started += 1 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: except Exception:
_log.exception("port_traffic dispatch failed task=%s", tid) _log.exception("port_traffic dispatch failed device=%s", did)
return started return started
def try_dispatch_due_tasks() -> int:
return try_dispatch_due_devices()
def _loop() -> None: def _loop() -> None:
global _purge_counter global _purge_counter
tick = max(5, int(settings.port_traffic_scheduler_tick_sec or 15)) tick = max(5, int(settings.port_traffic_scheduler_tick_sec or 15))
@ -63,9 +70,8 @@ def _loop() -> None:
while not _stop.is_set(): while not _stop.is_set():
try: try:
if bool(settings.port_traffic_scheduler_enabled): if bool(settings.port_traffic_scheduler_enabled):
try_dispatch_due_tasks() try_dispatch_due_devices()
_purge_counter += 1 _purge_counter += 1
# Retention purge roughly every ~20 ticks
if _purge_counter >= 20: if _purge_counter >= 20:
_purge_counter = 0 _purge_counter = 0
db = SessionLocal() db = SessionLocal()
@ -89,7 +95,6 @@ def start_port_traffic_scheduler() -> None:
_stop.clear() _stop.clear()
_thread = threading.Thread(target=_loop, name="port-traffic-scheduler", daemon=True) _thread = threading.Thread(target=_loop, name="port-traffic-scheduler", daemon=True)
_thread.start() _thread.start()
_log.info("started thread %s alive=%s", _thread.name, _thread.is_alive())
def stop_port_traffic_scheduler() -> None: 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 from __future__ import annotations
@ -8,17 +8,17 @@ from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
class PortTrafficNeRef(BaseModel): class PortTrafficIfaceIn(BaseModel):
source: Literal["managed", "ume"] ifname: str
id: str if_description: str = ""
ne_name: str = "" bw_bps: int = 0
ne_ip: str = ""
vendor: str = ""
class PortTrafficTargetIn(BaseModel): class PortTrafficTargetIn(BaseModel):
source: Literal["managed", "ume"] """Legacy-shaped input; create/device APIs prefer PortTrafficIfaceIn under one NE."""
target_id: str
source: Literal["managed", "ume"] = "managed"
target_id: str = ""
ne_name: str = "" ne_name: str = ""
ne_ip: str = "" ne_ip: str = ""
vendor: str = "" vendor: str = ""
@ -27,25 +27,38 @@ class PortTrafficTargetIn(BaseModel):
bw_bps: int = 0 bw_bps: int = 0
class PortTrafficTaskCreate(BaseModel): class PortTrafficDeviceCreate(BaseModel):
title: str = Field(min_length=1, max_length=256) 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) interval_sec: int = Field(default=60, ge=15, le=3600)
retention_days: int = Field(default=7, ge=1, le=90) retention_days: int = Field(default=7, ge=1, le=90)
concurrency: int = Field(default=5, ge=1, le=20) concurrency: int = Field(default=1, ge=1, le=5)
targets: list[PortTrafficTargetIn] = Field(default_factory=list) interfaces: list[PortTrafficIfaceIn] = Field(default_factory=list)
start_now: bool = False start_now: bool = False
class PortTrafficTaskUpdate(BaseModel): class PortTrafficDeviceUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=256) note: str | None = Field(default=None, max_length=256)
interval_sec: int | None = Field(default=None, ge=15, le=3600) interval_sec: int | None = Field(default=None, ge=15, le=3600)
retention_days: int | None = Field(default=None, ge=1, le=90) 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 id: str
title: str source: str
ne_id: str
ne_name: str
ne_ip: str
vendor: str
note: str = ""
status: str status: str
interval_sec: int interval_sec: int
retention_days: int retention_days: int
@ -62,7 +75,8 @@ class PortTrafficTaskOut(BaseModel):
class PortTrafficTargetOut(BaseModel): class PortTrafficTargetOut(BaseModel):
id: str id: str
task_id: str device_id: str
task_id: str = "" # alias of device_id for older clients
series_id: str = "" series_id: str = ""
source: str source: str
target_id: str target_id: str
@ -80,7 +94,8 @@ class PortTrafficTargetOut(BaseModel):
class PortTrafficSeriesOut(BaseModel): class PortTrafficSeriesOut(BaseModel):
id: str id: str
task_id: str device_id: str
task_id: str = ""
title: str title: str
status: str status: str
active_target: PortTrafficTargetOut | None = None active_target: PortTrafficTargetOut | None = None
@ -88,18 +103,13 @@ class PortTrafficSeriesOut(BaseModel):
created_at: datetime | None = None created_at: datetime | None = None
class PortTrafficTargetsPut(BaseModel): class PortTrafficInterfacesPut(BaseModel):
targets: list[PortTrafficTargetIn] """Replace active interface set for a device (keeps samples for retired/removed)."""
interfaces: list[PortTrafficIfaceIn]
class PortTrafficReplacePortRequest(BaseModel): 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 ifname: str
if_description: str = "" if_description: str = ""
bw_bps: int = 0 bw_bps: int = 0
@ -141,7 +151,7 @@ class PortTrafficSamplePoint(BaseModel):
out_util_pct: float out_util_pct: float
bw_bps: int bw_bps: int
rate_period_sec: int = 0 rate_period_sec: int = 0
ts_raw: datetime | None = None # baseline original time when aligned ts_raw: datetime | None = None
class PortTrafficSamplesOut(BaseModel): class PortTrafficSamplesOut(BaseModel):
@ -166,8 +176,26 @@ class PortTrafficCompareOut(BaseModel):
class PortTrafficDashboardOut(BaseModel): class PortTrafficDashboardOut(BaseModel):
task_count: int = 0 device_count: int = 0
running_task_count: int = 0 running_device_count: int = 0
active_target_count: int = 0 active_target_count: int = 0
sample_count_24h: int = 0 sample_count_24h: int = 0
last_sample_at: datetime | None = None 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 from __future__ import annotations
@ -13,12 +13,12 @@ from sqlalchemy.orm import Session
from .cli_resolve import resolve_cli_target from .cli_resolve import resolve_cli_target
from .config import settings 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_session_factory import close_netmiko_connection, open_netmiko_connection
from .ne_netmiko import send_show_command from .ne_netmiko import send_show_command
from .port_traffic_commands import commands_for_vendor from .port_traffic_commands import commands_for_vendor
from .port_traffic_migrate import default_series_title, unique_series_title 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 ( from .port_traffic_schemas import (
DiscoverPortItem, DiscoverPortItem,
DiscoverPortsRequest, DiscoverPortsRequest,
@ -26,16 +26,18 @@ from .port_traffic_schemas import (
PortTrafficCompareMeta, PortTrafficCompareMeta,
PortTrafficCompareOut, PortTrafficCompareOut,
PortTrafficDashboardOut, PortTrafficDashboardOut,
PortTrafficDeviceCreate,
PortTrafficDeviceOut,
PortTrafficDeviceUpdate,
PortTrafficEventOut,
PortTrafficEventsOut,
PortTrafficIfaceIn,
PortTrafficInterfacesPut,
PortTrafficReplacePortRequest, PortTrafficReplacePortRequest,
PortTrafficSamplePoint, PortTrafficSamplePoint,
PortTrafficSamplesOut, PortTrafficSamplesOut,
PortTrafficSeriesOut, PortTrafficSeriesOut,
PortTrafficTargetIn,
PortTrafficTargetOut, PortTrafficTargetOut,
PortTrafficTargetsPut,
PortTrafficTaskCreate,
PortTrafficTaskOut,
PortTrafficTaskUpdate,
) )
_log = logging.getLogger("netx.port_traffic.service") _log = logging.getLogger("netx.port_traffic.service")
@ -45,36 +47,12 @@ def _utcnow() -> datetime:
return datetime.utcnow() 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: def _target_out(row: PortTrafficTarget) -> PortTrafficTargetOut:
did = str(row.device_id or "")
return PortTrafficTargetOut( return PortTrafficTargetOut(
id=str(row.id), id=str(row.id),
task_id=str(row.task_id), device_id=did,
task_id=did,
series_id=str(row.series_id or ""), series_id=str(row.series_id or ""),
source=str(row.source or ""), source=str(row.source or ""),
target_id=str(row.target_id 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, db: Session,
*, *,
task_id: str, source: str,
t: PortTrafficTargetIn, 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, now: datetime,
) -> PortTrafficTarget: ) -> 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 sid = uuid4().hex
db.add( db.add(
PortTrafficSeries( PortTrafficSeries(
id=sid, id=sid,
task_id=task_id, device_id=str(device.id),
title=title, title=title,
status="active", status="active",
created_at=now, created_at=now,
@ -111,16 +159,16 @@ def _create_series_and_target(
) )
row = PortTrafficTarget( row = PortTrafficTarget(
id=uuid4().hex, id=uuid4().hex,
task_id=task_id, device_id=str(device.id),
series_id=sid, series_id=sid,
source=t.source, source=str(device.source),
target_id=t.target_id, target_id=str(device.ne_id),
ne_name=t.ne_name or "", ne_name=str(device.ne_name or ""),
ne_ip=t.ne_ip or "", ne_ip=str(device.ne_ip or ""),
vendor=t.vendor or "", vendor=str(device.vendor or ""),
ifname=t.ifname.strip(), ifname=ifname,
if_description=t.if_description or "", if_description=iface.if_description or "",
bw_bps=int(t.bw_bps or 0), bw_bps=int(iface.bw_bps or 0),
status="active", status="active",
created_at=now, created_at=now,
) )
@ -128,44 +176,49 @@ def _create_series_and_target(
return row return row
def _assert_supported_targets(targets: list[PortTrafficTargetIn]) -> None: def list_devices(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
for t in targets: q = db.query(PortTrafficDevice).order_by(PortTrafficDevice.ne_name.asc(), PortTrafficDevice.ne_ip.asc())
cmds = commands_for_vendor(t.vendor or "", "")
if cmds is None:
raise HTTPException(
status_code=400,
detail=f"vendor_not_supported_for_port_traffic: {t.vendor or 'unknown'} ({t.ne_name or t.target_id})",
)
def list_tasks(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
q = db.query(PortTrafficTask).order_by(PortTrafficTask.created_at.desc())
total = q.count() total = q.count()
rows = q.offset((page - 1) * page_size).limit(page_size).all() rows = q.offset((page - 1) * page_size).limit(page_size).all()
return { return {
"total": total, "total": total,
"page": page, "page": page,
"page_size": page_size, "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: def get_device(db: Session, device_id: str) -> PortTrafficDeviceOut:
task = db.get(PortTrafficTask, task_id) device = db.get(PortTrafficDevice, device_id)
if not task: if not device:
raise HTTPException(status_code=404, detail="task_not_found") raise HTTPException(status_code=404, detail="device_not_found")
return _task_out(db, task) return _device_out(db, device)
def create_task(db: Session, body: PortTrafficTaskCreate) -> PortTrafficTaskOut: def create_device(db: Session, body: PortTrafficDeviceCreate) -> PortTrafficDeviceOut:
_assert_supported_targets(body.targets) 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() now = _utcnow()
status = "running" if body.start_now and body.targets else "draft" status = "running" if body.start_now and body.interfaces else "draft"
if body.start_now and not body.targets: device = PortTrafficDevice(
status = "draft"
task = PortTrafficTask(
id=uuid4().hex, 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, status=status,
interval_sec=int(body.interval_sec), interval_sec=int(body.interval_sec),
retention_days=int(body.retention_days), retention_days=int(body.retention_days),
@ -173,134 +226,166 @@ def create_task(db: Session, body: PortTrafficTaskCreate) -> PortTrafficTaskOut:
created_at=now, created_at=now,
updated_at=now, updated_at=now,
) )
db.add(task) db.add(device)
db.flush() db.flush()
for t in body.targets: for iface in body.interfaces:
_create_series_and_target(db, task_id=task.id, t=t, now=now) if not iface.ifname.strip():
continue
_create_iface(db, device=device, iface=iface, now=now)
db.commit() db.commit()
db.refresh(task) db.refresh(device)
return _task_out(db, task) return _device_out(db, device)
def update_task(db: Session, task_id: str, body: PortTrafficTaskUpdate) -> PortTrafficTaskOut: def update_device(db: Session, device_id: str, body: PortTrafficDeviceUpdate) -> PortTrafficDeviceOut:
task = db.get(PortTrafficTask, task_id) device = db.get(PortTrafficDevice, device_id)
if not task: if not device:
raise HTTPException(status_code=404, detail="task_not_found") raise HTTPException(status_code=404, detail="device_not_found")
data = body.model_dump(exclude_unset=True) data = body.model_dump(exclude_unset=True)
if "title" in data and data["title"] is not None: for key in ("note", "ne_name", "ne_ip", "vendor"):
task.title = str(data["title"]).strip() 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: 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: 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: if "concurrency" in data and data["concurrency"] is not None:
task.concurrency = int(data["concurrency"]) device.concurrency = int(data["concurrency"])
task.updated_at = _utcnow() device.updated_at = _utcnow()
db.commit() db.commit()
db.refresh(task) db.refresh(device)
return _task_out(db, task) return _device_out(db, device)
def delete_task(db: Session, task_id: str) -> dict[str, Any]: def delete_device(db: Session, device_id: str) -> dict[str, Any]:
task = db.get(PortTrafficTask, task_id) device = db.get(PortTrafficDevice, device_id)
if not task: if not device:
raise HTTPException(status_code=404, detail="task_not_found") raise HTTPException(status_code=404, detail="device_not_found")
if bool(task.collect_running): if bool(device.collect_running):
raise HTTPException(status_code=409, detail="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] ids = [str(t.id) for t in targets]
series_ids = [str(t.series_id) for t in targets if t.series_id] series_ids = [str(t.series_id) for t in targets if t.series_id]
if ids: if ids:
db.query(PortTrafficSample).filter(PortTrafficSample.target_row_id.in_(ids)).delete( db.query(PortTrafficSample).filter(PortTrafficSample.target_row_id.in_(ids)).delete(
synchronize_session=False 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 synchronize_session=False
) )
db.query(PortTrafficEvent).filter(PortTrafficEvent.device_id == device_id).delete(
synchronize_session=False
)
if series_ids: if series_ids:
db.query(PortTrafficSeries).filter(PortTrafficSeries.id.in_(series_ids)).delete( db.query(PortTrafficSeries).filter(PortTrafficSeries.id.in_(series_ids)).delete(
synchronize_session=False synchronize_session=False
) )
else: else:
db.query(PortTrafficSeries).filter(PortTrafficSeries.task_id == task_id).delete( db.query(PortTrafficSeries).filter(PortTrafficSeries.device_id == device_id).delete(
synchronize_session=False synchronize_session=False
) )
db.delete(task) db.delete(device)
db.commit() 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: def set_device_status(db: Session, device_id: str, status: str) -> PortTrafficDeviceOut:
task = db.get(PortTrafficTask, task_id) device = db.get(PortTrafficDevice, device_id)
if not task: if not device:
raise HTTPException(status_code=404, detail="task_not_found") raise HTTPException(status_code=404, detail="device_not_found")
if status == "running": if status == "running":
active = ( active = (
db.query(PortTrafficTarget) db.query(PortTrafficTarget)
.filter(PortTrafficTarget.task_id == task_id, PortTrafficTarget.status == "active") .filter(PortTrafficTarget.device_id == device_id, PortTrafficTarget.status == "active")
.count() .count()
) )
if active <= 0: if active <= 0:
raise HTTPException(status_code=400, detail="no_active_targets") raise HTTPException(status_code=400, detail="no_active_targets")
# Allow scheduler/collect-now to fire immediately after start/resume. device.last_collect_ended_at = None
task.last_collect_ended_at = None device.status = status
task.status = status device.updated_at = _utcnow()
task.updated_at = _utcnow()
db.commit() db.commit()
db.refresh(task) db.refresh(device)
return _task_out(db, task) return _device_out(db, device)
def put_targets(db: Session, task_id: str, body: PortTrafficTargetsPut) -> list[PortTrafficTargetOut]: def put_interfaces(db: Session, device_id: str, body: PortTrafficInterfacesPut) -> list[PortTrafficTargetOut]:
task = db.get(PortTrafficTask, task_id) device = db.get(PortTrafficDevice, device_id)
if not task: if not device:
raise HTTPException(status_code=404, detail="task_not_found") raise HTTPException(status_code=404, detail="device_not_found")
if bool(task.collect_running): if bool(device.collect_running):
raise HTTPException(status_code=409, detail="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() wanted = {i.ifname.strip(): i for i in body.interfaces if i.ifname.strip()}
old_ids = [str(t.id) for t in old] _assert_ifaces_free(
old_series = list({str(t.series_id) for t in old if t.series_id}) db,
if old_ids: source=str(device.source),
db.query(PortTrafficSample).filter(PortTrafficSample.target_row_id.in_(old_ids)).delete( ne_id=str(device.ne_id),
synchronize_session=False ifnames=list(wanted.keys()),
) exclude_device_id=device_id,
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
)
now = _utcnow() now = _utcnow()
rows: list[PortTrafficTarget] = [] existing = (
for t in body.targets: db.query(PortTrafficTarget)
rows.append(_create_series_and_target(db, task_id=task_id, t=t, now=now)) .filter(PortTrafficTarget.device_id == device_id)
task.updated_at = now .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() 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]: def list_targets(db: Session, device_id: str) -> list[PortTrafficTargetOut]:
task = db.get(PortTrafficTask, task_id) device = db.get(PortTrafficDevice, device_id)
if not task: if not device:
raise HTTPException(status_code=404, detail="task_not_found") raise HTTPException(status_code=404, detail="device_not_found")
rows = ( rows = (
db.query(PortTrafficTarget) db.query(PortTrafficTarget)
.filter(PortTrafficTarget.task_id == task_id) .filter(PortTrafficTarget.device_id == device_id)
.order_by(PortTrafficTarget.ne_name, PortTrafficTarget.ifname) .order_by(PortTrafficTarget.ifname)
.all() .all()
) )
return [_target_out(r) for r in rows] return [_target_out(r) for r in rows]
def list_series(db: Session, task_id: str) -> list[PortTrafficSeriesOut]: def list_series(db: Session, device_id: str) -> list[PortTrafficSeriesOut]:
task = db.get(PortTrafficTask, task_id) device = db.get(PortTrafficDevice, device_id)
if not task: if not device:
raise HTTPException(status_code=404, detail="task_not_found") raise HTTPException(status_code=404, detail="device_not_found")
rows = ( rows = (
db.query(PortTrafficSeries) db.query(PortTrafficSeries)
.filter(PortTrafficSeries.task_id == task_id) .filter(PortTrafficSeries.device_id == device_id)
.order_by(PortTrafficSeries.title.asc()) .order_by(PortTrafficSeries.title.asc())
.all() .all()
) )
@ -317,10 +402,12 @@ def list_series(db: Session, task_id: str) -> list[PortTrafficSeriesOut]:
.filter(PortTrafficTarget.series_id == s.id, PortTrafficTarget.status == "retired") .filter(PortTrafficTarget.series_id == s.id, PortTrafficTarget.status == "retired")
.count() .count()
) )
did = str(s.device_id or "")
out.append( out.append(
PortTrafficSeriesOut( PortTrafficSeriesOut(
id=str(s.id), id=str(s.id),
task_id=str(s.task_id), device_id=did,
task_id=did,
title=str(s.title or ""), title=str(s.title or ""),
status=str(s.status or ""), status=str(s.status or ""),
active_target=_target_out(active) if active else None, 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( def replace_series_port(
db: Session, db: Session,
task_id: str, device_id: str,
series_id: str, series_id: str,
body: PortTrafficReplacePortRequest, body: PortTrafficReplacePortRequest,
) -> PortTrafficSeriesOut: ) -> PortTrafficSeriesOut:
task = db.get(PortTrafficTask, task_id) device = db.get(PortTrafficDevice, device_id)
if not task: if not device:
raise HTTPException(status_code=404, detail="task_not_found") raise HTTPException(status_code=404, detail="device_not_found")
if bool(task.collect_running): if bool(device.collect_running):
raise HTTPException(status_code=409, detail="collect_running") raise HTTPException(status_code=409, detail="collect_running")
series = db.get(PortTrafficSeries, series_id) 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") raise HTTPException(status_code=404, detail="series_not_found")
tmp = PortTrafficTargetIn( ifname = body.ifname.strip()
source=body.source, _assert_ifaces_free(
target_id=body.target_id, db,
ne_name=body.ne_name, source=str(device.source),
ne_ip=body.ne_ip, ne_id=str(device.ne_id),
vendor=body.vendor, ifnames=[ifname],
ifname=body.ifname, exclude_device_id=device_id,
if_description=body.if_description,
bw_bps=body.bw_bps,
) )
_assert_supported_targets([tmp])
now = _utcnow() now = _utcnow()
actives = ( actives = (
db.query(PortTrafficTarget) db.query(PortTrafficTarget)
@ -370,7 +454,7 @@ def replace_series_port(
clash = ( clash = (
db.query(PortTrafficSeries.id) db.query(PortTrafficSeries.id)
.filter( .filter(
PortTrafficSeries.task_id == task_id, PortTrafficSeries.device_id == device_id,
PortTrafficSeries.title == wanted, PortTrafficSeries.title == wanted,
PortTrafficSeries.id != series_id, PortTrafficSeries.id != series_id,
) )
@ -381,24 +465,23 @@ def replace_series_port(
series.title = wanted series.title = wanted
row = PortTrafficTarget( row = PortTrafficTarget(
id=uuid4().hex, id=uuid4().hex,
task_id=task_id, device_id=device_id,
series_id=series_id, series_id=series_id,
source=body.source, source=str(device.source),
target_id=body.target_id, target_id=str(device.ne_id),
ne_name=body.ne_name or "", ne_name=str(device.ne_name or ""),
ne_ip=body.ne_ip or "", ne_ip=str(device.ne_ip or ""),
vendor=body.vendor or "", vendor=str(device.vendor or ""),
ifname=body.ifname.strip(), ifname=ifname,
if_description=body.if_description or "", if_description=body.if_description or "",
bw_bps=int(body.bw_bps or 0), bw_bps=int(body.bw_bps or 0),
status="active", status="active",
created_at=now, created_at=now,
) )
db.add(row) db.add(row)
task.updated_at = now device.updated_at = now
db.commit() db.commit()
items = list_series(db, task_id) for item in list_series(db, device_id):
for item in items:
if item.id == series_id: if item.id == series_id:
return item return item
raise HTTPException(status_code=500, detail="series_replace_failed") 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: def _as_naive_utc(value: datetime | None) -> datetime | None:
"""Normalize query bounds to naive UTC (DB columns are naive utcnow)."""
if value is None: if value is None:
return None return None
if value.tzinfo is None: if value.tzinfo is None:
@ -468,15 +550,18 @@ def _sample_points(
ts = ts_raw ts = ts_raw
if align_offset is not None and ts_raw is not None: if align_offset is not None and ts_raw is not None:
ts = ts_raw + align_offset 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( points.append(
PortTrafficSamplePoint( PortTrafficSamplePoint(
ts=ts, ts=ts,
ts_raw=ts_raw if align_offset is not None else None, ts_raw=ts_raw if align_offset is not None else None,
in_bps=float(r.in_bps or 0), in_bps=in_bps,
out_bps=float(r.out_bps or 0), out_bps=out_bps,
in_util_pct=float(r.in_util_pct or 0), in_util_pct=resolve_util_pct(float(r.in_util_pct or 0), in_bps, bw),
out_util_pct=float(r.out_util_pct or 0), out_util_pct=resolve_util_pct(float(r.out_util_pct or 0), out_bps, bw),
bw_bps=int(r.bw_bps or 0), bw_bps=bw,
rate_period_sec=int(r.rate_period_sec or 0), rate_period_sec=int(r.rate_period_sec or 0),
) )
) )
@ -530,7 +615,6 @@ def compare_targets(
baseline_target_id: str | None = None, baseline_target_id: str | None = None,
to_ts: datetime | None = None, to_ts: datetime | None = None,
) -> PortTrafficCompareOut: ) -> PortTrafficCompareOut:
"""Compare current interface samples vs period and/or manually mapped interface."""
target = db.get(PortTrafficTarget, target_row_id) target = db.get(PortTrafficTarget, target_row_id)
if not target: if not target:
raise HTTPException(status_code=404, detail="target_not_found") 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)) range_h = max(0.25, float(range_hours or 24))
from_ts = to_ts - timedelta(hours=range_h) from_ts = to_ts - timedelta(hours=range_h)
to_q = to_ts + timedelta(seconds=5) to_q = to_ts + timedelta(seconds=5)
current_rows = _query_target_samples( current = _sample_points(
db, target_row_id=str(target.id), from_ts=from_ts, to_ts=to_q _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) off_h = baseline_offset_hours(baseline, range_h, offset_hours)
baseline_points: list[PortTrafficSamplePoint] = [] 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 base_src = mapped if mapped is not None else target
want_baseline = off_h is not None or mapped is not None want_baseline = off_h is not None or mapped is not None
if want_baseline: if want_baseline:
if off_h is not None: if off_h is not None:
delta = timedelta(hours=off_h) delta = timedelta(hours=off_h)
b_from = from_ts - delta
b_to = to_q - delta
base_rows = _query_target_samples( 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) baseline_points = _sample_points(base_rows, align_offset=delta)
else: else:
# Mapped port, same window (no time shift) — cross-device overlay.
base_rows = _query_target_samples( base_rows = _query_target_samples(
db, target_row_id=str(base_src.id), from_ts=from_ts, to_ts=to_q 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: def compare_series(db: Session, **kwargs: Any) -> PortTrafficCompareOut:
target_row_id = kwargs.pop("target_row_id", None) or kwargs.pop("target_id", None) target_row_id = kwargs.pop("target_row_id", None) or kwargs.pop("target_id", None)
series_id = kwargs.pop("series_id", None) series_id = kwargs.pop("series_id", None)
@ -622,15 +703,14 @@ def get_samples(
now = _utcnow() now = _utcnow()
to_ts = _as_naive_utc(to_ts) or now to_ts = _as_naive_utc(to_ts) or now
from_ts = _as_naive_utc(from_ts) or (to_ts - timedelta(hours=1)) 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) 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) 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)) return PortTrafficSamplesOut(target=_target_out(target), points=_sample_points(rows))
def dashboard(db: Session) -> PortTrafficDashboardOut: def dashboard(db: Session) -> PortTrafficDashboardOut:
task_count = db.query(PortTrafficTask).count() device_count = db.query(PortTrafficDevice).count()
running = db.query(PortTrafficTask).filter(PortTrafficTask.status == "running").count() running = db.query(PortTrafficDevice).filter(PortTrafficDevice.status == "running").count()
active_targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.status == "active").count() active_targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.status == "active").count()
since = _utcnow() - timedelta(hours=24) since = _utcnow() - timedelta(hours=24)
sample_count = ( sample_count = (
@ -640,38 +720,130 @@ def dashboard(db: Session) -> PortTrafficDashboardOut:
) )
last = db.query(func.max(PortTrafficSample.ts)).scalar() last = db.query(func.max(PortTrafficSample.ts)).scalar()
return PortTrafficDashboardOut( return PortTrafficDashboardOut(
task_count=int(task_count), device_count=int(device_count),
running_task_count=int(running), running_device_count=int(running),
active_target_count=int(active_targets), active_target_count=int(active_targets),
sample_count_24h=int(sample_count), sample_count_24h=int(sample_count),
last_sample_at=last, 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: def purge_expired_samples(db: Session) -> int:
"""Delete samples older than each task's retention_days.""" devices = db.query(PortTrafficDevice).all()
tasks = db.query(PortTrafficTask).all()
deleted = 0 deleted = 0
now = _utcnow() now = _utcnow()
for task in tasks: for device in devices:
days = max(1, int(task.retention_days or 7)) days = max(1, int(device.retention_days or 7))
cutoff = now - timedelta(days=days) cutoff = now - timedelta(days=days)
target_ids = [ target_ids = [
str(t.id) 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: if target_ids:
continue n = (
n = ( db.query(PortTrafficSample)
db.query(PortTrafficSample) .filter(
.filter( PortTrafficSample.target_row_id.in_(target_ids),
PortTrafficSample.target_row_id.in_(target_ids), PortTrafficSample.ts < cutoff,
PortTrafficSample.ts < cutoff, )
.delete(synchronize_session=False)
) )
.delete(synchronize_session=False) deleted += int(n or 0)
) db.query(PortTrafficEvent).filter(
deleted += int(n or 0) PortTrafficEvent.device_id == device.id,
PortTrafficEvent.created_at < cutoff,
).delete(synchronize_session=False)
db.commit()
if deleted: if deleted:
db.commit()
_log.info("port_traffic retention purged samples=%s", deleted) _log.info("port_traffic retention purged samples=%s", deleted)
return deleted return deleted

View file

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

View file

@ -15,6 +15,7 @@ from netx_api.port_traffic_parsers import (
parse_interface_detail, parse_interface_detail,
parse_zte_interface_brief, parse_zte_interface_brief,
parse_zte_interface_detail, parse_zte_interface_detail,
resolve_util_pct,
) )
SAMPLE_DETAIL = """\ 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__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

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

View file

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

View file

@ -60,9 +60,10 @@ export const queryKeys = {
["networkConfigs", page, keyword, source, vendor] as const, ["networkConfigs", page, keyword, source, vendor] as const,
networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const, networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const,
portTrafficDashboard: ["portTrafficDashboard"] as const, portTrafficDashboard: ["portTrafficDashboard"] as const,
portTrafficTasksAll: ["portTrafficTasks"] as const, portTrafficDevicesAll: ["portTrafficDevices"] as const,
portTrafficTasks: (page: number) => ["portTrafficTasks", page] as const, portTrafficDevices: (page: number) => ["portTrafficDevices", page] as const,
portTrafficTargets: (taskId: string) => ["portTrafficTargets", taskId] as const, portTrafficTargets: (deviceId: string) => ["portTrafficTargets", deviceId] as const,
portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const,
portTrafficSamples: (targetId: string, rangeHours: number) => portTrafficSamples: (targetId: string, rangeHours: number) =>
["portTrafficSamples", targetId, rangeHours] as const, ["portTrafficSamples", targetId, rangeHours] as const,
portTrafficCompare: ( portTrafficCompare: (

View file

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

View file

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

View file

@ -3695,7 +3695,8 @@ pre {
} }
.network-main:has(.topo-page), .network-main:has(.topo-page),
.network-main:has(.webcrt-shell) { .network-main:has(.webcrt-shell),
.network-main:has(.pt-wall-shell) {
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
@ -3714,6 +3715,12 @@ pre {
border: 0; border: 0;
} }
.network-main .pt-wall-shell {
height: 100%;
margin: 0;
min-height: 0;
}
.network-placeholder h2 { .network-placeholder h2 {
margin: 0 0 8px; margin: 0 0 8px;
} }
@ -3763,58 +3770,578 @@ pre {
color: #334155; color: #334155;
} }
.pt-wall-page__filters { .pt-list {
margin-bottom: 12px; display: flex;
gap: 12px 18px; flex-direction: column;
align-items: flex-end; gap: 14px;
} }
.pt-wall-page__filters label { .pt-list-kpis {
display: inline-flex; 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; 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; font-size: 12px;
color: #64748b; color: #64748b;
} }
.pt-wall-page__filters select { .pt-log-item__level {
min-width: 160px; 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-wall {
--pt-bg: #0b1220; --pt-bg: #0a1220;
--pt-panel: #111827; --pt-panel: transparent;
--pt-line: rgba(148, 163, 184, 0.22); --pt-line: rgba(148, 163, 184, 0.16);
--pt-text: #e2e8f0; --pt-text: #e2e8f0;
--pt-muted: #94a3b8; --pt-muted: #8b9bb0;
--pt-in: #2dd4bf; --pt-in: #2dd4bf;
--pt-out: #f59e0b; --pt-out: #fbbf24;
background: display: flex;
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(11, 18, 32, 0.98)), flex-direction: column;
repeating-linear-gradient( min-height: 0;
0deg, background: transparent;
transparent, border: 0;
transparent 23px, border-radius: 0;
rgba(148, 163, 184, 0.05) 24px padding: 4px 2px 0;
),
repeating-linear-gradient(
90deg,
transparent,
transparent 23px,
rgba(148, 163, 184, 0.05) 24px
);
border: 1px solid #1f2937;
border-radius: 10px;
padding: 16px 18px 12px;
color: var(--pt-text); color: var(--pt-text);
} }
.pt-wall__head { .pt-wall__head {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: baseline; align-items: flex-end;
gap: 12px; gap: 16px;
margin-bottom: 14px; margin-bottom: 12px;
flex: 0 0 auto;
} }
.pt-wall__head-title { .pt-wall__head-title {
@ -3832,51 +4359,71 @@ pre {
} }
.pt-wall__ne { .pt-wall__ne {
font-size: 13px; font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--pt-muted); color: var(--pt-muted);
} }
.pt-wall__if { .pt-wall__if {
font-size: 20px; font-size: 22px;
font-weight: 650; font-weight: 600;
letter-spacing: 0.02em; letter-spacing: 0.01em;
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; 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 { .pt-wall__range {
font-size: 12px; font-size: 12px;
color: var(--pt-muted); color: var(--pt-muted);
white-space: nowrap; white-space: nowrap;
padding-bottom: 3px;
font-variant-numeric: tabular-nums;
} }
.pt-wall__kpis { .pt-wall__kpis {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px; gap: 0;
margin-bottom: 14px; 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 { .pt-wall__kpi {
background: rgba(17, 24, 39, 0.72); background: transparent;
border: 1px solid var(--pt-line); border: 0;
border-radius: 8px; border-radius: 0;
padding: 10px 12px; 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 { .pt-wall__kpi-label {
font-size: 11px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.08em;
color: var(--pt-muted); color: #64748b;
margin-bottom: 4px; margin-bottom: 6px;
} }
.pt-wall__kpi-value { .pt-wall__kpi-value {
font-size: 22px; font-size: 20px;
font-weight: 650; font-weight: 600;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; 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 { .pt-wall__kpi--in .pt-wall__kpi-value {
@ -3887,16 +4434,33 @@ pre {
color: var(--pt-out); color: var(--pt-out);
} }
.pt-wall__kpi--util .pt-wall__kpi-value {
color: #7dd3fc;
}
.pt-wall__kpi-unit, .pt-wall__kpi-unit,
.pt-wall__kpi-sep { .pt-wall__kpi-sep {
margin-left: 4px; margin-left: 4px;
font-size: 12px; font-size: 11px;
color: var(--pt-muted); color: var(--pt-muted);
font-weight: 500; 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 { .pt-wall__chart {
min-height: 260px; min-height: 0;
height: 100%;
width: 100%; width: 100%;
} }
@ -3905,12 +4469,6 @@ pre {
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
} }
.pt-wall__chart-wrap {
position: relative;
min-height: 260px;
width: 100%;
}
.pt-wall__tip { .pt-wall__tip {
position: absolute; position: absolute;
top: 0; top: 0;
@ -3919,13 +4477,14 @@ pre {
min-width: 150px; min-width: 150px;
padding: 8px 10px; padding: 8px 10px;
border-radius: 8px; border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.35); border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(15, 23, 42, 0.94); background: rgba(8, 14, 26, 0.94);
color: #e2e8f0; color: #e2e8f0;
font-size: 12px; font-size: 12px;
line-height: 1.45; line-height: 1.45;
pointer-events: none; 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 { .pt-wall__tip-time {
@ -3946,45 +4505,99 @@ pre {
} }
.pt-wall__tip-row--out strong { .pt-wall__tip-row--out strong {
color: #f59e0b; color: #fbbf24;
}
.pt-wall__chart .u-axis {
border: 0 !important;
} }
.pt-wall__chart .u-legend { .pt-wall__chart .u-legend {
color: var(--pt-muted); display: none !important;
}
.pt-wall__chart .u-legend .u-value {
display: none;
} }
.pt-wall__foot { .pt-wall__foot {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px 28px; align-items: center;
margin-top: 12px; justify-content: space-between;
padding-top: 12px; gap: 8px 20px;
border-top: 1px solid rgba(148, 163, 184, 0.18); margin-top: 8px;
align-items: baseline; 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-ne,
.pt-wall__foot-if { .pt-wall__foot-if {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline; align-items: center;
gap: 6px 10px; gap: 6px 10px;
min-width: 0; min-width: 0;
} }
.pt-wall__foot-label { .pt-wall__foot-label {
font-size: 11px; font-size: 10px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.08em;
color: var(--pt-muted); color: #64748b;
} }
.pt-wall__foot-value { .pt-wall__foot-value {
font-size: 15px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #e2e8f0; color: #e2e8f0;
} }
@ -4002,7 +4615,7 @@ pre {
.pt-wall__empty { .pt-wall__empty {
position: absolute; position: absolute;
inset: 0; inset: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -4010,9 +4623,9 @@ pre {
text-align: center; text-align: center;
line-height: 1.5; line-height: 1.5;
color: var(--pt-muted); color: var(--pt-muted);
background: rgba(11, 18, 32, 0.72); background: rgba(8, 14, 26, 0.55);
border: 1px dashed rgba(148, 163, 184, 0.28); border: 1px dashed rgba(148, 163, 184, 0.22);
border-radius: 8px; border-radius: 6px;
pointer-events: none; pointer-events: none;
} }
@ -4020,4 +4633,12 @@ pre {
.pt-wall__kpis { .pt-wall__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr)); 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 }) => className={({ isActive }) =>
`network-nav__link${isActive ? " is-active" : ""}` `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)} {t(item.labelKey)}
</NavLink> </NavLink>
@ -127,7 +130,10 @@ export function NetworkLayout() {
className={({ isActive }) => className={({ isActive }) =>
`network-nav__rail-link${isActive ? " is-active" : ""}` `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)} {t(item.labelKey).slice(0, 1)}
</NavLink> </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 { formatSystemTime, parseApiTime } from "../../utils/time";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
export type WallYMode = "auto" | "current" | "util";
function formatBps(n: number): string { function formatBps(n: number): string {
const v = Math.abs(n); const v = Math.abs(n);
if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`; if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`;
@ -18,13 +20,59 @@ function formatBwLabel(bps: number): string {
return `${formatBps(bps)}bit/s`; 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 { function formatHoverTime(sec: number): string {
return formatSystemTime(new Date(sec * 1000)); 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( function toAlignedSeries(
current: PortTrafficSamplePoint[], current: PortTrafficSamplePoint[],
baseline: PortTrafficSamplePoint[], baseline: PortTrafficSamplePoint[],
mode: WallYMode,
bwFallback: number,
baseBwFallback: number,
): [number[], number[], number[], number[], number[]] { ): [number[], number[], number[], number[], number[]] {
type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number }; type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number };
const map = new Map<number, Row>(); const map = new Map<number, Row>();
@ -33,8 +81,8 @@ function toAlignedSeries(
if (!d) continue; if (!d) continue;
const x = Math.floor(d.getTime() / 1000); const x = Math.floor(d.getTime() / 1000);
const row = map.get(x) || { x }; const row = map.get(x) || { x };
row.inC = Number(p.in_bps) || 0; row.inC = pointValue(p, "in", mode, bwFallback);
row.outC = Number(p.out_bps) || 0; row.outC = pointValue(p, "out", mode, bwFallback);
map.set(x, row); map.set(x, row);
} }
for (const p of baseline) { for (const p of baseline) {
@ -42,8 +90,8 @@ function toAlignedSeries(
if (!d) continue; if (!d) continue;
const x = Math.floor(d.getTime() / 1000); const x = Math.floor(d.getTime() / 1000);
const row = map.get(x) || { x }; const row = map.get(x) || { x };
row.inB = Number(p.in_bps) || 0; row.inB = pointValue(p, "in", mode, baseBwFallback || bwFallback);
row.outB = Number(p.out_bps) || 0; row.outB = pointValue(p, "out", mode, baseBwFallback || bwFallback);
map.set(x, row); map.set(x, row);
} }
const rows = [...map.values()].sort((a, b) => a.x - b.x); 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 } { function chartSize(el: HTMLElement): { width: number; height: number } {
const width = Math.max(320, el.clientWidth || el.parentElement?.clientWidth || 800); 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 }; return { width, height };
} }
@ -73,6 +126,7 @@ type Props = {
points: PortTrafficSamplePoint[]; points: PortTrafficSamplePoint[];
baselinePoints?: PortTrafficSamplePoint[]; baselinePoints?: PortTrafficSamplePoint[];
rangeLabel: string; rangeLabel: string;
yMode?: WallYMode;
loading?: boolean; loading?: boolean;
hint?: string; hint?: string;
}; };
@ -83,6 +137,7 @@ export function PortTrafficWall({
points, points,
baselinePoints = EMPTY_POINTS, baselinePoints = EMPTY_POINTS,
rangeLabel, rangeLabel,
yMode = "auto",
loading, loading,
hint, hint,
}: Props) { }: Props) {
@ -92,21 +147,27 @@ export function PortTrafficWall({
const tipRef = useRef<HTMLDivElement | null>(null); const tipRef = useRef<HTMLDivElement | null>(null);
const pointsRef = useRef(points); const pointsRef = useRef(points);
const baselineRef = useRef(baselinePoints); const baselineRef = useRef(baselinePoints);
const yModeRef = useRef(yMode);
const bwRef = useRef(0);
pointsRef.current = points; pointsRef.current = points;
baselineRef.current = baselinePoints; baselineRef.current = baselinePoints;
yModeRef.current = yMode;
const latest = points.length ? points[points.length - 1] : null; const latest = points.length ? points[points.length - 1] : null;
const kpi = useMemo(() => { const kpi = useMemo(() => {
const bw = latest?.bw_bps || target?.bw_bps || 0; const bw = latest?.bw_bps || target?.bw_bps || 0;
const inBps = latest?.in_bps ?? 0;
const outBps = latest?.out_bps ?? 0;
return { return {
bw, bw,
inBps: latest?.in_bps ?? 0, inBps,
outBps: latest?.out_bps ?? 0, outBps,
inUtil: latest?.in_util_pct ?? 0, inUtil: resolveUtil(latest?.in_util_pct ?? 0, inBps, bw),
outUtil: latest?.out_util_pct ?? 0, outUtil: resolveUtil(latest?.out_util_pct ?? 0, outBps, bw),
ts: latest?.ts ?? null, ts: latest?.ts ?? null,
}; };
}, [latest, target]); }, [latest, target]);
bwRef.current = kpi.bw;
useEffect(() => { useEffect(() => {
const el = mountRef.current; const el = mountRef.current;
@ -139,16 +200,16 @@ export function PortTrafficWall({
label: t("portTraffic.seriesCurrentIn"), label: t("portTraffic.seriesCurrentIn"),
stroke: "#2dd4bf", stroke: "#2dd4bf",
width: 2, width: 2,
fill: "rgba(45, 212, 191, 0.12)", fill: "rgba(45, 212, 191, 0.08)",
points: { show: true, size: 5, fill: "#2dd4bf" }, points: { show: false },
spanGaps: false, spanGaps: false,
}, },
{ {
label: t("portTraffic.seriesCurrentOut"), label: t("portTraffic.seriesCurrentOut"),
stroke: "#f59e0b", stroke: "#fbbf24",
width: 2, width: 2,
fill: "rgba(245, 158, 11, 0.10)", fill: "rgba(251, 191, 36, 0.07)",
points: { show: true, size: 5, fill: "#f59e0b" }, points: { show: false },
spanGaps: false, spanGaps: false,
}, },
{ {
@ -162,7 +223,7 @@ export function PortTrafficWall({
}, },
{ {
label: t("portTraffic.seriesBaselineOut"), label: t("portTraffic.seriesBaselineOut"),
stroke: "rgba(245, 158, 11, 0.45)", stroke: "rgba(251, 191, 36, 0.4)",
width: 1.5, width: 1.5,
dash: [6, 4], dash: [6, 4],
points: { show: false }, points: { show: false },
@ -173,16 +234,20 @@ export function PortTrafficWall({
axes: [ axes: [
{ {
stroke: "#94a3b8", stroke: "#94a3b8",
grid: { stroke: "rgba(148,163,184,0.18)", width: 1 }, grid: { show: false },
ticks: { stroke: "rgba(148,163,184,0.35)" }, ticks: { stroke: "rgba(148,163,184,0.35)", size: 4 },
border: { show: false },
gap: 6,
}, },
{ {
stroke: "#94a3b8", stroke: "#94a3b8",
grid: { stroke: "rgba(148,163,184,0.12)", width: 1 }, grid: { stroke: "rgba(148,163,184,0.12)", width: 1 },
ticks: { stroke: "rgba(148,163,184,0.35)" }, 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, size: 64,
label: "bit/s", label: yMode === "util" ? "%" : "bit/s",
labelFont: "12px sans-serif", labelFont: "12px sans-serif",
labelSize: 14, labelSize: 14,
}, },
@ -191,17 +256,19 @@ export function PortTrafficWall({
x: { time: true }, x: { time: true },
y: { y: {
auto: true, auto: true,
range: (_u, min, max) => { range: (u, min, max) => {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1]; if (yModeRef.current === "current") {
if (min === max) { const vals = [...(u.data[1] || []), ...(u.data[2] || [])].filter((v) =>
const pad = Math.max(10, Math.abs(max) * 0.25 || 10); Number.isFinite(v),
return [Math.max(0, min - pad), max + pad]; ) 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: { cursor: {
drag: { x: true, y: false }, drag: { x: true, y: false },
focus: { prox: 32 }, focus: { prox: 32 },
@ -221,10 +288,6 @@ export function PortTrafficWall({
} }
const tsSec = Number(u.data[0][idx]); 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 sample = pointsRef.current.find((p) => {
const d = parseApiTime(p.ts); const d = parseApiTime(p.ts);
return d && Math.floor(d.getTime() / 1000) === tsSec; return d && Math.floor(d.getTime() / 1000) === tsSec;
@ -233,26 +296,39 @@ export function PortTrafficWall({
const d = parseApiTime(p.ts); const d = parseApiTime(p.ts);
return d && Math.floor(d.getTime() / 1000) === tsSec; 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; if (!tip) return;
tip.style.display = "block"; tip.style.display = "block";
const rows = [ const rows = [
`<div class="pt-wall__tip-time">${formatHoverTime(tsSec)}</div>`, `<div class="pt-wall__tip-time">${formatHoverTime(tsSec)}</div>`,
Number.isFinite(inBps) Number.isFinite(yIn)
? `<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${formatBps(inBps)} bit/s</strong></div>` ? `<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${fmtY(yIn)} ${unit}</strong></div>`
: "", : "",
Number.isFinite(outBps) Number.isFinite(yOut)
? `<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${formatBps(outBps)} bit/s</strong></div>` ? `<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${fmtY(yOut)} ${unit}</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>`
: "", : "",
]; ];
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( rows.push(
`<div class="pt-wall__tip-row"><span>${t("portTraffic.baseline")}</span><strong>${ `<div class="pt-wall__tip-row"><span>${t("portTraffic.baseline")}</span><strong>${
Number.isFinite(inBase) ? formatBps(inBase) : "—" Number.isFinite(yInB) ? fmtY(yInB) : "—"
} / ${Number.isFinite(outBase) ? formatBps(outBase) : "—"} bit/s</strong></div>`, } / ${Number.isFinite(yOutB) ? fmtY(yOutB) : "—"} ${unit}</strong></div>`,
); );
if (baseSample?.ts_raw) { if (baseSample?.ts_raw) {
rows.push( rows.push(
@ -279,8 +355,16 @@ export function PortTrafficWall({
plotRef.current.setSize(chartSize(mountRef.current)); plotRef.current.setSize(chartSize(mountRef.current));
}; };
window.addEventListener("resize", onResize); 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 () => { return () => {
window.removeEventListener("resize", onResize); window.removeEventListener("resize", onResize);
ro?.disconnect();
plotRef.current?.over.removeEventListener("mouseleave", onLeave); plotRef.current?.over.removeEventListener("mouseleave", onLeave);
plotRef.current?.destroy(); plotRef.current?.destroy();
plotRef.current = null; plotRef.current = null;
@ -291,9 +375,9 @@ export function PortTrafficWall({
useEffect(() => { useEffect(() => {
const plot = plotRef.current; const plot = plotRef.current;
if (!plot) return; if (!plot) return;
// Always setData with resetScales; toggling series.show before setData can leave const bw = kpi.bw || target?.bw_bps || 0;
// uPlot stuck on a blank [0,1] y-range until a later update. const baseBw = baselineTarget?.bw_bps || bw;
const data = toAlignedSeries(points, baselinePoints); const data = toAlignedSeries(points, baselinePoints, yMode, bw, baseBw);
const showBaseline = baselinePoints.length > 0; const showBaseline = baselinePoints.length > 0;
plot.setData(data, true); plot.setData(data, true);
const s3 = Boolean(plot.series[3]?.show); const s3 = Boolean(plot.series[3]?.show);
@ -302,15 +386,22 @@ export function PortTrafficWall({
plot.setSeries(3, { show: showBaseline }, false); plot.setSeries(3, { show: showBaseline }, false);
plot.setSeries(4, { 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; const el = mountRef.current;
if (el) { if (el) {
// Legend show/hide changes layout; size after paint.
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!plotRef.current || !mountRef.current) return; if (!plotRef.current || !mountRef.current) return;
plotRef.current.setSize(chartSize(mountRef.current)); 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 empty = points.length === 0 && baselinePoints.length === 0;
const neLabel = target ? target.ne_name || target.ne_ip || "—" : "—"; const neLabel = target ? target.ne_name || target.ne_ip || "—" : "—";
@ -326,7 +417,15 @@ export function PortTrafficWall({
return ( return (
<div className="pt-wall"> <div className="pt-wall">
<div className="pt-wall__head"> <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"> <div className="pt-wall__range">
{rangeLabel} {rangeLabel}
{kpi.ts ? ` · ${t("portTraffic.latestAt")} ${formatSystemTime(kpi.ts)}` : ""} {kpi.ts ? ` · ${t("portTraffic.latestAt")} ${formatSystemTime(kpi.ts)}` : ""}
@ -334,37 +433,29 @@ export function PortTrafficWall({
</div> </div>
<div className="pt-wall__kpis"> <div className="pt-wall__kpis">
<div className="pt-wall__kpi"> <div className="pt-wall__kpi">
<div className="pt-wall__kpi-label"> <div className="pt-wall__kpi-label">{t("portTraffic.kpiBw")}</div>
{t("portTraffic.kpiBw")} · {t("portTraffic.kpiLatest")}
</div>
<div className="pt-wall__kpi-value">{formatBwLabel(kpi.bw)}</div> <div className="pt-wall__kpi-value">{formatBwLabel(kpi.bw)}</div>
</div> </div>
<div className="pt-wall__kpi pt-wall__kpi--in"> <div className="pt-wall__kpi pt-wall__kpi--in">
<div className="pt-wall__kpi-label"> <div className="pt-wall__kpi-label">In</div>
In · {t("portTraffic.kpiLatest")}
</div>
<div className="pt-wall__kpi-value"> <div className="pt-wall__kpi-value">
{formatBps(kpi.inBps)} {formatBps(kpi.inBps)}
<span className="pt-wall__kpi-unit">bit/s</span> <span className="pt-wall__kpi-unit">bit/s</span>
</div> </div>
</div> </div>
<div className="pt-wall__kpi pt-wall__kpi--out"> <div className="pt-wall__kpi pt-wall__kpi--out">
<div className="pt-wall__kpi-label"> <div className="pt-wall__kpi-label">Out</div>
Out · {t("portTraffic.kpiLatest")}
</div>
<div className="pt-wall__kpi-value"> <div className="pt-wall__kpi-value">
{formatBps(kpi.outBps)} {formatBps(kpi.outBps)}
<span className="pt-wall__kpi-unit">bit/s</span> <span className="pt-wall__kpi-unit">bit/s</span>
</div> </div>
</div> </div>
<div className="pt-wall__kpi"> <div className="pt-wall__kpi pt-wall__kpi--util">
<div className="pt-wall__kpi-label"> <div className="pt-wall__kpi-label">Util</div>
Util · {t("portTraffic.kpiLatest")}
</div>
<div className="pt-wall__kpi-value"> <div className="pt-wall__kpi-value">
{kpi.inUtil.toFixed(1)}% {formatUtilPct(kpi.inUtil)}
<span className="pt-wall__kpi-sep">/</span> <span className="pt-wall__kpi-sep">/</span>
{kpi.outUtil.toFixed(1)}% {formatUtilPct(kpi.outUtil)}
</div> </div>
</div> </div>
</div> </div>
@ -378,17 +469,30 @@ export function PortTrafficWall({
) : null} ) : null}
</div> </div>
<div className="pt-wall__foot"> <div className="pt-wall__foot">
<div className="pt-wall__foot-ne" title={neLabel}> <div className="pt-wall__legend" aria-hidden={!points.length && !baselinePoints.length}>
<span className="pt-wall__foot-label">{t("portTraffic.wallDevice")}</span> <span className="pt-wall__legend-item pt-wall__legend-item--in">
<span className="pt-wall__foot-value">{neLabel}</span> <i className="pt-wall__legend-swatch" />
{ipLabel ? <span className="pt-wall__foot-ip">{ipLabel}</span> : null} {t("portTraffic.seriesCurrentIn")}
</div> </span>
<div className="pt-wall__foot-if" title={ifLabel}> <span className="pt-wall__legend-item pt-wall__legend-item--out">
<span className="pt-wall__foot-label">{t("portTraffic.wallPort")}</span> <i className="pt-wall__legend-swatch" />
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{ifLabel}</span> {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> </div>
{showBaselineMeta ? ( {showBaselineMeta ? (
<> <div className="pt-wall__foot-meta">
<div className="pt-wall__foot-ne" title={baseNeLabel}> <div className="pt-wall__foot-ne" title={baseNeLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.baselineDevice")}</span> <span className="pt-wall__foot-label">{t("portTraffic.baselineDevice")}</span>
<span className="pt-wall__foot-value">{baseNeLabel}</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-label">{t("portTraffic.baselinePort")}</span>
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{baseIfLabel}</span> <span className="pt-wall__foot-value pt-wall__foot-value--mono">{baseIfLabel}</span>
</div> </div>
</> </div>
) : null} ) : null}
</div> </div>
</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, NeConfigSnapshotMeta,
PortTrafficCompare, PortTrafficCompare,
PortTrafficDashboard, PortTrafficDashboard,
PortTrafficDevice,
PortTrafficDiscoverResponse, PortTrafficDiscoverResponse,
PortTrafficEvent,
PortTrafficIfaceIn,
PortTrafficSamples, PortTrafficSamples,
PortTrafficTarget, PortTrafficTarget,
PortTrafficTargetIn,
PortTrafficTask,
} from "../types"; } from "../types";
export const AUTH_TOKEN_KEY = "netx_access_token"; export const AUTH_TOKEN_KEY = "netx_access_token";
export const getAuthToken = (): string | null => { export const getAuthToken = (): string | null => {
@ -797,45 +799,77 @@ export const downloadNeConfigSnapshot = async (
export const fetchPortTrafficDashboard = () => export const fetchPortTrafficDashboard = () =>
apiGet<PortTrafficDashboard>("/v1/port-traffic/dashboard"); 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(); const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1)))); 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))))); 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[] }>( return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>(
`/v1/port-traffic/tasks?${p.toString()}`, `/v1/port-traffic/devices?${p.toString()}`,
); );
}; };
export const createPortTrafficTask = (body: { /** @deprecated */
title: string; 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; interval_sec?: number;
retention_days?: number; retention_days?: number;
concurrency?: number; concurrency?: number;
targets?: PortTrafficTargetIn[]; interfaces?: PortTrafficIfaceIn[];
start_now?: boolean; start_now?: boolean;
}) => apiPost<PortTrafficTask>("/v1/port-traffic/tasks", body); }) => apiPost<PortTrafficDevice>("/v1/port-traffic/devices", body);
export const startPortTrafficTask = (taskId: string) => export const updatePortTrafficDevice = (
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/start`, {}); 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) => export const startPortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/pause`, {}); apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/start`, {});
export const stopPortTrafficTask = (taskId: string) => export const pausePortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/stop`, {}); apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/pause`, {});
export const collectPortTrafficNow = (taskId: string) => export const stopPortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficTask & { ok: boolean; started: boolean; reason?: string }>( apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/stop`, {});
`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/collect-now`,
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) => export const deletePortTrafficDevice = (deviceId: string) =>
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}`); apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`);
export const fetchPortTrafficTargets = (taskId: string) => export const fetchPortTrafficTargets = (deviceId: string) =>
apiGet<{ items: PortTrafficTarget[] }>( 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 }) => export const discoverPortTrafficPorts = (body: { source: "managed" | "ume"; id: string }) =>

View file

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