From 00d3e95e2d50390d583af1d54236244b616283b8 Mon Sep 17 00:00:00 2001 From: oliver Date: Sat, 1 Aug 2026 17:03:11 +0800 Subject: [PATCH] Add named multi-panel traffic boards with dedicated wall tabs. Persist board/panel layouts server-side, open each board outside the network shell so workbench reloads do not refresh walls, and fix mapped-baseline chart overlay plus fullscreen fit. Co-authored-by: Cursor --- netx_api/models.py | 36 + netx_api/port_traffic_board_service.py | 236 +++++ netx_api/port_traffic_migrate.py | 48 + netx_api/port_traffic_router.py | 107 +++ netx_api/port_traffic_schemas.py | 67 ++ netx_api/port_traffic_service.py | 3 + web/WEB.md | 7 +- web/src/App.tsx | 14 +- web/src/config/modules.ts | 16 +- web/src/constants/queryKeys.ts | 2 + web/src/i18n/en.ts | 33 + web/src/i18n/zh.ts | 33 + web/src/index.css | 285 +++++- web/src/pages/WorkbenchPage.tsx | 1 + .../network/PortTrafficBoardListPage.tsx | 145 +++ .../network/PortTrafficBoardPanelCell.tsx | 132 +++ web/src/pages/network/PortTrafficPage.tsx | 6 +- web/src/pages/network/PortTrafficWall.tsx | 132 +-- web/src/pages/network/PortTrafficWallPage.tsx | 867 ++++++++++++------ web/src/services/api.ts | 31 + web/src/types.ts | 50 + web/src/utils/portTrafficBoardWindow.ts | 17 + 22 files changed, 1923 insertions(+), 345 deletions(-) create mode 100644 netx_api/port_traffic_board_service.py create mode 100644 web/src/pages/network/PortTrafficBoardListPage.tsx create mode 100644 web/src/pages/network/PortTrafficBoardPanelCell.tsx create mode 100644 web/src/utils/portTrafficBoardWindow.ts diff --git a/netx_api/models.py b/netx_api/models.py index d2fb6b0..0839887 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -709,3 +709,39 @@ class PortTrafficEvent(Base): 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) + + +class PortTrafficBoard(Base): + """Named multi-panel traffic ops wall (cutover / duty view).""" + + __tablename__ = "port_traffic_board" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + name: Mapped[str] = mapped_column(String(256), default="", index=True) + remark: Mapped[str] = mapped_column(String(1024), default="") + cols: Mapped[int] = mapped_column(Integer, default=2) + created_by: Mapped[str] = mapped_column(String(64), default="") + updated_by: Mapped[str] = mapped_column(String(64), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + +class PortTrafficPanel(Base): + """One chart cell on a traffic board.""" + + __tablename__ = "port_traffic_panel" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + board_id: Mapped[str] = mapped_column(String(64), default="", index=True) + title: Mapped[str] = mapped_column(String(256), default="") + target_id: Mapped[str] = mapped_column(String(64), default="", index=True) + range_hours: Mapped[int] = mapped_column(Integer, default=24) + baseline: Mapped[str] = mapped_column(String(16), default="off") # off|day|week|shift|custom + offset_hours: Mapped[int] = mapped_column(Integer, default=0) + baseline_target_id: Mapped[str] = mapped_column(String(64), default="") + y_mode: Mapped[str] = mapped_column(String(16), default="auto") # auto|current|util + ord: Mapped[int] = mapped_column(Integer, default=0) + col_span: Mapped[int] = mapped_column(Integer, default=1) + row_span: Mapped[int] = mapped_column(Integer, default=1) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/netx_api/port_traffic_board_service.py b/netx_api/port_traffic_board_service.py new file mode 100644 index 0000000..4d961d4 --- /dev/null +++ b/netx_api/port_traffic_board_service.py @@ -0,0 +1,236 @@ +"""Named traffic wall boards (multi-panel ops views).""" + +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from fastapi import HTTPException +from sqlalchemy import func +from sqlalchemy.orm import Session + +from .models import PortTrafficBoard, PortTrafficPanel, PortTrafficTarget +from .port_traffic_schemas import ( + PortTrafficBoardCreate, + PortTrafficBoardOut, + PortTrafficBoardPanelsPut, + PortTrafficBoardSummaryOut, + PortTrafficBoardUpdate, + PortTrafficPanelIn, + PortTrafficPanelOut, +) +from .port_traffic_service import _target_out, _utcnow + + +def _panel_out(db: Session, row: PortTrafficPanel) -> PortTrafficPanelOut: + target = db.get(PortTrafficTarget, str(row.target_id or "")) if row.target_id else None + baseline = ( + db.get(PortTrafficTarget, str(row.baseline_target_id or "")) + if row.baseline_target_id + else None + ) + stale = not target or str(target.status or "") != "active" + return PortTrafficPanelOut( + id=str(row.id), + board_id=str(row.board_id or ""), + title=str(row.title or ""), + target_id=str(row.target_id or ""), + range_hours=int(row.range_hours or 24), + baseline=str(row.baseline or "off"), + offset_hours=int(row.offset_hours or 0), + baseline_target_id=str(row.baseline_target_id or ""), + y_mode=str(row.y_mode or "auto"), + ord=int(row.ord or 0), + col_span=max(1, int(row.col_span or 1)), + row_span=max(1, int(row.row_span or 1)), + stale=stale, + target=_target_out(target) if target else None, + baseline_target=_target_out(baseline) if baseline else None, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def _board_summary(db: Session, board: PortTrafficBoard) -> PortTrafficBoardSummaryOut: + count = ( + db.query(func.count(PortTrafficPanel.id)) + .filter(PortTrafficPanel.board_id == board.id) + .scalar() + or 0 + ) + return PortTrafficBoardSummaryOut( + id=str(board.id), + name=str(board.name or ""), + remark=str(board.remark or ""), + cols=max(1, min(4, int(board.cols or 2))), + panel_count=int(count), + created_by=str(board.created_by or ""), + updated_by=str(board.updated_by or ""), + created_at=board.created_at, + updated_at=board.updated_at, + ) + + +def _board_out(db: Session, board: PortTrafficBoard) -> PortTrafficBoardOut: + panels = ( + db.query(PortTrafficPanel) + .filter(PortTrafficPanel.board_id == board.id) + .order_by(PortTrafficPanel.ord, PortTrafficPanel.created_at) + .all() + ) + summary = _board_summary(db, board) + return PortTrafficBoardOut( + **summary.model_dump(), + panels=[_panel_out(db, p) for p in panels], + ) + + +def _validate_panel_target(db: Session, target_id: str, *, allow_missing: bool = False) -> None: + tid = str(target_id or "").strip() + if not tid: + raise HTTPException(status_code=400, detail="target_id_required") + row = db.get(PortTrafficTarget, tid) + if not row: + if allow_missing: + return + raise HTTPException(status_code=400, detail="target_not_found") + + +def _apply_panels( + db: Session, + board: PortTrafficBoard, + panels: list[PortTrafficPanelIn], +) -> None: + cols = max(1, min(4, int(board.cols or 2))) + db.query(PortTrafficPanel).filter(PortTrafficPanel.board_id == board.id).delete( + synchronize_session=False + ) + now = _utcnow() + for i, item in enumerate(panels): + _validate_panel_target(db, item.target_id) + if item.baseline_target_id: + _validate_panel_target(db, item.baseline_target_id, allow_missing=True) + pid = str(item.id or "").strip() or uuid4().hex + db.add( + PortTrafficPanel( + id=pid, + board_id=str(board.id), + title=str(item.title or "")[:256], + target_id=str(item.target_id).strip(), + range_hours=int(item.range_hours), + baseline=str(item.baseline or "off"), + offset_hours=int(item.offset_hours or 0), + baseline_target_id=str(item.baseline_target_id or "").strip(), + y_mode=str(item.y_mode or "auto"), + ord=int(item.ord if item.ord is not None else i), + col_span=max(1, min(cols, int(item.col_span or 1))), + row_span=max(1, min(4, int(item.row_span or 1))), + created_at=now, + updated_at=now, + ) + ) + + +def list_boards(db: Session) -> list[PortTrafficBoardSummaryOut]: + rows = db.query(PortTrafficBoard).order_by(PortTrafficBoard.updated_at.desc()).all() + return [_board_summary(db, r) for r in rows] + + +def get_board(db: Session, board_id: str) -> PortTrafficBoardOut: + board = db.get(PortTrafficBoard, board_id) + if not board: + raise HTTPException(status_code=404, detail="board_not_found") + return _board_out(db, board) + + +def create_board( + db: Session, + body: PortTrafficBoardCreate, + *, + actor_user_id: str = "", +) -> PortTrafficBoardOut: + now = _utcnow() + board = PortTrafficBoard( + id=uuid4().hex, + name=str(body.name).strip()[:256], + remark=str(body.remark or "")[:1024], + cols=max(1, min(4, int(body.cols or 2))), + created_by=str(actor_user_id or ""), + updated_by=str(actor_user_id or ""), + created_at=now, + updated_at=now, + ) + db.add(board) + db.flush() + if body.panels: + _apply_panels(db, board, body.panels) + db.commit() + db.refresh(board) + return _board_out(db, board) + + +def update_board( + db: Session, + board_id: str, + body: PortTrafficBoardUpdate, + *, + actor_user_id: str = "", +) -> PortTrafficBoardOut: + board = db.get(PortTrafficBoard, board_id) + if not board: + raise HTTPException(status_code=404, detail="board_not_found") + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + board.name = str(data["name"]).strip()[:256] + if "remark" in data and data["remark"] is not None: + board.remark = str(data["remark"])[:1024] + if "cols" in data and data["cols"] is not None: + board.cols = max(1, min(4, int(data["cols"]))) + board.updated_by = str(actor_user_id or "") + board.updated_at = _utcnow() + db.commit() + db.refresh(board) + return _board_out(db, board) + + +def put_board_panels( + db: Session, + board_id: str, + body: PortTrafficBoardPanelsPut, + *, + actor_user_id: str = "", +) -> PortTrafficBoardOut: + board = db.get(PortTrafficBoard, board_id) + if not board: + raise HTTPException(status_code=404, detail="board_not_found") + _apply_panels(db, board, body.panels or []) + board.updated_by = str(actor_user_id or "") + board.updated_at = _utcnow() + db.commit() + db.refresh(board) + return _board_out(db, board) + + +def delete_board(db: Session, board_id: str) -> dict[str, Any]: + board = db.get(PortTrafficBoard, board_id) + if not board: + raise HTTPException(status_code=404, detail="board_not_found") + db.query(PortTrafficPanel).filter(PortTrafficPanel.board_id == board_id).delete( + synchronize_session=False + ) + db.delete(board) + db.commit() + return {"ok": True, "id": board_id} + + +def delete_panels_for_targets(db: Session, target_ids: list[str]) -> None: + ids = [str(x) for x in target_ids if x] + if not ids: + return + db.query(PortTrafficPanel).filter(PortTrafficPanel.target_id.in_(ids)).delete( + synchronize_session=False + ) + db.query(PortTrafficPanel).filter(PortTrafficPanel.baseline_target_id.in_(ids)).update( + {PortTrafficPanel.baseline_target_id: ""}, + synchronize_session=False, + ) diff --git a/netx_api/port_traffic_migrate.py b/netx_api/port_traffic_migrate.py index 20b612d..e500030 100644 --- a/netx_api/port_traffic_migrate.py +++ b/netx_api/port_traffic_migrate.py @@ -123,6 +123,54 @@ def ensure_port_traffic_series_schema(conn) -> None: "CREATE INDEX IF NOT EXISTS ix_port_traffic_event_level ON port_traffic_event (level)" ) + conn.exec_driver_sql( + """ + CREATE TABLE IF NOT EXISTS port_traffic_board ( + id VARCHAR(64) PRIMARY KEY, + name VARCHAR(256) DEFAULT '', + remark VARCHAR(1024) DEFAULT '', + cols INTEGER DEFAULT 2, + created_by VARCHAR(64) DEFAULT '', + updated_by VARCHAR(64) DEFAULT '', + created_at TIMESTAMP, + updated_at TIMESTAMP + ) + """ + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_port_traffic_board_name ON port_traffic_board (name)" + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_port_traffic_board_updated_at ON port_traffic_board (updated_at)" + ) + + conn.exec_driver_sql( + """ + CREATE TABLE IF NOT EXISTS port_traffic_panel ( + id VARCHAR(64) PRIMARY KEY, + board_id VARCHAR(64) DEFAULT '', + title VARCHAR(256) DEFAULT '', + target_id VARCHAR(64) DEFAULT '', + range_hours INTEGER DEFAULT 24, + baseline VARCHAR(16) DEFAULT 'off', + offset_hours INTEGER DEFAULT 0, + baseline_target_id VARCHAR(64) DEFAULT '', + y_mode VARCHAR(16) DEFAULT 'auto', + ord INTEGER DEFAULT 0, + col_span INTEGER DEFAULT 1, + row_span INTEGER DEFAULT 1, + created_at TIMESTAMP, + updated_at TIMESTAMP + ) + """ + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_port_traffic_panel_board_id ON port_traffic_panel (board_id)" + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS ix_port_traffic_panel_target_id ON port_traffic_panel (target_id)" + ) + def migrate_tasks_to_devices(db: Session) -> int: """Collapse legacy port_traffic_task rows into per-NE devices; reassign targets/series.""" diff --git a/netx_api/port_traffic_router.py b/netx_api/port_traffic_router.py index 90fc096..7ea640f 100644 --- a/netx_api/port_traffic_router.py +++ b/netx_api/port_traffic_router.py @@ -12,11 +12,22 @@ from .db import get_db from .models import PortTrafficDevice from .port_traffic_schemas import ( DiscoverPortsRequest, + PortTrafficBoardCreate, + PortTrafficBoardPanelsPut, + PortTrafficBoardUpdate, PortTrafficDeviceCreate, PortTrafficDeviceUpdate, PortTrafficInterfacesPut, PortTrafficReplacePortRequest, ) +from .port_traffic_board_service import ( + create_board, + delete_board, + get_board, + list_boards, + put_board_panels, + update_board, +) from .port_traffic_service import ( compare_targets, create_device, @@ -50,6 +61,102 @@ def api_dashboard(db: Session = Depends(get_db)): return dashboard(db).model_dump() +@router.get("/boards") +def api_list_boards(db: Session = Depends(get_db)): + return {"items": [b.model_dump() for b in list_boards(db)]} + + +@router.post("/boards") +def api_create_board( + body: PortTrafficBoardCreate, + request: Request, + db: Session = Depends(get_db), +): + uid, uname = _actor(request) + out = create_board(db, body, actor_user_id=uid) + write_audit( + db, + action="port_traffic.board.create", + actor_user_id=uid, + actor_username=uname, + method="POST", + path="/v1/port-traffic/boards", + status_code=200, + detail={"id": out.id, "name": out.name}, + ) + return out.model_dump() + + +@router.get("/boards/{board_id}") +def api_get_board(board_id: str, db: Session = Depends(get_db)): + return get_board(db, board_id).model_dump() + + +@router.patch("/boards/{board_id}") +def api_update_board( + board_id: str, + body: PortTrafficBoardUpdate, + request: Request, + db: Session = Depends(get_db), +): + uid, uname = _actor(request) + out = update_board(db, board_id, body, actor_user_id=uid) + write_audit( + db, + action="port_traffic.board.update", + actor_user_id=uid, + actor_username=uname, + method="PATCH", + path=f"/v1/port-traffic/boards/{board_id}", + status_code=200, + detail={"id": board_id}, + ) + return out.model_dump() + + +@router.put("/boards/{board_id}/panels") +def api_put_board_panels( + board_id: str, + body: PortTrafficBoardPanelsPut, + request: Request, + db: Session = Depends(get_db), +): + uid, uname = _actor(request) + out = put_board_panels(db, board_id, body, actor_user_id=uid) + write_audit( + db, + action="port_traffic.board.panels_put", + actor_user_id=uid, + actor_username=uname, + method="PUT", + path=f"/v1/port-traffic/boards/{board_id}/panels", + status_code=200, + detail={"id": board_id, "count": len(out.panels)}, + ) + return out.model_dump() + + +@router.delete("/boards/{board_id}") +def api_delete_board( + board_id: str, + request: Request, + db: Session = Depends(get_db), +): + uid, uname = _actor(request) + out = delete_board(db, board_id) + write_audit( + db, + action="port_traffic.board.delete", + actor_user_id=uid, + actor_username=uname, + method="DELETE", + path=f"/v1/port-traffic/boards/{board_id}", + status_code=200, + detail={"id": board_id}, + ) + return out + + @router.get("/devices") def api_list_devices( page: int = Query(default=1, ge=1), diff --git a/netx_api/port_traffic_schemas.py b/netx_api/port_traffic_schemas.py index ae3f321..099685e 100644 --- a/netx_api/port_traffic_schemas.py +++ b/netx_api/port_traffic_schemas.py @@ -199,3 +199,70 @@ class PortTrafficEventOut(BaseModel): class PortTrafficEventsOut(BaseModel): items: list[PortTrafficEventOut] = Field(default_factory=list) total: int = 0 + + +class PortTrafficPanelIn(BaseModel): + id: str | None = None + title: str = "" + target_id: str = Field(min_length=1, max_length=64) + range_hours: int = Field(default=24, ge=1, le=24 * 90) + baseline: Literal["off", "day", "week", "shift", "custom"] = "off" + offset_hours: int = Field(default=0, ge=0, le=24 * 90) + baseline_target_id: str = "" + y_mode: Literal["auto", "current", "util"] = "auto" + ord: int = 0 + col_span: int = Field(default=1, ge=1, le=4) + row_span: int = Field(default=1, ge=1, le=4) + + +class PortTrafficPanelOut(BaseModel): + id: str + board_id: str + title: str = "" + target_id: str + range_hours: int = 24 + baseline: str = "off" + offset_hours: int = 0 + baseline_target_id: str = "" + y_mode: str = "auto" + ord: int = 0 + col_span: int = 1 + row_span: int = 1 + stale: bool = False + target: PortTrafficTargetOut | None = None + baseline_target: PortTrafficTargetOut | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class PortTrafficBoardCreate(BaseModel): + name: str = Field(min_length=1, max_length=256) + remark: str = Field(default="", max_length=1024) + cols: int = Field(default=2, ge=1, le=4) + panels: list[PortTrafficPanelIn] = Field(default_factory=list) + + +class PortTrafficBoardUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=256) + remark: str | None = Field(default=None, max_length=1024) + cols: int | None = Field(default=None, ge=1, le=4) + + +class PortTrafficBoardSummaryOut(BaseModel): + id: str + name: str + remark: str = "" + cols: int = 2 + panel_count: int = 0 + created_by: str = "" + updated_by: str = "" + created_at: datetime | None = None + updated_at: datetime | None = None + + +class PortTrafficBoardOut(PortTrafficBoardSummaryOut): + panels: list[PortTrafficPanelOut] = Field(default_factory=list) + + +class PortTrafficBoardPanelsPut(BaseModel): + panels: list[PortTrafficPanelIn] = Field(default_factory=list) diff --git a/netx_api/port_traffic_service.py b/netx_api/port_traffic_service.py index 2ff643d..21b20aa 100644 --- a/netx_api/port_traffic_service.py +++ b/netx_api/port_traffic_service.py @@ -267,6 +267,9 @@ def delete_device(db: Session, device_id: str) -> dict[str, Any]: ids = [str(t.id) for t in targets] series_ids = [str(t.series_id) for t in targets if t.series_id] if ids: + from .port_traffic_board_service import delete_panels_for_targets + + delete_panels_for_targets(db, ids) db.query(PortTrafficSample).filter(PortTrafficSample.target_row_id.in_(ids)).delete( synchronize_session=False ) diff --git a/web/WEB.md b/web/WEB.md index d6511f2..8444020 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -43,7 +43,8 @@ src/ | `/network/tasks/collect` | 采集任务 | `network` | | `/network/tasks/config-sync` | 配置同步 | `network` | | `/network/tasks/port-traffic` | 端口流量监控(设备管理) | `network` | -| `/network/tasks/port-traffic/wall` | 端口流量大屏 | `network` | +| `/network/tasks/port-traffic/wall` | 流量大屏列表(打开独立页签) | `network` | +| `/port-traffic/wall/:boardId` | 流量大屏专有页签(无网络侧栏) | `port-traffic-wall` | | `/topology` | 拓扑管理(模式化编辑器:选择/平移/拖动/连线、框选、自动布局、拖放添加) | `topology` | | `/webcrt` | WebCRT 终端 | `webcrt` | | `/collect` | redirect → `/network/tasks/collect` | — | @@ -65,6 +66,7 @@ src/ - **工作台 → 模块**:`openOrFocusModule`(同 `moduleId` 仅一个标签,已打开则聚焦) - **模块 → 工作台**:顶栏四格图标,`returnToWorkbench`(聚焦原工作台标签;已关闭则新开) +- **流量大屏**:列表在网络管理内;打开单板用 `openNewModuleWindow`(`/port-traffic/wall/:boardId`),与 `network` 单例页签隔离 - 实现:`utils/tabChannel.ts` + 命名窗口 `netx-module-{id}` / `netx-workbench` ## i18n @@ -116,7 +118,8 @@ src/ - 厂商: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=`) +- 前端:`/network/tasks/port-traffic`(设备列表 / 向导 / 编辑 / 采集日志);`/network/tasks/port-traffic/wall`(大屏列表);`/port-traffic/wall/:boardId`(独立大屏页签,`openNewModuleWindow`,与网络管理页签互不影响) +- 定制大屏:`/v1/port-traffic/boards*`(Board + Panel);整板 PUT panels 保存;刷新/换页不丢;图数据仍走 compare - 采集日志:`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 37e1782..a0adbcf 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 { PortTrafficBoardListPage } from "./pages/network/PortTrafficBoardListPage"; import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage"; import { LoginPage } from "./pages/LoginPage"; import { UsersPage } from "./pages/UsersPage"; @@ -29,6 +30,16 @@ function NetworkWebcrtRedirect() { return ; } +/** Legacy `?board_id=` under network wall → dedicated board tab route. */ +function LegacyPortTrafficWallRedirect() { + const { search } = useLocation(); + const boardId = new URLSearchParams(search).get("board_id")?.trim() || ""; + if (boardId) { + return ; + } + return ; +} + function ProtectedApp() { const { ready, user } = useAuth(); const integrationsQuery = useQuery({ @@ -87,6 +98,7 @@ function ProtectedApp() { } /> } /> } /> + } /> }> } /> } /> @@ -96,7 +108,7 @@ function ProtectedApp() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/web/src/config/modules.ts b/web/src/config/modules.ts index 3509c96..bca63fd 100644 --- a/web/src/config/modules.ts +++ b/web/src/config/modules.ts @@ -14,6 +14,8 @@ export type ModuleDefinition = { iconTone: ModuleIconTone; titleKey: string; adminOnly?: boolean; + /** Hide from workbench launcher; still used for module window registration. */ + workbenchHidden?: boolean; }; export const MODULES: readonly ModuleDefinition[] = [ @@ -62,6 +64,16 @@ export const MODULES: readonly ModuleDefinition[] = [ iconTone: "slate", titleKey: "layout.titleWebcrt", }, + { + moduleId: "port-traffic-wall", + path: "/port-traffic/wall", + section: "operations", + labelKey: "workbench.cards.portTrafficWall", + descKey: "workbench.cards.portTrafficWallDesc", + iconTone: "amber", + titleKey: "layout.titlePortTrafficWall", + workbenchHidden: true, + }, { moduleId: "users", path: "/users", @@ -97,7 +109,9 @@ export function getModuleById(moduleId: string): ModuleDefinition | undefined { } export function moduleIdFromPath(pathname: string): string | null { - for (const m of MODULES) { + // Longer paths first so /port-traffic/wall wins over shorter prefixes if added later. + const ordered = [...MODULES].sort((a, b) => b.path.length - a.path.length); + for (const m of ordered) { if (pathname === m.path || pathname.startsWith(`${m.path}/`)) return m.moduleId; } return null; diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index d8b5825..cdba5d5 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -64,6 +64,8 @@ export const queryKeys = { portTrafficDevices: (page: number) => ["portTrafficDevices", page] as const, portTrafficTargets: (deviceId: string) => ["portTrafficTargets", deviceId] as const, portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const, + portTrafficBoards: ["portTrafficBoards"] as const, + portTrafficBoard: (boardId: string) => ["portTrafficBoard", boardId] 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 3d647c6..fd0e087 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -33,6 +33,8 @@ const en = { webcrtDesc: "Interactive browser login to connected NEs (SSH/Telnet)", topology: "Topology", topologyDesc: "Drag NE topology maps; discover links via LLDP/CDP", + portTrafficWall: "Traffic wall", + portTrafficWallDesc: "Named traffic boards in dedicated tabs", users: "Users", usersDesc: "Admin: create, disable users and reset passwords", audit: "Audit", @@ -87,6 +89,36 @@ const en = { wallEmpty: "No monitored devices yet. Add a device and pick interfaces first.", fullscreen: "Fullscreen", exitFullscreen: "Exit fullscreen", + board: "Board", + boardPick: "Select board", + boardPickHint: "Select or create a traffic wall board.", + boardEmpty: "No boards yet. Create one for cutover or duty monitoring.", + boardDefaultName: "Untitled board", + boardName: "Name", + boardCols: "Columns", + boardCreate: "New board", + boardEdit: "Edit layout", + boardSave: "Save", + boardCancel: "Cancel", + boardSaved: "Board saved", + boardCreated: "Board created", + boardDeleted: "Board deleted", + boardConfirmDelete: "Delete this board and its panels?", + boardUnsavedLeave: "You have unsaved changes. Leave anyway?", + boardOpenTab: "Open in new tab", + boardOpen: "Open wall", + boardListHint: "Open a board in its own browser tab; launching Network from the workbench will not refresh open walls.", + boardPanelCount: "Panels", + boardUpdated: "Updated", + boardClosedHint: "You can close this tab, or reopen from Network → Traffic wall list.", + boardAddPanel: "Add panel", + boardNoPanels: "No panels yet. Edit the board and add interfaces.", + boardPickDevice: "Select device", + boardPanelSettings: "Panel settings", + boardPanelTitle: "Title (optional)", + boardColSpan: "Column span", + boardPanelStale: "Interface missing or inactive. Please reselect.", + boardRetarget: "Reselect port", edit: "Edit", save: "Save", start: "Start", @@ -286,6 +318,7 @@ const en = { titleCollect: "Batch Collect", titleWebcrt: "WebCRT", titleTopology: "Topology", + titlePortTrafficWall: "Traffic wall", titleUsers: "Users", titleAudit: "Audit", titleApiKeys: "API Keys", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index b3afaad..4ab5e6b 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -33,6 +33,8 @@ const zh = { webcrtDesc: "浏览器内交互登录已连通网元(SSH/Telnet)", topology: "拓扑管理", topologyDesc: "拖拽编排网元拓扑,支持 LLDP/CDP 发现链路", + portTrafficWall: "流量大屏", + portTrafficWallDesc: "独立页签打开命名流量大屏", users: "用户管理", usersDesc: "管理员添加、禁用用户并重置密码", audit: "操作审计", @@ -87,6 +89,36 @@ const zh = { wallEmpty: "暂无监控设备。请先添加设备并勾选接口。", fullscreen: "全屏", exitFullscreen: "退出全屏", + board: "大屏", + boardPick: "选择大屏", + boardPickHint: "请选择或新建一张流量大屏。", + boardEmpty: "还没有大屏。可新建一张用于割接/值班监控。", + boardDefaultName: "未命名大屏", + boardName: "名称", + boardCols: "列数", + boardCreate: "新建大屏", + boardEdit: "编辑布局", + boardSave: "保存", + boardCancel: "取消", + boardSaved: "大屏已保存", + boardCreated: "大屏已创建", + boardDeleted: "大屏已删除", + boardConfirmDelete: "删除该大屏及其图格配置?", + boardUnsavedLeave: "有未保存的修改,确定离开?", + boardOpenTab: "新页签打开", + boardOpen: "打开大屏", + boardListHint: "点击条目在独立页签打开大屏;从工作台打开网络管理不会刷新已打开的大屏。", + boardPanelCount: "图格数", + boardUpdated: "更新时间", + boardClosedHint: "可关闭本页签;也可回到网络管理 → 流量大屏列表重新打开。", + boardAddPanel: "添加图格", + boardNoPanels: "该大屏还没有图格,请编辑并添加监控接口。", + boardPickDevice: "选择设备", + boardPanelSettings: "图格设置", + boardPanelTitle: "标题(可选)", + boardColSpan: "跨列", + boardPanelStale: "接口已失效或不存在,请重新选择。", + boardRetarget: "重选接口", edit: "编辑", save: "保存", start: "启动", @@ -286,6 +318,7 @@ const zh = { titleCollect: "批量采集", titleWebcrt: "WebCRT", titleTopology: "拓扑管理", + titlePortTrafficWall: "流量大屏", titleUsers: "用户管理", titleAudit: "操作审计", titleApiKeys: "API Key", diff --git a/web/src/index.css b/web/src/index.css index d9990b8..898963b 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -3701,6 +3701,14 @@ pre { overflow: hidden; } +.app-main:has(> .pt-wall-shell) { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; +} + .network-main .topo-page, .network-main .topo-page.is-sidebar-collapsed { height: 100%; @@ -3715,12 +3723,25 @@ pre { border: 0; } -.network-main .pt-wall-shell { +.network-main .pt-wall-shell, +.app-main > .pt-wall-shell { + flex: 1; height: 100%; margin: 0; min-height: 0; } +.pt-board-toolbar__title { + font-size: 15px; + font-weight: 600; + color: #e2e8f0; + line-height: 30px; + max-width: min(420px, 50vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .network-placeholder h2 { margin: 0 0 8px; } @@ -4061,6 +4082,137 @@ pre { linear-gradient(180deg, #0a1220 0%, #070d18 100%); } +.pt-board-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 10px 16px; + flex: 0 0 auto; + padding: 10px 14px; + border-bottom: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(15, 23, 42, 0.88); +} + +.pt-board-toolbar__left { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px 12px; + min-width: 0; +} + +.pt-board-toolbar__field { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 11px; + color: #94a3b8; + min-width: 0; +} + +.pt-board-toolbar__field select, +.pt-board-toolbar__field input { + height: 30px; + min-width: 160px; + padding: 0 9px; + border: 1px solid rgba(148, 163, 184, 0.28); + border-radius: 6px; + background: rgba(15, 23, 42, 0.7); + color: #e2e8f0; + font-size: 13px; +} + +.pt-board-toolbar__field--sm select { + min-width: 72px; +} + +.pt-board-toolbar__actions { + flex-wrap: wrap; +} + +.app-main .pt-board-toolbar button { + height: 30px; + font-size: 12px; +} + +.pt-board-page { + padding-top: 10px; +} + +.pt-board-add { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 10px 12px; + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid rgba(148, 163, 184, 0.16); + border-radius: 8px; + background: rgba(15, 23, 42, 0.45); +} + +.pt-board-grid { + display: grid; + gap: 12px; + flex: 1; + min-height: 0; + align-content: start; + overflow: auto; + padding-bottom: 8px; +} + +.pt-board-panel { + min-height: 320px; + display: flex; + flex-direction: column; + min-width: 0; + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: 10px; + background: rgba(8, 14, 26, 0.35); + overflow: hidden; +} + +.pt-board-panel.is-stale { + border-color: rgba(248, 113, 113, 0.45); +} + +.pt-board-panel .pt-wall { + flex: 1; + min-height: 280px; + padding: 12px 12px 8px; +} + +.pt-board-panel__editbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + flex: 0 0 auto; + padding: 6px 10px; + border-bottom: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(15, 23, 42, 0.72); +} + +.pt-board-panel__edit-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: #cbd5e1; +} + +.pt-board-panel__stale { + flex: 1; + display: grid; + place-content: center; + gap: 10px; + padding: 24px; + text-align: center; + color: #fca5a5; +} + .pt-wall-shell__empty { flex: 1; display: grid; @@ -4097,13 +4249,142 @@ pre { .pt-wall-page__stage.is-fullscreen:fullscreen { width: 100vw; height: 100vh; - padding: 20px 24px; + padding: 12px 14px; box-sizing: border-box; + display: flex; + flex-direction: column; + overflow: hidden; 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:fullscreen .pt-board-grid, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-grid { + flex: 1 1 auto; + width: 100%; + height: auto; + min-height: 0; + overflow: hidden; + align-content: stretch; + /* Equal row heights that fit the viewport — no fixed min that forces scroll. */ + grid-auto-rows: minmax(0, 1fr); + gap: 6px; + padding-bottom: 0; +} + +.pt-board-fs-legend { + display: none; +} + +.pt-wall-page__stage:fullscreen .pt-board-fs-legend, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-fs-legend { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 8px 18px; + margin-top: 6px; + padding: 4px 8px 0; + color: #94a3b8; + font-size: 11px; +} + +.pt-wall-page__stage:fullscreen .pt-board-panel, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-panel { + min-height: 0; + height: auto; + max-height: 100%; + overflow: hidden; + border-radius: 8px; +} + +.pt-wall-page__stage:fullscreen .pt-board-panel .pt-wall, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-board-panel .pt-wall { + flex: 1 1 auto; + min-height: 0; + height: 100%; + padding: 6px 8px 2px; +} + +.pt-wall--dense .pt-wall__ne { + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pt-wall--dense .pt-wall__ne-base { + color: #64748b; + font-weight: 400; +} + +.pt-wall--dense .pt-wall__if { + font-size: clamp(13px, 1.5vh, 18px); + max-width: min(560px, 55vw); +} + +.pt-wall--dense .pt-wall__range { + font-size: 11px; +} + +.pt-wall--dense .pt-wall__head { + margin-bottom: 4px; + align-items: flex-start; +} + +.pt-wall--dense .pt-wall__kpis { + margin-bottom: 4px; +} + +.pt-wall--dense .pt-wall__kpi { + padding: 4px 8px; +} + +.pt-wall--dense .pt-wall__kpi-label { + font-size: 9px; +} + +.pt-wall--dense .pt-wall__kpi-value { + font-size: clamp(12px, 1.6vh, 16px); +} + +.pt-wall--dense .pt-wall__kpi-unit { + font-size: 10px; +} + +.pt-wall-page__stage:fullscreen .pt-wall__if, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__if { + font-size: clamp(13px, 1.5vh, 18px); +} + +.pt-wall-page__stage:fullscreen .pt-wall__head, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__head { + margin-bottom: 4px; +} + +.pt-wall-page__stage:fullscreen .pt-wall__kpis, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__kpis { + margin-bottom: 4px; +} + +.pt-wall-page__stage:fullscreen .pt-wall__kpi, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__kpi { + padding: 4px 8px; +} + +.pt-wall-page__stage:fullscreen .pt-wall__kpi-value, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__kpi-value { + font-size: clamp(12px, 1.6vh, 16px); +} + +.pt-wall-page__stage:fullscreen .pt-wall__chart-wrap, +.pt-wall-page__stage.is-fullscreen:fullscreen .pt-wall__chart-wrap { + min-height: 0; + flex: 1 1 auto; +} + .pt-wall-page__stage .pt-wall { flex: 1; min-height: 0; diff --git a/web/src/pages/WorkbenchPage.tsx b/web/src/pages/WorkbenchPage.tsx index b1a76b7..758287c 100644 --- a/web/src/pages/WorkbenchPage.tsx +++ b/web/src/pages/WorkbenchPage.tsx @@ -17,6 +17,7 @@ export function WorkbenchPage() {

{t(`workbench.${section}`)}

{modulesInSection(section) + .filter((mod) => !mod.workbenchHidden) .filter((mod) => !mod.adminOnly || isAdmin) .map((mod) => ( +
+ +

+ {t("portTraffic.boardListHint")} +

+ +
+ {boardsQuery.isLoading ? ( +

…

+ ) : boardsQuery.isError ? ( +

{(boardsQuery.error as Error)?.message || t("common.opFailed")}

+ ) : !boards.length ? ( +
+

{t("portTraffic.boardEmpty")}

+ +
+ ) : ( +
+ + + + + + + + + + + + {boards.map((b) => ( + + + + + + + + ))} + +
{t("portTraffic.boardName")}{t("portTraffic.boardPanelCount")}{t("portTraffic.boardCols")}{t("portTraffic.boardUpdated")}{t("portTraffic.col.actions")}
+ + {b.panel_count}{b.cols}{b.updated_at ? formatSystemTime(b.updated_at) : "—"} +
+ + +
+
+
+ )} +
+ + ); +} diff --git a/web/src/pages/network/PortTrafficBoardPanelCell.tsx b/web/src/pages/network/PortTrafficBoardPanelCell.tsx new file mode 100644 index 0000000..5f93824 --- /dev/null +++ b/web/src/pages/network/PortTrafficBoardPanelCell.tsx @@ -0,0 +1,132 @@ +import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { fetchPortTrafficCompare } from "../../services/api"; +import { queryKeys } from "../../constants/queryKeys"; +import { useI18n } from "../../i18n"; +import type { PortTrafficBoardPanel } from "../../types"; +import { PortTrafficWall, type WallYMode } from "./PortTrafficWall"; + +const POLL_MS = 5000; +const EMPTY: never[] = []; + +type Props = { + boardId: string; + panel: PortTrafficBoardPanel; + editing?: boolean; + dense?: boolean; + onEdit?: () => void; + onRemove?: () => void; + onMove?: (dir: -1 | 1) => void; +}; + +export function PortTrafficBoardPanelCell({ + boardId, + panel, + editing, + dense, + onEdit, + onRemove, + onMove, +}: Props) { + const { t } = useI18n(); + const baseline = panel.baseline || "off"; + const compareQuery = useQuery({ + queryKey: [ + ...queryKeys.portTrafficCompare( + panel.target_id, + panel.range_hours, + baseline, + baseline === "custom" ? panel.offset_hours : 0, + panel.baseline_target_id || "", + ), + boardId, + panel.id, + ], + queryFn: () => + fetchPortTrafficCompare({ + targetId: panel.target_id, + rangeHours: panel.range_hours, + baseline, + offsetHours: baseline === "custom" ? panel.offset_hours : undefined, + baselineTargetId: panel.baseline_target_id || undefined, + }), + enabled: Boolean(panel.target_id) && !panel.stale, + staleTime: 1000, + placeholderData: keepPreviousData, + refetchInterval: POLL_MS, + }); + + const points = compareQuery.data?.current ?? EMPTY; + const baselinePoints = + baseline === "off" && !panel.baseline_target_id + ? EMPTY + : (compareQuery.data?.baseline ?? EMPTY); + const target = panel.target || compareQuery.data?.meta?.current_target || null; + const baselineTarget = + compareQuery.data?.meta?.baseline_target || panel.baseline_target || null; + const title = + panel.title || + (target ? `${target.ne_name || target.ne_ip || "—"} · ${target.ifname}` : t("portTraffic.pickPort")); + + const rangeLabel = `${panel.range_hours}h${ + baseline === "off" + ? "" + : baseline === "day" + ? ` · ${t("portTraffic.compareDay")}` + : baseline === "week" + ? ` · ${t("portTraffic.compareWeek")}` + : baseline === "shift" + ? ` · ${t("portTraffic.compareShift")}` + : ` · ${t("portTraffic.compareCustom")}` + }${panel.baseline_target_id ? ` · ${t("portTraffic.mapBaselinePort")}` : ""}`; + + return ( +
+ {editing ? ( +
+ + {title} + +
+ + + + +
+
+ ) : null} + {panel.stale ? ( +
+

{t("portTraffic.boardPanelStale")}

+ {editing ? ( + + ) : null} +
+ ) : ( + + )} +
+ ); +} diff --git a/web/src/pages/network/PortTrafficPage.tsx b/web/src/pages/network/PortTrafficPage.tsx index e3b677c..8da0e62 100644 --- a/web/src/pages/network/PortTrafficPage.tsx +++ b/web/src/pages/network/PortTrafficPage.tsx @@ -256,8 +256,8 @@ export function PortTrafficPage() { setView("wizard"); }; - const openWall = (deviceId: string) => { - navigate(`/network/tasks/port-traffic/wall?device_id=${encodeURIComponent(deviceId)}`); + const openWallList = () => { + navigate("/network/tasks/port-traffic/wall"); }; const openEdit = (row: PortTrafficDevice) => { @@ -523,7 +523,7 @@ export function PortTrafficPage() { diff --git a/web/src/pages/network/PortTrafficWall.tsx b/web/src/pages/network/PortTrafficWall.tsx index c8bcece..b7af4d4 100644 --- a/web/src/pages/network/PortTrafficWall.tsx +++ b/web/src/pages/network/PortTrafficWall.tsx @@ -12,6 +12,7 @@ function formatBps(n: number): string { if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`; if (v >= 1e6) return `${(n / 1e6).toFixed(2)} M`; if (v >= 1e3) return `${(n / 1e3).toFixed(1)} K`; + if (v > 0 && v < 1) return n.toFixed(2); return `${Math.round(n)}`; } @@ -75,6 +76,8 @@ function toAlignedSeries( baseBwFallback: number, ): [number[], number[], number[], number[], number[]] { type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number }; + // Shared X = union of both timelines; each series only has values at its own samples. + // Gaps stay NaN; series use spanGaps so each line connects on its own cadence. const map = new Map(); for (const p of current) { const d = parseApiTime(p.ts); @@ -108,14 +111,13 @@ function toAlignedSeries( } function chartSize(el: HTMLElement): { width: number; height: number } { - const width = Math.max(320, el.clientWidth || el.parentElement?.clientWidth || 800); + const width = Math.max(120, el.clientWidth || el.parentElement?.clientWidth || 800); 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 }; + // Follow container height in board/fullscreen panels (may be well under 180px). + if (parentH > 0) { + return { width, height: Math.max(64, parentH) }; + } + return { width, height: Math.max(220, Math.min(440, Math.floor(width * 0.34))) }; } const EMPTY_POINTS: PortTrafficSamplePoint[] = []; @@ -129,6 +131,8 @@ type Props = { yMode?: WallYMode; loading?: boolean; hint?: string; + /** Board fullscreen: denser chrome, no per-panel legend. */ + dense?: boolean; }; export function PortTrafficWall({ @@ -140,6 +144,7 @@ export function PortTrafficWall({ yMode = "auto", loading, hint, + dense = false, }: Props) { const { t } = useI18n(); const mountRef = useRef(null); @@ -202,7 +207,7 @@ export function PortTrafficWall({ width: 2, fill: "rgba(45, 212, 191, 0.08)", points: { show: false }, - spanGaps: false, + spanGaps: true, }, { label: t("portTraffic.seriesCurrentOut"), @@ -210,7 +215,7 @@ export function PortTrafficWall({ width: 2, fill: "rgba(251, 191, 36, 0.07)", points: { show: false }, - spanGaps: false, + spanGaps: true, }, { label: t("portTraffic.seriesBaselineIn"), @@ -218,7 +223,7 @@ export function PortTrafficWall({ width: 1.5, dash: [6, 4], points: { show: false }, - spanGaps: false, + spanGaps: true, show: false, }, { @@ -227,7 +232,7 @@ export function PortTrafficWall({ width: 1.5, dash: [6, 4], points: { show: false }, - spanGaps: false, + spanGaps: true, show: false, }, ], @@ -257,13 +262,18 @@ export function PortTrafficWall({ y: { auto: true, 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)); + const mode = yModeRef.current; + // "current": scale to live series only. "auto"/util: include baseline too, + // otherwise a near-zero current + busy mapped baseline clips baseline off-chart. + const seriesIdxs = + mode === "current" ? [1, 2] : mode === "util" ? [1, 2, 3, 4] : [1, 2, 3, 4]; + const vals: number[] = []; + for (const i of seriesIdxs) { + for (const v of u.data[i] || []) { + if (Number.isFinite(v)) vals.push(v as number); + } } + if (vals.length) return yPadRange(0, finiteMax(vals)); return yPadRange(min, max); }, }, @@ -355,6 +365,7 @@ export function PortTrafficWall({ plotRef.current.setSize(chartSize(mountRef.current)); }; window.addEventListener("resize", onResize); + document.addEventListener("fullscreenchange", onResize); const ro = typeof ResizeObserver !== "undefined" ? new ResizeObserver(() => onResize()) @@ -362,8 +373,15 @@ export function PortTrafficWall({ ro?.observe(el); const wrap = el.parentElement; if (wrap) ro?.observe(wrap); + // Observe panel / stage ancestors so fullscreen grid row shrinks resize plots. + let node: HTMLElement | null = wrap?.parentElement ?? null; + for (let i = 0; i < 4 && node; i += 1) { + ro?.observe(node); + node = node.parentElement; + } return () => { window.removeEventListener("resize", onResize); + document.removeEventListener("fullscreenchange", onResize); ro?.disconnect(); plotRef.current?.over.removeEventListener("mouseleave", onLeave); plotRef.current?.destroy(); @@ -415,12 +433,18 @@ export function PortTrafficWall({ const baseIpLabel = baselineTarget?.ne_ip || ""; return ( -
+
{neLabel} {ipLabel ? ` · ${ipLabel}` : ""} + {dense && showBaselineMeta ? ( + + {" · "} + {t("portTraffic.baseline")}: {baseNeLabel} · {baseIfLabel} + + ) : null}
{ifLabel} @@ -428,7 +452,7 @@ export function PortTrafficWall({
{rangeLabel} - {kpi.ts ? ` · ${t("portTraffic.latestAt")} ${formatSystemTime(kpi.ts)}` : ""} + {kpi.ts ? ` · ${formatSystemTime(kpi.ts)}` : ""}
@@ -468,43 +492,45 @@ export function PortTrafficWall({
) : null}
-
-
- - - {t("portTraffic.seriesCurrentIn")} - - - - {t("portTraffic.seriesCurrentOut")} - + {!dense ? ( +
+
+ + + {t("portTraffic.seriesCurrentIn")} + + + + {t("portTraffic.seriesCurrentOut")} + + {showBaselineMeta ? ( + <> + + + {t("portTraffic.seriesBaselineIn")} + + + + {t("portTraffic.seriesBaselineOut")} + + + ) : null} +
{showBaselineMeta ? ( - <> - - - {t("portTraffic.seriesBaselineIn")} - - - - {t("portTraffic.seriesBaselineOut")} - - +
+
+ {t("portTraffic.baselineDevice")} + {baseNeLabel} + {baseIpLabel ? {baseIpLabel} : null} +
+
+ {t("portTraffic.baselinePort")} + {baseIfLabel} +
+
) : null}
- {showBaselineMeta ? ( -
-
- {t("portTraffic.baselineDevice")} - {baseNeLabel} - {baseIpLabel ? {baseIpLabel} : null} -
-
- {t("portTraffic.baselinePort")} - {baseIfLabel} -
-
- ) : null} -
+ ) : null}
); } diff --git a/web/src/pages/network/PortTrafficWallPage.tsx b/web/src/pages/network/PortTrafficWallPage.tsx index 4ff92f1..05029d1 100644 --- a/web/src/pages/network/PortTrafficWallPage.tsx +++ b/web/src/pages/network/PortTrafficWallPage.tsx @@ -1,27 +1,23 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query"; -import { Link, useSearchParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; import { - collectPortTrafficNow, - fetchPortTrafficCompare, + deletePortTrafficBoard, + fetchPortTrafficBoard, fetchPortTrafficDevices, fetchPortTrafficTargets, + putPortTrafficBoardPanels, + updatePortTrafficBoard, } 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[] = []; +import type { PortTrafficBoardPanel, PortTrafficBoardPanelIn } from "../../types"; +import { PortTrafficBoardPanelCell } from "./PortTrafficBoardPanelCell"; +import type { WallYMode } from "./PortTrafficWall"; type BaselineMode = "off" | "shift" | "day" | "week" | "custom"; -function deviceLabel(d: PortTrafficDevice) { - return d.ne_name || d.ne_ip || d.ne_id || "—"; -} - function FullscreenIcon() { return (
+

{t("portTraffic.boardPickHint")}

+
+ + ); + } + + if (deleted) { + return ( +
+
+

{t("portTraffic.boardDeleted")}

+

{t("portTraffic.boardClosedHint")}

+
+
+ ); + } return ( -
- {!devices.length && !devicesQuery.isLoading ? ( -
-

{t("portTraffic.wallEmpty")}

- - {t("portTraffic.create")} - -
- ) : ( -
-
-
- + + + ) : ( +
+ {board?.name || t("portTraffic.wallTitle")} + {dirty ? " *" : ""} +
+ )} +
+
+ {panels.length && !editing ? ( + + ) : null} + {!editing ? ( + + ) : ( + <> + + + + )} + {!editing ? ( + + ) : null} +
+
+ + {boardQuery.isLoading ? ( +
+

…

+
+ ) : boardQuery.isError ? ( +
+

{(boardQuery.error as Error)?.message || t("common.opFailed")}

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

{t("portTraffic.retentionHint")}

) : null} -
- -
+ {!panels.length ? ( +
+

{t("portTraffic.boardNoPanels")}

+ {!editing ? ( + + ) : null} +
+ ) : ( +
+
+ {panels.map((p) => ( + openPanelSettings(p.id)} + onRemove={() => markDirty(draftPanels.filter((x) => x.id !== p.id))} + onMove={(dir) => movePanel(p.id, dir)} + /> + ))} +
+ {fullscreen ? ( +
+ + + {t("portTraffic.seriesCurrentIn")} + + + + {t("portTraffic.seriesCurrentOut")} + + + + {t("portTraffic.seriesBaselineIn")} + + + + {t("portTraffic.seriesBaselineOut")} + +
+ ) : null} +
+ )}
)} + + {editing && editPanel ? ( +
+
e.stopPropagation()} + > +

{t("portTraffic.boardPanelSettings")}

+
+ + + + + + {editPanel.baseline === "custom" ? ( + + ) : null} + + + + +
+ {editPanel.baseline === "week" ? ( +

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

+ ) : null} +
+ +
+
+
+ ) : null}
); } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 6b72445..1984681 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -34,6 +34,9 @@ import type { PortTrafficDevice, PortTrafficDiscoverResponse, PortTrafficEvent, + PortTrafficBoard, + PortTrafficBoardPanelIn, + PortTrafficBoardSummary, PortTrafficIfaceIn, PortTrafficSamples, PortTrafficTarget, @@ -904,3 +907,31 @@ export const fetchPortTrafficCompare = (params: { if (params.to) p.set("to", params.to); return apiGet(`/v1/port-traffic/compare?${p.toString()}`); }; + +export const fetchPortTrafficBoards = () => + apiGet<{ items: PortTrafficBoardSummary[] }>("/v1/port-traffic/boards"); + +export const fetchPortTrafficBoard = (boardId: string) => + apiGet(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`); + +export const createPortTrafficBoard = (body: { + name: string; + remark?: string; + cols?: number; + panels?: PortTrafficBoardPanelIn[]; +}) => apiPost("/v1/port-traffic/boards", body); + +export const updatePortTrafficBoard = ( + boardId: string, + body: { name?: string; remark?: string; cols?: number }, +) => apiPatch(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`, body); + +export const putPortTrafficBoardPanels = (boardId: string, panels: PortTrafficBoardPanelIn[]) => + apiPut(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}/panels`, { + panels, + }); + +export const deletePortTrafficBoard = (boardId: string) => + apiDelete<{ ok: boolean; id: string }>( + `/v1/port-traffic/boards/${encodeURIComponent(boardId)}`, + ); diff --git a/web/src/types.ts b/web/src/types.ts index 6701b81..6377a8f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -587,6 +587,56 @@ export type PortTrafficEvent = { created_at?: string | null; }; +export type PortTrafficBoardPanel = { + id: string; + board_id: string; + title: string; + target_id: string; + range_hours: number; + baseline: "off" | "day" | "week" | "shift" | "custom" | string; + offset_hours: number; + baseline_target_id: string; + y_mode: "auto" | "current" | "util" | string; + ord: number; + col_span: number; + row_span: number; + stale: boolean; + target: PortTrafficTarget | null; + baseline_target: PortTrafficTarget | null; + created_at?: string | null; + updated_at?: string | null; +}; + +export type PortTrafficBoardPanelIn = { + id?: string; + title?: string; + target_id: string; + range_hours?: number; + baseline?: string; + offset_hours?: number; + baseline_target_id?: string; + y_mode?: string; + ord?: number; + col_span?: number; + row_span?: number; +}; + +export type PortTrafficBoardSummary = { + id: string; + name: string; + remark: string; + cols: number; + panel_count: number; + created_by: string; + updated_by: string; + created_at?: string | null; + updated_at?: string | null; +}; + +export type PortTrafficBoard = PortTrafficBoardSummary & { + panels: PortTrafficBoardPanel[]; +}; + export type PortTrafficSeries = { id: string; device_id: string; diff --git a/web/src/utils/portTrafficBoardWindow.ts b/web/src/utils/portTrafficBoardWindow.ts new file mode 100644 index 0000000..17414d6 --- /dev/null +++ b/web/src/utils/portTrafficBoardWindow.ts @@ -0,0 +1,17 @@ +import { openNewModuleWindow } from "./moduleWindows"; + +export const PORT_TRAFFIC_WALL_MODULE_ID = "port-traffic-wall"; + +export function portTrafficBoardPath(boardId: string): string { + return `/port-traffic/wall/${encodeURIComponent(boardId)}`; +} + +/** Open a dedicated browser tab for one board (outside Network management). */ +export function openPortTrafficBoardWindow(boardId: string): void { + const id = String(boardId || "").trim(); + if (!id) return; + openNewModuleWindow({ + moduleId: PORT_TRAFFIC_WALL_MODULE_ID, + path: portTrafficBoardPath(id), + }); +}