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) => (