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
)