From 214bc56774b80b7fa730794e9469cb5bb276b879 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 1 Aug 2026 15:45:20 +0800 Subject: [PATCH] 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 --- netx_api/models.py | 65 +- netx_api/port_traffic_migrate.py | 256 +++- netx_api/port_traffic_parsers.py | 12 + netx_api/port_traffic_recovery.py | 20 +- netx_api/port_traffic_router.py | 219 +-- netx_api/port_traffic_runner.py | 354 +++-- netx_api/port_traffic_scheduler.py | 41 +- netx_api/port_traffic_schemas.py | 92 +- netx_api/port_traffic_service.py | 598 +++++--- tests/test_port_traffic_compare.py | 36 +- tests/test_port_traffic_parsers.py | 16 + web/WEB.md | 22 +- web/src/App.tsx | 2 + web/src/config/networkNav.ts | 6 + web/src/constants/queryKeys.ts | 7 +- web/src/i18n/en.ts | 54 +- web/src/i18n/zh.ts | 62 +- web/src/index.css | 785 +++++++++-- web/src/pages/network/NetworkLayout.tsx | 10 +- web/src/pages/network/PortTrafficPage.tsx | 1247 +++++++++-------- web/src/pages/network/PortTrafficWall.tsx | 246 +++- web/src/pages/network/PortTrafficWallPage.tsx | 389 +++++ web/src/services/api.ts | 78 +- web/src/types.ts | 49 +- 24 files changed, 3309 insertions(+), 1357 deletions(-) create mode 100644 web/src/pages/network/PortTrafficWallPage.tsx diff --git a/netx_api/models.py b/netx_api/models.py index d276af1..d2fb6b0 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -595,17 +595,25 @@ class NeConfigHistory(Base): task_id: Mapped[str] = mapped_column(String(64), default="") -class PortTrafficTask(Base): - """Port traffic monitoring job definition.""" +class PortTrafficDevice(Base): + """Per-NE port traffic monitoring config (device-centric).""" - __tablename__ = "port_traffic_task" + __tablename__ = "port_traffic_device" + __table_args__ = ( + UniqueConstraint("source", "ne_id", name="uq_port_traffic_device_ne"), + ) id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) - title: Mapped[str] = mapped_column(String(256), default="") + source: Mapped[str] = mapped_column(String(32), default="managed", index=True) + ne_id: Mapped[str] = mapped_column(String(128), default="", index=True) + ne_name: Mapped[str] = mapped_column(String(256), default="") + ne_ip: Mapped[str] = mapped_column(String(128), default="") + vendor: Mapped[str] = mapped_column(String(64), default="") + note: Mapped[str] = mapped_column(String(256), default="") # optional remark status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|running|paused|stopped interval_sec: Mapped[int] = mapped_column(Integer, default=60) retention_days: Mapped[int] = mapped_column(Integer, default=7) - concurrency: Mapped[int] = mapped_column(Integer, default=5) + concurrency: Mapped[int] = mapped_column(Integer, default=1) collect_running: Mapped[bool] = mapped_column(Boolean, default=False) last_collect_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) last_collect_ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) @@ -614,32 +622,41 @@ class PortTrafficTask(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) +# Back-compat alias while callers migrate. +PortTrafficTask = PortTrafficDevice + + class PortTrafficSeries(Base): """Logical port (business link) that survives physical NE/if replacement.""" __tablename__ = "port_traffic_series" - __table_args__ = (UniqueConstraint("task_id", "title", name="uq_port_traffic_series_title"),) + __table_args__ = (UniqueConstraint("device_id", "title", name="uq_port_traffic_series_title"),) id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) - task_id: Mapped[str] = mapped_column(String(64), index=True) + device_id: Mapped[str] = mapped_column(String(64), default="", index=True) title: Mapped[str] = mapped_column(String(256), default="") status: Mapped[str] = mapped_column(String(32), default="active", index=True) # active|disabled created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + @property + def task_id(self) -> str: + return str(self.device_id or "") + + @task_id.setter + def task_id(self, value: str) -> None: + self.device_id = str(value or "") + class PortTrafficTarget(Base): - """Monitored interface under a port traffic task.""" + """Monitored interface under a device monitoring config.""" __tablename__ = "port_traffic_target" - __table_args__ = ( - UniqueConstraint("task_id", "source", "target_id", "ifname", name="uq_port_traffic_target_if"), - ) id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) - task_id: Mapped[str] = mapped_column(String(64), index=True) + device_id: Mapped[str] = mapped_column(String(64), default="", index=True) series_id: Mapped[str] = mapped_column(String(64), default="", index=True) source: Mapped[str] = mapped_column(String(32), default="managed", index=True) - target_id: Mapped[str] = mapped_column(String(128), index=True) + target_id: Mapped[str] = mapped_column(String(128), index=True) # NE id ne_name: Mapped[str] = mapped_column(String(256), default="") ne_ip: Mapped[str] = mapped_column(String(128), default="") vendor: Mapped[str] = mapped_column(String(64), default="") @@ -651,6 +668,14 @@ class PortTrafficTarget(Base): last_sample_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + @property + def task_id(self) -> str: + return str(self.device_id or "") + + @task_id.setter + def task_id(self, value: str) -> None: + self.device_id = str(value or "") + class PortTrafficSample(Base): """Time-series sample for a monitored interface.""" @@ -670,3 +695,17 @@ class PortTrafficSample(Base): rate_period_sec: Mapped[int] = mapped_column(Integer, default=0) raw_ok: Mapped[bool] = mapped_column(Boolean, default=True) message: Mapped[str] = mapped_column(String(512), default="") + + +class PortTrafficEvent(Base): + """Collect / ops log line for a monitored device (and optional interface).""" + + __tablename__ = "port_traffic_event" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + device_id: Mapped[str] = mapped_column(String(64), default="", index=True) + target_row_id: Mapped[str] = mapped_column(String(64), default="", index=True) + ifname: Mapped[str] = mapped_column(String(128), default="") + level: Mapped[str] = mapped_column(String(16), default="error", index=True) # info|warn|error + message: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) diff --git a/netx_api/port_traffic_migrate.py b/netx_api/port_traffic_migrate.py index 30939cb..20b612d 100644 --- a/netx_api/port_traffic_migrate.py +++ b/netx_api/port_traffic_migrate.py @@ -1,4 +1,4 @@ -"""Startup migration / backfill for port traffic logical series.""" +"""Startup migration / backfill for port traffic device-centric model.""" from __future__ import annotations @@ -8,49 +8,274 @@ from uuid import uuid4 from sqlalchemy import text from sqlalchemy.orm import Session -from .models import PortTrafficSample, PortTrafficSeries, PortTrafficTarget +from .models import PortTrafficDevice, PortTrafficSample, PortTrafficSeries, PortTrafficTarget _log = logging.getLogger("netx.port_traffic.migrate") def ensure_port_traffic_series_schema(conn) -> None: - """DDL for series + series_id columns (Postgres IF NOT EXISTS).""" + """DDL for device + series + device_id columns (Postgres IF NOT EXISTS).""" + conn.exec_driver_sql( + """ + CREATE TABLE IF NOT EXISTS port_traffic_device ( + id VARCHAR(64) PRIMARY KEY, + source VARCHAR(32) DEFAULT 'managed', + ne_id VARCHAR(128) DEFAULT '', + ne_name VARCHAR(256) DEFAULT '', + ne_ip VARCHAR(128) DEFAULT '', + vendor VARCHAR(64) DEFAULT '', + note VARCHAR(256) DEFAULT '', + status VARCHAR(32) DEFAULT 'draft', + interval_sec INTEGER DEFAULT 60, + retention_days INTEGER DEFAULT 7, + concurrency INTEGER DEFAULT 1, + collect_running BOOLEAN DEFAULT FALSE, + last_collect_started_at TIMESTAMP, + last_collect_ended_at TIMESTAMP, + last_error VARCHAR(1024) DEFAULT '', + created_at TIMESTAMP, + updated_at TIMESTAMP + ) + """ + ) + conn.exec_driver_sql( + "CREATE UNIQUE INDEX IF NOT EXISTS uq_port_traffic_device_ne ON port_traffic_device (source, ne_id)" + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_port_traffic_device_status ON port_traffic_device (status)" + ) + conn.exec_driver_sql( """ CREATE TABLE IF NOT EXISTS port_traffic_series ( id VARCHAR(64) PRIMARY KEY, - task_id VARCHAR(64), + device_id VARCHAR(64) DEFAULT '', title VARCHAR(256) DEFAULT '', status VARCHAR(32) DEFAULT 'active', created_at TIMESTAMP ) """ ) + # Legacy column may still exist as task_id conn.exec_driver_sql( - "CREATE INDEX IF NOT EXISTS ix_port_traffic_series_task_id ON port_traffic_series (task_id)" + "ALTER TABLE port_traffic_series ADD COLUMN IF NOT EXISTS device_id VARCHAR(64) DEFAULT ''" + ) + conn.exec_driver_sql( + "ALTER TABLE port_traffic_series ADD COLUMN IF NOT EXISTS task_id VARCHAR(64) DEFAULT ''" + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_port_traffic_series_device_id ON port_traffic_series (device_id)" ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS ix_port_traffic_series_status ON port_traffic_series (status)" ) - conn.exec_driver_sql( - "CREATE UNIQUE INDEX IF NOT EXISTS uq_port_traffic_series_title ON port_traffic_series (task_id, title)" - ) + conn.exec_driver_sql( "ALTER TABLE port_traffic_target ADD COLUMN IF NOT EXISTS series_id VARCHAR(64) DEFAULT ''" ) + conn.exec_driver_sql( + "ALTER TABLE port_traffic_target ADD COLUMN IF NOT EXISTS device_id VARCHAR(64) DEFAULT ''" + ) + conn.exec_driver_sql( + "ALTER TABLE port_traffic_target ADD COLUMN IF NOT EXISTS task_id VARCHAR(64) DEFAULT ''" + ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS ix_port_traffic_target_series_id ON port_traffic_target (series_id)" ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_port_traffic_target_device_id ON port_traffic_target (device_id)" + ) conn.exec_driver_sql( "ALTER TABLE port_traffic_sample ADD COLUMN IF NOT EXISTS series_id VARCHAR(64) DEFAULT ''" ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS ix_port_traffic_sample_series_id ON port_traffic_sample (series_id)" ) + # One active interface globally per physical port + conn.exec_driver_sql( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_port_traffic_target_active_if + ON port_traffic_target (source, target_id, ifname) + WHERE status = 'active' + """ + ) + + conn.exec_driver_sql( + """ + CREATE TABLE IF NOT EXISTS port_traffic_event ( + id VARCHAR(64) PRIMARY KEY, + device_id VARCHAR(64) DEFAULT '', + target_row_id VARCHAR(64) DEFAULT '', + ifname VARCHAR(128) DEFAULT '', + level VARCHAR(16) DEFAULT 'error', + message TEXT DEFAULT '', + created_at TIMESTAMP + ) + """ + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_port_traffic_event_device_id ON port_traffic_event (device_id)" + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_port_traffic_event_created_at ON port_traffic_event (created_at)" + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_port_traffic_event_level ON port_traffic_event (level)" + ) + + +def migrate_tasks_to_devices(db: Session) -> int: + """Collapse legacy port_traffic_task rows into per-NE devices; reassign targets/series.""" + # Copy task_id → device_id where missing + try: + db.execute( + text( + """ + UPDATE port_traffic_target + SET device_id = task_id + WHERE COALESCE(device_id, '') = '' AND COALESCE(task_id, '') <> '' + """ + ) + ) + db.execute( + text( + """ + UPDATE port_traffic_series + SET device_id = task_id + WHERE COALESCE(device_id, '') = '' AND COALESCE(task_id, '') <> '' + """ + ) + ) + db.commit() + except Exception: + db.rollback() + + # If legacy task table missing, nothing else to do + try: + db.execute(text("SELECT 1 FROM port_traffic_task LIMIT 1")) + except Exception: + db.rollback() + return 0 + + targets = db.query(PortTrafficTarget).all() + if not targets: + # Still create devices from empty? skip + return 0 + + # Group active+any targets by (source, ne_id) + groups: dict[tuple[str, str], list[PortTrafficTarget]] = {} + for t in targets: + src = str(t.source or "managed").strip().lower() or "managed" + ne = str(t.target_id or "").strip() + if not ne: + continue + groups.setdefault((src, ne), []).append(t) + + created = 0 + # Load legacy tasks for interval/status + legacy: dict[str, dict] = {} + try: + rows = db.execute( + text( + """ + SELECT id, title, status, interval_sec, retention_days, concurrency, + collect_running, last_collect_started_at, last_collect_ended_at, + last_error, created_at, updated_at + FROM port_traffic_task + """ + ) + ).mappings().all() + for r in rows: + legacy[str(r["id"])] = dict(r) + except Exception: + db.rollback() + legacy = {} + + for (src, ne), members in groups.items(): + existing = ( + db.query(PortTrafficDevice) + .filter(PortTrafficDevice.source == src, PortTrafficDevice.ne_id == ne) + .first() + ) + if existing: + device = existing + else: + # Pick policy from the richest legacy task among members + intervals: list[int] = [] + retentions: list[int] = [] + statuses: list[str] = [] + note = "" + created_at = None + for m in members: + tid = str(getattr(m, "device_id", None) or getattr(m, "task_id", None) or "") + meta = legacy.get(tid) or {} + if meta.get("interval_sec"): + intervals.append(int(meta["interval_sec"])) + if meta.get("retention_days"): + retentions.append(int(meta["retention_days"])) + if meta.get("status"): + statuses.append(str(meta["status"])) + if not note and meta.get("title"): + note = str(meta["title"])[:256] + if created_at is None and meta.get("created_at"): + created_at = meta["created_at"] + status = "running" if "running" in statuses else (statuses[0] if statuses else "stopped") + sample = members[0] + device = PortTrafficDevice( + id=uuid4().hex, + source=src, + ne_id=ne, + ne_name=str(sample.ne_name or ""), + ne_ip=str(sample.ne_ip or ""), + vendor=str(sample.vendor or ""), + note=note, + status=status if status in ("running", "paused", "stopped", "draft") else "stopped", + interval_sec=min(intervals) if intervals else 60, + retention_days=max(retentions) if retentions else 7, + concurrency=1, + collect_running=False, + created_at=created_at, + ) + db.add(device) + db.flush() + created += 1 + + # Deduplicate active ifnames: keep earliest active, retire the rest + seen_active: set[str] = set() + for m in sorted(members, key=lambda x: str(x.created_at or "")): + m.device_id = str(device.id) + if str(m.status) == "active": + key = str(m.ifname or "").strip() + if key in seen_active: + m.status = "retired" + else: + seen_active.add(key) + # Keep ne metadata in sync + if not device.ne_name and m.ne_name: + device.ne_name = str(m.ne_name) + if not device.ne_ip and m.ne_ip: + device.ne_ip = str(m.ne_ip) + if not device.vendor and m.vendor: + device.vendor = str(m.vendor) + + # Re-point series + series_ids = {str(m.series_id) for m in members if m.series_id} + if series_ids: + db.query(PortTrafficSeries).filter(PortTrafficSeries.id.in_(list(series_ids))).update( + {PortTrafficSeries.device_id: str(device.id)}, + synchronize_session=False, + ) + + db.commit() + if created: + _log.info("port_traffic migrated devices created=%s groups=%s", created, len(groups)) + return created def backfill_port_traffic_series(db: Session) -> int: """Create one series per target missing series_id; stamp samples.""" + migrate_tasks_to_devices(db) + targets = ( db.query(PortTrafficTarget) .filter((PortTrafficTarget.series_id == None) | (PortTrafficTarget.series_id == "")) # noqa: E711 @@ -58,13 +283,14 @@ def backfill_port_traffic_series(db: Session) -> int: ) created = 0 for t in targets: + device_id = str(t.device_id or "") title = _default_series_title(t.ne_name or "", t.ifname or "") - title = _unique_series_title(db, str(t.task_id), title) + title = _unique_series_title(db, device_id, title) sid = uuid4().hex db.add( PortTrafficSeries( id=sid, - task_id=str(t.task_id), + device_id=device_id, title=title, status="active", ) @@ -103,8 +329,8 @@ def default_series_title(ne_name: str, ifname: str) -> str: return _default_series_title(ne_name, ifname) -def unique_series_title(db: Session, task_id: str, base: str) -> str: - return _unique_series_title(db, task_id, base) +def unique_series_title(db: Session, device_id: str, base: str) -> str: + return _unique_series_title(db, device_id, base) def _default_series_title(ne_name: str, ifname: str) -> str: @@ -113,11 +339,11 @@ def _default_series_title(ne_name: str, ifname: str) -> str: return f"{ne}:{iface}"[:256] -def _unique_series_title(db: Session, task_id: str, base: str) -> str: +def _unique_series_title(db: Session, device_id: str, base: str) -> str: title = base[:256] exists = ( db.query(PortTrafficSeries.id) - .filter(PortTrafficSeries.task_id == task_id, PortTrafficSeries.title == title) + .filter(PortTrafficSeries.device_id == device_id, PortTrafficSeries.title == title) .first() ) if not exists: @@ -126,7 +352,7 @@ def _unique_series_title(db: Session, task_id: str, base: str) -> str: candidate = f"{base[:240]}#{i}" exists = ( db.query(PortTrafficSeries.id) - .filter(PortTrafficSeries.task_id == task_id, PortTrafficSeries.title == candidate) + .filter(PortTrafficSeries.device_id == device_id, PortTrafficSeries.title == candidate) .first() ) if not exists: diff --git a/netx_api/port_traffic_parsers.py b/netx_api/port_traffic_parsers.py index fec42d9..6e56546 100644 --- a/netx_api/port_traffic_parsers.py +++ b/netx_api/port_traffic_parsers.py @@ -116,6 +116,18 @@ class DetailRates: out_util_pct: float = 0.0 +def resolve_util_pct(vendor_util: float, bps: float, bw_bps: int) -> float: + """Prefer vendor-reported util; derive from rate/BW when util is missing but traffic exists.""" + u = float(vendor_util or 0.0) + if u > 0: + return u + bw = int(bw_bps or 0) + rate = float(bps or 0.0) + if bw > 0 and rate > 0: + return rate / float(bw) * 100.0 + return u + + def parse_bw_to_bps(raw: str) -> int: text = (raw or "").strip() if not text or text.upper() == "N/A": diff --git a/netx_api/port_traffic_recovery.py b/netx_api/port_traffic_recovery.py index 8f68020..fd7df87 100644 --- a/netx_api/port_traffic_recovery.py +++ b/netx_api/port_traffic_recovery.py @@ -7,7 +7,7 @@ from datetime import datetime from sqlalchemy.orm import Session -from .models import PortTrafficTask +from .models import PortTrafficDevice _log = logging.getLogger("netx.port_traffic.recovery") @@ -15,17 +15,17 @@ _log = logging.getLogger("netx.port_traffic.recovery") def recover_port_traffic_on_startup(db: Session) -> int: """Clear stuck collect_running flags so scheduler can resume.""" now = datetime.utcnow() - rows = db.query(PortTrafficTask).filter(PortTrafficTask.collect_running.is_(True)).all() + rows = db.query(PortTrafficDevice).filter(PortTrafficDevice.collect_running.is_(True)).all() n = 0 - for task in rows: - task.collect_running = False - if not task.last_collect_ended_at: - task.last_collect_ended_at = now - if not task.last_error: - task.last_error = "requeued_after_restart" - task.updated_at = now + for device in rows: + device.collect_running = False + if not device.last_collect_ended_at: + device.last_collect_ended_at = now + if not device.last_error: + device.last_error = "requeued_after_restart" + device.updated_at = now n += 1 if n: db.commit() - _log.info("port_traffic recovery cleared collect_running on %s task(s)", n) + _log.info("port_traffic recovery cleared collect_running on %s device(s)", n) return n diff --git a/netx_api/port_traffic_router.py b/netx_api/port_traffic_router.py index e685a56..90fc096 100644 --- a/netx_api/port_traffic_router.py +++ b/netx_api/port_traffic_router.py @@ -1,4 +1,4 @@ -"""HTTP API for port traffic monitoring.""" +"""HTTP API for port traffic monitoring (device-centric).""" from __future__ import annotations @@ -9,28 +9,30 @@ from sqlalchemy.orm import Session from .auth_service import write_audit from .db import get_db +from .models import PortTrafficDevice from .port_traffic_schemas import ( DiscoverPortsRequest, + PortTrafficDeviceCreate, + PortTrafficDeviceUpdate, + PortTrafficInterfacesPut, PortTrafficReplacePortRequest, - PortTrafficTaskCreate, - PortTrafficTaskUpdate, - PortTrafficTargetsPut, ) from .port_traffic_service import ( compare_targets, - create_task, + create_device, dashboard, - delete_task, + delete_device, discover_ports, + get_device, get_samples, - get_task, + list_device_events, + list_devices, list_series, list_targets, - list_tasks, - put_targets, + put_interfaces, replace_series_port, - set_task_status, - update_task, + set_device_status, + update_device, ) router = APIRouter(prefix="/v1/port-traffic", tags=["port-traffic"]) @@ -48,23 +50,23 @@ def api_dashboard(db: Session = Depends(get_db)): return dashboard(db).model_dump() -@router.get("/tasks") -def api_list_tasks( +@router.get("/devices") +def api_list_devices( page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), db: Session = Depends(get_db), ): - return list_tasks(db, page=page, page_size=page_size) + return list_devices(db, page=page, page_size=page_size) -@router.post("/tasks") -def api_create_task( - body: PortTrafficTaskCreate, +@router.post("/devices") +def api_create_device( + body: PortTrafficDeviceCreate, background_tasks: BackgroundTasks, request: Request, db: Session = Depends(get_db), ): - out = create_task(db, body) + out = create_device(db, body) if body.start_now and out.status == "running": from .port_traffic_runner import dispatch_collect @@ -72,179 +74,206 @@ def api_create_task( uid, uname = _actor(request) write_audit( db, - action="port_traffic.task.create", + action="port_traffic.device.create", actor_user_id=uid, actor_username=uname, method="POST", - path="/v1/port-traffic/tasks", + path="/v1/port-traffic/devices", status_code=200, - detail={"id": out.id, "title": out.title, "start_now": body.start_now}, + detail={"id": out.id, "ne_id": out.ne_id, "start_now": body.start_now}, ) return out.model_dump() -@router.get("/tasks/{task_id}") -def api_get_task(task_id: str, db: Session = Depends(get_db)): - return get_task(db, task_id).model_dump() +@router.get("/devices/{device_id}") +def api_get_device(device_id: str, db: Session = Depends(get_db)): + return get_device(db, device_id).model_dump() -@router.patch("/tasks/{task_id}") -def api_patch_task( - task_id: str, - body: PortTrafficTaskUpdate, +@router.patch("/devices/{device_id}") +def api_patch_device( + device_id: str, + body: PortTrafficDeviceUpdate, request: Request, db: Session = Depends(get_db), ): - out = update_task(db, task_id, body) + out = update_device(db, device_id, body) uid, uname = _actor(request) write_audit( db, - action="port_traffic.task.update", + action="port_traffic.device.update", actor_user_id=uid, actor_username=uname, method="PATCH", - path=f"/v1/port-traffic/tasks/{task_id}", + path=f"/v1/port-traffic/devices/{device_id}", status_code=200, detail=body.model_dump(exclude_unset=True), ) return out.model_dump() -@router.delete("/tasks/{task_id}") -def api_delete_task(task_id: str, request: Request, db: Session = Depends(get_db)): - out = delete_task(db, task_id) +@router.delete("/devices/{device_id}") +def api_delete_device(device_id: str, request: Request, db: Session = Depends(get_db)): + out = delete_device(db, device_id) uid, uname = _actor(request) write_audit( db, - action="port_traffic.task.delete", + action="port_traffic.device.delete", actor_user_id=uid, actor_username=uname, method="DELETE", - path=f"/v1/port-traffic/tasks/{task_id}", + path=f"/v1/port-traffic/devices/{device_id}", status_code=200, - detail={"id": task_id}, + detail={"id": device_id}, ) return out -@router.post("/tasks/{task_id}/start") -def api_start_task( - task_id: str, +@router.post("/devices/{device_id}/start") +def api_start_device( + device_id: str, background_tasks: BackgroundTasks, request: Request, db: Session = Depends(get_db), ): - out = set_task_status(db, task_id, "running") + out = set_device_status(db, device_id, "running") from .port_traffic_runner import dispatch_collect - background_tasks.add_task(dispatch_collect, task_id) + background_tasks.add_task(dispatch_collect, device_id) uid, uname = _actor(request) write_audit( db, - action="port_traffic.task.start", + action="port_traffic.device.start", actor_user_id=uid, actor_username=uname, method="POST", - path=f"/v1/port-traffic/tasks/{task_id}/start", + path=f"/v1/port-traffic/devices/{device_id}/start", status_code=200, - detail={"id": task_id}, + detail={"id": device_id}, ) return out.model_dump() -@router.post("/tasks/{task_id}/collect-now") +@router.post("/devices/{device_id}/collect-now") def api_collect_now( - task_id: str, + device_id: str, background_tasks: BackgroundTasks, request: Request, db: Session = Depends(get_db), ): - task = get_task(db, task_id) - if task.status not in ("running", "paused", "draft", "stopped"): - raise HTTPException(status_code=400, detail="invalid_status") - # Force due: clear last end so claim accepts, ensure running for this round. - from .models import PortTrafficTask + device = get_device(db, device_id) from .port_traffic_runner import dispatch_collect - row = db.get(PortTrafficTask, task_id) + row = db.get(PortTrafficDevice, device_id) if not row: - raise HTTPException(status_code=404, detail="task_not_found") + raise HTTPException(status_code=404, detail="device_not_found") if bool(row.collect_running): - return {"ok": True, "started": False, "reason": "already_collecting", **task.model_dump()} + return {"ok": True, "started": False, "reason": "already_collecting", **device.model_dump()} if str(row.status) != "running": row.status = "running" row.last_collect_ended_at = None row.updated_at = datetime.utcnow() db.commit() - background_tasks.add_task(dispatch_collect, task_id) + background_tasks.add_task(dispatch_collect, device_id) uid, uname = _actor(request) write_audit( db, - action="port_traffic.task.collect_now", + action="port_traffic.device.collect_now", actor_user_id=uid, actor_username=uname, method="POST", - path=f"/v1/port-traffic/tasks/{task_id}/collect-now", + path=f"/v1/port-traffic/devices/{device_id}/collect-now", status_code=200, - detail={"id": task_id}, + detail={"id": device_id}, ) - out = get_task(db, task_id) + out = get_device(db, device_id) return {"ok": True, "started": True, **out.model_dump()} -@router.post("/tasks/{task_id}/pause") -def api_pause_task(task_id: str, request: Request, db: Session = Depends(get_db)): - out = set_task_status(db, task_id, "paused") +@router.post("/devices/{device_id}/pause") +def api_pause_device(device_id: str, request: Request, db: Session = Depends(get_db)): + out = set_device_status(db, device_id, "paused") uid, uname = _actor(request) write_audit( db, - action="port_traffic.task.pause", + action="port_traffic.device.pause", actor_user_id=uid, actor_username=uname, method="POST", - path=f"/v1/port-traffic/tasks/{task_id}/pause", + path=f"/v1/port-traffic/devices/{device_id}/pause", status_code=200, - detail={"id": task_id}, + detail={"id": device_id}, ) return out.model_dump() -@router.post("/tasks/{task_id}/stop") -def api_stop_task(task_id: str, request: Request, db: Session = Depends(get_db)): - out = set_task_status(db, task_id, "stopped") +@router.post("/devices/{device_id}/stop") +def api_stop_device(device_id: str, request: Request, db: Session = Depends(get_db)): + out = set_device_status(db, device_id, "stopped") uid, uname = _actor(request) write_audit( db, - action="port_traffic.task.stop", + action="port_traffic.device.stop", actor_user_id=uid, actor_username=uname, method="POST", - path=f"/v1/port-traffic/tasks/{task_id}/stop", + path=f"/v1/port-traffic/devices/{device_id}/stop", status_code=200, - detail={"id": task_id}, + detail={"id": device_id}, ) return out.model_dump() -@router.get("/tasks/{task_id}/targets") -def api_list_targets(task_id: str, db: Session = Depends(get_db)): - return {"items": [t.model_dump() for t in list_targets(db, task_id)]} +@router.get("/devices/{device_id}/targets") +def api_list_targets(device_id: str, db: Session = Depends(get_db)): + return {"items": [t.model_dump() for t in list_targets(db, device_id)]} -@router.get("/tasks/{task_id}/series") -def api_list_series(task_id: str, db: Session = Depends(get_db)): - return {"items": [s.model_dump() for s in list_series(db, task_id)]} +@router.get("/devices/{device_id}/events") +def api_list_device_events( + device_id: str, + limit: int = Query(100, ge=1, le=500), + db: Session = Depends(get_db), +): + return list_device_events(db, device_id, limit=limit).model_dump() -@router.post("/tasks/{task_id}/series/{series_id}/replace") +@router.put("/devices/{device_id}/interfaces") +def api_put_interfaces( + device_id: str, + body: PortTrafficInterfacesPut, + request: Request, + db: Session = Depends(get_db), +): + items = put_interfaces(db, device_id, body) + uid, uname = _actor(request) + write_audit( + db, + action="port_traffic.device.interfaces_put", + actor_user_id=uid, + actor_username=uname, + method="PUT", + path=f"/v1/port-traffic/devices/{device_id}/interfaces", + status_code=200, + detail={"id": device_id, "count": len(items)}, + ) + return {"items": [t.model_dump() for t in items]} + + +@router.get("/devices/{device_id}/series") +def api_list_series(device_id: str, db: Session = Depends(get_db)): + return {"items": [s.model_dump() for s in list_series(db, device_id)]} + + +@router.post("/devices/{device_id}/series/{series_id}/replace") def api_replace_series_port( - task_id: str, + device_id: str, series_id: str, body: PortTrafficReplacePortRequest, request: Request, db: Session = Depends(get_db), ): - out = replace_series_port(db, task_id, series_id, body) + out = replace_series_port(db, device_id, series_id, body) uid, uname = _actor(request) write_audit( db, @@ -252,35 +281,13 @@ def api_replace_series_port( actor_user_id=uid, actor_username=uname, method="POST", - path=f"/v1/port-traffic/tasks/{task_id}/series/{series_id}/replace", + path=f"/v1/port-traffic/devices/{device_id}/series/{series_id}/replace", status_code=200, - detail={"series_id": series_id, "ifname": body.ifname, "target_id": body.target_id}, + detail={"series_id": series_id, "ifname": body.ifname}, ) return out.model_dump() -@router.put("/tasks/{task_id}/targets") -def api_put_targets( - task_id: str, - body: PortTrafficTargetsPut, - request: Request, - db: Session = Depends(get_db), -): - items = put_targets(db, task_id, body) - uid, uname = _actor(request) - write_audit( - db, - action="port_traffic.targets.put", - actor_user_id=uid, - actor_username=uname, - method="PUT", - path=f"/v1/port-traffic/tasks/{task_id}/targets", - status_code=200, - detail={"id": task_id, "count": len(items)}, - ) - return {"items": [t.model_dump() for t in items]} - - @router.post("/discover/ports") def api_discover_ports(body: DiscoverPortsRequest, db: Session = Depends(get_db)): return discover_ports(db, body).model_dump() @@ -304,7 +311,7 @@ def api_compare( offset_hours: float | None = Query(default=None, ge=0.25, le=24 * 90), baseline_target_id: str | None = Query( default=None, - description="optional mapped interface for baseline overlay (any task)", + description="optional mapped interface for baseline overlay", ), to_ts: datetime | None = Query(default=None, alias="to"), db: Session = Depends(get_db), diff --git a/netx_api/port_traffic_runner.py b/netx_api/port_traffic_runner.py index 2332c8c..ee0815d 100644 --- a/netx_api/port_traffic_runner.py +++ b/netx_api/port_traffic_runner.py @@ -1,4 +1,4 @@ -"""Port traffic collection worker: claim task round, sample interfaces via CLI.""" +"""Port traffic collection worker: claim device round, sample interfaces via one CLI session.""" from __future__ import annotations @@ -15,11 +15,11 @@ from fastapi import HTTPException from .cli_resolve import resolve_cli_target from .config import settings from .db import SessionLocal -from .models import PortTrafficSample, PortTrafficTarget, PortTrafficTask +from .models import PortTrafficDevice, PortTrafficEvent, PortTrafficSample, PortTrafficTarget from .ne_session_factory import close_netmiko_connection, open_netmiko_connection from .ne_netmiko import send_show_command from .port_traffic_commands import commands_for_vendor, detail_command -from .port_traffic_parsers import parse_interface_detail +from .port_traffic_parsers import parse_interface_detail, resolve_util_pct _log = logging.getLogger("netx.port_traffic.runner") _pools: dict[str, ThreadPoolExecutor] = {} @@ -34,26 +34,29 @@ def _format_error(exc: BaseException) -> str: return f"{type(exc).__name__}: {exc}"[:1020] -def _pool_for_task(task_id: str, concurrency: int) -> ThreadPoolExecutor: - with _pools_lock: - pool = _pools.get(task_id) - if pool is None: - workers = max(1, min(20, int(concurrency or 5))) - pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix=f"pt-{task_id[:8]}") - _pools[task_id] = pool - return pool - - -def _release_pool(task_id: str) -> None: - with _pools_lock: - pool = _pools.pop(task_id, None) - if pool is not None: - try: - pool.shutdown(wait=False, cancel_futures=False) - except TypeError: - pool.shutdown(wait=False) - except Exception: - _log.exception("port_traffic pool shutdown failed task=%s", task_id) +def _append_event( + db, + *, + device_id: str, + message: str, + level: str = "error", + target_row_id: str = "", + ifname: str = "", +) -> None: + msg = str(message or "").strip() + if not msg or not device_id: + return + db.add( + PortTrafficEvent( + id=uuid4().hex, + device_id=device_id, + target_row_id=str(target_row_id or ""), + ifname=str(ifname or ""), + level=str(level or "error")[:16], + message=msg[:4000], + created_at=_utcnow(), + ) + ) def _set_target_error(target_row_id: str, message: str) -> None: @@ -62,25 +65,72 @@ def _set_target_error(target_row_id: str, message: str) -> None: row = db.get(PortTrafficTarget, target_row_id) if row: row.last_error = message[:1020] + _append_event( + db, + device_id=str(row.device_id or ""), + target_row_id=str(row.id), + ifname=str(row.ifname or ""), + message=message, + level="error", + ) db.commit() finally: db.close() -def _claim_collect_round(task_id: str) -> list[str] | None: - """Mark task collect_running and return active target row ids, or None if skip.""" +def _finish_collect_round(device_id: str, *, error: str = "") -> None: + db = SessionLocal() + try: + device = db.get(PortTrafficDevice, device_id) + if not device: + return + device.collect_running = False + device.last_collect_ended_at = _utcnow() + if error: + device.last_error = error[:1020] + _append_event(db, device_id=device_id, message=error, level="error") + device.updated_at = _utcnow() + db.commit() + finally: + db.close() + _release_pool(device_id) + + +def _pool_for_device(device_id: str, concurrency: int) -> ThreadPoolExecutor: + with _pools_lock: + pool = _pools.get(device_id) + if pool is None: + workers = max(1, min(5, int(concurrency or 1))) + pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix=f"pt-{device_id[:8]}") + _pools[device_id] = pool + return pool + + +def _release_pool(device_id: str) -> None: + with _pools_lock: + pool = _pools.pop(device_id, None) + if pool is not None: + try: + pool.shutdown(wait=False, cancel_futures=False) + except TypeError: + pool.shutdown(wait=False) + except Exception: + _log.exception("port_traffic pool shutdown failed device=%s", device_id) + + +def _claim_collect_round(device_id: str) -> list[str] | None: for attempt in range(8): db = SessionLocal() try: - task = db.get(PortTrafficTask, task_id) - if not task: + device = db.get(PortTrafficDevice, device_id) + if not device: return None - if str(task.status or "") != "running": + if str(device.status or "") != "running": return None - if bool(task.collect_running): + if bool(device.collect_running): return None - ended = task.last_collect_ended_at - interval = max(15, int(task.interval_sec or 60)) + ended = device.last_collect_ended_at + interval = max(15, int(device.interval_sec or 60)) if ended is not None: elapsed = (_utcnow() - ended).total_seconds() if elapsed < interval: @@ -88,142 +138,69 @@ def _claim_collect_round(task_id: str) -> list[str] | None: targets = ( db.query(PortTrafficTarget) .filter( - PortTrafficTarget.task_id == task_id, + PortTrafficTarget.device_id == device_id, PortTrafficTarget.status == "active", ) .all() ) if not targets: return None - task.collect_running = True - task.last_collect_started_at = _utcnow() - task.last_error = "" - task.updated_at = _utcnow() + device.collect_running = True + device.last_collect_started_at = _utcnow() + device.last_error = "" + device.updated_at = _utcnow() db.commit() return [str(t.id) for t in targets] except Exception: db.rollback() - _log.exception("port_traffic claim failed task=%s attempt=%s", task_id, attempt) + _log.exception("port_traffic claim failed device=%s attempt=%s", device_id, attempt) time.sleep(0.05 * (attempt + 1)) finally: db.close() return None -def _finish_collect_round(task_id: str, *, error: str = "") -> None: - db = SessionLocal() - try: - task = db.get(PortTrafficTask, task_id) - if not task: - return - task.collect_running = False - task.last_collect_ended_at = _utcnow() - if error: - task.last_error = error[:1020] - task.updated_at = _utcnow() - db.commit() - finally: - db.close() - _release_pool(task_id) - - -def _run_show(creds: dict[str, Any], command: str, read_timeout: int) -> str: - conn = open_netmiko_connection(creds, session_timeout=read_timeout + 60) - try: - return send_show_command(conn, command, read_timeout=read_timeout) - finally: - close_netmiko_connection(conn) - - -def _sample_one_target(target_row_id: str) -> None: - creds: dict[str, Any] | None = None - cmd = "" - vendor_key = "zte" - per_cmd = int(settings.ne_collect_read_timeout_sec or 120) - cap = int(settings.ne_collect_run_timeout_cap_sec or 600) - - db = SessionLocal() - try: - row = db.get(PortTrafficTarget, target_row_id) - if not row or str(row.status or "") != "active": - return - source = str(row.source or "").strip().lower() - target_id = str(row.target_id or "").strip() - ifname = str(row.ifname or "").strip() - vendor_hint = str(row.vendor or "") - try: - if source == "managed": - creds, device = resolve_cli_target(db, managed_ne_id=target_id) - elif source == "ume": - creds, device = resolve_cli_target(db, ume_ne_id=target_id) - else: - row.last_error = "invalid_source" - db.commit() - return - except HTTPException as exc: - row.last_error = str(exc.detail or "resolve_failed")[:1020] - db.commit() - return - except Exception as exc: - row.last_error = _format_error(exc) - db.commit() - return - - vendor = str(device.get("vendor") or vendor_hint or "") - device_type = str(device.get("device_type") or "") - cmds = commands_for_vendor(vendor, device_type) - if cmds is None: - row.last_error = "unsupported_vendor" - db.commit() - return - vendor_key = cmds.vendor_key - cmd = detail_command(cmds, ifname) - finally: - db.close() - - if not creds or not cmd: - return - - budget = min(cap, per_cmd + 90) - try: - with ThreadPoolExecutor(max_workers=1) as pool: - fut = pool.submit(_run_show, creds, cmd, per_cmd) - raw = fut.result(timeout=budget) - except Exception as exc: - _set_target_error(target_row_id, _format_error(exc)) - return - - parsed = parse_interface_detail(raw, vendor_key) - if ( - parsed.in_bps == 0 - and parsed.out_bps == 0 - and parsed.bw_bps == 0 - and parsed.in_util_pct == 0 - and parsed.out_util_pct == 0 - and not parsed.ifname - ): - _set_target_error(target_row_id, "parse_empty") - return - +def _save_sample(target_row_id: str, parsed: Any, vendor_hint_bw: int = 0) -> None: now = _utcnow() db = SessionLocal() try: row = db.get(PortTrafficTarget, target_row_id) if not row: return - bw = int(parsed.bw_bps or row.bw_bps or 0) + bw = int(parsed.bw_bps or row.bw_bps or vendor_hint_bw or 0) if bw and not row.bw_bps: row.bw_bps = bw + in_bps = float(parsed.in_bps) + out_bps = float(parsed.out_bps) + if ( + in_bps == 0 + and out_bps == 0 + and bw == 0 + and float(parsed.in_util_pct or 0) == 0 + and float(parsed.out_util_pct or 0) == 0 + and not parsed.ifname + ): + row.last_error = "parse_empty" + _append_event( + db, + device_id=str(row.device_id or ""), + target_row_id=str(row.id), + ifname=str(row.ifname or ""), + message="parse_empty", + level="warn", + ) + db.commit() + return db.add( PortTrafficSample( id=uuid4().hex, target_row_id=target_row_id, series_id=str(row.series_id or ""), ts=now, - in_bps=float(parsed.in_bps), - out_bps=float(parsed.out_bps), - in_util_pct=float(parsed.in_util_pct), - out_util_pct=float(parsed.out_util_pct), + in_bps=in_bps, + out_bps=out_bps, + in_util_pct=resolve_util_pct(float(parsed.in_util_pct), in_bps, bw), + out_util_pct=resolve_util_pct(float(parsed.out_util_pct), out_bps, bw), bw_bps=bw, rate_period_sec=int(parsed.rate_period_sec or 0), raw_ok=True, @@ -240,30 +217,103 @@ def _sample_one_target(target_row_id: str) -> None: db.close() -def dispatch_collect(task_id: str) -> int: - """Claim and sample all active targets for a running task. Returns target count.""" - target_ids = _claim_collect_round(task_id) - if not target_ids: - return 0 +def _sample_targets_shared_session(device_id: str, target_ids: list[str]) -> int: + """One CLI login for the device; run show per interface. Returns error count.""" + per_cmd = int(settings.ne_collect_read_timeout_sec or 120) + cap = int(settings.ne_collect_run_timeout_cap_sec or 600) + errors = 0 db = SessionLocal() try: - task = db.get(PortTrafficTask, task_id) - concurrency = int(task.concurrency or 5) if task else 5 + device = db.get(PortTrafficDevice, device_id) + if not device: + return len(target_ids) + source = str(device.source or "").strip().lower() + ne_id = str(device.ne_id or "").strip() + vendor_hint = str(device.vendor or "") + try: + if source == "managed": + creds, info = resolve_cli_target(db, managed_ne_id=ne_id) + elif source == "ume": + creds, info = resolve_cli_target(db, ume_ne_id=ne_id) + else: + for tid in target_ids: + _set_target_error(tid, "invalid_source") + return len(target_ids) + except HTTPException as exc: + msg = str(exc.detail or "resolve_failed")[:1020] + for tid in target_ids: + _set_target_error(tid, msg) + return len(target_ids) + except Exception as exc: + msg = _format_error(exc) + for tid in target_ids: + _set_target_error(tid, msg) + return len(target_ids) + + vendor = str(info.get("vendor") or vendor_hint or "") + device_type = str(info.get("device_type") or "") + cmds = commands_for_vendor(vendor, device_type) + if cmds is None: + for tid in target_ids: + _set_target_error(tid, "unsupported_vendor") + return len(target_ids) + vendor_key = cmds.vendor_key + + targets = ( + db.query(PortTrafficTarget) + .filter(PortTrafficTarget.id.in_(target_ids), PortTrafficTarget.status == "active") + .all() + ) + ifaces = [(str(t.id), str(t.ifname or "").strip()) for t in targets if t.ifname] finally: db.close() - pool = _pool_for_task(task_id, concurrency) - futures = [pool.submit(_sample_one_target, tid) for tid in target_ids] - errors = 0 + if not ifaces: + return 0 + + budget = min(cap, per_cmd * max(1, len(ifaces)) + 90) + conn = None try: - for fut in as_completed(futures): + conn = open_netmiko_connection(creds, session_timeout=budget) + for tid, ifname in ifaces: try: - fut.result() - except Exception: + cmd = detail_command(cmds, ifname) + raw = send_show_command(conn, cmd, read_timeout=per_cmd) + parsed = parse_interface_detail(raw, vendor_key) + _save_sample(tid, parsed) + except Exception as exc: errors += 1 - _log.exception("port_traffic target worker failed task=%s", task_id) + _set_target_error(tid, _format_error(exc)) + _log.exception("port_traffic iface sample failed device=%s if=%s", device_id, ifname) + except Exception as exc: + msg = _format_error(exc) + for tid, _ in ifaces: + _set_target_error(tid, msg) + errors = len(ifaces) + _log.exception("port_traffic session failed device=%s", device_id) + finally: + if conn is not None: + close_netmiko_connection(conn) + return errors + + +def dispatch_collect(device_id: str) -> int: + """Claim and sample all active interfaces for a running device. Returns target count.""" + target_ids = _claim_collect_round(device_id) + if not target_ids: + return 0 + + try: + errors = _sample_targets_shared_session(device_id, target_ids) + except Exception: + errors = len(target_ids) + _log.exception("port_traffic collect failed device=%s", device_id) finally: err_msg = f"{errors}_target_errors" if errors else "" - _finish_collect_round(task_id, error=err_msg) + _finish_collect_round(device_id, error=err_msg) return len(target_ids) + + +# Alias used by older call sites +dispatch_collect_device = dispatch_collect diff --git a/netx_api/port_traffic_scheduler.py b/netx_api/port_traffic_scheduler.py index 3690cf9..2622551 100644 --- a/netx_api/port_traffic_scheduler.py +++ b/netx_api/port_traffic_scheduler.py @@ -8,7 +8,7 @@ from datetime import datetime from .config import settings from .db import SessionLocal -from .models import PortTrafficTask +from .models import PortTrafficDevice from .port_traffic_runner import dispatch_collect from .port_traffic_service import purge_expired_samples @@ -22,40 +22,47 @@ def _utcnow() -> datetime: return datetime.utcnow() -def try_dispatch_due_tasks() -> int: - """Dispatch collect rounds for due running tasks. Returns number started.""" +def try_dispatch_due_devices() -> int: + """Dispatch collect rounds for due running devices. Returns number started.""" db = SessionLocal() try: - tasks = ( - db.query(PortTrafficTask) - .filter(PortTrafficTask.status == "running", PortTrafficTask.collect_running.is_(False)) + devices = ( + db.query(PortTrafficDevice) + .filter( + PortTrafficDevice.status == "running", + PortTrafficDevice.collect_running.is_(False), + ) .all() ) due_ids: list[str] = [] now = _utcnow() - for task in tasks: - interval = max(15, int(task.interval_sec or 60)) - ended = task.last_collect_ended_at + for device in devices: + interval = max(15, int(device.interval_sec or 60)) + ended = device.last_collect_ended_at if ended is None: - due_ids.append(str(task.id)) + due_ids.append(str(device.id)) continue if (now - ended).total_seconds() >= interval: - due_ids.append(str(task.id)) + due_ids.append(str(device.id)) finally: db.close() started = 0 - for tid in due_ids: + for did in due_ids: try: - n = dispatch_collect(tid) + n = dispatch_collect(did) if n: started += 1 - _log.info("port_traffic collect started task=%s targets=%s", tid, n) + _log.info("port_traffic collect started device=%s targets=%s", did, n) except Exception: - _log.exception("port_traffic dispatch failed task=%s", tid) + _log.exception("port_traffic dispatch failed device=%s", did) return started +def try_dispatch_due_tasks() -> int: + return try_dispatch_due_devices() + + def _loop() -> None: global _purge_counter tick = max(5, int(settings.port_traffic_scheduler_tick_sec or 15)) @@ -63,9 +70,8 @@ def _loop() -> None: while not _stop.is_set(): try: if bool(settings.port_traffic_scheduler_enabled): - try_dispatch_due_tasks() + try_dispatch_due_devices() _purge_counter += 1 - # Retention purge roughly every ~20 ticks if _purge_counter >= 20: _purge_counter = 0 db = SessionLocal() @@ -89,7 +95,6 @@ def start_port_traffic_scheduler() -> None: _stop.clear() _thread = threading.Thread(target=_loop, name="port-traffic-scheduler", daemon=True) _thread.start() - _log.info("started thread %s alive=%s", _thread.name, _thread.is_alive()) def stop_port_traffic_scheduler() -> None: diff --git a/netx_api/port_traffic_schemas.py b/netx_api/port_traffic_schemas.py index 2c7cf61..ae3f321 100644 --- a/netx_api/port_traffic_schemas.py +++ b/netx_api/port_traffic_schemas.py @@ -1,4 +1,4 @@ -"""Pydantic schemas for port traffic monitoring API.""" +"""Pydantic schemas for port traffic monitoring API (device-centric).""" from __future__ import annotations @@ -8,17 +8,17 @@ from typing import Literal from pydantic import BaseModel, Field -class PortTrafficNeRef(BaseModel): - source: Literal["managed", "ume"] - id: str - ne_name: str = "" - ne_ip: str = "" - vendor: str = "" +class PortTrafficIfaceIn(BaseModel): + ifname: str + if_description: str = "" + bw_bps: int = 0 class PortTrafficTargetIn(BaseModel): - source: Literal["managed", "ume"] - target_id: str + """Legacy-shaped input; create/device APIs prefer PortTrafficIfaceIn under one NE.""" + + source: Literal["managed", "ume"] = "managed" + target_id: str = "" ne_name: str = "" ne_ip: str = "" vendor: str = "" @@ -27,25 +27,38 @@ class PortTrafficTargetIn(BaseModel): bw_bps: int = 0 -class PortTrafficTaskCreate(BaseModel): - title: str = Field(min_length=1, max_length=256) +class PortTrafficDeviceCreate(BaseModel): + source: Literal["managed", "ume"] + ne_id: str = Field(min_length=1, max_length=128) + ne_name: str = "" + ne_ip: str = "" + vendor: str = "" + note: str = Field(default="", max_length=256) interval_sec: int = Field(default=60, ge=15, le=3600) retention_days: int = Field(default=7, ge=1, le=90) - concurrency: int = Field(default=5, ge=1, le=20) - targets: list[PortTrafficTargetIn] = Field(default_factory=list) + concurrency: int = Field(default=1, ge=1, le=5) + interfaces: list[PortTrafficIfaceIn] = Field(default_factory=list) start_now: bool = False -class PortTrafficTaskUpdate(BaseModel): - title: str | None = Field(default=None, min_length=1, max_length=256) +class PortTrafficDeviceUpdate(BaseModel): + note: str | None = Field(default=None, max_length=256) interval_sec: int | None = Field(default=None, ge=15, le=3600) retention_days: int | None = Field(default=None, ge=1, le=90) - concurrency: int | None = Field(default=None, ge=1, le=20) + concurrency: int | None = Field(default=None, ge=1, le=5) + ne_name: str | None = None + ne_ip: str | None = None + vendor: str | None = None -class PortTrafficTaskOut(BaseModel): +class PortTrafficDeviceOut(BaseModel): id: str - title: str + source: str + ne_id: str + ne_name: str + ne_ip: str + vendor: str + note: str = "" status: str interval_sec: int retention_days: int @@ -62,7 +75,8 @@ class PortTrafficTaskOut(BaseModel): class PortTrafficTargetOut(BaseModel): id: str - task_id: str + device_id: str + task_id: str = "" # alias of device_id for older clients series_id: str = "" source: str target_id: str @@ -80,7 +94,8 @@ class PortTrafficTargetOut(BaseModel): class PortTrafficSeriesOut(BaseModel): id: str - task_id: str + device_id: str + task_id: str = "" title: str status: str active_target: PortTrafficTargetOut | None = None @@ -88,18 +103,13 @@ class PortTrafficSeriesOut(BaseModel): created_at: datetime | None = None -class PortTrafficTargetsPut(BaseModel): - targets: list[PortTrafficTargetIn] +class PortTrafficInterfacesPut(BaseModel): + """Replace active interface set for a device (keeps samples for retired/removed).""" + + interfaces: list[PortTrafficIfaceIn] class PortTrafficReplacePortRequest(BaseModel): - """Replace the active interface under a logical series (keeps history).""" - - source: Literal["managed", "ume"] - target_id: str - ne_name: str = "" - ne_ip: str = "" - vendor: str = "" ifname: str if_description: str = "" bw_bps: int = 0 @@ -141,7 +151,7 @@ class PortTrafficSamplePoint(BaseModel): out_util_pct: float bw_bps: int rate_period_sec: int = 0 - ts_raw: datetime | None = None # baseline original time when aligned + ts_raw: datetime | None = None class PortTrafficSamplesOut(BaseModel): @@ -166,8 +176,26 @@ class PortTrafficCompareOut(BaseModel): class PortTrafficDashboardOut(BaseModel): - task_count: int = 0 - running_task_count: int = 0 + device_count: int = 0 + running_device_count: int = 0 active_target_count: int = 0 sample_count_24h: int = 0 last_sample_at: datetime | None = None + # Back-compat aliases for older UI + task_count: int = 0 + running_task_count: int = 0 + + +class PortTrafficEventOut(BaseModel): + id: str + device_id: str + target_row_id: str = "" + ifname: str = "" + level: str = "error" + message: str = "" + created_at: datetime | None = None + + +class PortTrafficEventsOut(BaseModel): + items: list[PortTrafficEventOut] = Field(default_factory=list) + total: int = 0 diff --git a/netx_api/port_traffic_service.py b/netx_api/port_traffic_service.py index 03ab62c..2ff643d 100644 --- a/netx_api/port_traffic_service.py +++ b/netx_api/port_traffic_service.py @@ -1,4 +1,4 @@ -"""Port traffic monitoring service: CRUD, discover, samples, dashboard.""" +"""Port traffic monitoring service: device-centric CRUD, discover, samples, dashboard.""" from __future__ import annotations @@ -13,12 +13,12 @@ from sqlalchemy.orm import Session from .cli_resolve import resolve_cli_target from .config import settings -from .models import PortTrafficSample, PortTrafficSeries, PortTrafficTarget, PortTrafficTask +from .models import PortTrafficDevice, PortTrafficEvent, PortTrafficSample, PortTrafficSeries, PortTrafficTarget from .ne_session_factory import close_netmiko_connection, open_netmiko_connection from .ne_netmiko import send_show_command from .port_traffic_commands import commands_for_vendor from .port_traffic_migrate import default_series_title, unique_series_title -from .port_traffic_parsers import brief_port_to_dict, parse_interface_brief +from .port_traffic_parsers import brief_port_to_dict, parse_interface_brief, resolve_util_pct from .port_traffic_schemas import ( DiscoverPortItem, DiscoverPortsRequest, @@ -26,16 +26,18 @@ from .port_traffic_schemas import ( PortTrafficCompareMeta, PortTrafficCompareOut, PortTrafficDashboardOut, + PortTrafficDeviceCreate, + PortTrafficDeviceOut, + PortTrafficDeviceUpdate, + PortTrafficEventOut, + PortTrafficEventsOut, + PortTrafficIfaceIn, + PortTrafficInterfacesPut, PortTrafficReplacePortRequest, PortTrafficSamplePoint, PortTrafficSamplesOut, PortTrafficSeriesOut, - PortTrafficTargetIn, PortTrafficTargetOut, - PortTrafficTargetsPut, - PortTrafficTaskCreate, - PortTrafficTaskOut, - PortTrafficTaskUpdate, ) _log = logging.getLogger("netx.port_traffic.service") @@ -45,36 +47,12 @@ def _utcnow() -> datetime: return datetime.utcnow() -def _task_out(db: Session, task: PortTrafficTask) -> PortTrafficTaskOut: - tid = str(task.id) - total = db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == tid).count() - active = ( - db.query(PortTrafficTarget) - .filter(PortTrafficTarget.task_id == tid, PortTrafficTarget.status == "active") - .count() - ) - return PortTrafficTaskOut( - id=tid, - title=str(task.title or ""), - status=str(task.status or ""), - interval_sec=int(task.interval_sec or 60), - retention_days=int(task.retention_days or 7), - concurrency=int(task.concurrency or 5), - collect_running=bool(task.collect_running), - target_count=int(total), - active_target_count=int(active), - last_collect_started_at=task.last_collect_started_at, - last_collect_ended_at=task.last_collect_ended_at, - last_error=str(task.last_error or ""), - created_at=task.created_at, - updated_at=task.updated_at, - ) - - def _target_out(row: PortTrafficTarget) -> PortTrafficTargetOut: + did = str(row.device_id or "") return PortTrafficTargetOut( id=str(row.id), - task_id=str(row.task_id), + device_id=did, + task_id=did, series_id=str(row.series_id or ""), source=str(row.source or ""), target_id=str(row.target_id or ""), @@ -91,19 +69,89 @@ def _target_out(row: PortTrafficTarget) -> PortTrafficTargetOut: ) -def _create_series_and_target( +def _device_out(db: Session, device: PortTrafficDevice) -> PortTrafficDeviceOut: + did = str(device.id) + total = db.query(PortTrafficTarget).filter(PortTrafficTarget.device_id == did).count() + active = ( + db.query(PortTrafficTarget) + .filter(PortTrafficTarget.device_id == did, PortTrafficTarget.status == "active") + .count() + ) + return PortTrafficDeviceOut( + id=did, + source=str(device.source or ""), + ne_id=str(device.ne_id or ""), + ne_name=str(device.ne_name or ""), + ne_ip=str(device.ne_ip or ""), + vendor=str(device.vendor or ""), + note=str(device.note or ""), + status=str(device.status or ""), + interval_sec=int(device.interval_sec or 60), + retention_days=int(device.retention_days or 7), + concurrency=int(device.concurrency or 1), + collect_running=bool(device.collect_running), + target_count=int(total), + active_target_count=int(active), + last_collect_started_at=device.last_collect_started_at, + last_collect_ended_at=device.last_collect_ended_at, + last_error=str(device.last_error or ""), + created_at=device.created_at, + updated_at=device.updated_at, + ) + + +def _assert_vendor(vendor: str, label: str) -> None: + cmds = commands_for_vendor(vendor or "", "") + if cmds is None: + raise HTTPException( + status_code=400, + detail=f"vendor_not_supported_for_port_traffic: {vendor or 'unknown'} ({label})", + ) + + +def _assert_ifaces_free( db: Session, *, - task_id: str, - t: PortTrafficTargetIn, + source: str, + ne_id: str, + ifnames: list[str], + exclude_device_id: str | None = None, +) -> None: + names = [str(x).strip() for x in ifnames if str(x).strip()] + if not names: + return + q = db.query(PortTrafficTarget).filter( + PortTrafficTarget.source == source, + PortTrafficTarget.target_id == ne_id, + PortTrafficTarget.ifname.in_(names), + PortTrafficTarget.status == "active", + ) + if exclude_device_id: + q = q.filter(PortTrafficTarget.device_id != exclude_device_id) + hit = q.first() + if hit: + raise HTTPException( + status_code=409, + detail=f"interface_already_monitored: {hit.ifname} on device {hit.device_id}", + ) + + +def _create_iface( + db: Session, + *, + device: PortTrafficDevice, + iface: PortTrafficIfaceIn, now: datetime, ) -> PortTrafficTarget: - title = unique_series_title(db, task_id, default_series_title(t.ne_name or "", t.ifname.strip())) + ifname = iface.ifname.strip() + title = unique_series_title( + db, str(device.id), default_series_title(device.ne_name or "", ifname) + ) sid = uuid4().hex db.add( PortTrafficSeries( id=sid, - task_id=task_id, + device_id=str(device.id), title=title, status="active", created_at=now, @@ -111,16 +159,16 @@ def _create_series_and_target( ) row = PortTrafficTarget( id=uuid4().hex, - task_id=task_id, + device_id=str(device.id), series_id=sid, - source=t.source, - target_id=t.target_id, - ne_name=t.ne_name or "", - ne_ip=t.ne_ip or "", - vendor=t.vendor or "", - ifname=t.ifname.strip(), - if_description=t.if_description or "", - bw_bps=int(t.bw_bps or 0), + source=str(device.source), + target_id=str(device.ne_id), + ne_name=str(device.ne_name or ""), + ne_ip=str(device.ne_ip or ""), + vendor=str(device.vendor or ""), + ifname=ifname, + if_description=iface.if_description or "", + bw_bps=int(iface.bw_bps or 0), status="active", created_at=now, ) @@ -128,44 +176,49 @@ def _create_series_and_target( return row -def _assert_supported_targets(targets: list[PortTrafficTargetIn]) -> None: - for t in targets: - cmds = commands_for_vendor(t.vendor or "", "") - if cmds is None: - raise HTTPException( - status_code=400, - detail=f"vendor_not_supported_for_port_traffic: {t.vendor or 'unknown'} ({t.ne_name or t.target_id})", - ) - - -def list_tasks(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]: - q = db.query(PortTrafficTask).order_by(PortTrafficTask.created_at.desc()) +def list_devices(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]: + q = db.query(PortTrafficDevice).order_by(PortTrafficDevice.ne_name.asc(), PortTrafficDevice.ne_ip.asc()) total = q.count() rows = q.offset((page - 1) * page_size).limit(page_size).all() return { "total": total, "page": page, "page_size": page_size, - "items": [_task_out(db, r).model_dump() for r in rows], + "items": [_device_out(db, r).model_dump() for r in rows], } -def get_task(db: Session, task_id: str) -> PortTrafficTaskOut: - task = db.get(PortTrafficTask, task_id) - if not task: - raise HTTPException(status_code=404, detail="task_not_found") - return _task_out(db, task) +def get_device(db: Session, device_id: str) -> PortTrafficDeviceOut: + device = db.get(PortTrafficDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="device_not_found") + return _device_out(db, device) -def create_task(db: Session, body: PortTrafficTaskCreate) -> PortTrafficTaskOut: - _assert_supported_targets(body.targets) +def create_device(db: Session, body: PortTrafficDeviceCreate) -> PortTrafficDeviceOut: + source = body.source + ne_id = body.ne_id.strip() + _assert_vendor(body.vendor, body.ne_name or ne_id) + clash = ( + db.query(PortTrafficDevice) + .filter(PortTrafficDevice.source == source, PortTrafficDevice.ne_id == ne_id) + .first() + ) + if clash: + raise HTTPException(status_code=409, detail="device_already_monitored") + ifnames = [i.ifname for i in body.interfaces] + _assert_ifaces_free(db, source=source, ne_id=ne_id, ifnames=ifnames) + now = _utcnow() - status = "running" if body.start_now and body.targets else "draft" - if body.start_now and not body.targets: - status = "draft" - task = PortTrafficTask( + status = "running" if body.start_now and body.interfaces else "draft" + device = PortTrafficDevice( id=uuid4().hex, - title=body.title.strip(), + source=source, + ne_id=ne_id, + ne_name=(body.ne_name or "").strip(), + ne_ip=(body.ne_ip or "").strip(), + vendor=(body.vendor or "").strip(), + note=(body.note or "").strip(), status=status, interval_sec=int(body.interval_sec), retention_days=int(body.retention_days), @@ -173,134 +226,166 @@ def create_task(db: Session, body: PortTrafficTaskCreate) -> PortTrafficTaskOut: created_at=now, updated_at=now, ) - db.add(task) + db.add(device) db.flush() - for t in body.targets: - _create_series_and_target(db, task_id=task.id, t=t, now=now) + for iface in body.interfaces: + if not iface.ifname.strip(): + continue + _create_iface(db, device=device, iface=iface, now=now) db.commit() - db.refresh(task) - return _task_out(db, task) + db.refresh(device) + return _device_out(db, device) -def update_task(db: Session, task_id: str, body: PortTrafficTaskUpdate) -> PortTrafficTaskOut: - task = db.get(PortTrafficTask, task_id) - if not task: - raise HTTPException(status_code=404, detail="task_not_found") +def update_device(db: Session, device_id: str, body: PortTrafficDeviceUpdate) -> PortTrafficDeviceOut: + device = db.get(PortTrafficDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="device_not_found") data = body.model_dump(exclude_unset=True) - if "title" in data and data["title"] is not None: - task.title = str(data["title"]).strip() + for key in ("note", "ne_name", "ne_ip", "vendor"): + if key in data and data[key] is not None: + setattr(device, key, str(data[key]).strip()) if "interval_sec" in data and data["interval_sec"] is not None: - task.interval_sec = int(data["interval_sec"]) + device.interval_sec = int(data["interval_sec"]) if "retention_days" in data and data["retention_days"] is not None: - task.retention_days = int(data["retention_days"]) + device.retention_days = int(data["retention_days"]) if "concurrency" in data and data["concurrency"] is not None: - task.concurrency = int(data["concurrency"]) - task.updated_at = _utcnow() + device.concurrency = int(data["concurrency"]) + device.updated_at = _utcnow() db.commit() - db.refresh(task) - return _task_out(db, task) + db.refresh(device) + return _device_out(db, device) -def delete_task(db: Session, task_id: str) -> dict[str, Any]: - task = db.get(PortTrafficTask, task_id) - if not task: - raise HTTPException(status_code=404, detail="task_not_found") - if bool(task.collect_running): +def delete_device(db: Session, device_id: str) -> dict[str, Any]: + device = db.get(PortTrafficDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="device_not_found") + if bool(device.collect_running): raise HTTPException(status_code=409, detail="collect_running") - targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).all() + targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.device_id == device_id).all() ids = [str(t.id) for t in targets] series_ids = [str(t.series_id) for t in targets if t.series_id] if ids: db.query(PortTrafficSample).filter(PortTrafficSample.target_row_id.in_(ids)).delete( synchronize_session=False ) - db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).delete( + db.query(PortTrafficTarget).filter(PortTrafficTarget.device_id == device_id).delete( synchronize_session=False ) + db.query(PortTrafficEvent).filter(PortTrafficEvent.device_id == device_id).delete( + synchronize_session=False + ) if series_ids: db.query(PortTrafficSeries).filter(PortTrafficSeries.id.in_(series_ids)).delete( synchronize_session=False ) else: - db.query(PortTrafficSeries).filter(PortTrafficSeries.task_id == task_id).delete( + db.query(PortTrafficSeries).filter(PortTrafficSeries.device_id == device_id).delete( synchronize_session=False ) - db.delete(task) + db.delete(device) db.commit() - return {"ok": True, "id": task_id} + return {"ok": True, "id": device_id} -def set_task_status(db: Session, task_id: str, status: str) -> PortTrafficTaskOut: - task = db.get(PortTrafficTask, task_id) - if not task: - raise HTTPException(status_code=404, detail="task_not_found") +def set_device_status(db: Session, device_id: str, status: str) -> PortTrafficDeviceOut: + device = db.get(PortTrafficDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="device_not_found") if status == "running": active = ( db.query(PortTrafficTarget) - .filter(PortTrafficTarget.task_id == task_id, PortTrafficTarget.status == "active") + .filter(PortTrafficTarget.device_id == device_id, PortTrafficTarget.status == "active") .count() ) if active <= 0: raise HTTPException(status_code=400, detail="no_active_targets") - # Allow scheduler/collect-now to fire immediately after start/resume. - task.last_collect_ended_at = None - task.status = status - task.updated_at = _utcnow() + device.last_collect_ended_at = None + device.status = status + device.updated_at = _utcnow() db.commit() - db.refresh(task) - return _task_out(db, task) + db.refresh(device) + return _device_out(db, device) -def put_targets(db: Session, task_id: str, body: PortTrafficTargetsPut) -> list[PortTrafficTargetOut]: - task = db.get(PortTrafficTask, task_id) - if not task: - raise HTTPException(status_code=404, detail="task_not_found") - if bool(task.collect_running): +def put_interfaces(db: Session, device_id: str, body: PortTrafficInterfacesPut) -> list[PortTrafficTargetOut]: + device = db.get(PortTrafficDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="device_not_found") + if bool(device.collect_running): raise HTTPException(status_code=409, detail="collect_running") - _assert_supported_targets(body.targets) - old = db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).all() - old_ids = [str(t.id) for t in old] - old_series = list({str(t.series_id) for t in old if t.series_id}) - if old_ids: - db.query(PortTrafficSample).filter(PortTrafficSample.target_row_id.in_(old_ids)).delete( - synchronize_session=False - ) - db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).delete( - synchronize_session=False - ) - if old_series: - db.query(PortTrafficSeries).filter(PortTrafficSeries.id.in_(old_series)).delete( - synchronize_session=False - ) + + wanted = {i.ifname.strip(): i for i in body.interfaces if i.ifname.strip()} + _assert_ifaces_free( + db, + source=str(device.source), + ne_id=str(device.ne_id), + ifnames=list(wanted.keys()), + exclude_device_id=device_id, + ) + now = _utcnow() - rows: list[PortTrafficTarget] = [] - for t in body.targets: - rows.append(_create_series_and_target(db, task_id=task_id, t=t, now=now)) - task.updated_at = now + existing = ( + db.query(PortTrafficTarget) + .filter(PortTrafficTarget.device_id == device_id) + .all() + ) + by_if = {str(t.ifname): t for t in existing if str(t.status) == "active"} + + for ifname, row in list(by_if.items()): + if ifname not in wanted: + row.status = "retired" + + for ifname, iface in wanted.items(): + if ifname in by_if: + row = by_if[ifname] + row.if_description = iface.if_description or row.if_description + if iface.bw_bps: + row.bw_bps = int(iface.bw_bps) + continue + # Reactivate retired same ifname if present + retired = next( + ( + t + for t in existing + if str(t.ifname) == ifname and str(t.status) in ("retired", "disabled") + ), + None, + ) + if retired: + retired.status = "active" + retired.if_description = iface.if_description or retired.if_description + if iface.bw_bps: + retired.bw_bps = int(iface.bw_bps) + continue + _create_iface(db, device=device, iface=iface, now=now) + + device.updated_at = now db.commit() - return [_target_out(r) for r in rows] + return list_targets(db, device_id) -def list_targets(db: Session, task_id: str) -> list[PortTrafficTargetOut]: - task = db.get(PortTrafficTask, task_id) - if not task: - raise HTTPException(status_code=404, detail="task_not_found") +def list_targets(db: Session, device_id: str) -> list[PortTrafficTargetOut]: + device = db.get(PortTrafficDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="device_not_found") rows = ( db.query(PortTrafficTarget) - .filter(PortTrafficTarget.task_id == task_id) - .order_by(PortTrafficTarget.ne_name, PortTrafficTarget.ifname) + .filter(PortTrafficTarget.device_id == device_id) + .order_by(PortTrafficTarget.ifname) .all() ) return [_target_out(r) for r in rows] -def list_series(db: Session, task_id: str) -> list[PortTrafficSeriesOut]: - task = db.get(PortTrafficTask, task_id) - if not task: - raise HTTPException(status_code=404, detail="task_not_found") +def list_series(db: Session, device_id: str) -> list[PortTrafficSeriesOut]: + device = db.get(PortTrafficDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="device_not_found") rows = ( db.query(PortTrafficSeries) - .filter(PortTrafficSeries.task_id == task_id) + .filter(PortTrafficSeries.device_id == device_id) .order_by(PortTrafficSeries.title.asc()) .all() ) @@ -317,10 +402,12 @@ def list_series(db: Session, task_id: str) -> list[PortTrafficSeriesOut]: .filter(PortTrafficTarget.series_id == s.id, PortTrafficTarget.status == "retired") .count() ) + did = str(s.device_id or "") out.append( PortTrafficSeriesOut( id=str(s.id), - task_id=str(s.task_id), + device_id=did, + task_id=did, title=str(s.title or ""), status=str(s.status or ""), active_target=_target_out(active) if active else None, @@ -333,29 +420,26 @@ def list_series(db: Session, task_id: str) -> list[PortTrafficSeriesOut]: def replace_series_port( db: Session, - task_id: str, + device_id: str, series_id: str, body: PortTrafficReplacePortRequest, ) -> PortTrafficSeriesOut: - task = db.get(PortTrafficTask, task_id) - if not task: - raise HTTPException(status_code=404, detail="task_not_found") - if bool(task.collect_running): + device = db.get(PortTrafficDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="device_not_found") + if bool(device.collect_running): raise HTTPException(status_code=409, detail="collect_running") series = db.get(PortTrafficSeries, series_id) - if not series or str(series.task_id) != task_id: + if not series or str(series.device_id) != device_id: raise HTTPException(status_code=404, detail="series_not_found") - tmp = PortTrafficTargetIn( - source=body.source, - target_id=body.target_id, - ne_name=body.ne_name, - ne_ip=body.ne_ip, - vendor=body.vendor, - ifname=body.ifname, - if_description=body.if_description, - bw_bps=body.bw_bps, + ifname = body.ifname.strip() + _assert_ifaces_free( + db, + source=str(device.source), + ne_id=str(device.ne_id), + ifnames=[ifname], + exclude_device_id=device_id, ) - _assert_supported_targets([tmp]) now = _utcnow() actives = ( db.query(PortTrafficTarget) @@ -370,7 +454,7 @@ def replace_series_port( clash = ( db.query(PortTrafficSeries.id) .filter( - PortTrafficSeries.task_id == task_id, + PortTrafficSeries.device_id == device_id, PortTrafficSeries.title == wanted, PortTrafficSeries.id != series_id, ) @@ -381,24 +465,23 @@ def replace_series_port( series.title = wanted row = PortTrafficTarget( id=uuid4().hex, - task_id=task_id, + device_id=device_id, series_id=series_id, - source=body.source, - target_id=body.target_id, - ne_name=body.ne_name or "", - ne_ip=body.ne_ip or "", - vendor=body.vendor or "", - ifname=body.ifname.strip(), + source=str(device.source), + target_id=str(device.ne_id), + ne_name=str(device.ne_name or ""), + ne_ip=str(device.ne_ip or ""), + vendor=str(device.vendor or ""), + ifname=ifname, if_description=body.if_description or "", bw_bps=int(body.bw_bps or 0), status="active", created_at=now, ) db.add(row) - task.updated_at = now + device.updated_at = now db.commit() - items = list_series(db, task_id) - for item in items: + for item in list_series(db, device_id): if item.id == series_id: return item raise HTTPException(status_code=500, detail="series_replace_failed") @@ -449,7 +532,6 @@ def discover_ports(db: Session, body: DiscoverPortsRequest) -> DiscoverPortsResp def _as_naive_utc(value: datetime | None) -> datetime | None: - """Normalize query bounds to naive UTC (DB columns are naive utcnow).""" if value is None: return None if value.tzinfo is None: @@ -468,15 +550,18 @@ def _sample_points( ts = ts_raw if align_offset is not None and ts_raw is not None: ts = ts_raw + align_offset + in_bps = float(r.in_bps or 0) + out_bps = float(r.out_bps or 0) + bw = int(r.bw_bps or 0) points.append( PortTrafficSamplePoint( ts=ts, ts_raw=ts_raw if align_offset is not None else None, - in_bps=float(r.in_bps or 0), - out_bps=float(r.out_bps or 0), - in_util_pct=float(r.in_util_pct or 0), - out_util_pct=float(r.out_util_pct or 0), - bw_bps=int(r.bw_bps or 0), + in_bps=in_bps, + out_bps=out_bps, + in_util_pct=resolve_util_pct(float(r.in_util_pct or 0), in_bps, bw), + out_util_pct=resolve_util_pct(float(r.out_util_pct or 0), out_bps, bw), + bw_bps=bw, rate_period_sec=int(r.rate_period_sec or 0), ) ) @@ -530,7 +615,6 @@ def compare_targets( baseline_target_id: str | None = None, to_ts: datetime | None = None, ) -> PortTrafficCompareOut: - """Compare current interface samples vs period and/or manually mapped interface.""" target = db.get(PortTrafficTarget, target_row_id) if not target: raise HTTPException(status_code=404, detail="target_not_found") @@ -549,27 +633,25 @@ def compare_targets( range_h = max(0.25, float(range_hours or 24)) from_ts = to_ts - timedelta(hours=range_h) to_q = to_ts + timedelta(seconds=5) - current_rows = _query_target_samples( - db, target_row_id=str(target.id), from_ts=from_ts, to_ts=to_q + current = _sample_points( + _query_target_samples(db, target_row_id=str(target.id), from_ts=from_ts, to_ts=to_q) ) - current = _sample_points(current_rows) off_h = baseline_offset_hours(baseline, range_h, offset_hours) baseline_points: list[PortTrafficSamplePoint] = [] - # Baseline source: mapped interface if set, else same interface (period compare only). base_src = mapped if mapped is not None else target want_baseline = off_h is not None or mapped is not None if want_baseline: if off_h is not None: delta = timedelta(hours=off_h) - b_from = from_ts - delta - b_to = to_q - delta base_rows = _query_target_samples( - db, target_row_id=str(base_src.id), from_ts=b_from, to_ts=b_to + db, + target_row_id=str(base_src.id), + from_ts=from_ts - delta, + to_ts=to_q - delta, ) baseline_points = _sample_points(base_rows, align_offset=delta) else: - # Mapped port, same window (no time shift) — cross-device overlay. base_rows = _query_target_samples( db, target_row_id=str(base_src.id), from_ts=from_ts, to_ts=to_q ) @@ -590,7 +672,6 @@ def compare_targets( ) -# Back-compat alias for older imports/tests. def compare_series(db: Session, **kwargs: Any) -> PortTrafficCompareOut: target_row_id = kwargs.pop("target_row_id", None) or kwargs.pop("target_id", None) series_id = kwargs.pop("series_id", None) @@ -622,15 +703,14 @@ def get_samples( now = _utcnow() to_ts = _as_naive_utc(to_ts) or now from_ts = _as_naive_utc(from_ts) or (to_ts - timedelta(hours=1)) - # Slight skew so just-written samples are not clipped by client clock. to_ts = to_ts + timedelta(seconds=5) rows = _query_target_samples(db, target_row_id=target_row_id, from_ts=from_ts, to_ts=to_ts) return PortTrafficSamplesOut(target=_target_out(target), points=_sample_points(rows)) def dashboard(db: Session) -> PortTrafficDashboardOut: - task_count = db.query(PortTrafficTask).count() - running = db.query(PortTrafficTask).filter(PortTrafficTask.status == "running").count() + device_count = db.query(PortTrafficDevice).count() + running = db.query(PortTrafficDevice).filter(PortTrafficDevice.status == "running").count() active_targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.status == "active").count() since = _utcnow() - timedelta(hours=24) sample_count = ( @@ -640,38 +720,130 @@ def dashboard(db: Session) -> PortTrafficDashboardOut: ) last = db.query(func.max(PortTrafficSample.ts)).scalar() return PortTrafficDashboardOut( - task_count=int(task_count), - running_task_count=int(running), + device_count=int(device_count), + running_device_count=int(running), active_target_count=int(active_targets), sample_count_24h=int(sample_count), last_sample_at=last, + task_count=int(device_count), + running_task_count=int(running), + ) + + +def list_device_events( + db: Session, + device_id: str, + *, + limit: int = 100, +) -> PortTrafficEventsOut: + device = db.get(PortTrafficDevice, device_id) + if not device: + raise HTTPException(status_code=404, detail="device_not_found") + lim = max(1, min(500, int(limit or 100))) + q = db.query(PortTrafficEvent).filter(PortTrafficEvent.device_id == device_id) + total = q.count() + # Seed once from current last_error snapshot so older failures still show in log UI. + if total == 0: + seeded = False + if str(device.last_error or "").strip(): + append_device_event( + db, + device_id=device_id, + message=str(device.last_error), + level="error", + ) + seeded = True + for t in ( + db.query(PortTrafficTarget) + .filter(PortTrafficTarget.device_id == device_id) + .order_by(PortTrafficTarget.ifname) + .all() + ): + err = str(t.last_error or "").strip() + if not err: + continue + append_device_event( + db, + device_id=device_id, + target_row_id=str(t.id), + ifname=str(t.ifname or ""), + message=err, + level="error", + ) + seeded = True + if seeded: + db.commit() + total = db.query(PortTrafficEvent).filter(PortTrafficEvent.device_id == device_id).count() + q = db.query(PortTrafficEvent).filter(PortTrafficEvent.device_id == device_id) + rows = q.order_by(PortTrafficEvent.created_at.desc()).limit(lim).all() + items = [ + PortTrafficEventOut( + id=str(r.id), + device_id=str(r.device_id or ""), + target_row_id=str(r.target_row_id or ""), + ifname=str(r.ifname or ""), + level=str(r.level or "error"), + message=str(r.message or ""), + created_at=r.created_at, + ) + for r in rows + ] + return PortTrafficEventsOut(items=items, total=int(total)) + + +def append_device_event( + db: Session, + *, + device_id: str, + message: str, + level: str = "error", + target_row_id: str = "", + ifname: str = "", +) -> None: + msg = str(message or "").strip() + if not msg or not device_id: + return + db.add( + PortTrafficEvent( + id=uuid4().hex, + device_id=device_id, + target_row_id=str(target_row_id or ""), + ifname=str(ifname or ""), + level=str(level or "error")[:16], + message=msg[:4000], + created_at=_utcnow(), + ) ) def purge_expired_samples(db: Session) -> int: - """Delete samples older than each task's retention_days.""" - tasks = db.query(PortTrafficTask).all() + devices = db.query(PortTrafficDevice).all() deleted = 0 now = _utcnow() - for task in tasks: - days = max(1, int(task.retention_days or 7)) + for device in devices: + days = max(1, int(device.retention_days or 7)) cutoff = now - timedelta(days=days) target_ids = [ str(t.id) - for t in db.query(PortTrafficTarget.id).filter(PortTrafficTarget.task_id == task.id).all() + for t in db.query(PortTrafficTarget.id) + .filter(PortTrafficTarget.device_id == device.id) + .all() ] - if not target_ids: - continue - n = ( - db.query(PortTrafficSample) - .filter( - PortTrafficSample.target_row_id.in_(target_ids), - PortTrafficSample.ts < cutoff, + if target_ids: + n = ( + db.query(PortTrafficSample) + .filter( + PortTrafficSample.target_row_id.in_(target_ids), + PortTrafficSample.ts < cutoff, + ) + .delete(synchronize_session=False) ) - .delete(synchronize_session=False) - ) - deleted += int(n or 0) + deleted += int(n or 0) + db.query(PortTrafficEvent).filter( + PortTrafficEvent.device_id == device.id, + PortTrafficEvent.created_at < cutoff, + ).delete(synchronize_session=False) + db.commit() if deleted: - db.commit() _log.info("port_traffic retention purged samples=%s", deleted) return deleted diff --git a/tests/test_port_traffic_compare.py b/tests/test_port_traffic_compare.py index 4580657..04fbac6 100644 --- a/tests/test_port_traffic_compare.py +++ b/tests/test_port_traffic_compare.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from netx_api.db import Base -from netx_api.models import PortTrafficSample, PortTrafficSeries, PortTrafficTarget, PortTrafficTask +from netx_api.models import PortTrafficDevice, PortTrafficSample, PortTrafficSeries, PortTrafficTarget from netx_api.port_traffic_service import baseline_offset_hours, compare_targets @@ -34,16 +34,32 @@ class CompareTargetsTests(unittest.TestCase): Base.metadata.create_all(bind=engine) self.Session = sessionmaker(bind=engine) self.db = self.Session() - self.task_id = uuid4().hex + self.device_id = uuid4().hex + self.device2_id = uuid4().hex self.series_id = uuid4().hex self.target_id = uuid4().hex self.mapped_id = uuid4().hex now = datetime(2026, 7, 31, 12, 0, 0) self.now = now self.db.add( - PortTrafficTask( - id=self.task_id, - title="t1", + PortTrafficDevice( + id=self.device_id, + source="managed", + ne_id="ne1", + ne_name="R1", + vendor="Huawei", + status="running", + created_at=now, + updated_at=now, + ) + ) + self.db.add( + PortTrafficDevice( + id=self.device2_id, + source="managed", + ne_id="ne2", + ne_name="R2", + vendor="ZTE", status="running", created_at=now, updated_at=now, @@ -52,7 +68,7 @@ class CompareTargetsTests(unittest.TestCase): self.db.add( PortTrafficSeries( id=self.series_id, - task_id=self.task_id, + device_id=self.device_id, title="uplink1", status="active", created_at=now, @@ -61,7 +77,7 @@ class CompareTargetsTests(unittest.TestCase): self.db.add( PortTrafficTarget( id=self.target_id, - task_id=self.task_id, + device_id=self.device_id, series_id=self.series_id, source="managed", target_id="ne1", @@ -75,7 +91,7 @@ class CompareTargetsTests(unittest.TestCase): self.db.add( PortTrafficTarget( id=self.mapped_id, - task_id=self.task_id, + device_id=self.device2_id, series_id="", source="managed", target_id="ne2", @@ -86,7 +102,6 @@ class CompareTargetsTests(unittest.TestCase): created_at=now, ) ) - # current window points (last 2h) on primary interface for i in range(3): self.db.add( PortTrafficSample( @@ -99,7 +114,6 @@ class CompareTargetsTests(unittest.TestCase): raw_ok=True, ) ) - # baseline day-1 points on same port for i in range(3): self.db.add( PortTrafficSample( @@ -112,7 +126,6 @@ class CompareTargetsTests(unittest.TestCase): raw_ok=True, ) ) - # mapped port same-window points for i in range(2): self.db.add( PortTrafficSample( @@ -175,7 +188,6 @@ class CompareTargetsTests(unittest.TestCase): self.assertIsNone(out.baseline[0].ts_raw) def test_mapped_port_with_day_offset(self): - # day-ago samples on mapped port for i in range(2): self.db.add( PortTrafficSample( diff --git a/tests/test_port_traffic_parsers.py b/tests/test_port_traffic_parsers.py index 391d26f..066f866 100644 --- a/tests/test_port_traffic_parsers.py +++ b/tests/test_port_traffic_parsers.py @@ -15,6 +15,7 @@ from netx_api.port_traffic_parsers import ( parse_interface_detail, parse_zte_interface_brief, parse_zte_interface_detail, + resolve_util_pct, ) SAMPLE_DETAIL = """\ @@ -280,5 +281,20 @@ class CommandsTests(unittest.TestCase): ) +class ResolveUtilTests(unittest.TestCase): + def test_keeps_vendor_util(self): + self.assertEqual(resolve_util_pct(0.01, 0.0, 1_000_000_000), 0.01) + + def test_derives_when_vendor_zero(self): + # 98 Kbit/s on 1 Gbit/s ≈ 0.0098% + self.assertAlmostEqual(resolve_util_pct(0.0, 98_000.0, 1_000_000_000), 0.0098) + + def test_zero_when_no_traffic(self): + self.assertEqual(resolve_util_pct(0.0, 0.0, 1_000_000_000), 0.0) + + def test_zero_when_no_bw(self): + self.assertEqual(resolve_util_pct(0.0, 98_000.0, 0), 0.0) + + if __name__ == "__main__": unittest.main() diff --git a/web/WEB.md b/web/WEB.md index 1782f07..d6511f2 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -42,7 +42,8 @@ src/ | `/network/topology` | redirect → `/topology` | — | | `/network/tasks/collect` | 采集任务 | `network` | | `/network/tasks/config-sync` | 配置同步 | `network` | -| `/network/tasks/port-traffic` | 端口流量监控(任务向导 + 大屏) | `network` | +| `/network/tasks/port-traffic` | 端口流量监控(设备管理) | `network` | +| `/network/tasks/port-traffic/wall` | 端口流量大屏 | `network` | | `/topology` | 拓扑管理(模式化编辑器:选择/平移/拖动/连线、框选、自动布局、拖放添加) | `topology` | | `/webcrt` | WebCRT 终端 | `webcrt` | | `/collect` | redirect → `/network/tasks/collect` | — | @@ -107,15 +108,16 @@ src/ ## 端口流量监控 -- API:`/v1/port-traffic/*`(任务 CRUD、discover/ports、samples、compare、dashboard) -- 接口:大屏与取样均按 `port_traffic_target`(网元 + ifname,含各类接口);样点按 `target_row_id` -- 周期对比:`GET /v1/port-traffic/compare?target_id=&range_hours=&baseline=off|shift|day|week|custom`(同一接口时间平移叠图) -- 手工映射:可选 `baseline_target_id`(可跨任务),基线取自另一个接口;可与周期偏移叠加 -- 厂商:ZTE / 华为 / 思科;解析速率 **bit/s** -- 调度:`NETX_PORT_TRAFFIC_SCHEDULER_ENABLED`(默认开),tick `NETX_PORT_TRAFFIC_SCHEDULER_TICK_SEC`(默认 15) -- 单飞:同一任务同时只允许一轮采集;崩溃启动清除 `collect_running` -- 保留:按任务 `retention_days` 清理过期 sample(周对比建议 ≥8 天) -- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 大屏;接口选择 + 周期对比 + 跨任务映射基线接口) +- API:`/v1/port-traffic/devices/*`(设备 CRUD、interfaces、samples、compare、dashboard);`discover/ports` 仍按网元拉取接口 +- **设备中心**:每台网元(`source`+`ne_id`)一份监控配置;周期/保留/启停挂在设备上;接口归属于该设备 +- 唯一性:同一物理口 `(source, ne_id, ifname)` 全局仅允许一个 **active** 监控行,避免重复 CLI 采集 +- 调度:按设备到期;一轮采集对该设备 **登录一次** 后批量 `show interface` +- 手工映射:大屏可选其它设备接口作基线叠图;可与周期偏移叠加 +- 厂商:ZTE / 华为 / 思科;解析速率 **bit/s**;缺厂商 util 时按 bps/bw 回算 +- 调度开关:`NETX_PORT_TRAFFIC_SCHEDULER_ENABLED`(默认开),tick `NETX_PORT_TRAFFIC_SCHEDULER_TICK_SEC`(默认 15) +- 保留:按设备 `retention_days` 清理过期 sample(周对比建议 ≥8 天) +- 前端:`/network/tasks/port-traffic`(设备列表 / 向导 / 编辑 / 采集日志);`/network/tasks/port-traffic/wall`(独立 uPlot 大屏,支持 `?device_id=&target_id=`) +- 采集日志:`GET /v1/port-traffic/devices/{id}/events`;失败写入 `port_traffic_event`,列表操作可查看 - 支持拓扑深链:`?ne_id=&source=managed|ume&ifname=` 打开向导并预填网元 ## 拓扑管理 diff --git a/web/src/App.tsx b/web/src/App.tsx index 2b79f86..37e1782 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,6 +14,7 @@ import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage"; import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage"; import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage"; import { PortTrafficPage } from "./pages/network/PortTrafficPage"; +import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage"; import { LoginPage } from "./pages/LoginPage"; import { UsersPage } from "./pages/UsersPage"; import { AuditPage } from "./pages/AuditPage"; @@ -95,6 +96,7 @@ function ProtectedApp() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/config/networkNav.ts b/web/src/config/networkNav.ts index 2c34720..5bcc146 100644 --- a/web/src/config/networkNav.ts +++ b/web/src/config/networkNav.ts @@ -50,6 +50,12 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [ labelKey: "network.nav.portTraffic", group: "tasks", }, + { + id: "port-traffic-wall", + path: "/network/tasks/port-traffic/wall", + labelKey: "network.nav.portTrafficWall", + group: "tasks", + }, ], }, ] as const; diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index 5b86b48..d8b5825 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -60,9 +60,10 @@ export const queryKeys = { ["networkConfigs", page, keyword, source, vendor] as const, networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const, portTrafficDashboard: ["portTrafficDashboard"] as const, - portTrafficTasksAll: ["portTrafficTasks"] as const, - portTrafficTasks: (page: number) => ["portTrafficTasks", page] as const, - portTrafficTargets: (taskId: string) => ["portTrafficTargets", taskId] as const, + portTrafficDevicesAll: ["portTrafficDevices"] as const, + portTrafficDevices: (page: number) => ["portTrafficDevices", page] as const, + portTrafficTargets: (deviceId: string) => ["portTrafficTargets", deviceId] as const, + portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const, portTrafficSamples: (targetId: string, rangeHours: number) => ["portTrafficSamples", targetId, rangeHours] as const, portTrafficCompare: ( diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index c458956..3d647c6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -54,6 +54,7 @@ const en = { collectTasks: "Collection tasks", configSync: "Config sync", portTraffic: "Port traffic", + portTrafficWall: "Traffic wall", }, collapseNav: "Collapse sidebar", expandNav: "Expand sidebar", @@ -64,21 +65,37 @@ const en = { }, portTraffic: { title: "Port traffic", - create: "New task", + create: "Monitor device", backList: "Back to list", - created: "Task created", + created: "Device monitoring added", createFailed: "Create failed", + updated: "Saved", + interfacesSaved: "Interfaces updated", started: "Started", paused: "Paused", stopped: "Stopped", + statusRunning: "Running", + statusPaused: "Paused", + statusStopped: "Stopped", + collecting: "Collecting", deleted: "Deleted", - confirmDelete: "Delete this task and its samples?", - empty: "No monitoring tasks yet. Create one to start.", + confirmDelete: "Delete this device monitoring and its samples?", + empty: "No devices monitored yet. Pick a NE and interfaces to start.", wall: "Ops wall", + wallTitle: "Port traffic wall", + toDevices: "Manage devices", + wallEmpty: "No monitored devices yet. Add a device and pick interfaces first.", + fullscreen: "Fullscreen", + exitFullscreen: "Exit fullscreen", + edit: "Edit", + save: "Save", start: "Start", pause: "Pause", stop: "Stop", delete: "Delete", + wizStep1: "Device", + wizStep2: "Interfaces", + wizStep3: "Params", step1: "Params", step2: "NEs", step3: "Interfaces", @@ -93,18 +110,22 @@ const en = { retention: "Retention (days)", retentionHintWizard: "Use ≥8 days for week-over-week compare", concurrency: "Concurrency", + note: "Note", + notePh: "Optional", startNow: "Start after create", neKeywordPh: "Name / IP / vendor", deepLinkHint: "Opened from topology: NE {{ne}}{{ifname}}. Confirm selection, then continue.", deepLinkIfname: ", interface {{ifname}}", selectedNe: "{{count}} NE(s) selected", + selectedOneNe: "Selected", selectedPorts: "{{count}} interface(s) selected", - pickNeFirst: "Select NEs in the previous step first", + pickNeFirst: "Select one NE first", discover: "Discover interfaces", - confirmCreate: "Create task", + confirmCreate: "Start monitoring", wallTask: "Task", wallPort: "Interface", wallDevice: "Device", + wallDeviceLabel: "Device", pickPort: "Select interface", range: "Range", compare: "Compare", @@ -130,21 +151,38 @@ const en = { collectStarted: "Collect triggered", collectBusy: "Collect already running", targetError: "Last interface error", - waitSamples: "Samples appear after the task runs; or click Collect now. Chart auto-refreshes.", - waitAfterError: "Last collect failed — fix connectivity, then Collect now.", + waitSamples: "Samples appear after the device runs; or click Collect now. Chart auto-refreshes.", + waitAfterError: "Collect failed — open Logs under Traffic monitoring for details, then Collect now.", + log: "Logs", + logTitle: "Collect logs", + logEmpty: "No log entries yet.", + logDevice: "Device", + logLevel: "Level", + logTime: "Time", + logMessage: "Message", + logClose: "Close", + logHasError: "Has error", kpiLatest: "latest", kpiBw: "Bandwidth", latestAt: "Latest sample", hoverPoint: "Cursor", wallLiveTitle: "Live traffic", + filterView: "View", + filterMap: "Mapped baseline", + yMode: "Y axis", + yModeAuto: "Auto (incl. baseline)", + yModeCurrent: "Fit current", + yModeUtil: "Utilization %", kpi: { tasks: "Tasks", + devices: "Devices", running: "Running", ports: "Interfaces", samples24h: "Samples 24h", }, col: { title: "Task", + device: "Device", status: "Status", ports: "Interfaces", interval: "Interval", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index e0e2dfd..b3afaad 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -53,7 +53,8 @@ const zh = { webcrt: "WebCRT", collectTasks: "采集任务", configSync: "配置同步", - portTraffic: "端口流量监控", + portTraffic: "流量监控", + portTrafficWall: "流量大屏", }, collapseNav: "折叠侧栏", expandNav: "展开侧栏", @@ -64,21 +65,37 @@ const zh = { }, portTraffic: { title: "端口流量监控", - create: "新建任务", + create: "添加设备监控", backList: "返回列表", - created: "任务已创建", + created: "已添加设备监控", createFailed: "创建失败", + updated: "已保存", + interfacesSaved: "接口已更新", started: "已启动", paused: "已暂停", stopped: "已停止", + statusRunning: "运行中", + statusPaused: "已暂停", + statusStopped: "已停止", + collecting: "采集中", deleted: "已删除", - confirmDelete: "删除该任务及其采样数据?", - empty: "暂无监控任务。请先创建任务。", + confirmDelete: "删除该设备监控及其采样数据?", + empty: "暂无设备监控。请先选择网元并勾选接口。", wall: "监控大屏", + wallTitle: "端口流量大屏", + toDevices: "设备管理", + wallEmpty: "暂无监控设备。请先添加设备并勾选接口。", + fullscreen: "全屏", + exitFullscreen: "退出全屏", + edit: "编辑", + save: "保存", start: "启动", pause: "暂停", stop: "停止", delete: "删除", + wizStep1: "选设备", + wizStep2: "选接口", + wizStep3: "参数", step1: "参数", step2: "选网元", step3: "选接口", @@ -93,18 +110,22 @@ const zh = { retention: "保留(天)", retentionHintWizard: "若需上周同日对比,建议 ≥8 天", concurrency: "并发", + note: "备注", + notePh: "可选", startNow: "创建后立即启动", neKeywordPh: "名称 / IP / 厂商", - deepLinkHint: "已从拓扑跳转:网元 {{ne}}{{ifname}}。请确认选中后继续创建监控任务。", + deepLinkHint: "已从拓扑跳转:网元 {{ne}}{{ifname}}。请确认选中后继续。", deepLinkIfname: ",接口 {{ifname}}", selectedNe: "已选 {{count}} 台网元", + selectedOneNe: "已选", selectedPorts: "已选 {{count}} 个接口", - pickNeFirst: "请先在上一步选择网元", + pickNeFirst: "请先选择一台网元", discover: "拉取接口", - confirmCreate: "创建任务", + confirmCreate: "开始监控", wallTask: "任务", + wallDevice: "设备", wallPort: "接口", - wallDevice: "网元", + wallDeviceLabel: "网元", pickPort: "选择接口", range: "时间范围", compare: "周期对比", @@ -125,26 +146,43 @@ const zh = { mapBaselinePort: "映射基线接口", mapBaselineTask: "映射基线任务", mapBaselineNone: "不映射", - retentionHint: "周对比建议任务保留天数 ≥ 8;否则基线可能无数据。", + retentionHint: "周对比建议保留天数 ≥ 8;否则基线可能无数据。", collectNow: "立即采集", collectStarted: "已触发采集", collectBusy: "采集进行中", targetError: "接口最近错误", - waitSamples: "任务运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。", - waitAfterError: "采集失败,请检查连通性后点「立即采集」重试。", + waitSamples: "设备运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。", + waitAfterError: "采集失败,请到「流量监控」操作中的日志查看详情,修复后可点「立即采集」重试。", + log: "日志", + logTitle: "采集日志", + logEmpty: "暂无日志。", + logDevice: "设备", + logLevel: "级别", + logTime: "时间", + logMessage: "内容", + logClose: "关闭", + logHasError: "有错误", kpiLatest: "最新", kpiBw: "带宽", latestAt: "最新样点", hoverPoint: "光标位置", wallLiveTitle: "实时流量", + filterView: "视图", + filterMap: "映射基线", + yMode: "纵轴", + yModeAuto: "自动(含基线)", + yModeCurrent: "按当前流量", + yModeUtil: "利用率 %", kpi: { tasks: "任务数", + devices: "监控设备", running: "运行中", ports: "监控接口", samples24h: "24h 样点", }, col: { title: "任务", + device: "设备", status: "状态", ports: "接口", interval: "周期", diff --git a/web/src/index.css b/web/src/index.css index 91052e1..d9990b8 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -3695,7 +3695,8 @@ pre { } .network-main:has(.topo-page), -.network-main:has(.webcrt-shell) { +.network-main:has(.webcrt-shell), +.network-main:has(.pt-wall-shell) { padding: 0; overflow: hidden; } @@ -3714,6 +3715,12 @@ pre { border: 0; } +.network-main .pt-wall-shell { + height: 100%; + margin: 0; + min-height: 0; +} + .network-placeholder h2 { margin: 0 0 8px; } @@ -3763,58 +3770,578 @@ pre { color: #334155; } -.pt-wall-page__filters { - margin-bottom: 12px; - gap: 12px 18px; - align-items: flex-end; +.pt-list { + display: flex; + flex-direction: column; + gap: 14px; } -.pt-wall-page__filters label { - display: inline-flex; +.pt-list-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.pt-list-kpi { + padding: 12px 14px; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; +} + +.pt-list-kpi--live { + background: #eff6ff; + border-color: #bfdbfe; +} + +.pt-list-kpi__label { + font-size: 12px; + color: #64748b; + margin-bottom: 4px; +} + +.pt-list-kpi__value { + font-size: 22px; + font-weight: 650; + font-variant-numeric: tabular-nums; + color: #0f172a; + line-height: 1.2; +} + +.pt-list-kpi--live .pt-list-kpi__value { + color: #1d4ed8; +} + +.pt-list-table-wrap { + border: 1px solid #e2e8f0; + border-radius: 8px; + overflow: auto; +} + +.pt-list-table { + margin: 0; +} + +.pt-list-table thead th { + background: #f8fafc; + font-size: 12px; + font-weight: 600; + color: #64748b; + white-space: nowrap; +} + +.pt-list-table tbody td { + vertical-align: middle; +} + +.pt-list-table tbody tr:hover td { + background: #f8fafc; +} + +.pt-list-task-name { + font-weight: 600; + color: #0f172a; +} + +.pt-list-task-err { + margin-top: 2px; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: #b91c1c; +} + +.pt-list-task-err-btn { + display: inline-block; + margin-top: 4px; + padding: 0; + border: 0; + background: transparent; + color: #b91c1c; + font-size: 12px; + font-weight: 600; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.pt-list-task-err-btn:hover { + color: #991b1b; +} + +.pt-log-modal__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.pt-log-modal__head h3 { + margin: 0; +} + +.pt-log-modal__sub { + margin: 4px 0 0; + font-size: 13px; +} + +.pt-log-modal__list { + display: flex; flex-direction: column; - gap: 4px; + gap: 10px; + max-height: min(60vh, 520px); + overflow: auto; +} + +.pt-log-item { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 10px 12px; + background: #f8fafc; +} + +.pt-log-item--error { + border-color: #fecaca; + background: #fef2f2; +} + +.pt-log-item--warn { + border-color: #fde68a; + background: #fffbeb; +} + +.pt-log-item__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-bottom: 6px; font-size: 12px; color: #64748b; } -.pt-wall-page__filters select { - min-width: 160px; +.pt-log-item__level { + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 700; + color: #b91c1c; +} + +.pt-log-item--warn .pt-log-item__level { + color: #b45309; +} + +.pt-log-item--info .pt-log-item__level { + color: #0369a1; +} + +.pt-log-item__if { + font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; + color: #0f172a; +} + +.pt-log-item__msg { + margin: 0; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.45; + color: #1e293b; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.pt-list-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; +} + +.pt-list-status + .pt-list-status { + margin-left: 6px; +} + +.pt-list-status--running { + background: #dcfce7; + color: #166534; +} + +.pt-list-status--running::before, +.pt-list-status--collect::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +.pt-list-status--paused { + background: #fef9c3; + color: #854d0e; +} + +.pt-list-status--stopped, +.pt-list-status--other { + background: #f1f5f9; + color: #475569; +} + +.pt-list-status--collect { + background: #e0e7ff; + color: #3730a3; +} + +.pt-list-num, +.pt-list-time { + font-variant-numeric: tabular-nums; + white-space: nowrap; + color: #334155; +} + +.pt-list-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.pt-list-actions .btn-primary, +.pt-list-actions .btn--danger, +.pt-list-actions button { + padding: 4px 10px; + font-size: 12px; + line-height: 1.4; +} + +.pt-list-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 200px; + padding: 32px 16px; + border: 1px dashed #cbd5e1; + border-radius: 8px; + background: #f8fafc; + color: #64748b; + text-align: center; +} + +.pt-list-empty p { + margin: 0; +} + +.pt-list-pager { + justify-content: center; +} + +@media (max-width: 900px) { + .pt-list-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.pt-wall-shell { + display: flex; + flex-direction: column; + min-height: 0; + color: #e2e8f0; + background: + radial-gradient(1200px 480px at 12% -10%, rgba(45, 212, 191, 0.08), transparent 55%), + radial-gradient(900px 420px at 88% 0%, rgba(56, 189, 248, 0.06), transparent 50%), + linear-gradient(180deg, #0a1220 0%, #070d18 100%); +} + +.pt-wall-shell__empty { + flex: 1; + display: grid; + place-content: center; + gap: 12px; + color: #94a3b8; +} + +.pt-wall-page { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 10px 14px 12px; + gap: 0; +} + +.pt-wall-page__hint { + margin: 0 0 8px; + flex: 0 0 auto; + font-size: 12px; + color: #94a3b8; +} + +.pt-wall-page__stage { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.pt-wall-page__stage:fullscreen, +.pt-wall-page__stage.is-fullscreen:fullscreen { + width: 100vw; + height: 100vh; + padding: 20px 24px; + box-sizing: border-box; + background: + radial-gradient(1200px 480px at 12% -10%, rgba(45, 212, 191, 0.08), transparent 55%), + linear-gradient(180deg, #0a1220 0%, #070d18 100%); +} + +.pt-wall-page__stage .pt-wall { + flex: 1; + min-height: 0; + height: 100%; +} + +.pt-wall-page__fs-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 11px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: transparent; + color: #cbd5e1; + font-size: 12px; + cursor: pointer; +} + +.pt-wall-page__fs-btn--toolbar { + flex: 0 0 auto; + align-self: flex-end; +} + +.pt-wall-page__fs-btn:hover { + background: rgba(148, 163, 184, 0.12); + border-color: rgba(148, 163, 184, 0.45); + color: #f8fafc; +} + +.app-main .pt-wall-page__fs-btn { + background: transparent; + color: #cbd5e1; +} + +.app-main .pt-wall-page__fs-btn:hover { + background: rgba(148, 163, 184, 0.12); + color: #f8fafc; +} + +.pt-wall-page__toolbar { + display: flex; + flex-direction: column; + gap: 8px; + flex: 0 0 auto; + margin-bottom: 10px; + padding: 0 0 10px; + background: transparent; + border: 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); + border-radius: 0; +} + +.pt-wall-page__row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px 10px; +} + +.pt-wall-page__row--primary { + padding-bottom: 0; + border-bottom: 0; +} + +.pt-wall-page__row--secondary { + align-items: flex-end; +} + +.pt-wall-page__field { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11px; + color: #64748b; + min-width: 0; +} + +.pt-wall-page__field select, +.pt-wall-page__field input { + height: 30px; + padding: 0 9px; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 6px; + background: rgba(15, 23, 42, 0.55); + color: #e2e8f0; + font-size: 12px; +} + +.pt-wall-page__field select:hover, +.pt-wall-page__field input:hover, +.pt-wall-page__field select:focus, +.pt-wall-page__field input:focus { + border-color: rgba(45, 212, 191, 0.45); + outline: none; +} + +.app-main .pt-wall-page__field select, +.app-main .pt-wall-page__field input { + background: rgba(15, 23, 42, 0.55); + color: #e2e8f0; + border-color: rgba(148, 163, 184, 0.22); +} + +.pt-wall-page__field--task { + flex: 0 1 160px; + min-width: 128px; +} + +.pt-wall-page__field--port { + flex: 1 1 220px; + min-width: 180px; +} + +.pt-wall-page__field--sm { + flex: 0 0 78px; +} + +.pt-wall-page__field--md { + flex: 0 1 148px; + min-width: 120px; +} + +.pt-wall-page__field--port select { + width: 100%; +} + +.pt-wall-page__collect { + flex: 0 0 auto; + height: 30px; + margin-left: auto; + align-self: flex-end; + white-space: nowrap; + border-radius: 6px; + border: 1px solid rgba(45, 212, 191, 0.35); + background: rgba(45, 212, 191, 0.16); + color: #99f6e4; + font-size: 12px; +} + +.app-main .pt-wall-page__collect.btn-primary { + background: rgba(45, 212, 191, 0.16); + border-color: rgba(45, 212, 191, 0.35); + color: #99f6e4; +} + +.app-main .pt-wall-page__collect.btn-primary:hover:not(:disabled) { + background: rgba(45, 212, 191, 0.28); + color: #ecfdf5; +} + +.pt-wall-page__cluster { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 8px 10px; + min-width: 0; +} + +.pt-wall-page__cluster--map { + flex: 1 1 280px; +} + +.pt-wall-page__cluster-label { + flex: 0 0 auto; + padding-bottom: 7px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #475569; + white-space: nowrap; +} + +.pt-wall-page__cluster-fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px 10px; +} + +.pt-wall-page__cluster-sep { + width: 1px; + align-self: stretch; + margin: 4px 6px 0; + background: rgba(148, 163, 184, 0.14); + flex: 0 0 1px; +} + +@media (max-width: 900px) { + .pt-wall-page__cluster { + flex-direction: column; + align-items: stretch; + } + + .pt-wall-page__cluster-label { + padding-bottom: 0; + } + + .pt-wall-page__cluster-sep { + display: none; + } + + .pt-wall-page__collect { + margin-left: 0; + } + + .pt-wall-page__field--port { + flex-basis: 100%; + } } .pt-wall { - --pt-bg: #0b1220; - --pt-panel: #111827; - --pt-line: rgba(148, 163, 184, 0.22); + --pt-bg: #0a1220; + --pt-panel: transparent; + --pt-line: rgba(148, 163, 184, 0.16); --pt-text: #e2e8f0; - --pt-muted: #94a3b8; + --pt-muted: #8b9bb0; --pt-in: #2dd4bf; - --pt-out: #f59e0b; - background: - linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(11, 18, 32, 0.98)), - repeating-linear-gradient( - 0deg, - transparent, - transparent 23px, - rgba(148, 163, 184, 0.05) 24px - ), - repeating-linear-gradient( - 90deg, - transparent, - transparent 23px, - rgba(148, 163, 184, 0.05) 24px - ); - border: 1px solid #1f2937; - border-radius: 10px; - padding: 16px 18px 12px; + --pt-out: #fbbf24; + display: flex; + flex-direction: column; + min-height: 0; + background: transparent; + border: 0; + border-radius: 0; + padding: 4px 2px 0; color: var(--pt-text); } .pt-wall__head { display: flex; justify-content: space-between; - align-items: baseline; - gap: 12px; - margin-bottom: 14px; + align-items: flex-end; + gap: 16px; + margin-bottom: 12px; + flex: 0 0 auto; } .pt-wall__head-title { @@ -3832,51 +4359,71 @@ pre { } .pt-wall__ne { - font-size: 13px; + font-size: 12px; + font-weight: 500; + letter-spacing: 0.02em; color: var(--pt-muted); } .pt-wall__if { - font-size: 20px; - font-weight: 650; - letter-spacing: 0.02em; + font-size: 22px; + font-weight: 600; + letter-spacing: 0.01em; font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; + color: #f8fafc; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: min(720px, 72vw); } .pt-wall__range { font-size: 12px; color: var(--pt-muted); white-space: nowrap; + padding-bottom: 3px; + font-variant-numeric: tabular-nums; } .pt-wall__kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; + gap: 0; margin-bottom: 14px; + flex: 0 0 auto; + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 8px; + background: rgba(15, 23, 42, 0.35); + overflow: hidden; } .pt-wall__kpi { - background: rgba(17, 24, 39, 0.72); - border: 1px solid var(--pt-line); - border-radius: 8px; - padding: 10px 12px; + background: transparent; + border: 0; + border-radius: 0; + border-right: 1px solid rgba(148, 163, 184, 0.1); + padding: 12px 16px; +} + +.pt-wall__kpi:last-child { + border-right: 0; } .pt-wall__kpi-label { - font-size: 11px; + font-size: 10px; text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--pt-muted); - margin-bottom: 4px; + letter-spacing: 0.08em; + color: #64748b; + margin-bottom: 6px; } .pt-wall__kpi-value { - font-size: 22px; - font-weight: 650; + font-size: 20px; + font-weight: 600; font-variant-numeric: tabular-nums; font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; - line-height: 1.2; + line-height: 1.15; + color: #f1f5f9; } .pt-wall__kpi--in .pt-wall__kpi-value { @@ -3887,16 +4434,33 @@ pre { color: var(--pt-out); } +.pt-wall__kpi--util .pt-wall__kpi-value { + color: #7dd3fc; +} + .pt-wall__kpi-unit, .pt-wall__kpi-sep { margin-left: 4px; - font-size: 12px; + font-size: 11px; color: var(--pt-muted); font-weight: 500; } +.pt-wall__chart-wrap { + position: relative; + flex: 1; + min-height: 180px; + width: 100%; + border-radius: 8px; + background: rgba(8, 14, 26, 0.45); + border: 1px solid rgba(148, 163, 184, 0.1); + padding: 8px 8px 4px; + box-sizing: border-box; +} + .pt-wall__chart { - min-height: 260px; + min-height: 0; + height: 100%; width: 100%; } @@ -3905,12 +4469,6 @@ pre { font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; } -.pt-wall__chart-wrap { - position: relative; - min-height: 260px; - width: 100%; -} - .pt-wall__tip { position: absolute; top: 0; @@ -3919,13 +4477,14 @@ pre { min-width: 150px; padding: 8px 10px; border-radius: 8px; - border: 1px solid rgba(148, 163, 184, 0.35); - background: rgba(15, 23, 42, 0.94); + border: 1px solid rgba(148, 163, 184, 0.28); + background: rgba(8, 14, 26, 0.94); color: #e2e8f0; font-size: 12px; line-height: 1.45; pointer-events: none; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(6px); } .pt-wall__tip-time { @@ -3946,45 +4505,99 @@ pre { } .pt-wall__tip-row--out strong { - color: #f59e0b; + color: #fbbf24; +} + +.pt-wall__chart .u-axis { + border: 0 !important; } .pt-wall__chart .u-legend { - color: var(--pt-muted); -} - -.pt-wall__chart .u-legend .u-value { - display: none; + display: none !important; } .pt-wall__foot { display: flex; flex-wrap: wrap; - gap: 10px 28px; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid rgba(148, 163, 184, 0.18); - align-items: baseline; + align-items: center; + justify-content: space-between; + gap: 8px 20px; + margin-top: 8px; + padding-top: 0; + border-top: 0; + flex: 0 0 auto; + min-height: 28px; +} + +.pt-wall__legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + color: var(--pt-muted); + font-size: 12px; + line-height: 1.2; +} + +.pt-wall__legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.pt-wall__legend-swatch { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 2px; + flex: 0 0 10px; +} + +.pt-wall__legend-item--in .pt-wall__legend-swatch { + background: #2dd4bf; +} + +.pt-wall__legend-item--out .pt-wall__legend-swatch { + background: #fbbf24; +} + +.pt-wall__legend-item--base-in .pt-wall__legend-swatch { + background: rgba(45, 212, 191, 0.45); + box-shadow: inset 0 0 0 1px rgba(45, 212, 191, 0.7); +} + +.pt-wall__legend-item--base-out .pt-wall__legend-swatch { + background: rgba(251, 191, 36, 0.4); + box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.65); +} + +.pt-wall__foot-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 20px; + margin-left: auto; } .pt-wall__foot-ne, .pt-wall__foot-if { display: flex; flex-wrap: wrap; - align-items: baseline; + align-items: center; gap: 6px 10px; min-width: 0; } .pt-wall__foot-label { - font-size: 11px; + font-size: 10px; text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--pt-muted); + letter-spacing: 0.08em; + color: #64748b; } .pt-wall__foot-value { - font-size: 15px; + font-size: 13px; font-weight: 600; color: #e2e8f0; } @@ -4002,7 +4615,7 @@ pre { .pt-wall__empty { position: absolute; - inset: 0; + inset: 8px; display: flex; align-items: center; justify-content: center; @@ -4010,9 +4623,9 @@ pre { text-align: center; line-height: 1.5; color: var(--pt-muted); - background: rgba(11, 18, 32, 0.72); - border: 1px dashed rgba(148, 163, 184, 0.28); - border-radius: 8px; + background: rgba(8, 14, 26, 0.55); + border: 1px dashed rgba(148, 163, 184, 0.22); + border-radius: 6px; pointer-events: none; } @@ -4020,4 +4633,12 @@ pre { .pt-wall__kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .pt-wall__kpi:nth-child(2n) { + border-right: 0; + } + + .pt-wall__kpi:nth-child(-n + 2) { + border-bottom: 1px solid rgba(148, 163, 184, 0.1); + } } diff --git a/web/src/pages/network/NetworkLayout.tsx b/web/src/pages/network/NetworkLayout.tsx index 4d2c089..cabd233 100644 --- a/web/src/pages/network/NetworkLayout.tsx +++ b/web/src/pages/network/NetworkLayout.tsx @@ -104,7 +104,10 @@ export function NetworkLayout() { className={({ isActive }) => `network-nav__link${isActive ? " is-active" : ""}` } - end={item.path === "/network/devices"} + end={ + item.path === "/network/devices" || + item.path === "/network/tasks/port-traffic" + } > {t(item.labelKey)} @@ -127,7 +130,10 @@ export function NetworkLayout() { className={({ isActive }) => `network-nav__rail-link${isActive ? " is-active" : ""}` } - end={item.path === "/network/devices"} + end={ + item.path === "/network/devices" || + item.path === "/network/tasks/port-traffic" + } > {t(item.labelKey).slice(0, 1)} diff --git a/web/src/pages/network/PortTrafficPage.tsx b/web/src/pages/network/PortTrafficPage.tsx index 232445e..e3b677c 100644 --- a/web/src/pages/network/PortTrafficPage.tsx +++ b/web/src/pages/network/PortTrafficPage.tsx @@ -1,41 +1,43 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query"; -import { useSearchParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { - collectPortTrafficNow, - createPortTrafficTask, - deletePortTrafficTask, + createPortTrafficDevice, + deletePortTrafficDevice, discoverPortTrafficPorts, fetchCliTargets, - fetchPortTrafficCompare, fetchPortTrafficDashboard, + fetchPortTrafficDevices, + fetchPortTrafficEvents, fetchPortTrafficTargets, - fetchPortTrafficTasks, - pausePortTrafficTask, - startPortTrafficTask, - stopPortTrafficTask, + pausePortTrafficDevice, + putPortTrafficInterfaces, + startPortTrafficDevice, + stopPortTrafficDevice, + updatePortTrafficDevice, } from "../../services/api"; import { queryKeys } from "../../constants/queryKeys"; import { useI18n } from "../../i18n"; import { useToast } from "../../hooks/useToast"; -import type { CliTargetItem, PortTrafficDiscoverPort, PortTrafficSamplePoint, PortTrafficTargetIn } from "../../types"; +import type { + CliTargetItem, + PortTrafficDevice, + PortTrafficDiscoverPort, + PortTrafficIfaceIn, +} from "../../types"; import { pageCount } from "../../utils/display"; import { formatSystemTime } from "../../utils/time"; -import { PortTrafficWall } from "./PortTrafficWall"; const POLL_MS = 5000; const TARGET_PAGE_SIZE = 20; -const EMPTY_COMPARE_POINTS: PortTrafficSamplePoint[] = []; -type PortPick = PortTrafficTargetIn & { key: string }; -type BaselineMode = "off" | "shift" | "day" | "week" | "custom"; +type ViewMode = "list" | "wizard" | "edit"; -function neKey(source: string, id: string) { - return `${source}:${id}`; -} - -function portKey(source: string, id: string, ifname: string) { - return `${source}:${id}:${ifname}`; +function statusTone(status: string): "running" | "paused" | "stopped" | "other" { + if (status === "running") return "running"; + if (status === "paused") return "paused"; + if (status === "stopped") return "stopped"; + return "other"; } function formatBw(bps: number) { @@ -45,40 +47,43 @@ function formatBw(bps: number) { return `${bps}`; } +function deviceLabel(d: PortTrafficDevice) { + return d.ne_name || d.ne_ip || d.ne_id || "—"; +} + export function PortTrafficPage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const queryClient = useQueryClient(); + const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const deepLinkAppliedRef = useRef(false); - const [view, setView] = useState<"list" | "wizard" | "wall">("list"); - const [taskPage, setTaskPage] = useState(1); - const [wallTaskId, setWallTaskId] = useState(""); - const [wallTargetId, setWallTargetId] = useState(""); - const [mapBaselineTaskId, setMapBaselineTaskId] = useState(""); - const [mapBaselineTargetId, setMapBaselineTargetId] = useState(""); - const [rangeHours, setRangeHours] = useState(24); - const [baseline, setBaseline] = useState("off"); - const [customOffsetHours, setCustomOffsetHours] = useState(48); + const [view, setView] = useState("list"); + const [listPage, setListPage] = useState(1); - // Wizard state - const [step, setStep] = useState(1); - const [title, setTitle] = useState(""); + // Wizard / edit + const [editDeviceId, setEditDeviceId] = useState(""); + const [editDeviceSnap, setEditDeviceSnap] = useState(null); const [intervalSec, setIntervalSec] = useState(60); const [retentionDays, setRetentionDays] = useState(7); - const [concurrency, setConcurrency] = useState(5); + const [concurrency, setConcurrency] = useState(1); + const [note, setNote] = useState(""); const [startNow, setStartNow] = useState(true); const [neKeyword, setNeKeyword] = useState(""); const [nePage, setNePage] = useState(1); - const [selectedNes, setSelectedNes] = useState>({}); - const [portsByNe, setPortsByNe] = useState< - Record - >({}); - const [pickedPorts, setPickedPorts] = useState>({}); + const [selectedNe, setSelectedNe] = useState(null); + const [ports, setPorts] = useState([]); + const [portsLoading, setPortsLoading] = useState(false); + const [portsError, setPortsError] = useState(""); + const [pickedIfnames, setPickedIfnames] = useState>({}); const [deepLinkHint, setDeepLinkHint] = useState(""); const [deepLinkSource, setDeepLinkSource] = useState<"managed" | "ume" | "">(""); const [deepLinkNeId, setDeepLinkNeId] = useState(""); + const [deepLinkIfname, setDeepLinkIfname] = useState(""); + const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1); + const [logDeviceId, setLogDeviceId] = useState(""); + const [logDeviceLabel, setLogDeviceLabel] = useState(""); const dashQuery = useQuery({ queryKey: queryKeys.portTrafficDashboard, @@ -87,75 +92,36 @@ export function PortTrafficPage() { refetchInterval: POLL_MS, }); - const tasksQuery = useQuery({ - queryKey: queryKeys.portTrafficTasks(taskPage), - queryFn: () => fetchPortTrafficTasks({ page: taskPage, pageSize: 10 }), + const devicesQuery = useQuery({ + queryKey: queryKeys.portTrafficDevices(listPage), + queryFn: () => fetchPortTrafficDevices({ page: listPage, pageSize: 10 }), staleTime: 2000, refetchInterval: POLL_MS, }); - const wallTasksQuery = useQuery({ - queryKey: queryKeys.portTrafficTasks(0), - queryFn: () => fetchPortTrafficTasks({ page: 1, pageSize: 100 }), - enabled: view === "wall", - staleTime: 5000, - }); - const nesQuery = useQuery({ queryKey: queryKeys.cliTargets(neKeyword, nePage, TARGET_PAGE_SIZE), queryFn: () => fetchCliTargets({ source: "all", keyword: neKeyword, page: nePage, pageSize: TARGET_PAGE_SIZE }), - enabled: view === "wizard" && step === 2, + enabled: view === "wizard" && wizardStep === 1, staleTime: 5000, }); - const wallTargetsQuery = useQuery({ - queryKey: queryKeys.portTrafficTargets(wallTaskId), - queryFn: () => fetchPortTrafficTargets(wallTaskId), - enabled: view === "wall" && Boolean(wallTaskId), + const editTargetsQuery = useQuery({ + queryKey: queryKeys.portTrafficTargets(editDeviceId), + queryFn: () => fetchPortTrafficTargets(editDeviceId), + enabled: view === "edit" && Boolean(editDeviceId), staleTime: 2000, - refetchInterval: POLL_MS, }); - const mapBaselineTargetsQuery = useQuery({ - queryKey: queryKeys.portTrafficTargets(mapBaselineTaskId), - queryFn: () => fetchPortTrafficTargets(mapBaselineTaskId), - enabled: view === "wall" && Boolean(mapBaselineTaskId), + const logQuery = useQuery({ + queryKey: queryKeys.portTrafficEvents(logDeviceId), + queryFn: () => fetchPortTrafficEvents(logDeviceId, 200), + enabled: Boolean(logDeviceId), staleTime: 2000, - refetchInterval: POLL_MS, + refetchInterval: logDeviceId ? POLL_MS : false, }); - 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: view === "wall" && Boolean(wallTargetId), - staleTime: 1000, - placeholderData: keepPreviousData, - refetchInterval: (q) => { - const n = q.state.data?.current?.length ?? 0; - return n === 0 ? 2500 : POLL_MS; - }, - }); - - 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 neId = (searchParams.get("ne_id") || "").trim(); @@ -165,8 +131,9 @@ export function PortTrafficPage() { const ifname = (searchParams.get("ifname") || "").trim(); setDeepLinkNeId(neId); setDeepLinkSource(source); + setDeepLinkIfname(ifname); setView("wizard"); - setStep(2); + setWizardStep(1); setNeKeyword(neId); setNePage(1); setDeepLinkHint( @@ -181,7 +148,7 @@ export function PortTrafficPage() { }, [searchParams, setSearchParams, t]); useEffect(() => { - if (!deepLinkNeId || view !== "wizard" || step !== 2) return; + if (!deepLinkNeId || view !== "wizard" || wizardStep !== 1) return; const items = nesQuery.data?.items || []; const hit = items.find((row) => { const src = row.source === "ume" ? "ume" : "managed"; @@ -189,18 +156,17 @@ export function PortTrafficPage() { return row.id === deepLinkNeId; }); if (!hit) return; - const k = neKey(hit.source, hit.id); - setSelectedNes((prev) => (prev[k] ? prev : { ...prev, [k]: hit })); + setSelectedNe(hit); setDeepLinkNeId(""); - }, [deepLinkNeId, deepLinkSource, nesQuery.data, view, step]); + }, [deepLinkNeId, deepLinkSource, nesQuery.data, view, wizardStep]); const invalidateAll = () => { - void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTasksAll }); + void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficDevicesAll }); void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficDashboard }); }; const createMut = useMutation({ - mutationFn: createPortTrafficTask, + mutationFn: createPortTrafficDevice, onSuccess: () => { showOk(t("portTraffic.created")); invalidateAll(); @@ -210,8 +176,29 @@ export function PortTrafficPage() { onError: (e: Error) => showError(e.message || t("portTraffic.createFailed")), }); + const updateMut = useMutation({ + mutationFn: ({ id, body }: { id: string; body: Parameters[1] }) => + updatePortTrafficDevice(id, body), + onSuccess: () => { + showOk(t("portTraffic.updated")); + invalidateAll(); + }, + onError: (e: Error) => showError(e.message), + }); + + const putIfacesMut = useMutation({ + mutationFn: ({ id, ifaces }: { id: string; ifaces: PortTrafficIfaceIn[] }) => + putPortTrafficInterfaces(id, ifaces), + onSuccess: () => { + showOk(t("portTraffic.interfacesSaved")); + invalidateAll(); + void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTargets(editDeviceId) }); + }, + onError: (e: Error) => showError(e.message), + }); + const startMut = useMutation({ - mutationFn: startPortTrafficTask, + mutationFn: startPortTrafficDevice, onSuccess: () => { showOk(t("portTraffic.started")); invalidateAll(); @@ -219,7 +206,7 @@ export function PortTrafficPage() { onError: (e: Error) => showError(e.message), }); const pauseMut = useMutation({ - mutationFn: pausePortTrafficTask, + mutationFn: pausePortTrafficDevice, onSuccess: () => { showOk(t("portTraffic.paused")); invalidateAll(); @@ -227,25 +214,15 @@ export function PortTrafficPage() { onError: (e: Error) => showError(e.message), }); const stopMut = useMutation({ - mutationFn: stopPortTrafficTask, + mutationFn: stopPortTrafficDevice, onSuccess: () => { showOk(t("portTraffic.stopped")); invalidateAll(); }, onError: (e: Error) => showError(e.message), }); - const collectMut = useMutation({ - mutationFn: collectPortTrafficNow, - onSuccess: (res) => { - showOk(res.started ? t("portTraffic.collectStarted") : t("portTraffic.collectBusy")); - invalidateAll(); - void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTargets(wallTaskId) }); - void queryClient.invalidateQueries({ queryKey: ["portTrafficCompare"] }); - }, - onError: (e: Error) => showError(e.message), - }); const deleteMut = useMutation({ - mutationFn: deletePortTrafficTask, + mutationFn: deletePortTrafficDevice, onSuccess: () => { showOk(t("portTraffic.deleted")); invalidateAll(); @@ -254,20 +231,24 @@ export function PortTrafficPage() { }); const resetWizard = () => { - setStep(1); - setTitle(""); + setWizardStep(1); setIntervalSec(60); setRetentionDays(7); - setConcurrency(5); + setConcurrency(1); + setNote(""); setStartNow(true); - setSelectedNes({}); - setPortsByNe({}); - setPickedPorts({}); + setSelectedNe(null); + setPorts([]); + setPortsError(""); + setPickedIfnames({}); setNeKeyword(""); setNePage(1); setDeepLinkHint(""); setDeepLinkSource(""); setDeepLinkNeId(""); + setDeepLinkIfname(""); + setEditDeviceId(""); + setEditDeviceSnap(null); }; const openWizard = () => { @@ -275,61 +256,85 @@ export function PortTrafficPage() { setView("wizard"); }; - const openWall = (taskId: string) => { - setWallTaskId(taskId); - setWallTargetId(""); - setMapBaselineTaskId(""); - setMapBaselineTargetId(""); - setBaseline("off"); - setView("wall"); + const openWall = (deviceId: string) => { + navigate(`/network/tasks/port-traffic/wall?device_id=${encodeURIComponent(deviceId)}`); }; - const toggleNe = (row: CliTargetItem) => { - const k = neKey(row.source, row.id); - setSelectedNes((prev) => { - const next = { ...prev }; - if (next[k]) delete next[k]; - else next[k] = row; - return next; - }); + const openEdit = (row: PortTrafficDevice) => { + setEditDeviceId(row.id); + setEditDeviceSnap(row); + setIntervalSec(row.interval_sec); + setRetentionDays(row.retention_days); + setConcurrency(row.concurrency || 1); + setNote(row.note || ""); + setPickedIfnames({}); + setPorts([]); + setPortsError(""); + setView("edit"); }; - const discoverNe = async (row: CliTargetItem) => { - const k = neKey(row.source, row.id); - setPortsByNe((prev) => ({ ...prev, [k]: { loading: true, ports: prev[k]?.ports || [] } })); + useEffect(() => { + if (view !== "edit" || !editTargetsQuery.data) return; + const next: Record = {}; + for (const tgt of editTargetsQuery.data.items) { + if (tgt.status !== "active") continue; + next[tgt.ifname] = { + ifname: tgt.ifname, + if_description: tgt.if_description, + bw_bps: tgt.bw_bps, + }; + } + setPickedIfnames(next); + }, [view, editTargetsQuery.data]); + + const discoverSelected = async () => { + const source = + view === "edit" + ? editDeviceSnap?.source === "ume" + ? "ume" + : "managed" + : selectedNe?.source === "ume" + ? "ume" + : "managed"; + const id = view === "edit" ? editDeviceSnap?.ne_id || "" : selectedNe?.id || ""; + if (!id) return; + setPortsLoading(true); + setPortsError(""); try { - const source = row.source === "ume" ? "ume" : "managed"; - const res = await discoverPortTrafficPorts({ source, id: row.id }); - setPortsByNe((prev) => ({ - ...prev, - [k]: { - ports: res.ports, - meta: { ne_name: res.ne_name || row.name, ne_ip: res.ne_ip || row.ip_address, vendor: res.vendor || row.vendor || "" }, - }, - })); + const res = await discoverPortTrafficPorts({ source, id }); + setPorts(res.ports); + if (deepLinkIfname) { + const hit = res.ports.find((p) => p.ifname === deepLinkIfname); + if (hit) { + setPickedIfnames((prev) => ({ + ...prev, + [hit.ifname]: { + ifname: hit.ifname, + if_description: hit.description, + bw_bps: hit.bw_bps, + }, + })); + } + setDeepLinkIfname(""); + } } catch (e) { const msg = e instanceof Error ? e.message : "discover_failed"; - setPortsByNe((prev) => ({ ...prev, [k]: { ports: [], error: msg } })); + setPorts([]); + setPortsError(msg); showError(msg); + } finally { + setPortsLoading(false); } }; - const togglePort = (ne: CliTargetItem, port: PortTrafficDiscoverPort, meta?: { ne_name: string; ne_ip: string; vendor: string }) => { - const source = (ne.source === "ume" ? "ume" : "managed") as "managed" | "ume"; - const k = portKey(source, ne.id, port.ifname); - setPickedPorts((prev) => { + const toggleIfname = (port: PortTrafficDiscoverPort) => { + setPickedIfnames((prev) => { const next = { ...prev }; - if (next[k]) { - delete next[k]; + if (next[port.ifname]) { + delete next[port.ifname]; return next; } - next[k] = { - key: k, - source, - target_id: ne.id, - ne_name: meta?.ne_name || ne.name, - ne_ip: meta?.ne_ip || ne.ip_address, - vendor: meta?.vendor || ne.vendor || "", + next[port.ifname] = { ifname: port.ifname, if_description: port.description, bw_bps: port.bw_bps, @@ -338,70 +343,64 @@ export function PortTrafficPage() { }); }; - const selectedNeList = useMemo(() => Object.values(selectedNes), [selectedNes]); - const pickedList = useMemo(() => Object.values(pickedPorts), [pickedPorts]); - const wallTaskOptions = wallTasksQuery.data?.items || tasksQuery.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], - ); - - useEffect(() => { - if (view !== "wall" || wallTargetId || !wallTargets.length) return; - setWallTargetId(wallTargets[0].id); - }, [view, wallTargetId, wallTargets]); - - useEffect(() => { - if (!mapBaselineTargetId) return; - if (mapBaselineTargetId === wallTargetId) { - setMapBaselineTargetId(""); - return; - } - if ( - mapBaselineTaskId && - mapBaselineTargetsQuery.isFetched && - !mapBaselineOptions.some((x) => x.id === mapBaselineTargetId) - ) { - setMapBaselineTargetId(""); - } - }, [ - mapBaselineTargetId, - wallTargetId, - mapBaselineTaskId, - mapBaselineOptions, - mapBaselineTargetsQuery.isFetched, - ]); + const pickedList = useMemo(() => Object.values(pickedIfnames), [pickedIfnames]); const submitWizard = () => { - if (!title.trim()) { - showError(t("portTraffic.titleRequired")); + if (!selectedNe) { + showError(t("portTraffic.pickNeFirst")); return; } if (!pickedList.length) { showError(t("portTraffic.portsRequired")); return; } + const source = selectedNe.source === "ume" ? "ume" : "managed"; createMut.mutate({ - title: title.trim(), + source, + ne_id: selectedNe.id, + ne_name: selectedNe.name, + ne_ip: selectedNe.ip_address, + vendor: selectedNe.vendor || "", + note: note.trim(), interval_sec: intervalSec, retention_days: retentionDays, concurrency, start_now: startNow, - targets: pickedList.map(({ key: _k, ...rest }) => rest), + interfaces: pickedList, }); }; + const saveEdit = async () => { + if (!editDeviceId) return; + try { + await updateMut.mutateAsync({ + id: editDeviceId, + body: { + note: note.trim(), + interval_sec: intervalSec, + retention_days: retentionDays, + concurrency, + }, + }); + await putIfacesMut.mutateAsync({ id: editDeviceId, ifaces: pickedList }); + setView("list"); + } catch { + /* toast already shown */ + } + }; + const dash = dashQuery.data; - const tasks = tasksQuery.data?.items || []; - const taskPages = pageCount(tasksQuery.data?.total || 0, 10); + const devices = devicesQuery.data?.items || []; + const pages = pageCount(devicesQuery.data?.total || 0, 10); + const editDevice = editDeviceSnap; + + const statusText = (status: string) => { + const tone = statusTone(status); + if (tone === "running") return t("portTraffic.statusRunning"); + if (tone === "paused") return t("portTraffic.statusPaused"); + if (tone === "stopped") return t("portTraffic.statusStopped"); + return status; + }; return (
@@ -409,185 +408,225 @@ export function PortTrafficPage() {

{t("portTraffic.title")}

{view !== "list" ? ( - ) : ( - + <> + + + )}
{view === "list" ? ( - <> -
-
-
{t("portTraffic.kpi.tasks")}
-
{dash?.task_count ?? "—"}
+
+
+
+
{t("portTraffic.kpi.devices")}
+
+ {dash?.device_count ?? dash?.task_count ?? "—"} +
-
-
{t("portTraffic.kpi.running")}
-
{dash?.running_task_count ?? "—"}
+
+
{t("portTraffic.kpi.running")}
+
+ {dash?.running_device_count ?? dash?.running_task_count ?? "—"} +
-
-
{t("portTraffic.kpi.ports")}
-
{dash?.active_target_count ?? "—"}
+
+
{t("portTraffic.kpi.ports")}
+
{dash?.active_target_count ?? "—"}
-
-
{t("portTraffic.kpi.samples24h")}
-
{dash?.sample_count_24h ?? "—"}
+
+
{t("portTraffic.kpi.samples24h")}
+
{dash?.sample_count_24h ?? "—"}
- - - - - - - - - - - - - {!tasks.length ? ( - - - - ) : ( - tasks.map((row) => ( - - - - - - - - - )) - )} - -
{t("portTraffic.col.title")}{t("portTraffic.col.status")}{t("portTraffic.col.ports")}{t("portTraffic.col.interval")}{t("portTraffic.col.last")}{t("portTraffic.col.actions")}
- {t("portTraffic.empty")} -
{row.title} - {row.status} - {row.collect_running ? " · collecting" : ""} - - {row.active_target_count}/{row.target_count} - {row.interval_sec}s{formatSystemTime(row.last_collect_ended_at) || "—"} -
- - {row.status !== "running" ? ( - - ) : ( - - )} - {row.status !== "stopped" ? ( - - ) : null} - -
-
-
- - - {taskPage}/{Math.max(1, taskPages)} - - -
- + {!devices.length ? ( +
+

{t("portTraffic.empty")}

+ +
+ ) : ( + <> +
+ + + + + + + + + + + + + {devices.map((row) => { + const tone = statusTone(row.status); + return ( + + + + + + + + + ); + })} + +
{t("portTraffic.col.device")}{t("portTraffic.col.status")}{t("portTraffic.col.ports")}{t("portTraffic.col.interval")}{t("portTraffic.col.last")}{t("portTraffic.col.actions")}
+
{deviceLabel(row)}
+
+ {row.ne_ip || row.ne_id} + {row.vendor ? ` · ${row.vendor}` : ""} + {row.note ? ` · ${row.note}` : ""} +
+ {row.last_error ? ( + + ) : null} +
+ + {statusText(row.status)} + + {row.collect_running ? ( + + {t("portTraffic.collecting")} + + ) : null} + + {row.active_target_count}/{row.target_count} + {row.interval_sec}s + {formatSystemTime(row.last_collect_ended_at) || "—"} + +
+ + + {row.status !== "running" ? ( + + ) : ( + + )} + {row.status !== "stopped" ? ( + + ) : null} + + +
+
+
+
+ + + {listPage}/{Math.max(1, pages)} + + +
+ + )} +
) : null} {view === "wizard" ? (
- {[1, 2, 3, 4].map((n) => ( + {[1, 2, 3].map((n) => ( ))}
- {step === 1 ? ( -
- - - - - -
- ) : null} - - {step === 2 ? ( + {wizardStep === 1 ? ( <> {deepLinkHint ?

{deepLinkHint}

: null}
@@ -599,7 +638,11 @@ export function PortTrafficPage() { }} placeholder={t("portTraffic.neKeywordPh")} /> - {t("portTraffic.selectedNe", { count: selectedNeList.length })} + {selectedNe ? ( + + {t("portTraffic.selectedOneNe")}: {selectedNe.name} ({selectedNe.ip_address}) + + ) : null}
@@ -613,11 +656,23 @@ export function PortTrafficPage() { {(nesQuery.data?.items || []).map((row) => { - const k = neKey(row.source, row.id); + const checked = + selectedNe?.id === row.id && + (selectedNe.source === "ume" ? "ume" : "managed") === + (row.source === "ume" ? "ume" : "managed"); return ( - + @@ -631,75 +686,114 @@ export function PortTrafficPage() { ) : null} - {step === 3 ? ( + {wizardStep === 2 ? (
- {!selectedNeList.length ?

{t("portTraffic.pickNeFirst")}

: null} - {selectedNeList.map((ne) => { - const k = neKey(ne.source, ne.id); - const bucket = portsByNe[k]; - return ( -
-
- - {ne.name} ({ne.ip_address}) - - - {bucket?.error ? {bucket.error} : null} -
- {bucket?.ports?.length ? ( -
- toggleNe(row)} /> + { + setSelectedNe(row); + setPorts([]); + setPickedIfnames({}); + }} + /> {row.source} {row.name}
- - - - - - - - - - {bucket.ports.map((p) => { - const pk = portKey(ne.source === "ume" ? "ume" : "managed", ne.id, p.ifname); - return ( - - - - - - - - ); - })} - -
- InterfaceBWAdmin/Phy/ProtDescription
- togglePort(ne, p, bucket.meta)} - /> - {p.ifname}{p.bw_raw || formatBw(p.bw_bps)} - {p.admin}/{p.phy}/{p.prot} - {p.description || "—"}
- ) : null} + {!selectedNe ?

{t("portTraffic.pickNeFirst")}

: null} + {selectedNe ? ( + <> +
+ + {selectedNe.name} ({selectedNe.ip_address}) + + + {portsError ? {portsError} : null}
- ); - })} -

{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}

+ {ports.length ? ( + + + + + + + + + + + {ports.map((p) => ( + + + + + + + + ))} + +
+ InterfaceBWAdmin/Phy/ProtDescription
+ toggleIfname(p)} + /> + {p.ifname}{p.bw_raw || formatBw(p.bw_bps)} + {p.admin}/{p.phy}/{p.prot} + {p.description || "—"}
+ ) : null} +

{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}

+ + ) : null}
) : null} - {step === 4 ? ( + {wizardStep === 3 ? (
+
+ + + + + +

- {title || "—"} · {intervalSec}s · {retentionDays}d · ×{concurrency} + + {selectedNe?.name || "—"} ({selectedNe?.ip_address || "—"}) +

{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}

    {pickedList.slice(0, 40).map((p) => ( -
  • - {p.ne_name} / {p.ifname} ({formatBw(p.bw_bps || 0)}) +
  • + {p.ifname} ({formatBw(p.bw_bps || 0)})
  • ))} {pickedList.length > 40 ?
  • … +{pickedList.length - 40}
  • : null} @@ -711,165 +805,190 @@ export function PortTrafficPage() { ) : null}
    - -
) : null} - {view === "wall" ? ( -
-
- + + + +
+
+ + {t("portTraffic.selectedPorts", { count: String(pickedList.length) })} +
+ {portsError ?

{portsError}

: null} + {ports.length ? ( + + + + + + + + + + {ports.map((p) => ( + + + + + + ))} - - - - - - {baseline === "custom" ? ( - - ) : null} - - + +
+ InterfaceBWDescription
+ toggleIfname(p)} + /> + {p.ifname}{p.bw_raw || formatBw(p.bw_bps)}{p.description || "—"}
+ ) : ( +
    + {pickedList.map((p) => ( +
  • {p.ifname}
  • + ))} +
+ )} +
- {baseline === "week" ? ( -

- {t("portTraffic.retentionHint")} -

- ) : null} - {selectedWallTarget?.last_error ? ( -

- {t("portTraffic.targetError")}: {selectedWallTarget.last_error} -

- ) : null} - +
+ ) : null} + + {logDeviceId ? ( +
{ + setLogDeviceId(""); + setLogDeviceLabel(""); + }} + > +
e.stopPropagation()} + > +
+
+

{t("portTraffic.logTitle")}

+

+ {t("portTraffic.logDevice")}: {logDeviceLabel || "—"} +

+
+ +
+ {logQuery.isLoading ? ( +

…

+ ) : !(logQuery.data?.items || []).length ? ( +

{t("portTraffic.logEmpty")}

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

{t("portTraffic.wallEmpty")}

+ + {t("portTraffic.create")} + +
+ ) : ( +
+
+
+ + + + +
+ +
+
+ {t("portTraffic.filterView")} +
+ + + {baseline === "custom" ? ( + + ) : null} + +
+
+ +
+ +
+ {t("portTraffic.filterMap")} +
+ + +
+
+
+
+ + {baseline === "week" ? ( +

{t("portTraffic.retentionHint")}

+ ) : null} + +
+ +
+
+ )} +
+ ); +} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index d901728..6b72445 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -31,13 +31,15 @@ import type { NeConfigSnapshotMeta, PortTrafficCompare, PortTrafficDashboard, + PortTrafficDevice, PortTrafficDiscoverResponse, + PortTrafficEvent, + PortTrafficIfaceIn, PortTrafficSamples, PortTrafficTarget, - PortTrafficTargetIn, - PortTrafficTask, } from "../types"; + export const AUTH_TOKEN_KEY = "netx_access_token"; export const getAuthToken = (): string | null => { @@ -797,45 +799,77 @@ export const downloadNeConfigSnapshot = async ( export const fetchPortTrafficDashboard = () => apiGet("/v1/port-traffic/dashboard"); -export const fetchPortTrafficTasks = (params: { page?: number; pageSize?: number }) => { +export const fetchPortTrafficDevices = (params: { page?: number; pageSize?: number }) => { const p = new URLSearchParams(); p.set("page", String(Math.max(1, Number(params.page || 1)))); p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20))))); - return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficTask[] }>( - `/v1/port-traffic/tasks?${p.toString()}`, + return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>( + `/v1/port-traffic/devices?${p.toString()}`, ); }; -export const createPortTrafficTask = (body: { - title: string; +/** @deprecated */ +export const fetchPortTrafficTasks = fetchPortTrafficDevices; + +export const createPortTrafficDevice = (body: { + source: "managed" | "ume"; + ne_id: string; + ne_name?: string; + ne_ip?: string; + vendor?: string; + note?: string; interval_sec?: number; retention_days?: number; concurrency?: number; - targets?: PortTrafficTargetIn[]; + interfaces?: PortTrafficIfaceIn[]; start_now?: boolean; -}) => apiPost("/v1/port-traffic/tasks", body); +}) => apiPost("/v1/port-traffic/devices", body); -export const startPortTrafficTask = (taskId: string) => - apiPost(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/start`, {}); +export const updatePortTrafficDevice = ( + deviceId: string, + body: { + note?: string; + interval_sec?: number; + retention_days?: number; + concurrency?: number; + ne_name?: string; + ne_ip?: string; + vendor?: string; + }, +) => apiPatch(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`, body); -export const pausePortTrafficTask = (taskId: string) => - apiPost(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/pause`, {}); +export const startPortTrafficDevice = (deviceId: string) => + apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/start`, {}); -export const stopPortTrafficTask = (taskId: string) => - apiPost(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/stop`, {}); +export const pausePortTrafficDevice = (deviceId: string) => + apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/pause`, {}); -export const collectPortTrafficNow = (taskId: string) => - apiPost( - `/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/collect-now`, +export const stopPortTrafficDevice = (deviceId: string) => + apiPost(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/stop`, {}); + +export const collectPortTrafficNow = (deviceId: string) => + apiPost( + `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/collect-now`, {}, ); -export const deletePortTrafficTask = (taskId: string) => - apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}`); +export const deletePortTrafficDevice = (deviceId: string) => + apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`); -export const fetchPortTrafficTargets = (taskId: string) => +export const fetchPortTrafficTargets = (deviceId: string) => apiGet<{ items: PortTrafficTarget[] }>( - `/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/targets`, + `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/targets`, + ); + +export const fetchPortTrafficEvents = (deviceId: string, limit = 100) => + apiGet<{ items: PortTrafficEvent[]; total: number }>( + `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/events?limit=${limit}`, + ); + +export const putPortTrafficInterfaces = (deviceId: string, interfaces: PortTrafficIfaceIn[]) => + apiPut<{ items: PortTrafficTarget[] }>( + `/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/interfaces`, + { interfaces }, ); export const discoverPortTrafficPorts = (body: { source: "managed" | "ume"; id: string }) => diff --git a/web/src/types.ts b/web/src/types.ts index b58a8a5..6701b81 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -533,9 +533,14 @@ export type NeConfigSnapshotDetail = NeConfigSnapshotMeta & { config_alt_text: string; }; -export type PortTrafficTask = { +export type PortTrafficDevice = { id: string; - title: string; + source: string; + ne_id: string; + ne_name: string; + ne_ip: string; + vendor: string; + note: string; status: string; interval_sec: number; retention_days: number; @@ -550,9 +555,13 @@ export type PortTrafficTask = { updated_at?: string | null; }; +/** @deprecated use PortTrafficDevice */ +export type PortTrafficTask = PortTrafficDevice; + export type PortTrafficTarget = { id: string; - task_id: string; + device_id: string; + task_id?: string; series_id?: string; source: string; target_id: string; @@ -568,9 +577,20 @@ export type PortTrafficTarget = { created_at?: string | null; }; +export type PortTrafficEvent = { + id: string; + device_id: string; + target_row_id: string; + ifname: string; + level: string; + message: string; + created_at?: string | null; +}; + export type PortTrafficSeries = { id: string; - task_id: string; + device_id: string; + task_id?: string; title: string; status: string; active_target: PortTrafficTarget | null; @@ -578,17 +598,20 @@ export type PortTrafficSeries = { created_at?: string | null; }; -export type PortTrafficTargetIn = { - source: "managed" | "ume"; - target_id: string; - ne_name?: string; - ne_ip?: string; - vendor?: string; +export type PortTrafficIfaceIn = { ifname: string; if_description?: string; bw_bps?: number; }; +export type PortTrafficTargetIn = PortTrafficIfaceIn & { + source?: "managed" | "ume"; + target_id?: string; + ne_name?: string; + ne_ip?: string; + vendor?: string; +}; + export type PortTrafficDiscoverPort = { ifname: string; attribute: string; @@ -642,10 +665,12 @@ export type PortTrafficCompare = { }; export type PortTrafficDashboard = { - task_count: number; - running_task_count: number; + device_count: number; + running_device_count: number; active_target_count: number; sample_count_24h: number; last_sample_at?: string | null; + task_count?: number; + running_task_count?: number; };