mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
Add period and cross-task interface compare for port traffic wall.
Compare by interface target_id with optional mapped baseline (same window or time-shifted), keep chart samples on target_row_id, and unify UI wording to interface. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0d85b87be7
commit
653baa1b9f
16 changed files with 1185 additions and 129 deletions
|
|
@ -813,8 +813,11 @@ def on_startup() -> None:
|
|||
"ALTER TABLE app_user ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT FALSE"
|
||||
)
|
||||
conn.exec_driver_sql("ALTER TABLE api_token ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP")
|
||||
from .port_traffic_migrate import ensure_port_traffic_series_schema
|
||||
|
||||
ensure_port_traffic_series_schema(conn)
|
||||
except Exception:
|
||||
_schedule_log.exception("startup: auth schema migration failed")
|
||||
_schedule_log.exception("startup: auth/port_traffic schema migration failed")
|
||||
_reset_runtime_pause_flags()
|
||||
_fail_stale_running_sync_jobs_on_startup()
|
||||
if _needs_startup_alarm_sync_before_ws():
|
||||
|
|
@ -847,6 +850,12 @@ def on_startup() -> None:
|
|||
pt_cleared = recover_port_traffic_on_startup(db)
|
||||
if pt_cleared:
|
||||
_schedule_log.info("startup: cleared %s port_traffic stuck collect_running flag(s)", pt_cleared)
|
||||
try:
|
||||
from .port_traffic_migrate import backfill_port_traffic_series
|
||||
|
||||
backfill_port_traffic_series(db)
|
||||
except Exception:
|
||||
_schedule_log.exception("startup: port_traffic series backfill failed")
|
||||
except Exception:
|
||||
_schedule_log.exception("startup: ne collection / config_sync recovery failed")
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -609,6 +609,19 @@ class PortTrafficTask(Base):
|
|||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class PortTrafficSeries(Base):
|
||||
"""Logical port (business link) that survives physical NE/if replacement."""
|
||||
|
||||
__tablename__ = "port_traffic_series"
|
||||
__table_args__ = (UniqueConstraint("task_id", "title", name="uq_port_traffic_series_title"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
title: Mapped[str] = mapped_column(String(256), default="")
|
||||
status: Mapped[str] = mapped_column(String(32), default="active", index=True) # active|disabled
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class PortTrafficTarget(Base):
|
||||
"""Monitored interface under a port traffic task."""
|
||||
|
||||
|
|
@ -619,6 +632,7 @@ class PortTrafficTarget(Base):
|
|||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
series_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
source: Mapped[str] = mapped_column(String(32), default="managed", index=True)
|
||||
target_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
ne_name: Mapped[str] = mapped_column(String(256), default="")
|
||||
|
|
@ -627,7 +641,7 @@ class PortTrafficTarget(Base):
|
|||
ifname: Mapped[str] = mapped_column(String(128), default="")
|
||||
if_description: Mapped[str] = mapped_column(String(512), default="")
|
||||
bw_bps: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
status: Mapped[str] = mapped_column(String(32), default="active", index=True) # active|disabled
|
||||
status: Mapped[str] = mapped_column(String(32), default="active", index=True) # active|disabled|retired
|
||||
last_error: Mapped[str] = mapped_column(String(1024), default="")
|
||||
last_sample_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
|
@ -641,6 +655,7 @@ class PortTrafficSample(Base):
|
|||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
target_row_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
series_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
ts: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
in_bps: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
out_bps: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
|
|
|||
134
netx_api/port_traffic_migrate.py
Normal file
134
netx_api/port_traffic_migrate.py
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"""Startup migration / backfill for port traffic logical series."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import PortTrafficSample, PortTrafficSeries, PortTrafficTarget
|
||||
|
||||
_log = logging.getLogger("netx.port_traffic.migrate")
|
||||
|
||||
|
||||
def ensure_port_traffic_series_schema(conn) -> None:
|
||||
"""DDL for series + series_id columns (Postgres IF NOT EXISTS)."""
|
||||
conn.exec_driver_sql(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS port_traffic_series (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
task_id VARCHAR(64),
|
||||
title VARCHAR(256) DEFAULT '',
|
||||
status VARCHAR(32) DEFAULT 'active',
|
||||
created_at TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_port_traffic_series_task_id ON port_traffic_series (task_id)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_port_traffic_series_status ON port_traffic_series (status)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_port_traffic_series_title ON port_traffic_series (task_id, title)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE port_traffic_target ADD COLUMN IF NOT EXISTS series_id VARCHAR(64) DEFAULT ''"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_port_traffic_target_series_id ON port_traffic_target (series_id)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE port_traffic_sample ADD COLUMN IF NOT EXISTS series_id VARCHAR(64) DEFAULT ''"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_port_traffic_sample_series_id ON port_traffic_sample (series_id)"
|
||||
)
|
||||
|
||||
|
||||
def backfill_port_traffic_series(db: Session) -> int:
|
||||
"""Create one series per target missing series_id; stamp samples."""
|
||||
targets = (
|
||||
db.query(PortTrafficTarget)
|
||||
.filter((PortTrafficTarget.series_id == None) | (PortTrafficTarget.series_id == "")) # noqa: E711
|
||||
.all()
|
||||
)
|
||||
created = 0
|
||||
for t in targets:
|
||||
title = _default_series_title(t.ne_name or "", t.ifname or "")
|
||||
title = _unique_series_title(db, str(t.task_id), title)
|
||||
sid = uuid4().hex
|
||||
db.add(
|
||||
PortTrafficSeries(
|
||||
id=sid,
|
||||
task_id=str(t.task_id),
|
||||
title=title,
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
t.series_id = sid
|
||||
db.query(PortTrafficSample).filter(
|
||||
PortTrafficSample.target_row_id == str(t.id),
|
||||
(PortTrafficSample.series_id == None) | (PortTrafficSample.series_id == ""), # noqa: E711
|
||||
).update({"series_id": sid}, synchronize_session=False)
|
||||
created += 1
|
||||
if created:
|
||||
db.commit()
|
||||
_log.info("port_traffic series backfill targets=%s", created)
|
||||
try:
|
||||
orphans = db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE port_traffic_sample AS s
|
||||
SET series_id = t.series_id
|
||||
FROM port_traffic_target AS t
|
||||
WHERE s.target_row_id = t.id
|
||||
AND COALESCE(s.series_id, '') = ''
|
||||
AND COALESCE(t.series_id, '') <> ''
|
||||
"""
|
||||
)
|
||||
)
|
||||
if orphans.rowcount:
|
||||
db.commit()
|
||||
_log.info("port_traffic series sample stamp rows=%s", orphans.rowcount)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return created
|
||||
|
||||
|
||||
def default_series_title(ne_name: str, ifname: str) -> str:
|
||||
return _default_series_title(ne_name, ifname)
|
||||
|
||||
|
||||
def unique_series_title(db: Session, task_id: str, base: str) -> str:
|
||||
return _unique_series_title(db, task_id, base)
|
||||
|
||||
|
||||
def _default_series_title(ne_name: str, ifname: str) -> str:
|
||||
ne = (ne_name or "").strip() or "NE"
|
||||
iface = (ifname or "").strip() or "if"
|
||||
return f"{ne}:{iface}"[:256]
|
||||
|
||||
|
||||
def _unique_series_title(db: Session, task_id: str, base: str) -> str:
|
||||
title = base[:256]
|
||||
exists = (
|
||||
db.query(PortTrafficSeries.id)
|
||||
.filter(PortTrafficSeries.task_id == task_id, PortTrafficSeries.title == title)
|
||||
.first()
|
||||
)
|
||||
if not exists:
|
||||
return title
|
||||
for i in range(2, 1000):
|
||||
candidate = f"{base[:240]}#{i}"
|
||||
exists = (
|
||||
db.query(PortTrafficSeries.id)
|
||||
.filter(PortTrafficSeries.task_id == task_id, PortTrafficSeries.title == candidate)
|
||||
.first()
|
||||
)
|
||||
if not exists:
|
||||
return candidate
|
||||
return f"{base[:200]}#{uuid4().hex[:8]}"
|
||||
|
|
@ -11,20 +11,24 @@ from .auth_service import write_audit
|
|||
from .db import get_db
|
||||
from .port_traffic_schemas import (
|
||||
DiscoverPortsRequest,
|
||||
PortTrafficReplacePortRequest,
|
||||
PortTrafficTaskCreate,
|
||||
PortTrafficTaskUpdate,
|
||||
PortTrafficTargetsPut,
|
||||
)
|
||||
from .port_traffic_service import (
|
||||
compare_targets,
|
||||
create_task,
|
||||
dashboard,
|
||||
delete_task,
|
||||
discover_ports,
|
||||
get_samples,
|
||||
get_task,
|
||||
list_series,
|
||||
list_targets,
|
||||
list_tasks,
|
||||
put_targets,
|
||||
replace_series_port,
|
||||
set_task_status,
|
||||
update_task,
|
||||
)
|
||||
|
|
@ -227,6 +231,34 @@ def api_list_targets(task_id: str, db: Session = Depends(get_db)):
|
|||
return {"items": [t.model_dump() for t in list_targets(db, task_id)]}
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/series")
|
||||
def api_list_series(task_id: str, db: Session = Depends(get_db)):
|
||||
return {"items": [s.model_dump() for s in list_series(db, task_id)]}
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/series/{series_id}/replace")
|
||||
def api_replace_series_port(
|
||||
task_id: str,
|
||||
series_id: str,
|
||||
body: PortTrafficReplacePortRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
out = replace_series_port(db, task_id, series_id, body)
|
||||
uid, uname = _actor(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="port_traffic.series.replace",
|
||||
actor_user_id=uid,
|
||||
actor_username=uname,
|
||||
method="POST",
|
||||
path=f"/v1/port-traffic/tasks/{task_id}/series/{series_id}/replace",
|
||||
status_code=200,
|
||||
detail={"series_id": series_id, "ifname": body.ifname, "target_id": body.target_id},
|
||||
)
|
||||
return out.model_dump()
|
||||
|
||||
|
||||
@router.put("/tasks/{task_id}/targets")
|
||||
def api_put_targets(
|
||||
task_id: str,
|
||||
|
|
@ -262,3 +294,27 @@ def api_samples(
|
|||
db: Session = Depends(get_db),
|
||||
):
|
||||
return get_samples(db, target_row_id=target_id, from_ts=from_ts, to_ts=to_ts).model_dump()
|
||||
|
||||
|
||||
@router.get("/compare")
|
||||
def api_compare(
|
||||
target_id: str = Query(..., description="port_traffic_target row id (interface)"),
|
||||
range_hours: float = Query(default=24, ge=0.25, le=24 * 90),
|
||||
baseline: str = Query(default="off", description="off|shift|day|week|custom"),
|
||||
offset_hours: float | None = Query(default=None, ge=0.25, le=24 * 90),
|
||||
baseline_target_id: str | None = Query(
|
||||
default=None,
|
||||
description="optional mapped interface for baseline overlay (any task)",
|
||||
),
|
||||
to_ts: datetime | None = Query(default=None, alias="to"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return compare_targets(
|
||||
db,
|
||||
target_row_id=target_id,
|
||||
range_hours=range_hours,
|
||||
baseline=baseline,
|
||||
offset_hours=offset_hours,
|
||||
baseline_target_id=baseline_target_id,
|
||||
to_ts=to_ts,
|
||||
).model_dump()
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ def _sample_one_target(target_row_id: str) -> None:
|
|||
PortTrafficSample(
|
||||
id=uuid4().hex,
|
||||
target_row_id=target_row_id,
|
||||
series_id=str(row.series_id or ""),
|
||||
ts=now,
|
||||
in_bps=float(parsed.in_bps),
|
||||
out_bps=float(parsed.out_bps),
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ class PortTrafficTaskOut(BaseModel):
|
|||
class PortTrafficTargetOut(BaseModel):
|
||||
id: str
|
||||
task_id: str
|
||||
series_id: str = ""
|
||||
source: str
|
||||
target_id: str
|
||||
ne_name: str
|
||||
|
|
@ -77,10 +78,34 @@ class PortTrafficTargetOut(BaseModel):
|
|||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class PortTrafficSeriesOut(BaseModel):
|
||||
id: str
|
||||
task_id: str
|
||||
title: str
|
||||
status: str
|
||||
active_target: PortTrafficTargetOut | None = None
|
||||
retired_target_count: int = 0
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class PortTrafficTargetsPut(BaseModel):
|
||||
targets: list[PortTrafficTargetIn]
|
||||
|
||||
|
||||
class PortTrafficReplacePortRequest(BaseModel):
|
||||
"""Replace the active interface under a logical series (keeps history)."""
|
||||
|
||||
source: Literal["managed", "ume"]
|
||||
target_id: str
|
||||
ne_name: str = ""
|
||||
ne_ip: str = ""
|
||||
vendor: str = ""
|
||||
ifname: str
|
||||
if_description: str = ""
|
||||
bw_bps: int = 0
|
||||
series_title: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class DiscoverPortsRequest(BaseModel):
|
||||
source: Literal["managed", "ume"]
|
||||
id: str
|
||||
|
|
@ -116,6 +141,7 @@ class PortTrafficSamplePoint(BaseModel):
|
|||
out_util_pct: float
|
||||
bw_bps: int
|
||||
rate_period_sec: int = 0
|
||||
ts_raw: datetime | None = None # baseline original time when aligned
|
||||
|
||||
|
||||
class PortTrafficSamplesOut(BaseModel):
|
||||
|
|
@ -123,6 +149,22 @@ class PortTrafficSamplesOut(BaseModel):
|
|||
points: list[PortTrafficSamplePoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PortTrafficCompareMeta(BaseModel):
|
||||
target_id: str
|
||||
baseline: str = "off"
|
||||
offset_hours: float = 0
|
||||
range_hours: float = 24
|
||||
current_target: PortTrafficTargetOut | None = None
|
||||
baseline_target: PortTrafficTargetOut | None = None
|
||||
baseline_target_id: str = ""
|
||||
|
||||
|
||||
class PortTrafficCompareOut(BaseModel):
|
||||
meta: PortTrafficCompareMeta
|
||||
current: list[PortTrafficSamplePoint] = Field(default_factory=list)
|
||||
baseline: list[PortTrafficSamplePoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PortTrafficDashboardOut(BaseModel):
|
||||
task_count: int = 0
|
||||
running_task_count: int = 0
|
||||
|
|
|
|||
|
|
@ -13,18 +13,23 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from .cli_resolve import resolve_cli_target
|
||||
from .config import settings
|
||||
from .models import PortTrafficSample, PortTrafficTarget, PortTrafficTask
|
||||
from .models import PortTrafficSample, PortTrafficSeries, PortTrafficTarget, PortTrafficTask
|
||||
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
|
||||
from .ne_netmiko import send_show_command
|
||||
from .port_traffic_commands import commands_for_vendor
|
||||
from .port_traffic_migrate import default_series_title, unique_series_title
|
||||
from .port_traffic_parsers import brief_port_to_dict, parse_interface_brief
|
||||
from .port_traffic_schemas import (
|
||||
DiscoverPortItem,
|
||||
DiscoverPortsRequest,
|
||||
DiscoverPortsResponse,
|
||||
PortTrafficCompareMeta,
|
||||
PortTrafficCompareOut,
|
||||
PortTrafficDashboardOut,
|
||||
PortTrafficReplacePortRequest,
|
||||
PortTrafficSamplePoint,
|
||||
PortTrafficSamplesOut,
|
||||
PortTrafficSeriesOut,
|
||||
PortTrafficTargetIn,
|
||||
PortTrafficTargetOut,
|
||||
PortTrafficTargetsPut,
|
||||
|
|
@ -70,6 +75,7 @@ def _target_out(row: PortTrafficTarget) -> PortTrafficTargetOut:
|
|||
return PortTrafficTargetOut(
|
||||
id=str(row.id),
|
||||
task_id=str(row.task_id),
|
||||
series_id=str(row.series_id or ""),
|
||||
source=str(row.source or ""),
|
||||
target_id=str(row.target_id or ""),
|
||||
ne_name=str(row.ne_name or ""),
|
||||
|
|
@ -85,6 +91,43 @@ def _target_out(row: PortTrafficTarget) -> PortTrafficTargetOut:
|
|||
)
|
||||
|
||||
|
||||
def _create_series_and_target(
|
||||
db: Session,
|
||||
*,
|
||||
task_id: str,
|
||||
t: PortTrafficTargetIn,
|
||||
now: datetime,
|
||||
) -> PortTrafficTarget:
|
||||
title = unique_series_title(db, task_id, default_series_title(t.ne_name or "", t.ifname.strip()))
|
||||
sid = uuid4().hex
|
||||
db.add(
|
||||
PortTrafficSeries(
|
||||
id=sid,
|
||||
task_id=task_id,
|
||||
title=title,
|
||||
status="active",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
row = PortTrafficTarget(
|
||||
id=uuid4().hex,
|
||||
task_id=task_id,
|
||||
series_id=sid,
|
||||
source=t.source,
|
||||
target_id=t.target_id,
|
||||
ne_name=t.ne_name or "",
|
||||
ne_ip=t.ne_ip or "",
|
||||
vendor=t.vendor or "",
|
||||
ifname=t.ifname.strip(),
|
||||
if_description=t.if_description or "",
|
||||
bw_bps=int(t.bw_bps or 0),
|
||||
status="active",
|
||||
created_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
return row
|
||||
|
||||
|
||||
def _assert_supported_targets(targets: list[PortTrafficTargetIn]) -> None:
|
||||
for t in targets:
|
||||
cmds = commands_for_vendor(t.vendor or "", "")
|
||||
|
|
@ -133,22 +176,7 @@ def create_task(db: Session, body: PortTrafficTaskCreate) -> PortTrafficTaskOut:
|
|||
db.add(task)
|
||||
db.flush()
|
||||
for t in body.targets:
|
||||
db.add(
|
||||
PortTrafficTarget(
|
||||
id=uuid4().hex,
|
||||
task_id=task.id,
|
||||
source=t.source,
|
||||
target_id=t.target_id,
|
||||
ne_name=t.ne_name or "",
|
||||
ne_ip=t.ne_ip or "",
|
||||
vendor=t.vendor or "",
|
||||
ifname=t.ifname.strip(),
|
||||
if_description=t.if_description or "",
|
||||
bw_bps=int(t.bw_bps or 0),
|
||||
status="active",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
_create_series_and_target(db, task_id=task.id, t=t, now=now)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return _task_out(db, task)
|
||||
|
|
@ -181,6 +209,7 @@ def delete_task(db: Session, task_id: str) -> dict[str, Any]:
|
|||
raise HTTPException(status_code=409, detail="collect_running")
|
||||
targets = db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).all()
|
||||
ids = [str(t.id) for t in targets]
|
||||
series_ids = [str(t.series_id) for t in targets if t.series_id]
|
||||
if ids:
|
||||
db.query(PortTrafficSample).filter(PortTrafficSample.target_row_id.in_(ids)).delete(
|
||||
synchronize_session=False
|
||||
|
|
@ -188,6 +217,14 @@ def delete_task(db: Session, task_id: str) -> dict[str, Any]:
|
|||
db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
if series_ids:
|
||||
db.query(PortTrafficSeries).filter(PortTrafficSeries.id.in_(series_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
else:
|
||||
db.query(PortTrafficSeries).filter(PortTrafficSeries.task_id == task_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
return {"ok": True, "id": task_id}
|
||||
|
|
@ -223,6 +260,7 @@ def put_targets(db: Session, task_id: str, body: PortTrafficTargetsPut) -> list[
|
|||
_assert_supported_targets(body.targets)
|
||||
old = db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).all()
|
||||
old_ids = [str(t.id) for t in old]
|
||||
old_series = list({str(t.series_id) for t in old if t.series_id})
|
||||
if old_ids:
|
||||
db.query(PortTrafficSample).filter(PortTrafficSample.target_row_id.in_(old_ids)).delete(
|
||||
synchronize_session=False
|
||||
|
|
@ -230,25 +268,14 @@ def put_targets(db: Session, task_id: str, body: PortTrafficTargetsPut) -> list[
|
|||
db.query(PortTrafficTarget).filter(PortTrafficTarget.task_id == task_id).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
if old_series:
|
||||
db.query(PortTrafficSeries).filter(PortTrafficSeries.id.in_(old_series)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
now = _utcnow()
|
||||
rows: list[PortTrafficTarget] = []
|
||||
for t in body.targets:
|
||||
row = PortTrafficTarget(
|
||||
id=uuid4().hex,
|
||||
task_id=task_id,
|
||||
source=t.source,
|
||||
target_id=t.target_id,
|
||||
ne_name=t.ne_name or "",
|
||||
ne_ip=t.ne_ip or "",
|
||||
vendor=t.vendor or "",
|
||||
ifname=t.ifname.strip(),
|
||||
if_description=t.if_description or "",
|
||||
bw_bps=int(t.bw_bps or 0),
|
||||
status="active",
|
||||
created_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
rows.append(row)
|
||||
rows.append(_create_series_and_target(db, task_id=task_id, t=t, now=now))
|
||||
task.updated_at = now
|
||||
db.commit()
|
||||
return [_target_out(r) for r in rows]
|
||||
|
|
@ -267,6 +294,116 @@ def list_targets(db: Session, task_id: str) -> list[PortTrafficTargetOut]:
|
|||
return [_target_out(r) for r in rows]
|
||||
|
||||
|
||||
def list_series(db: Session, task_id: str) -> list[PortTrafficSeriesOut]:
|
||||
task = db.get(PortTrafficTask, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="task_not_found")
|
||||
rows = (
|
||||
db.query(PortTrafficSeries)
|
||||
.filter(PortTrafficSeries.task_id == task_id)
|
||||
.order_by(PortTrafficSeries.title.asc())
|
||||
.all()
|
||||
)
|
||||
out: list[PortTrafficSeriesOut] = []
|
||||
for s in rows:
|
||||
active = (
|
||||
db.query(PortTrafficTarget)
|
||||
.filter(PortTrafficTarget.series_id == s.id, PortTrafficTarget.status == "active")
|
||||
.order_by(PortTrafficTarget.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
retired = (
|
||||
db.query(PortTrafficTarget)
|
||||
.filter(PortTrafficTarget.series_id == s.id, PortTrafficTarget.status == "retired")
|
||||
.count()
|
||||
)
|
||||
out.append(
|
||||
PortTrafficSeriesOut(
|
||||
id=str(s.id),
|
||||
task_id=str(s.task_id),
|
||||
title=str(s.title or ""),
|
||||
status=str(s.status or ""),
|
||||
active_target=_target_out(active) if active else None,
|
||||
retired_target_count=int(retired),
|
||||
created_at=s.created_at,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def replace_series_port(
|
||||
db: Session,
|
||||
task_id: str,
|
||||
series_id: str,
|
||||
body: PortTrafficReplacePortRequest,
|
||||
) -> PortTrafficSeriesOut:
|
||||
task = db.get(PortTrafficTask, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="task_not_found")
|
||||
if bool(task.collect_running):
|
||||
raise HTTPException(status_code=409, detail="collect_running")
|
||||
series = db.get(PortTrafficSeries, series_id)
|
||||
if not series or str(series.task_id) != task_id:
|
||||
raise HTTPException(status_code=404, detail="series_not_found")
|
||||
tmp = PortTrafficTargetIn(
|
||||
source=body.source,
|
||||
target_id=body.target_id,
|
||||
ne_name=body.ne_name,
|
||||
ne_ip=body.ne_ip,
|
||||
vendor=body.vendor,
|
||||
ifname=body.ifname,
|
||||
if_description=body.if_description,
|
||||
bw_bps=body.bw_bps,
|
||||
)
|
||||
_assert_supported_targets([tmp])
|
||||
now = _utcnow()
|
||||
actives = (
|
||||
db.query(PortTrafficTarget)
|
||||
.filter(PortTrafficTarget.series_id == series_id, PortTrafficTarget.status == "active")
|
||||
.all()
|
||||
)
|
||||
for old in actives:
|
||||
old.status = "retired"
|
||||
if body.series_title is not None and str(body.series_title).strip():
|
||||
wanted = str(body.series_title).strip()[:256]
|
||||
if wanted != str(series.title or ""):
|
||||
clash = (
|
||||
db.query(PortTrafficSeries.id)
|
||||
.filter(
|
||||
PortTrafficSeries.task_id == task_id,
|
||||
PortTrafficSeries.title == wanted,
|
||||
PortTrafficSeries.id != series_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if clash:
|
||||
raise HTTPException(status_code=400, detail="series_title_exists")
|
||||
series.title = wanted
|
||||
row = PortTrafficTarget(
|
||||
id=uuid4().hex,
|
||||
task_id=task_id,
|
||||
series_id=series_id,
|
||||
source=body.source,
|
||||
target_id=body.target_id,
|
||||
ne_name=body.ne_name or "",
|
||||
ne_ip=body.ne_ip or "",
|
||||
vendor=body.vendor or "",
|
||||
ifname=body.ifname.strip(),
|
||||
if_description=body.if_description or "",
|
||||
bw_bps=int(body.bw_bps or 0),
|
||||
status="active",
|
||||
created_at=now,
|
||||
)
|
||||
db.add(row)
|
||||
task.updated_at = now
|
||||
db.commit()
|
||||
items = list_series(db, task_id)
|
||||
for item in items:
|
||||
if item.id == series_id:
|
||||
return item
|
||||
raise HTTPException(status_code=500, detail="series_replace_failed")
|
||||
|
||||
|
||||
def discover_ports(db: Session, body: DiscoverPortsRequest) -> DiscoverPortsResponse:
|
||||
try:
|
||||
if body.source == "managed":
|
||||
|
|
@ -320,6 +457,158 @@ def _as_naive_utc(value: datetime | None) -> datetime | None:
|
|||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _sample_points(
|
||||
rows: list[PortTrafficSample],
|
||||
*,
|
||||
align_offset: timedelta | None = None,
|
||||
) -> list[PortTrafficSamplePoint]:
|
||||
points: list[PortTrafficSamplePoint] = []
|
||||
for r in rows:
|
||||
ts_raw = r.ts
|
||||
ts = ts_raw
|
||||
if align_offset is not None and ts_raw is not None:
|
||||
ts = ts_raw + align_offset
|
||||
points.append(
|
||||
PortTrafficSamplePoint(
|
||||
ts=ts,
|
||||
ts_raw=ts_raw if align_offset is not None else None,
|
||||
in_bps=float(r.in_bps or 0),
|
||||
out_bps=float(r.out_bps or 0),
|
||||
in_util_pct=float(r.in_util_pct or 0),
|
||||
out_util_pct=float(r.out_util_pct or 0),
|
||||
bw_bps=int(r.bw_bps or 0),
|
||||
rate_period_sec=int(r.rate_period_sec or 0),
|
||||
)
|
||||
)
|
||||
return points
|
||||
|
||||
|
||||
def _query_target_samples(
|
||||
db: Session,
|
||||
*,
|
||||
target_row_id: str,
|
||||
from_ts: datetime,
|
||||
to_ts: datetime,
|
||||
) -> list[PortTrafficSample]:
|
||||
return (
|
||||
db.query(PortTrafficSample)
|
||||
.filter(
|
||||
PortTrafficSample.target_row_id == target_row_id,
|
||||
PortTrafficSample.ts >= from_ts,
|
||||
PortTrafficSample.ts <= to_ts,
|
||||
PortTrafficSample.raw_ok.is_(True),
|
||||
)
|
||||
.order_by(PortTrafficSample.ts.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def baseline_offset_hours(baseline: str, range_hours: float, offset_hours: float | None) -> float | None:
|
||||
key = str(baseline or "off").strip().lower()
|
||||
if key in ("", "off", "none"):
|
||||
return None
|
||||
if key == "shift":
|
||||
return float(range_hours)
|
||||
if key == "day":
|
||||
return 24.0
|
||||
if key == "week":
|
||||
return 24.0 * 7
|
||||
if key == "custom":
|
||||
if offset_hours is None or float(offset_hours) <= 0:
|
||||
raise HTTPException(status_code=400, detail="offset_hours_required")
|
||||
return float(offset_hours)
|
||||
raise HTTPException(status_code=400, detail=f"invalid_baseline: {baseline}")
|
||||
|
||||
|
||||
def compare_targets(
|
||||
db: Session,
|
||||
*,
|
||||
target_row_id: str,
|
||||
range_hours: float = 24,
|
||||
baseline: str = "off",
|
||||
offset_hours: float | None = None,
|
||||
baseline_target_id: str | None = None,
|
||||
to_ts: datetime | None = None,
|
||||
) -> PortTrafficCompareOut:
|
||||
"""Compare current interface samples vs period and/or manually mapped interface."""
|
||||
target = db.get(PortTrafficTarget, target_row_id)
|
||||
if not target:
|
||||
raise HTTPException(status_code=404, detail="target_not_found")
|
||||
|
||||
mapped: PortTrafficTarget | None = None
|
||||
mapped_id = str(baseline_target_id or "").strip()
|
||||
if mapped_id:
|
||||
if mapped_id == str(target.id):
|
||||
raise HTTPException(status_code=400, detail="baseline_target_same_as_current")
|
||||
mapped = db.get(PortTrafficTarget, mapped_id)
|
||||
if not mapped:
|
||||
raise HTTPException(status_code=404, detail="baseline_target_not_found")
|
||||
|
||||
now = _utcnow()
|
||||
to_ts = _as_naive_utc(to_ts) or now
|
||||
range_h = max(0.25, float(range_hours or 24))
|
||||
from_ts = to_ts - timedelta(hours=range_h)
|
||||
to_q = to_ts + timedelta(seconds=5)
|
||||
current_rows = _query_target_samples(
|
||||
db, target_row_id=str(target.id), from_ts=from_ts, to_ts=to_q
|
||||
)
|
||||
current = _sample_points(current_rows)
|
||||
|
||||
off_h = baseline_offset_hours(baseline, range_h, offset_hours)
|
||||
baseline_points: list[PortTrafficSamplePoint] = []
|
||||
# Baseline source: mapped interface if set, else same interface (period compare only).
|
||||
base_src = mapped if mapped is not None else target
|
||||
want_baseline = off_h is not None or mapped is not None
|
||||
if want_baseline:
|
||||
if off_h is not None:
|
||||
delta = timedelta(hours=off_h)
|
||||
b_from = from_ts - delta
|
||||
b_to = to_q - delta
|
||||
base_rows = _query_target_samples(
|
||||
db, target_row_id=str(base_src.id), from_ts=b_from, to_ts=b_to
|
||||
)
|
||||
baseline_points = _sample_points(base_rows, align_offset=delta)
|
||||
else:
|
||||
# Mapped port, same window (no time shift) — cross-device overlay.
|
||||
base_rows = _query_target_samples(
|
||||
db, target_row_id=str(base_src.id), from_ts=from_ts, to_ts=to_q
|
||||
)
|
||||
baseline_points = _sample_points(base_rows)
|
||||
|
||||
return PortTrafficCompareOut(
|
||||
meta=PortTrafficCompareMeta(
|
||||
target_id=str(target.id),
|
||||
baseline=str(baseline or "off"),
|
||||
offset_hours=float(off_h or 0),
|
||||
range_hours=range_h,
|
||||
current_target=_target_out(target),
|
||||
baseline_target=_target_out(mapped) if mapped is not None else None,
|
||||
baseline_target_id=str(mapped.id) if mapped is not None else "",
|
||||
),
|
||||
current=current,
|
||||
baseline=baseline_points,
|
||||
)
|
||||
|
||||
|
||||
# Back-compat alias for older imports/tests.
|
||||
def compare_series(db: Session, **kwargs: Any) -> PortTrafficCompareOut:
|
||||
target_row_id = kwargs.pop("target_row_id", None) or kwargs.pop("target_id", None)
|
||||
series_id = kwargs.pop("series_id", None)
|
||||
if not target_row_id and series_id:
|
||||
active = (
|
||||
db.query(PortTrafficTarget)
|
||||
.filter(PortTrafficTarget.series_id == series_id, PortTrafficTarget.status == "active")
|
||||
.order_by(PortTrafficTarget.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if not active:
|
||||
raise HTTPException(status_code=404, detail="series_active_target_not_found")
|
||||
target_row_id = str(active.id)
|
||||
if not target_row_id:
|
||||
raise HTTPException(status_code=400, detail="target_id_required")
|
||||
return compare_targets(db, target_row_id=str(target_row_id), **kwargs)
|
||||
|
||||
|
||||
def get_samples(
|
||||
db: Session,
|
||||
*,
|
||||
|
|
@ -335,30 +624,8 @@ def get_samples(
|
|||
from_ts = _as_naive_utc(from_ts) or (to_ts - timedelta(hours=1))
|
||||
# Slight skew so just-written samples are not clipped by client clock.
|
||||
to_ts = to_ts + timedelta(seconds=5)
|
||||
rows = (
|
||||
db.query(PortTrafficSample)
|
||||
.filter(
|
||||
PortTrafficSample.target_row_id == target_row_id,
|
||||
PortTrafficSample.ts >= from_ts,
|
||||
PortTrafficSample.ts <= to_ts,
|
||||
PortTrafficSample.raw_ok.is_(True),
|
||||
)
|
||||
.order_by(PortTrafficSample.ts.asc())
|
||||
.all()
|
||||
)
|
||||
points = [
|
||||
PortTrafficSamplePoint(
|
||||
ts=r.ts,
|
||||
in_bps=float(r.in_bps or 0),
|
||||
out_bps=float(r.out_bps or 0),
|
||||
in_util_pct=float(r.in_util_pct or 0),
|
||||
out_util_pct=float(r.out_util_pct or 0),
|
||||
bw_bps=int(r.bw_bps or 0),
|
||||
rate_period_sec=int(r.rate_period_sec or 0),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return PortTrafficSamplesOut(target=_target_out(target), points=points)
|
||||
rows = _query_target_samples(db, target_row_id=target_row_id, from_ts=from_ts, to_ts=to_ts)
|
||||
return PortTrafficSamplesOut(target=_target_out(target), points=_sample_points(rows))
|
||||
|
||||
|
||||
def dashboard(db: Session) -> PortTrafficDashboardOut:
|
||||
|
|
|
|||
229
tests/test_port_traffic_compare.py
Normal file
229
tests/test_port_traffic_compare.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""Unit tests for port traffic compare: interface + optional mapped baseline."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from netx_api.db import Base
|
||||
from netx_api.models import PortTrafficSample, PortTrafficSeries, PortTrafficTarget, PortTrafficTask
|
||||
from netx_api.port_traffic_service import baseline_offset_hours, compare_targets
|
||||
|
||||
|
||||
class BaselineOffsetTests(unittest.TestCase):
|
||||
def test_presets(self):
|
||||
self.assertIsNone(baseline_offset_hours("off", 24, None))
|
||||
self.assertEqual(baseline_offset_hours("shift", 24, None), 24.0)
|
||||
self.assertEqual(baseline_offset_hours("day", 6, None), 24.0)
|
||||
self.assertEqual(baseline_offset_hours("week", 24, None), 168.0)
|
||||
self.assertEqual(baseline_offset_hours("custom", 24, 36), 36.0)
|
||||
|
||||
|
||||
class CompareTargetsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
self.Session = sessionmaker(bind=engine)
|
||||
self.db = self.Session()
|
||||
self.task_id = uuid4().hex
|
||||
self.series_id = uuid4().hex
|
||||
self.target_id = uuid4().hex
|
||||
self.mapped_id = uuid4().hex
|
||||
now = datetime(2026, 7, 31, 12, 0, 0)
|
||||
self.now = now
|
||||
self.db.add(
|
||||
PortTrafficTask(
|
||||
id=self.task_id,
|
||||
title="t1",
|
||||
status="running",
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
PortTrafficSeries(
|
||||
id=self.series_id,
|
||||
task_id=self.task_id,
|
||||
title="uplink1",
|
||||
status="active",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
PortTrafficTarget(
|
||||
id=self.target_id,
|
||||
task_id=self.task_id,
|
||||
series_id=self.series_id,
|
||||
source="managed",
|
||||
target_id="ne1",
|
||||
ne_name="R1",
|
||||
vendor="Huawei",
|
||||
ifname="Ethernet1/0/0",
|
||||
status="active",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
PortTrafficTarget(
|
||||
id=self.mapped_id,
|
||||
task_id=self.task_id,
|
||||
series_id="",
|
||||
source="managed",
|
||||
target_id="ne2",
|
||||
ne_name="R2",
|
||||
vendor="ZTE",
|
||||
ifname="gei-0/0/1",
|
||||
status="active",
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
# current window points (last 2h) on primary interface
|
||||
for i in range(3):
|
||||
self.db.add(
|
||||
PortTrafficSample(
|
||||
id=uuid4().hex,
|
||||
target_row_id=self.target_id,
|
||||
series_id=self.series_id,
|
||||
ts=now - timedelta(hours=1, minutes=30) + timedelta(minutes=30 * i),
|
||||
in_bps=1000 + i,
|
||||
out_bps=2000 + i,
|
||||
raw_ok=True,
|
||||
)
|
||||
)
|
||||
# baseline day-1 points on same port
|
||||
for i in range(3):
|
||||
self.db.add(
|
||||
PortTrafficSample(
|
||||
id=uuid4().hex,
|
||||
target_row_id=self.target_id,
|
||||
series_id=self.series_id,
|
||||
ts=now - timedelta(days=1, hours=1, minutes=30) + timedelta(minutes=30 * i),
|
||||
in_bps=500 + i,
|
||||
out_bps=800 + i,
|
||||
raw_ok=True,
|
||||
)
|
||||
)
|
||||
# mapped port same-window points
|
||||
for i in range(2):
|
||||
self.db.add(
|
||||
PortTrafficSample(
|
||||
id=uuid4().hex,
|
||||
target_row_id=self.mapped_id,
|
||||
series_id="",
|
||||
ts=now - timedelta(hours=1) + timedelta(minutes=30 * i),
|
||||
in_bps=90 + i,
|
||||
out_bps=110 + i,
|
||||
raw_ok=True,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
|
||||
def test_off_returns_current_only(self):
|
||||
out = compare_targets(
|
||||
self.db,
|
||||
target_row_id=self.target_id,
|
||||
range_hours=2,
|
||||
baseline="off",
|
||||
to_ts=self.now,
|
||||
)
|
||||
self.assertEqual(len(out.current), 3)
|
||||
self.assertEqual(len(out.baseline), 0)
|
||||
self.assertEqual(out.meta.target_id, self.target_id)
|
||||
|
||||
def test_day_baseline_same_port(self):
|
||||
out = compare_targets(
|
||||
self.db,
|
||||
target_row_id=self.target_id,
|
||||
range_hours=2,
|
||||
baseline="day",
|
||||
to_ts=self.now,
|
||||
)
|
||||
self.assertEqual(len(out.current), 3)
|
||||
self.assertEqual(len(out.baseline), 3)
|
||||
self.assertEqual(out.meta.offset_hours, 24.0)
|
||||
for p in out.baseline:
|
||||
self.assertIsNotNone(p.ts_raw)
|
||||
self.assertGreaterEqual(p.ts, self.now - timedelta(hours=2))
|
||||
self.assertLessEqual(p.ts, self.now + timedelta(seconds=5))
|
||||
self.assertEqual(out.baseline[0].in_bps, 500.0)
|
||||
|
||||
def test_mapped_port_same_window(self):
|
||||
out = compare_targets(
|
||||
self.db,
|
||||
target_row_id=self.target_id,
|
||||
range_hours=2,
|
||||
baseline="off",
|
||||
baseline_target_id=self.mapped_id,
|
||||
to_ts=self.now,
|
||||
)
|
||||
self.assertEqual(len(out.current), 3)
|
||||
self.assertEqual(len(out.baseline), 2)
|
||||
self.assertEqual(out.meta.baseline_target_id, self.mapped_id)
|
||||
self.assertEqual(out.baseline[0].in_bps, 90.0)
|
||||
self.assertIsNone(out.baseline[0].ts_raw)
|
||||
|
||||
def test_mapped_port_with_day_offset(self):
|
||||
# day-ago samples on mapped port
|
||||
for i in range(2):
|
||||
self.db.add(
|
||||
PortTrafficSample(
|
||||
id=uuid4().hex,
|
||||
target_row_id=self.mapped_id,
|
||||
ts=self.now - timedelta(days=1, hours=1) + timedelta(minutes=30 * i),
|
||||
in_bps=40 + i,
|
||||
out_bps=50 + i,
|
||||
raw_ok=True,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
out = compare_targets(
|
||||
self.db,
|
||||
target_row_id=self.target_id,
|
||||
range_hours=2,
|
||||
baseline="day",
|
||||
baseline_target_id=self.mapped_id,
|
||||
to_ts=self.now,
|
||||
)
|
||||
self.assertEqual(len(out.baseline), 2)
|
||||
self.assertEqual(out.baseline[0].in_bps, 40.0)
|
||||
self.assertIsNotNone(out.baseline[0].ts_raw)
|
||||
|
||||
def test_shift_baseline(self):
|
||||
for i in range(2):
|
||||
self.db.add(
|
||||
PortTrafficSample(
|
||||
id=uuid4().hex,
|
||||
target_row_id=self.target_id,
|
||||
series_id=self.series_id,
|
||||
ts=self.now - timedelta(hours=3, minutes=30) + timedelta(minutes=30 * i),
|
||||
in_bps=50 + i,
|
||||
out_bps=60 + i,
|
||||
raw_ok=True,
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
out = compare_targets(
|
||||
self.db,
|
||||
target_row_id=self.target_id,
|
||||
range_hours=2,
|
||||
baseline="shift",
|
||||
to_ts=self.now,
|
||||
)
|
||||
self.assertGreaterEqual(len(out.baseline), 2)
|
||||
self.assertEqual(out.meta.offset_hours, 2.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
11
web/WEB.md
11
web/WEB.md
|
|
@ -107,12 +107,15 @@ src/
|
|||
|
||||
## 端口流量监控
|
||||
|
||||
- API:`/v1/port-traffic/*`(任务 CRUD、discover/ports、samples、dashboard)
|
||||
- 厂商:ZTE(`show interface brief` / `show interface {if}`)、华为(`display interface brief` / `display interface {if}`)、思科(`show ip interface brief` / `show interfaces {if}`);解析速率 **bit/s**(华为暂无带宽则 `bw_bps=0`;思科暂无利用率则 util=0)
|
||||
- API:`/v1/port-traffic/*`(任务 CRUD、discover/ports、samples、compare、dashboard)
|
||||
- 接口:大屏与取样均按 `port_traffic_target`(网元 + ifname,含各类接口);样点按 `target_row_id`
|
||||
- 周期对比:`GET /v1/port-traffic/compare?target_id=&range_hours=&baseline=off|shift|day|week|custom`(同一接口时间平移叠图)
|
||||
- 手工映射:可选 `baseline_target_id`(可跨任务),基线取自另一个接口;可与周期偏移叠加
|
||||
- 厂商:ZTE / 华为 / 思科;解析速率 **bit/s**
|
||||
- 调度:`NETX_PORT_TRAFFIC_SCHEDULER_ENABLED`(默认开),tick `NETX_PORT_TRAFFIC_SCHEDULER_TICK_SEC`(默认 15)
|
||||
- 单飞:同一任务同时只允许一轮采集;崩溃启动清除 `collect_running`
|
||||
- 保留:按任务 `retention_days` 清理过期 sample
|
||||
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 监控大屏)
|
||||
- 保留:按任务 `retention_days` 清理过期 sample(周对比建议 ≥8 天)
|
||||
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 大屏;接口选择 + 周期对比 + 跨任务映射基线接口)
|
||||
|
||||
## WebCRT
|
||||
|
||||
|
|
|
|||
|
|
@ -65,4 +65,11 @@ export const queryKeys = {
|
|||
portTrafficTargets: (taskId: string) => ["portTrafficTargets", taskId] as const,
|
||||
portTrafficSamples: (targetId: string, rangeHours: number) =>
|
||||
["portTrafficSamples", targetId, rangeHours] as const,
|
||||
portTrafficCompare: (
|
||||
targetId: string,
|
||||
rangeHours: number,
|
||||
baseline: string,
|
||||
offsetHours: number,
|
||||
baselineTargetId: string,
|
||||
) => ["portTrafficCompare", targetId, rangeHours, baseline, offsetHours, baselineTargetId] as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -81,28 +81,47 @@ const en = {
|
|||
delete: "Delete",
|
||||
step1: "Params",
|
||||
step2: "NEs",
|
||||
step3: "Ports",
|
||||
step3: "Interfaces",
|
||||
step4: "Confirm",
|
||||
prev: "Back",
|
||||
next: "Next",
|
||||
fieldTitle: "Task name",
|
||||
titlePh: "e.g. Access uplinks",
|
||||
titleRequired: "Task name is required",
|
||||
portsRequired: "Select at least one port",
|
||||
portsRequired: "Select at least one interface",
|
||||
interval: "Interval (sec)",
|
||||
retention: "Retention (days)",
|
||||
retentionHintWizard: "Use ≥8 days for week-over-week compare",
|
||||
concurrency: "Concurrency",
|
||||
startNow: "Start after create",
|
||||
neKeywordPh: "Name / IP / vendor",
|
||||
selectedNe: "{{count}} NE(s) selected",
|
||||
selectedPorts: "{{count}} port(s) selected",
|
||||
selectedPorts: "{{count}} interface(s) selected",
|
||||
pickNeFirst: "Select NEs in the previous step first",
|
||||
discover: "Discover ports",
|
||||
discover: "Discover interfaces",
|
||||
confirmCreate: "Create task",
|
||||
wallTask: "Task",
|
||||
wallPort: "Interface",
|
||||
wallDevice: "Device",
|
||||
pickPort: "Select interface",
|
||||
range: "Range",
|
||||
compare: "Compare",
|
||||
compareOff: "Off",
|
||||
compareDay: "Yesterday (same TOD)",
|
||||
compareWeek: "Last week (same weekday)",
|
||||
compareShift: "Previous window",
|
||||
compareCustom: "Custom offset",
|
||||
offsetHours: "Offset (hours)",
|
||||
baseline: "Baseline",
|
||||
baselineAt: "Baseline time",
|
||||
seriesCurrentIn: "Current In",
|
||||
seriesCurrentOut: "Current Out",
|
||||
seriesBaselineIn: "Baseline In",
|
||||
seriesBaselineOut: "Baseline Out",
|
||||
mapBaselinePort: "Mapped baseline interface",
|
||||
mapBaselineTask: "Mapped baseline task",
|
||||
mapBaselineNone: "None",
|
||||
retentionHint: "Week compare needs retention_days ≥ 8 or the baseline may be empty.",
|
||||
collectNow: "Collect now",
|
||||
collectStarted: "Collect triggered",
|
||||
collectBusy: "Collect already running",
|
||||
|
|
@ -114,17 +133,16 @@ const en = {
|
|||
latestAt: "Latest sample",
|
||||
hoverPoint: "Cursor",
|
||||
wallLiveTitle: "Live traffic",
|
||||
wallDevice: "Device",
|
||||
kpi: {
|
||||
tasks: "Tasks",
|
||||
running: "Running",
|
||||
ports: "Ports",
|
||||
ports: "Interfaces",
|
||||
samples24h: "Samples 24h",
|
||||
},
|
||||
col: {
|
||||
title: "Task",
|
||||
status: "Status",
|
||||
ports: "Ports",
|
||||
ports: "Interfaces",
|
||||
interval: "Interval",
|
||||
last: "Last collect",
|
||||
actions: "Actions",
|
||||
|
|
|
|||
|
|
@ -81,28 +81,47 @@ const zh = {
|
|||
delete: "删除",
|
||||
step1: "参数",
|
||||
step2: "选网元",
|
||||
step3: "选端口",
|
||||
step3: "选接口",
|
||||
step4: "确认",
|
||||
prev: "上一步",
|
||||
next: "下一步",
|
||||
fieldTitle: "任务名称",
|
||||
titlePh: "例如:接入侧上联",
|
||||
titleRequired: "请填写任务名称",
|
||||
portsRequired: "请至少选择一个端口",
|
||||
portsRequired: "请至少选择一个接口",
|
||||
interval: "周期(秒)",
|
||||
retention: "保留(天)",
|
||||
retentionHintWizard: "若需上周同日对比,建议 ≥8 天",
|
||||
concurrency: "并发",
|
||||
startNow: "创建后立即启动",
|
||||
neKeywordPh: "名称 / IP / 厂商",
|
||||
selectedNe: "已选 {{count}} 台网元",
|
||||
selectedPorts: "已选 {{count}} 个端口",
|
||||
selectedPorts: "已选 {{count}} 个接口",
|
||||
pickNeFirst: "请先在上一步选择网元",
|
||||
discover: "拉取端口",
|
||||
discover: "拉取接口",
|
||||
confirmCreate: "创建任务",
|
||||
wallTask: "任务",
|
||||
wallPort: "接口",
|
||||
wallDevice: "网元",
|
||||
pickPort: "选择接口",
|
||||
range: "时间范围",
|
||||
compare: "周期对比",
|
||||
compareOff: "关闭",
|
||||
compareDay: "昨日同时刻",
|
||||
compareWeek: "上周同日",
|
||||
compareShift: "前一窗",
|
||||
compareCustom: "自定义偏移",
|
||||
offsetHours: "偏移(小时)",
|
||||
baseline: "基线",
|
||||
baselineAt: "基线原时刻",
|
||||
seriesCurrentIn: "当前 In",
|
||||
seriesCurrentOut: "当前 Out",
|
||||
seriesBaselineIn: "基线 In",
|
||||
seriesBaselineOut: "基线 Out",
|
||||
mapBaselinePort: "映射基线接口",
|
||||
mapBaselineTask: "映射基线任务",
|
||||
mapBaselineNone: "不映射",
|
||||
retentionHint: "周对比建议任务保留天数 ≥ 8;否则基线可能无数据。",
|
||||
collectNow: "立即采集",
|
||||
collectStarted: "已触发采集",
|
||||
collectBusy: "采集进行中",
|
||||
|
|
@ -114,17 +133,16 @@ const zh = {
|
|||
latestAt: "最新样点",
|
||||
hoverPoint: "光标位置",
|
||||
wallLiveTitle: "实时流量",
|
||||
wallDevice: "设备",
|
||||
kpi: {
|
||||
tasks: "任务数",
|
||||
running: "运行中",
|
||||
ports: "监控端口",
|
||||
ports: "监控接口",
|
||||
samples24h: "24h 样点",
|
||||
},
|
||||
col: {
|
||||
title: "任务",
|
||||
status: "状态",
|
||||
ports: "端口",
|
||||
ports: "接口",
|
||||
interval: "周期",
|
||||
last: "最近采集",
|
||||
actions: "操作",
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import {
|
|||
deletePortTrafficTask,
|
||||
discoverPortTrafficPorts,
|
||||
fetchCliTargets,
|
||||
fetchPortTrafficCompare,
|
||||
fetchPortTrafficDashboard,
|
||||
fetchPortTrafficSamples,
|
||||
fetchPortTrafficTargets,
|
||||
fetchPortTrafficTasks,
|
||||
pausePortTrafficTask,
|
||||
|
|
@ -26,6 +26,7 @@ const POLL_MS = 5000;
|
|||
const TARGET_PAGE_SIZE = 20;
|
||||
|
||||
type PortPick = PortTrafficTargetIn & { key: string };
|
||||
type BaselineMode = "off" | "shift" | "day" | "week" | "custom";
|
||||
|
||||
function neKey(source: string, id: string) {
|
||||
return `${source}:${id}`;
|
||||
|
|
@ -51,7 +52,11 @@ export function PortTrafficPage() {
|
|||
const [taskPage, setTaskPage] = useState(1);
|
||||
const [wallTaskId, setWallTaskId] = useState("");
|
||||
const [wallTargetId, setWallTargetId] = useState("");
|
||||
const [rangeHours, setRangeHours] = useState(1);
|
||||
const [mapBaselineTaskId, setMapBaselineTaskId] = useState("");
|
||||
const [mapBaselineTargetId, setMapBaselineTargetId] = useState("");
|
||||
const [rangeHours, setRangeHours] = useState(24);
|
||||
const [baseline, setBaseline] = useState<BaselineMode>("off");
|
||||
const [customOffsetHours, setCustomOffsetHours] = useState(48);
|
||||
|
||||
// Wizard state
|
||||
const [step, setStep] = useState(1);
|
||||
|
|
@ -105,22 +110,34 @@ export function PortTrafficPage() {
|
|||
refetchInterval: POLL_MS,
|
||||
});
|
||||
|
||||
const samplesQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficSamples(wallTargetId, rangeHours),
|
||||
queryFn: () => {
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - rangeHours * 3600 * 1000);
|
||||
return fetchPortTrafficSamples({
|
||||
const mapBaselineTargetsQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficTargets(mapBaselineTaskId),
|
||||
queryFn: () => fetchPortTrafficTargets(mapBaselineTaskId),
|
||||
enabled: view === "wall" && Boolean(mapBaselineTaskId),
|
||||
staleTime: 2000,
|
||||
refetchInterval: POLL_MS,
|
||||
});
|
||||
|
||||
const compareQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficCompare(
|
||||
wallTargetId,
|
||||
rangeHours,
|
||||
baseline,
|
||||
baseline === "custom" ? customOffsetHours : 0,
|
||||
mapBaselineTargetId,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchPortTrafficCompare({
|
||||
targetId: wallTargetId,
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
});
|
||||
},
|
||||
rangeHours,
|
||||
baseline,
|
||||
offsetHours: baseline === "custom" ? customOffsetHours : undefined,
|
||||
baselineTargetId: mapBaselineTargetId || undefined,
|
||||
}),
|
||||
enabled: view === "wall" && Boolean(wallTargetId),
|
||||
staleTime: 1000,
|
||||
refetchInterval: (q) => {
|
||||
const n = q.state.data?.points?.length ?? 0;
|
||||
// Faster while waiting for the first sample; then every 5s for live wall.
|
||||
const n = q.state.data?.current?.length ?? 0;
|
||||
return n === 0 ? 2500 : POLL_MS;
|
||||
},
|
||||
});
|
||||
|
|
@ -171,7 +188,7 @@ export function PortTrafficPage() {
|
|||
showOk(res.started ? t("portTraffic.collectStarted") : t("portTraffic.collectBusy"));
|
||||
invalidateAll();
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTargets(wallTaskId) });
|
||||
void queryClient.invalidateQueries({ queryKey: ["portTrafficSamples"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["portTrafficCompare"] });
|
||||
},
|
||||
onError: (e: Error) => showError(e.message),
|
||||
});
|
||||
|
|
@ -206,6 +223,9 @@ export function PortTrafficPage() {
|
|||
const openWall = (taskId: string) => {
|
||||
setWallTaskId(taskId);
|
||||
setWallTargetId("");
|
||||
setMapBaselineTaskId("");
|
||||
setMapBaselineTargetId("");
|
||||
setBaseline("off");
|
||||
setView("wall");
|
||||
};
|
||||
|
||||
|
|
@ -265,14 +285,46 @@ export function PortTrafficPage() {
|
|||
|
||||
const selectedNeList = useMemo(() => Object.values(selectedNes), [selectedNes]);
|
||||
const pickedList = useMemo(() => Object.values(pickedPorts), [pickedPorts]);
|
||||
const wallTargets = wallTargetsQuery.data?.items || [];
|
||||
const wallTaskOptions = wallTasksQuery.data?.items || tasksQuery.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,
|
||||
),
|
||||
[mapBaselineTargetsQuery.data?.items, wallTargetId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== "wall" || wallTargetId || !wallTargets.length) return;
|
||||
setWallTargetId(wallTargets[0].id);
|
||||
}, [view, wallTargetId, wallTargets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapBaselineTargetId) return;
|
||||
if (mapBaselineTargetId === wallTargetId) {
|
||||
setMapBaselineTargetId("");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
mapBaselineTaskId &&
|
||||
mapBaselineTargetsQuery.isFetched &&
|
||||
!mapBaselineOptions.some((x) => x.id === mapBaselineTargetId)
|
||||
) {
|
||||
setMapBaselineTargetId("");
|
||||
}
|
||||
}, [
|
||||
mapBaselineTargetId,
|
||||
wallTargetId,
|
||||
mapBaselineTaskId,
|
||||
mapBaselineOptions,
|
||||
mapBaselineTargetsQuery.isFetched,
|
||||
]);
|
||||
|
||||
const submitWizard = () => {
|
||||
if (!title.trim()) {
|
||||
showError(t("portTraffic.titleRequired"));
|
||||
|
|
@ -459,6 +511,9 @@ export function PortTrafficPage() {
|
|||
value={retentionDays}
|
||||
onChange={(e) => setRetentionDays(Number(e.target.value) || 7)}
|
||||
/>
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{t("portTraffic.retentionHintWizard")}
|
||||
</span>
|
||||
</label>
|
||||
<label className="config-sync-policy-field">
|
||||
{t("portTraffic.concurrency")}
|
||||
|
|
@ -622,7 +677,7 @@ export function PortTrafficPage() {
|
|||
setWallTargetId("");
|
||||
}}
|
||||
>
|
||||
{(wallTasksQuery.data?.items || tasksQuery.data?.items || []).map((task) => (
|
||||
{wallTaskOptions.map((task) => (
|
||||
<option key={task.id} value={task.id}>
|
||||
{task.title}
|
||||
</option>
|
||||
|
|
@ -631,11 +686,15 @@ export function PortTrafficPage() {
|
|||
</label>
|
||||
<label>
|
||||
{t("portTraffic.wallPort")}
|
||||
<select value={wallTargetId} onChange={(e) => setWallTargetId(e.target.value)}>
|
||||
<select
|
||||
value={wallTargetId}
|
||||
onChange={(e) => setWallTargetId(e.target.value)}
|
||||
>
|
||||
<option value="">{t("portTraffic.pickPort")}</option>
|
||||
{wallTargets.map((tg) => (
|
||||
<option key={tg.id} value={tg.id}>
|
||||
{tg.ne_name} / {tg.ifname}
|
||||
{wallTargets.map((tgt) => (
|
||||
<option key={tgt.id} value={tgt.id}>
|
||||
{tgt.ne_name || tgt.ne_ip || "—"} / {tgt.ifname}
|
||||
{tgt.ne_ip ? ` (${tgt.ne_ip})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
|
@ -648,6 +707,66 @@ export function PortTrafficPage() {
|
|||
<option value={24}>24h</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{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>
|
||||
{t("portTraffic.offsetHours")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={24 * 90}
|
||||
value={customOffsetHours}
|
||||
onChange={(e) => setCustomOffsetHours(Number(e.target.value) || 24)}
|
||||
style={{ width: 88 }}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
{t("portTraffic.mapBaselineTask")}
|
||||
<select
|
||||
value={mapBaselineTaskId}
|
||||
onChange={(e) => {
|
||||
setMapBaselineTaskId(e.target.value);
|
||||
setMapBaselineTargetId("");
|
||||
}}
|
||||
disabled={!wallTargetId}
|
||||
>
|
||||
<option value="">{t("portTraffic.mapBaselineNone")}</option>
|
||||
{wallTaskOptions.map((task) => (
|
||||
<option key={task.id} value={task.id}>
|
||||
{task.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.mapBaselinePort")}
|
||||
<select
|
||||
value={mapBaselineTargetId}
|
||||
onChange={(e) => setMapBaselineTargetId(e.target.value)}
|
||||
disabled={!mapBaselineTaskId}
|
||||
>
|
||||
<option value="">{t("portTraffic.pickPort")}</option>
|
||||
{mapBaselineOptions.map((tgt) => (
|
||||
<option key={tgt.id} value={tgt.id}>
|
||||
{tgt.ne_name || tgt.ne_ip || "—"} / {tgt.ifname}
|
||||
{tgt.ne_ip ? ` (${tgt.ne_ip})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
|
|
@ -657,6 +776,11 @@ export function PortTrafficPage() {
|
|||
{t("portTraffic.collectNow")}
|
||||
</button>
|
||||
</div>
|
||||
{baseline === "week" ? (
|
||||
<p className="muted" style={{ marginBottom: 8 }}>
|
||||
{t("portTraffic.retentionHint")}
|
||||
</p>
|
||||
) : null}
|
||||
{selectedWallTarget?.last_error ? (
|
||||
<p className="muted" style={{ marginBottom: 8 }}>
|
||||
{t("portTraffic.targetError")}: {selectedWallTarget.last_error}
|
||||
|
|
@ -664,9 +788,24 @@ export function PortTrafficPage() {
|
|||
) : null}
|
||||
<PortTrafficWall
|
||||
target={selectedWallTarget}
|
||||
points={samplesQuery.data?.points || []}
|
||||
rangeLabel={`${rangeHours}h`}
|
||||
loading={samplesQuery.isLoading || samplesQuery.isFetching}
|
||||
points={compareQuery.data?.current || []}
|
||||
baselinePoints={
|
||||
baseline === "off" && !mapBaselineTargetId
|
||||
? []
|
||||
: compareQuery.data?.baseline || []
|
||||
}
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -22,23 +22,41 @@ function formatHoverTime(sec: number): string {
|
|||
return formatSystemTime(new Date(sec * 1000));
|
||||
}
|
||||
|
||||
function toSeries(points: PortTrafficSamplePoint[]): [number[], number[], number[]] {
|
||||
const xs: number[] = [];
|
||||
const ins: number[] = [];
|
||||
const outs: number[] = [];
|
||||
for (const p of points) {
|
||||
function toAlignedSeries(
|
||||
current: PortTrafficSamplePoint[],
|
||||
baseline: PortTrafficSamplePoint[],
|
||||
): [number[], number[], number[], number[], number[]] {
|
||||
type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number };
|
||||
const map = new Map<number, Row>();
|
||||
for (const p of current) {
|
||||
const d = parseApiTime(p.ts);
|
||||
if (!d) continue;
|
||||
xs.push(Math.floor(d.getTime() / 1000));
|
||||
ins.push(Number(p.in_bps) || 0);
|
||||
outs.push(Number(p.out_bps) || 0);
|
||||
const x = Math.floor(d.getTime() / 1000);
|
||||
const row = map.get(x) || { x };
|
||||
row.inC = Number(p.in_bps) || 0;
|
||||
row.outC = Number(p.out_bps) || 0;
|
||||
map.set(x, row);
|
||||
}
|
||||
if (xs.length === 1) {
|
||||
xs.push(xs[0] + 60);
|
||||
ins.push(ins[0]);
|
||||
outs.push(outs[0]);
|
||||
for (const p of baseline) {
|
||||
const d = parseApiTime(p.ts);
|
||||
if (!d) continue;
|
||||
const x = Math.floor(d.getTime() / 1000);
|
||||
const row = map.get(x) || { x };
|
||||
row.inB = Number(p.in_bps) || 0;
|
||||
row.outB = Number(p.out_bps) || 0;
|
||||
map.set(x, row);
|
||||
}
|
||||
return [xs, ins, outs];
|
||||
const rows = [...map.values()].sort((a, b) => a.x - b.x);
|
||||
if (rows.length === 1) {
|
||||
rows.push({ ...rows[0], x: rows[0].x + 60 });
|
||||
}
|
||||
return [
|
||||
rows.map((r) => r.x),
|
||||
rows.map((r) => r.inC ?? NaN),
|
||||
rows.map((r) => r.outC ?? NaN),
|
||||
rows.map((r) => r.inB ?? NaN),
|
||||
rows.map((r) => r.outB ?? NaN),
|
||||
];
|
||||
}
|
||||
|
||||
function chartSize(el: HTMLElement): { width: number; height: number } {
|
||||
|
|
@ -50,18 +68,28 @@ function chartSize(el: HTMLElement): { width: number; height: number } {
|
|||
type Props = {
|
||||
target: PortTrafficTarget | null;
|
||||
points: PortTrafficSamplePoint[];
|
||||
baselinePoints?: PortTrafficSamplePoint[];
|
||||
rangeLabel: string;
|
||||
loading?: boolean;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: Props) {
|
||||
export function PortTrafficWall({
|
||||
target,
|
||||
points,
|
||||
baselinePoints = [],
|
||||
rangeLabel,
|
||||
loading,
|
||||
hint,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const plotRef = useRef<uPlot | null>(null);
|
||||
const tipRef = useRef<HTMLDivElement | null>(null);
|
||||
const pointsRef = useRef(points);
|
||||
const baselineRef = useRef(baselinePoints);
|
||||
pointsRef.current = points;
|
||||
baselineRef.current = baselinePoints;
|
||||
|
||||
const latest = points.length ? points[points.length - 1] : null;
|
||||
const kpi = useMemo(() => {
|
||||
|
|
@ -104,18 +132,38 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
|
|||
series: [
|
||||
{},
|
||||
{
|
||||
label: "In",
|
||||
label: t("portTraffic.seriesCurrentIn"),
|
||||
stroke: "#2dd4bf",
|
||||
width: 2,
|
||||
fill: "rgba(45, 212, 191, 0.12)",
|
||||
points: { show: true, size: 5, fill: "#2dd4bf" },
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
label: "Out",
|
||||
label: t("portTraffic.seriesCurrentOut"),
|
||||
stroke: "#f59e0b",
|
||||
width: 2,
|
||||
fill: "rgba(245, 158, 11, 0.10)",
|
||||
points: { show: true, size: 5, fill: "#f59e0b" },
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
label: t("portTraffic.seriesBaselineIn"),
|
||||
stroke: "rgba(45, 212, 191, 0.45)",
|
||||
width: 1.5,
|
||||
dash: [6, 4],
|
||||
points: { show: false },
|
||||
spanGaps: false,
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: t("portTraffic.seriesBaselineOut"),
|
||||
stroke: "rgba(245, 158, 11, 0.45)",
|
||||
width: 1.5,
|
||||
dash: [6, 4],
|
||||
points: { show: false },
|
||||
spanGaps: false,
|
||||
show: false,
|
||||
},
|
||||
],
|
||||
axes: [
|
||||
|
|
@ -149,7 +197,6 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
|
|||
},
|
||||
},
|
||||
},
|
||||
// Color keys only — no live numeric values (those are in the hover tip).
|
||||
legend: { show: true, live: false },
|
||||
cursor: {
|
||||
drag: { x: true, y: false },
|
||||
|
|
@ -170,30 +217,53 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
|
|||
}
|
||||
|
||||
const tsSec = Number(u.data[0][idx]);
|
||||
const inBps = Number(u.data[1]?.[idx] ?? 0);
|
||||
const outBps = Number(u.data[2]?.[idx] ?? 0);
|
||||
const inBps = Number(u.data[1]?.[idx]);
|
||||
const outBps = Number(u.data[2]?.[idx]);
|
||||
const inBase = Number(u.data[3]?.[idx]);
|
||||
const outBase = Number(u.data[4]?.[idx]);
|
||||
const sample = pointsRef.current.find((p) => {
|
||||
const d = parseApiTime(p.ts);
|
||||
return d && Math.floor(d.getTime() / 1000) === tsSec;
|
||||
});
|
||||
const baseSample = baselineRef.current.find((p) => {
|
||||
const d = parseApiTime(p.ts);
|
||||
return d && Math.floor(d.getTime() / 1000) === tsSec;
|
||||
});
|
||||
|
||||
if (!tip) return;
|
||||
tip.style.display = "block";
|
||||
tip.innerHTML = [
|
||||
const rows = [
|
||||
`<div class="pt-wall__tip-time">${formatHoverTime(tsSec)}</div>`,
|
||||
`<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${formatBps(inBps)} bit/s</strong></div>`,
|
||||
`<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${formatBps(outBps)} bit/s</strong></div>`,
|
||||
Number.isFinite(inBps)
|
||||
? `<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${formatBps(inBps)} bit/s</strong></div>`
|
||||
: "",
|
||||
Number.isFinite(outBps)
|
||||
? `<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${formatBps(outBps)} bit/s</strong></div>`
|
||||
: "",
|
||||
sample
|
||||
? `<div class="pt-wall__tip-row"><span>Util</span><strong>${Number(sample.in_util_pct || 0).toFixed(1)}% / ${Number(sample.out_util_pct || 0).toFixed(1)}%</strong></div>`
|
||||
: "",
|
||||
].join("");
|
||||
];
|
||||
if (Number.isFinite(inBase) || Number.isFinite(outBase)) {
|
||||
rows.push(
|
||||
`<div class="pt-wall__tip-row"><span>${t("portTraffic.baseline")}</span><strong>${
|
||||
Number.isFinite(inBase) ? formatBps(inBase) : "—"
|
||||
} / ${Number.isFinite(outBase) ? formatBps(outBase) : "—"} bit/s</strong></div>`,
|
||||
);
|
||||
if (baseSample?.ts_raw) {
|
||||
rows.push(
|
||||
`<div class="pt-wall__tip-row"><span>${t("portTraffic.baselineAt")}</span><strong>${formatSystemTime(baseSample.ts_raw)}</strong></div>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
tip.innerHTML = rows.join("");
|
||||
placeTip(u, left, top);
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
plotRef.current = new uPlot(opts, [[], [], []], el);
|
||||
plotRef.current = new uPlot(opts, [[], [], [], [], []], el);
|
||||
|
||||
const onLeave = () => {
|
||||
if (tipRef.current) tipRef.current.style.display = "none";
|
||||
|
|
@ -211,16 +281,20 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
|
|||
plotRef.current?.destroy();
|
||||
plotRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const plot = plotRef.current;
|
||||
if (!plot) return;
|
||||
plot.setData(toSeries(points));
|
||||
const showBaseline = baselinePoints.length > 0;
|
||||
plot.setSeries(3, { show: showBaseline });
|
||||
plot.setSeries(4, { show: showBaseline });
|
||||
plot.setData(toAlignedSeries(points, baselinePoints));
|
||||
if (mountRef.current) plot.setSize(chartSize(mountRef.current));
|
||||
}, [points]);
|
||||
}, [points, baselinePoints]);
|
||||
|
||||
const empty = points.length === 0;
|
||||
const empty = points.length === 0 && baselinePoints.length === 0;
|
||||
const neLabel = target ? target.ne_name || target.ne_ip || "—" : "—";
|
||||
const ifLabel = target?.ifname || "—";
|
||||
const ipLabel = target?.ne_ip || "";
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ import type {
|
|||
ConfigSyncTask,
|
||||
NeConfigSnapshotDetail,
|
||||
NeConfigSnapshotMeta,
|
||||
PortTrafficCompare,
|
||||
PortTrafficDashboard,
|
||||
PortTrafficDiscoverResponse,
|
||||
PortTrafficSamples,
|
||||
PortTrafficTarget,
|
||||
PortTrafficTargetIn,
|
||||
PortTrafficTask,
|
||||
} from "../types";
|
||||
|
||||
|
|
@ -847,3 +847,21 @@ export const fetchPortTrafficSamples = (params: {
|
|||
if (params.to) p.set("to", params.to);
|
||||
return apiGet<PortTrafficSamples>(`/v1/port-traffic/samples?${p.toString()}`);
|
||||
};
|
||||
|
||||
export const fetchPortTrafficCompare = (params: {
|
||||
targetId: string;
|
||||
rangeHours: number;
|
||||
baseline: string;
|
||||
offsetHours?: number;
|
||||
baselineTargetId?: string;
|
||||
to?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("target_id", params.targetId);
|
||||
p.set("range_hours", String(params.rangeHours));
|
||||
p.set("baseline", params.baseline);
|
||||
if (params.offsetHours != null) p.set("offset_hours", String(params.offsetHours));
|
||||
if (params.baselineTargetId) p.set("baseline_target_id", params.baselineTargetId);
|
||||
if (params.to) p.set("to", params.to);
|
||||
return apiGet<PortTrafficCompare>(`/v1/port-traffic/compare?${p.toString()}`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -527,6 +527,7 @@ export type PortTrafficTask = {
|
|||
export type PortTrafficTarget = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
series_id?: string;
|
||||
source: string;
|
||||
target_id: string;
|
||||
ne_name: string;
|
||||
|
|
@ -541,6 +542,16 @@ export type PortTrafficTarget = {
|
|||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type PortTrafficSeries = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
active_target: PortTrafficTarget | null;
|
||||
retired_target_count: number;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type PortTrafficTargetIn = {
|
||||
source: "managed" | "ume";
|
||||
target_id: string;
|
||||
|
|
@ -582,6 +593,7 @@ export type PortTrafficSamplePoint = {
|
|||
out_util_pct: number;
|
||||
bw_bps: number;
|
||||
rate_period_sec: number;
|
||||
ts_raw?: string | null;
|
||||
};
|
||||
|
||||
export type PortTrafficSamples = {
|
||||
|
|
@ -589,6 +601,20 @@ export type PortTrafficSamples = {
|
|||
points: PortTrafficSamplePoint[];
|
||||
};
|
||||
|
||||
export type PortTrafficCompare = {
|
||||
meta: {
|
||||
target_id: string;
|
||||
baseline: string;
|
||||
offset_hours: number;
|
||||
range_hours: number;
|
||||
current_target: PortTrafficTarget | null;
|
||||
baseline_target: PortTrafficTarget | null;
|
||||
baseline_target_id: string;
|
||||
};
|
||||
current: PortTrafficSamplePoint[];
|
||||
baseline: PortTrafficSamplePoint[];
|
||||
};
|
||||
|
||||
export type PortTrafficDashboard = {
|
||||
task_count: number;
|
||||
running_task_count: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue