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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 17:03:11 +08:00
parent 214bc56774
commit 00d3e95e2d
22 changed files with 1923 additions and 345 deletions

View file

@ -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)

View file

@ -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,
)

View file

@ -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."""

View file

@ -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),

View file

@ -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)

View file

@ -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
)

View file

@ -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=` 打开向导并预填网元

View file

@ -14,6 +14,7 @@ import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage";
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
import { PortTrafficPage } from "./pages/network/PortTrafficPage";
import { 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 <Navigate to={`/webcrt${search}`} replace />;
}
/** 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 <Navigate to={`/port-traffic/wall/${encodeURIComponent(boardId)}`} replace />;
}
return <PortTrafficBoardListPage />;
}
function ProtectedApp() {
const { ready, user } = useAuth();
const integrationsQuery = useQuery({
@ -87,6 +98,7 @@ function ProtectedApp() {
<Route path="/ne" element={<NePage />} />
<Route path="/topology" element={<TopologyPage />} />
<Route path="/webcrt" element={<WebcrtPage />} />
<Route path="/port-traffic/wall/:boardId" element={<PortTrafficWallPage />} />
<Route path="/network" element={<NetworkLayout />}>
<Route index element={<Navigate to="devices" replace />} />
<Route path="devices" element={<NetworkDevicesPage />} />
@ -96,7 +108,7 @@ function ProtectedApp() {
<Route path="webcrt" element={<NetworkWebcrtRedirect />} />
<Route path="tasks/collect" element={<CollectPage />} />
<Route path="tasks/config-sync" element={<ConfigSyncPage />} />
<Route path="tasks/port-traffic/wall" element={<PortTrafficWallPage />} />
<Route path="tasks/port-traffic/wall" element={<LegacyPortTrafficWallRedirect />} />
<Route path="tasks/port-traffic" element={<PortTrafficPage />} />
</Route>
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />

View file

@ -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;

View file

@ -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: (

View file

@ -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",

View file

@ -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",

View file

@ -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;

View file

@ -17,6 +17,7 @@ export function WorkbenchPage() {
<h2 className="wb-section__title">{t(`workbench.${section}`)}</h2>
<div className="wb-grid">
{modulesInSection(section)
.filter((mod) => !mod.workbenchHidden)
.filter((mod) => !mod.adminOnly || isAdmin)
.map((mod) => (
<button

View file

@ -0,0 +1,145 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createPortTrafficBoard,
deletePortTrafficBoard,
fetchPortTrafficBoards,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { openPortTrafficBoardWindow } from "../../utils/portTrafficBoardWindow";
import { formatSystemTime } from "../../utils/time";
export function PortTrafficBoardListPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const boardsQuery = useQuery({
queryKey: queryKeys.portTrafficBoards,
queryFn: fetchPortTrafficBoards,
staleTime: 5000,
});
const createMut = useMutation({
mutationFn: () =>
createPortTrafficBoard({
name: t("portTraffic.boardDefaultName"),
cols: 2,
panels: [],
}),
onSuccess: (created) => {
showOk(t("portTraffic.boardCreated"));
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
openPortTrafficBoardWindow(created.id);
},
onError: (e: Error) => showError(e.message),
});
const deleteMut = useMutation({
mutationFn: (boardId: string) => deletePortTrafficBoard(boardId),
onSuccess: () => {
showOk(t("portTraffic.boardDeleted"));
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
},
onError: (e: Error) => showError(e.message),
});
const boards = boardsQuery.data?.items || [];
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("portTraffic.wallTitle")}</h2>
<div className="btn-row">
<button
type="button"
className="btn-primary"
disabled={createMut.isPending}
onClick={() => createMut.mutate()}
>
{t("portTraffic.boardCreate")}
</button>
</div>
</div>
<p className="muted" style={{ marginTop: 0 }}>
{t("portTraffic.boardListHint")}
</p>
<div className="pt-list">
{boardsQuery.isLoading ? (
<p className="muted">…</p>
) : boardsQuery.isError ? (
<p className="error">{(boardsQuery.error as Error)?.message || t("common.opFailed")}</p>
) : !boards.length ? (
<div className="pt-list-empty">
<p>{t("portTraffic.boardEmpty")}</p>
<button
type="button"
className="btn-primary"
disabled={createMut.isPending}
onClick={() => createMut.mutate()}
>
{t("portTraffic.boardCreate")}
</button>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("portTraffic.boardName")}</th>
<th>{t("portTraffic.boardPanelCount")}</th>
<th>{t("portTraffic.boardCols")}</th>
<th>{t("portTraffic.boardUpdated")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{boards.map((b) => (
<tr key={b.id}>
<td>
<button
type="button"
className="link-btn pt-list-task-name"
onClick={() => openPortTrafficBoardWindow(b.id)}
>
{b.name || t("portTraffic.boardDefaultName")}
</button>
</td>
<td>{b.panel_count}</td>
<td>{b.cols}</td>
<td>{b.updated_at ? formatSystemTime(b.updated_at) : "—"}</td>
<td>
<div className="btn-row table-actions">
<button
type="button"
className="btn-primary"
onClick={() => openPortTrafficBoardWindow(b.id)}
>
{t("portTraffic.boardOpen")}
</button>
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending}
onClick={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
deleteMut.mutate(b.id);
}
}}
>
{t("portTraffic.delete")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
);
}

View file

@ -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 (
<div
className={`pt-board-panel${panel.stale ? " is-stale" : ""}`}
style={{ gridColumn: `span ${Math.max(1, panel.col_span || 1)}` }}
>
{editing ? (
<div className="pt-board-panel__editbar">
<span className="pt-board-panel__edit-title" title={title}>
{title}
</span>
<div className="btn-row">
<button type="button" onClick={() => onMove?.(-1)}>
↑
</button>
<button type="button" onClick={() => onMove?.(1)}>
↓
</button>
<button type="button" onClick={() => onEdit?.()}>
{t("portTraffic.edit")}
</button>
<button type="button" className="btn--danger" onClick={() => onRemove?.()}>
{t("portTraffic.delete")}
</button>
</div>
</div>
) : null}
{panel.stale ? (
<div className="pt-board-panel__stale">
<p>{t("portTraffic.boardPanelStale")}</p>
{editing ? (
<button type="button" className="btn-primary" onClick={() => onEdit?.()}>
{t("portTraffic.boardRetarget")}
</button>
) : null}
</div>
) : (
<PortTrafficWall
target={target}
baselineTarget={baselineTarget}
points={points}
baselinePoints={baselinePoints}
yMode={(panel.y_mode as WallYMode) || "auto"}
rangeLabel={rangeLabel}
loading={compareQuery.isLoading || compareQuery.isFetching}
hint={t("portTraffic.waitSamples")}
dense={dense}
/>
)}
</div>
);
}

View file

@ -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() {
<button
type="button"
className="btn-primary"
onClick={() => openWall(row.id)}
onClick={() => openWallList()}
>
{t("portTraffic.wall")}
</button>

View file

@ -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<number, Row>();
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<HTMLDivElement | null>(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 (
<div className="pt-wall">
<div className={`pt-wall${dense ? " pt-wall--dense" : ""}`}>
<div className="pt-wall__head">
<div className="pt-wall__title">
<div className="pt-wall__ne">
{neLabel}
{ipLabel ? ` · ${ipLabel}` : ""}
{dense && showBaselineMeta ? (
<span className="pt-wall__ne-base" title={`${baseNeLabel} · ${baseIfLabel}`}>
{" · "}
{t("portTraffic.baseline")}: {baseNeLabel} · {baseIfLabel}
</span>
) : null}
</div>
<div className="pt-wall__if" title={ifLabel}>
{ifLabel}
@ -428,7 +452,7 @@ export function PortTrafficWall({
</div>
<div className="pt-wall__range">
{rangeLabel}
{kpi.ts ? ` · ${t("portTraffic.latestAt")} ${formatSystemTime(kpi.ts)}` : ""}
{kpi.ts ? ` · ${formatSystemTime(kpi.ts)}` : ""}
</div>
</div>
<div className="pt-wall__kpis">
@ -468,43 +492,45 @@ export function PortTrafficWall({
</div>
) : null}
</div>
<div className="pt-wall__foot">
<div className="pt-wall__legend" aria-hidden={!points.length && !baselinePoints.length}>
<span className="pt-wall__legend-item pt-wall__legend-item--in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentOut")}
</span>
{!dense ? (
<div className="pt-wall__foot">
<div className="pt-wall__legend" aria-hidden={!points.length && !baselinePoints.length}>
<span className="pt-wall__legend-item pt-wall__legend-item--in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentOut")}
</span>
{showBaselineMeta ? (
<>
<span className="pt-wall__legend-item pt-wall__legend-item--base-in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--base-out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineOut")}
</span>
</>
) : null}
</div>
{showBaselineMeta ? (
<>
<span className="pt-wall__legend-item pt-wall__legend-item--base-in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--base-out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineOut")}
</span>
</>
<div className="pt-wall__foot-meta">
<div className="pt-wall__foot-ne" title={baseNeLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.baselineDevice")}</span>
<span className="pt-wall__foot-value">{baseNeLabel}</span>
{baseIpLabel ? <span className="pt-wall__foot-ip">{baseIpLabel}</span> : null}
</div>
<div className="pt-wall__foot-if" title={baseIfLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.baselinePort")}</span>
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{baseIfLabel}</span>
</div>
</div>
) : null}
</div>
{showBaselineMeta ? (
<div className="pt-wall__foot-meta">
<div className="pt-wall__foot-ne" title={baseNeLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.baselineDevice")}</span>
<span className="pt-wall__foot-value">{baseNeLabel}</span>
{baseIpLabel ? <span className="pt-wall__foot-ip">{baseIpLabel}</span> : null}
</div>
<div className="pt-wall__foot-if" title={baseIfLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.baselinePort")}</span>
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{baseIfLabel}</span>
</div>
</div>
) : null}
</div>
) : null}
</div>
);
}

View file

@ -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 (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
@ -33,138 +29,188 @@ function FullscreenIcon() {
);
}
function newPanelId() {
return `p_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
}
function panelToIn(p: PortTrafficBoardPanel): PortTrafficBoardPanelIn {
return {
id: p.id,
title: p.title || "",
target_id: p.target_id,
range_hours: p.range_hours,
baseline: p.baseline || "off",
offset_hours: p.offset_hours || 0,
baseline_target_id: p.baseline_target_id || "",
y_mode: p.y_mode || "auto",
ord: p.ord,
col_span: p.col_span || 1,
row_span: p.row_span || 1,
};
}
/** Dedicated board wall tab at `/port-traffic/wall/:boardId` (outside Network shell). */
export function PortTrafficWallPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkAppliedRef = useRef(false);
const { boardId: boardIdParam = "" } = useParams<{ boardId: string }>();
const boardId = decodeURIComponent(boardIdParam).trim();
const stageRef = useRef<HTMLDivElement | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const [wallDeviceId, setWallDeviceId] = useState(() => (searchParams.get("device_id") || "").trim());
const [wallTargetId, setWallTargetId] = useState(() => (searchParams.get("target_id") || "").trim());
const [mapBaselineDeviceId, setMapBaselineDeviceId] = useState("");
const [mapBaselineTargetId, setMapBaselineTargetId] = useState("");
const [rangeHours, setRangeHours] = useState(24);
const [baseline, setBaseline] = useState<BaselineMode>("off");
const [customOffsetHours, setCustomOffsetHours] = useState(48);
const [yMode, setYMode] = useState<WallYMode>("auto");
const [editing, setEditing] = useState(false);
const [dirty, setDirty] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [draftPanels, setDraftPanels] = useState<PortTrafficBoardPanel[]>([]);
const [draftName, setDraftName] = useState("");
const [draftCols, setDraftCols] = useState(2);
const [editPanelId, setEditPanelId] = useState("");
const [editPickDeviceId, setEditPickDeviceId] = useState("");
const [editMapBaselineDeviceId, setEditMapBaselineDeviceId] = useState("");
const [addDeviceId, setAddDeviceId] = useState("");
const [addTargetId, setAddTargetId] = useState("");
const [deleted, setDeleted] = useState(false);
const boardQuery = useQuery({
queryKey: queryKeys.portTrafficBoard(boardId),
queryFn: () => fetchPortTrafficBoard(boardId),
enabled: Boolean(boardId) && !deleted,
staleTime: 2000,
});
const devicesQuery = useQuery({
queryKey: queryKeys.portTrafficDevices(0),
queryFn: () => fetchPortTrafficDevices({ page: 1, pageSize: 100 }),
enabled: editing,
staleTime: 5000,
refetchInterval: POLL_MS,
});
const wallTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(wallDeviceId),
queryFn: () => fetchPortTrafficTargets(wallDeviceId),
enabled: Boolean(wallDeviceId),
const addTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(addDeviceId),
queryFn: () => fetchPortTrafficTargets(addDeviceId),
enabled: editing && Boolean(addDeviceId),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const editPanel = draftPanels.find((p) => p.id === editPanelId) || null;
const editTargetsDeviceId = editPickDeviceId || editPanel?.target?.device_id || "";
const editTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(editTargetsDeviceId),
queryFn: () => fetchPortTrafficTargets(editTargetsDeviceId),
enabled: editing && Boolean(editPanelId) && Boolean(editTargetsDeviceId),
staleTime: 2000,
});
const mapBaselineTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(mapBaselineDeviceId),
queryFn: () => fetchPortTrafficTargets(mapBaselineDeviceId),
enabled: Boolean(mapBaselineDeviceId),
queryKey: queryKeys.portTrafficTargets(editMapBaselineDeviceId),
queryFn: () => fetchPortTrafficTargets(editMapBaselineDeviceId),
enabled: editing && Boolean(editPanelId) && Boolean(editMapBaselineDeviceId),
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,
(x) =>
(x.status === "active" || x.id === editPanel?.baseline_target_id) &&
x.id !== editPanel?.target_id,
),
[mapBaselineTargetsQuery.data?.items, wallTargetId],
[mapBaselineTargetsQuery.data?.items, editPanel?.baseline_target_id, editPanel?.target_id],
);
const wallPoints = compareQuery.data?.current ?? EMPTY_COMPARE_POINTS;
const wallBaselinePoints =
baseline === "off" && !mapBaselineTargetId
? EMPTY_COMPARE_POINTS
: (compareQuery.data?.baseline ?? EMPTY_COMPARE_POINTS);
const closePanelSettings = () => {
setEditPanelId("");
setEditPickDeviceId("");
setEditMapBaselineDeviceId("");
};
const openPanelSettings = (panelId: string) => {
const p = draftPanels.find((x) => x.id === panelId);
setEditPanelId(panelId);
setEditPickDeviceId(p?.target?.device_id || "");
setEditMapBaselineDeviceId(p?.baseline_target?.device_id || "");
};
const board = boardQuery.data || null;
const panels = editing ? draftPanels : board?.panels || [];
const cols = editing ? draftCols : board?.cols || 2;
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]);
if (!board || editing) return;
setDraftPanels(board.panels || []);
setDraftName(board.name || "");
setDraftCols(board.cols || 2);
}, [board, editing]);
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 (!board?.name) return;
try {
document.title = `${board.name} · NetX`;
} catch {
/* ignore */
}
if (
mapBaselineDeviceId &&
mapBaselineTargetsQuery.isFetched &&
!mapBaselineOptions.some((x) => x.id === mapBaselineTargetId)
) {
setMapBaselineTargetId("");
}, [board?.name]);
const savePanelsMut = useMutation({
mutationFn: async () => {
if (draftName.trim() && draftName.trim() !== (board?.name || "")) {
await updatePortTrafficBoard(boardId, { name: draftName.trim(), cols: draftCols });
} else if (draftCols !== (board?.cols || 2)) {
await updatePortTrafficBoard(boardId, { cols: draftCols });
}
return putPortTrafficBoardPanels(
boardId,
draftPanels.map((p, i) => ({ ...panelToIn(p), ord: i })),
);
},
onSuccess: () => {
showOk(t("portTraffic.boardSaved"));
setDirty(false);
setEditing(false);
closePanelSettings();
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoard(boardId) });
},
onError: (e: Error) => showError(e.message),
});
const deleteMut = useMutation({
mutationFn: () => deletePortTrafficBoard(boardId),
onSuccess: () => {
showOk(t("portTraffic.boardDeleted"));
setDeleted(true);
setEditing(false);
setDirty(false);
closePanelSettings();
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
},
onError: (e: Error) => showError(e.message),
});
const startEdit = () => {
if (!board) return;
setDraftPanels([...(board.panels || [])]);
setDraftName(board.name || "");
setDraftCols(board.cols || 2);
setEditing(true);
setDirty(false);
};
const cancelEdit = () => {
if (dirty && !window.confirm(t("portTraffic.boardUnsavedLeave"))) return;
setEditing(false);
setDirty(false);
closePanelSettings();
if (board) {
setDraftPanels(board.panels || []);
setDraftName(board.name || "");
setDraftCols(board.cols || 2);
}
}, [
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);
@ -184,206 +230,461 @@ export function PortTrafficWallPage() {
}
};
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),
});
const markDirty = (next: PortTrafficBoardPanel[]) => {
setDraftPanels(next);
setDirty(true);
};
const addPanel = () => {
if (!addTargetId) {
showError(t("portTraffic.pickPort"));
return;
}
const tgt = (addTargetsQuery.data?.items || []).find((x) => x.id === addTargetId);
const panel: PortTrafficBoardPanel = {
id: newPanelId(),
board_id: boardId,
title: "",
target_id: addTargetId,
range_hours: 24,
baseline: "off",
offset_hours: 0,
baseline_target_id: "",
y_mode: "auto",
ord: draftPanels.length,
col_span: 1,
row_span: 1,
stale: false,
target: tgt || null,
baseline_target: null,
};
markDirty([...draftPanels, panel]);
setAddTargetId("");
};
const updatePanel = (id: string, patch: Partial<PortTrafficBoardPanel>) => {
markDirty(draftPanels.map((p) => (p.id === id ? { ...p, ...patch } : p)));
};
const movePanel = (id: string, dir: -1 | 1) => {
const idx = draftPanels.findIndex((p) => p.id === id);
if (idx < 0) return;
const j = idx + dir;
if (j < 0 || j >= draftPanels.length) return;
const next = [...draftPanels];
const tmp = next[idx];
next[idx] = next[j];
next[j] = tmp;
markDirty(next.map((p, i) => ({ ...p, ord: i })));
};
const activeAddTargets = useMemo(
() => (addTargetsQuery.data?.items || []).filter((x) => x.status === "active"),
[addTargetsQuery.data?.items],
);
if (!boardId) {
return (
<section className="pt-wall-shell">
<div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardPickHint")}</p>
</div>
</section>
);
}
if (deleted) {
return (
<section className="pt-wall-shell">
<div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardDeleted")}</p>
<p className="muted">{t("portTraffic.boardClosedHint")}</p>
</div>
</section>
);
}
return (
<section className="pt-wall-shell">
{!devices.length && !devicesQuery.isLoading ? (
<div className="pt-list-empty pt-wall-shell__empty">
<p>{t("portTraffic.wallEmpty")}</p>
<Link to="/network/tasks/port-traffic" className="btn-primary" style={{ textDecoration: "none" }}>
{t("portTraffic.create")}
</Link>
</div>
) : (
<div className="pt-wall-page">
<div className="pt-wall-page__toolbar">
<div className="pt-wall-page__row pt-wall-page__row--primary">
<label className="pt-wall-page__field pt-wall-page__field--task">
{t("portTraffic.wallDevice")}
<select
value={wallDeviceId}
<section className={`pt-wall-shell${editing ? " is-editing" : ""}`}>
<div className="pt-board-toolbar">
<div className="pt-board-toolbar__left">
{editing ? (
<>
<label className="pt-board-toolbar__field">
{t("portTraffic.boardName")}
<input
value={draftName}
onChange={(e) => {
setWallDeviceId(e.target.value);
setWallTargetId("");
setDraftName(e.target.value);
setDirty(true);
}}
/>
</label>
<label className="pt-board-toolbar__field pt-board-toolbar__field--sm">
{t("portTraffic.boardCols")}
<select
value={draftCols}
onChange={(e) => {
setDraftCols(Number(e.target.value) || 2);
setDirty(true);
}}
>
{devices.map((d) => (
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
</select>
</label>
</>
) : (
<div className="pt-board-toolbar__title">
{board?.name || t("portTraffic.wallTitle")}
{dirty ? " *" : ""}
</div>
)}
</div>
<div className="btn-row pt-board-toolbar__actions">
{panels.length && !editing ? (
<button
type="button"
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
onClick={() => void toggleFullscreen()}
title={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
aria-label={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
>
<FullscreenIcon />
<span>{fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}</span>
</button>
) : null}
{!editing ? (
<button type="button" className="btn-primary" onClick={startEdit} disabled={!board}>
{t("portTraffic.boardEdit")}
</button>
) : (
<>
<button
type="button"
className="btn-primary"
disabled={savePanelsMut.isPending}
onClick={() => savePanelsMut.mutate()}
>
{t("portTraffic.boardSave")}
</button>
<button type="button" onClick={cancelEdit}>
{t("portTraffic.boardCancel")}
</button>
</>
)}
{!editing ? (
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending || !board}
onClick={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) deleteMut.mutate();
}}
>
{t("portTraffic.delete")}
</button>
) : null}
</div>
</div>
{boardQuery.isLoading ? (
<div className="pt-wall-shell__empty">
<p>…</p>
</div>
) : boardQuery.isError ? (
<div className="pt-wall-shell__empty">
<p>{(boardQuery.error as Error)?.message || t("common.opFailed")}</p>
</div>
) : (
<div className="pt-wall-page pt-board-page">
{editing ? (
<div className="pt-board-add">
<label className="pt-wall-page__field">
{t("portTraffic.wallDevice")}
<select
value={addDeviceId}
onChange={(e) => {
setAddDeviceId(e.target.value);
setAddTargetId("");
}}
>
<option value="">{t("portTraffic.boardPickDevice")}</option>
{(devicesQuery.data?.items || []).map((d) => (
<option key={d.id} value={d.id}>
{deviceLabel(d)}
{d.ne_ip ? ` (${d.ne_ip})` : ""}
{d.ne_name || d.ne_ip || d.ne_id}
</option>
))}
</select>
</label>
<label className="pt-wall-page__field pt-wall-page__field--port">
{t("portTraffic.wallPort")}
<select value={wallTargetId} onChange={(e) => setWallTargetId(e.target.value)}>
<select value={addTargetId} onChange={(e) => setAddTargetId(e.target.value)}>
<option value="">{t("portTraffic.pickPort")}</option>
{wallTargets.map((tgt) => (
{activeAddTargets.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
<button
type="button"
className="btn-primary pt-wall-page__collect"
disabled={!wallDeviceId || collectMut.isPending}
onClick={() => collectMut.mutate(wallDeviceId)}
>
{t("portTraffic.collectNow")}
</button>
<button
type="button"
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
onClick={() => void toggleFullscreen()}
title={t("portTraffic.fullscreen")}
aria-label={t("portTraffic.fullscreen")}
>
<FullscreenIcon />
<span>{t("portTraffic.fullscreen")}</span>
<button type="button" className="btn-primary" onClick={addPanel}>
{t("portTraffic.boardAddPanel")}
</button>
</div>
<div className="pt-wall-page__row pt-wall-page__row--secondary">
<div className="pt-wall-page__cluster">
<span className="pt-wall-page__cluster-label">{t("portTraffic.filterView")}</span>
<div className="pt-wall-page__cluster-fields">
<label className="pt-wall-page__field pt-wall-page__field--sm">
{t("portTraffic.range")}
<select value={rangeHours} onChange={(e) => setRangeHours(Number(e.target.value))}>
<option value={1}>1h</option>
<option value={6}>6h</option>
<option value={24}>24h</option>
</select>
</label>
<label className="pt-wall-page__field pt-wall-page__field--md">
{t("portTraffic.compare")}
<select value={baseline} onChange={(e) => setBaseline(e.target.value as BaselineMode)}>
<option value="off">{t("portTraffic.compareOff")}</option>
<option value="day">{t("portTraffic.compareDay")}</option>
<option value="week">{t("portTraffic.compareWeek")}</option>
<option value="shift">{t("portTraffic.compareShift")}</option>
<option value="custom">{t("portTraffic.compareCustom")}</option>
</select>
</label>
{baseline === "custom" ? (
<label className="pt-wall-page__field pt-wall-page__field--sm">
{t("portTraffic.offsetHours")}
<input
type="number"
min={1}
max={24 * 90}
value={customOffsetHours}
onChange={(e) => setCustomOffsetHours(Number(e.target.value) || 24)}
/>
</label>
) : null}
<label className="pt-wall-page__field pt-wall-page__field--md">
{t("portTraffic.yMode")}
<select value={yMode} onChange={(e) => setYMode(e.target.value as WallYMode)}>
<option value="auto">{t("portTraffic.yModeAuto")}</option>
<option value="current">{t("portTraffic.yModeCurrent")}</option>
<option value="util">{t("portTraffic.yModeUtil")}</option>
</select>
</label>
</div>
</div>
<div className="pt-wall-page__cluster-sep" aria-hidden />
<div className="pt-wall-page__cluster pt-wall-page__cluster--map">
<span className="pt-wall-page__cluster-label">{t("portTraffic.filterMap")}</span>
<div className="pt-wall-page__cluster-fields">
<label className="pt-wall-page__field pt-wall-page__field--task">
{t("portTraffic.wallDevice")}
<select
value={mapBaselineDeviceId}
onChange={(e) => {
setMapBaselineDeviceId(e.target.value);
setMapBaselineTargetId("");
}}
disabled={!wallTargetId}
>
<option value="">{t("portTraffic.mapBaselineNone")}</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{deviceLabel(d)}
</option>
))}
</select>
</label>
<label className="pt-wall-page__field pt-wall-page__field--port">
{t("portTraffic.wallPort")}
<select
value={mapBaselineTargetId}
onChange={(e) => setMapBaselineTargetId(e.target.value)}
disabled={!mapBaselineDeviceId}
>
<option value="">{t("portTraffic.pickPort")}</option>
{mapBaselineOptions.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
</div>
</div>
</div>
</div>
{baseline === "week" ? (
<p className="muted pt-wall-page__hint">{t("portTraffic.retentionHint")}</p>
) : null}
<div
ref={stageRef}
className={`pt-wall-page__stage${fullscreen ? " is-fullscreen" : ""}`}
>
<PortTrafficWall
target={selectedWallTarget}
baselineTarget={
compareQuery.data?.meta?.baseline_target ||
(baseline !== "off" && wallBaselinePoints.length ? selectedWallTarget : null)
}
points={wallPoints}
baselinePoints={wallBaselinePoints}
yMode={yMode}
rangeLabel={`${rangeHours}h${
baseline === "off"
? ""
: baseline === "day"
? ` · ${t("portTraffic.compareDay")}`
: baseline === "week"
? ` · ${t("portTraffic.compareWeek")}`
: baseline === "shift"
? ` · ${t("portTraffic.compareShift")}`
: ` · ${t("portTraffic.compareCustom")}`
}${mapBaselineTargetId ? ` · ${t("portTraffic.mapBaselinePort")}` : ""}`}
loading={compareQuery.isLoading || compareQuery.isFetching}
hint={
!wallTargetId
? t("portTraffic.pickPort")
: selectedWallTarget?.last_error
? t("portTraffic.waitAfterError")
: t("portTraffic.waitSamples")
}
/>
</div>
{!panels.length ? (
<div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardNoPanels")}</p>
{!editing ? (
<button type="button" className="btn-primary" onClick={startEdit}>
{t("portTraffic.boardEdit")}
</button>
) : null}
</div>
) : (
<div
ref={stageRef}
className={`pt-wall-page__stage${fullscreen ? " is-fullscreen" : ""}`}
>
<div
className="pt-board-grid"
style={{ gridTemplateColumns: `repeat(${Math.max(1, cols)}, minmax(0, 1fr))` }}
>
{panels.map((p) => (
<PortTrafficBoardPanelCell
key={p.id}
boardId={boardId}
panel={p}
editing={editing}
dense={fullscreen}
onEdit={() => openPanelSettings(p.id)}
onRemove={() => markDirty(draftPanels.filter((x) => x.id !== p.id))}
onMove={(dir) => movePanel(p.id, dir)}
/>
))}
</div>
{fullscreen ? (
<div className="pt-board-fs-legend" aria-hidden={false}>
<span className="pt-wall__legend-item pt-wall__legend-item--in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentOut")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--base-in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--base-out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineOut")}
</span>
</div>
) : null}
</div>
)}
</div>
)}
{editing && editPanel ? (
<div className="modal-backdrop" role="presentation" onClick={closePanelSettings}>
<div
className="modal"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<h3>{t("portTraffic.boardPanelSettings")}</h3>
<div className="form-grid" style={{ marginTop: 12 }}>
<label>
{t("portTraffic.boardPanelTitle")}
<input
value={editPanel.title}
onChange={(e) => updatePanel(editPanel.id, { title: e.target.value })}
/>
</label>
<label>
{t("portTraffic.boardPickDevice")}
<select
value={editTargetsDeviceId}
onChange={(e) => {
setEditPickDeviceId(e.target.value);
updatePanel(editPanel.id, { target_id: "", target: null, stale: true });
}}
>
<option value="">{t("portTraffic.boardPickDevice")}</option>
{(devicesQuery.data?.items || []).map((d) => (
<option key={d.id} value={d.id}>
{d.ne_name || d.ne_ip || d.id}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.wallPort")}
<select
value={editPanel.target_id}
disabled={!editTargetsDeviceId}
onChange={(e) => {
const tid = e.target.value;
const tgt = (editTargetsQuery.data?.items || []).find((x) => x.id === tid);
updatePanel(editPanel.id, {
target_id: tid,
target: tgt || null,
stale: !tgt,
...(tid && tid === editPanel.baseline_target_id
? { baseline_target_id: "", baseline_target: null }
: {}),
});
}}
>
<option value="">{t("portTraffic.pickPort")}</option>
{(editTargetsQuery.data?.items || [])
.filter((x) => x.status === "active" || x.id === editPanel.target_id)
.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.range")}
<select
value={editPanel.range_hours}
onChange={(e) =>
updatePanel(editPanel.id, { range_hours: Number(e.target.value) || 24 })
}
>
<option value={1}>1h</option>
<option value={6}>6h</option>
<option value={24}>24h</option>
</select>
</label>
<label>
{t("portTraffic.compare")}
<select
value={editPanel.baseline}
onChange={(e) =>
updatePanel(editPanel.id, { baseline: e.target.value as BaselineMode })
}
>
<option value="off">{t("portTraffic.compareOff")}</option>
<option value="day">{t("portTraffic.compareDay")}</option>
<option value="week">{t("portTraffic.compareWeek")}</option>
<option value="shift">{t("portTraffic.compareShift")}</option>
<option value="custom">{t("portTraffic.compareCustom")}</option>
</select>
</label>
{editPanel.baseline === "custom" ? (
<label>
{t("portTraffic.offsetHours")}
<input
type="number"
min={1}
max={24 * 90}
value={editPanel.offset_hours || 48}
onChange={(e) =>
updatePanel(editPanel.id, {
offset_hours: Number(e.target.value) || 24,
})
}
/>
</label>
) : null}
<label>
{t("portTraffic.yMode")}
<select
value={editPanel.y_mode}
onChange={(e) =>
updatePanel(editPanel.id, { y_mode: e.target.value as WallYMode })
}
>
<option value="auto">{t("portTraffic.yModeAuto")}</option>
<option value="current">{t("portTraffic.yModeCurrent")}</option>
<option value="util">{t("portTraffic.yModeUtil")}</option>
</select>
</label>
<label>
{t("portTraffic.boardColSpan")}
<select
value={editPanel.col_span || 1}
onChange={(e) =>
updatePanel(editPanel.id, {
col_span: Math.min(cols, Number(e.target.value) || 1),
})
}
>
{Array.from({ length: cols }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.filterMap")} · {t("portTraffic.wallDevice")}
<select
value={editMapBaselineDeviceId}
disabled={!editPanel.target_id}
onChange={(e) => {
setEditMapBaselineDeviceId(e.target.value);
updatePanel(editPanel.id, {
baseline_target_id: "",
baseline_target: null,
});
}}
>
<option value="">{t("portTraffic.mapBaselineNone")}</option>
{(devicesQuery.data?.items || []).map((d) => (
<option key={d.id} value={d.id}>
{d.ne_name || d.ne_ip || d.id}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.mapBaselinePort")}
<select
value={editPanel.baseline_target_id || ""}
disabled={!editMapBaselineDeviceId}
onChange={(e) => {
const tid = e.target.value;
const tgt = mapBaselineOptions.find((x) => x.id === tid) || null;
updatePanel(editPanel.id, {
baseline_target_id: tid,
baseline_target: tgt,
});
}}
>
<option value="">{t("portTraffic.pickPort")}</option>
{mapBaselineOptions.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
</div>
{editPanel.baseline === "week" ? (
<p className="muted" style={{ marginTop: 10 }}>
{t("portTraffic.retentionHint")}
</p>
) : null}
<div className="modal__actions">
<button type="button" className="btn-primary" onClick={closePanelSettings}>
{t("portTraffic.logClose")}
</button>
</div>
</div>
</div>
) : null}
</section>
);
}

View file

@ -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<PortTrafficCompare>(`/v1/port-traffic/compare?${p.toString()}`);
};
export const fetchPortTrafficBoards = () =>
apiGet<{ items: PortTrafficBoardSummary[] }>("/v1/port-traffic/boards");
export const fetchPortTrafficBoard = (boardId: string) =>
apiGet<PortTrafficBoard>(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`);
export const createPortTrafficBoard = (body: {
name: string;
remark?: string;
cols?: number;
panels?: PortTrafficBoardPanelIn[];
}) => apiPost<PortTrafficBoard>("/v1/port-traffic/boards", body);
export const updatePortTrafficBoard = (
boardId: string,
body: { name?: string; remark?: string; cols?: number },
) => apiPatch<PortTrafficBoard>(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`, body);
export const putPortTrafficBoardPanels = (boardId: string, panels: PortTrafficBoardPanelIn[]) =>
apiPut<PortTrafficBoard>(`/v1/port-traffic/boards/${encodeURIComponent(boardId)}/panels`, {
panels,
});
export const deletePortTrafficBoard = (boardId: string) =>
apiDelete<{ ok: boolean; id: string }>(
`/v1/port-traffic/boards/${encodeURIComponent(boardId)}`,
);

View file

@ -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;

View file

@ -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),
});
}