mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
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:
parent
214bc56774
commit
00d3e95e2d
22 changed files with 1923 additions and 345 deletions
|
|
@ -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)
|
||||
|
|
|
|||
236
netx_api/port_traffic_board_service.py
Normal file
236
netx_api/port_traffic_board_service.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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=` 打开向导并预填网元
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
145
web/src/pages/network/PortTrafficBoardListPage.tsx
Normal file
145
web/src/pages/network/PortTrafficBoardListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
web/src/pages/network/PortTrafficBoardPanelCell.tsx
Normal file
132
web/src/pages/network/PortTrafficBoardPanelCell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
17
web/src/utils/portTrafficBoardWindow.ts
Normal file
17
web/src/utils/portTrafficBoardWindow.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue