diff --git a/netx_api/main.py b/netx_api/main.py index 5c379fb..d26e8cd 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -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: diff --git a/netx_api/models.py b/netx_api/models.py index d8527a9..5940517 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -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) diff --git a/netx_api/port_traffic_migrate.py b/netx_api/port_traffic_migrate.py new file mode 100644 index 0000000..30939cb --- /dev/null +++ b/netx_api/port_traffic_migrate.py @@ -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]}" diff --git a/netx_api/port_traffic_router.py b/netx_api/port_traffic_router.py index 33764fb..e685a56 100644 --- a/netx_api/port_traffic_router.py +++ b/netx_api/port_traffic_router.py @@ -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() diff --git a/netx_api/port_traffic_runner.py b/netx_api/port_traffic_runner.py index dc78b38..2332c8c 100644 --- a/netx_api/port_traffic_runner.py +++ b/netx_api/port_traffic_runner.py @@ -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), diff --git a/netx_api/port_traffic_schemas.py b/netx_api/port_traffic_schemas.py index 1e0f5ad..2c7cf61 100644 --- a/netx_api/port_traffic_schemas.py +++ b/netx_api/port_traffic_schemas.py @@ -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 diff --git a/netx_api/port_traffic_service.py b/netx_api/port_traffic_service.py index 71b2f8d..03ab62c 100644 --- a/netx_api/port_traffic_service.py +++ b/netx_api/port_traffic_service.py @@ -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: diff --git a/tests/test_port_traffic_compare.py b/tests/test_port_traffic_compare.py new file mode 100644 index 0000000..4580657 --- /dev/null +++ b/tests/test_port_traffic_compare.py @@ -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() diff --git a/web/WEB.md b/web/WEB.md index f22ff78..d3dd367 100644 --- a/web/WEB.md +++ b/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 diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index 601b6b5..5b86b48 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -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, }; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index a86dcbf..959b012 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 03ed73c..4c58e67 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "操作", diff --git a/web/src/pages/network/PortTrafficPage.tsx b/web/src/pages/network/PortTrafficPage.tsx index 02d53bf..d6b68df 100644 --- a/web/src/pages/network/PortTrafficPage.tsx +++ b/web/src/pages/network/PortTrafficPage.tsx @@ -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("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)} /> + + {t("portTraffic.retentionHintWizard")} + + + {baseline === "custom" ? ( + + ) : null} + +