From 3908b78dbb136012dc34344c364bce57b0f10556 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 2 Aug 2026 14:05:59 +0800 Subject: [PATCH] Add ops task overview, chart ahead window, and WebCRT lifecycle status. Unify live runners in /audit/tasks (including UME sync and connect tests), extend port-traffic compare past now via ahead_hours, and distinguish WebCRT connecting/ready/detached. Co-authored-by: Cursor --- netx_api/main.py | 2 + netx_api/models.py | 1 + netx_api/ops_router.py | 23 + netx_api/ops_tasks_service.py | 472 ++++++++++++++++++ netx_api/port_traffic_board_service.py | 2 + netx_api/port_traffic_migrate.py | 4 + netx_api/port_traffic_router.py | 7 + netx_api/port_traffic_schemas.py | 13 +- netx_api/port_traffic_service.py | 12 +- netx_api/topology_lldp.py | 2 +- netx_api/webcrt_service.py | 77 +-- tests/test_port_traffic_compare.py | 40 ++ tests/test_topology.py | 23 +- tests/test_webcrt.py | 38 ++ web/src/App.tsx | 8 +- web/src/config/auditNav.ts | 10 + web/src/config/modules.ts | 2 + web/src/constants/queryKeys.ts | 4 +- web/src/i18n/en.ts | 56 ++- web/src/i18n/zh.ts | 55 +- web/src/index.css | 17 + web/src/pages/AuditPage.tsx | 2 +- web/src/pages/audit/AuditLayout.tsx | 32 ++ web/src/pages/audit/TaskOverviewPage.tsx | 149 ++++++ .../network/PortTrafficBoardPanelCell.tsx | 6 + web/src/pages/network/PortTrafficWallPage.tsx | 48 +- web/src/services/api.ts | 29 ++ web/src/types.ts | 3 + 28 files changed, 1083 insertions(+), 54 deletions(-) create mode 100644 netx_api/ops_router.py create mode 100644 netx_api/ops_tasks_service.py create mode 100644 web/src/config/auditNav.ts create mode 100644 web/src/pages/audit/AuditLayout.tsx create mode 100644 web/src/pages/audit/TaskOverviewPage.tsx diff --git a/netx_api/main.py b/netx_api/main.py index 1ee43cf..fbd6c3d 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -32,6 +32,7 @@ from .managed_ne_router import router as managed_ne_router from .webcrt_router import router as webcrt_router from .topology_router import router as topology_router from .lldp_collect_router import router as lldp_collect_router +from .ops_router import router as ops_router from .importer import aggregate_alarms, import_alarm_excel, query_alarms from .models import ( AiAnalyzeHistory, @@ -141,6 +142,7 @@ app.include_router(port_traffic_router) app.include_router(webcrt_router) app.include_router(topology_router) app.include_router(lldp_collect_router) +app.include_router(ops_router) parser_cfg = load_parser_config() _UME_CLIENT_SINGLETON = UMEClient( token_loader=lambda: load_shared_token(), diff --git a/netx_api/models.py b/netx_api/models.py index 37d8f4a..aa58f40 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -930,6 +930,7 @@ class PortTrafficPanel(Base): range_hours: Mapped[int] = mapped_column(Integer, default=24) baseline: Mapped[str] = mapped_column(String(16), default="off") # off|day|week|shift|custom offset_hours: Mapped[int] = mapped_column(Integer, default=0) + ahead_hours: Mapped[int] = mapped_column(Integer, default=1) # extend chart past "now" for baseline peek baseline_target_id: Mapped[str] = mapped_column(String(64), default="") y_mode: Mapped[str] = mapped_column(String(16), default="auto") # auto|current|util ord: Mapped[int] = mapped_column(Integer, default=0) diff --git a/netx_api/ops_router.py b/netx_api/ops_router.py new file mode 100644 index 0000000..72af5cb --- /dev/null +++ b/netx_api/ops_router.py @@ -0,0 +1,23 @@ +"""Ops overview APIs (live task board).""" + +from __future__ import annotations + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from .auth_deps import AuthContext, require_user +from .db import get_db +from .ops_tasks_service import list_ops_tasks + +router = APIRouter(prefix="/v1/ops", tags=["ops"]) + + +@router.get("/tasks") +def api_ops_tasks( + ctx: Annotated[AuthContext, Depends(require_user)], + db: Session = Depends(get_db), +) -> dict[str, Any]: + _ = ctx + return list_ops_tasks(db) diff --git a/netx_api/ops_tasks_service.py b/netx_api/ops_tasks_service.py new file mode 100644 index 0000000..2d37739 --- /dev/null +++ b/netx_api/ops_tasks_service.py @@ -0,0 +1,472 @@ +"""Unified live task overview across NetX runners.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any + +from sqlalchemy.orm import Session + +from .models import ( + AuditLog, + ConfigSyncCycle, + ConfigSyncTask, + ManagedNE, + NeCollectionJob, + NeCollectionRun, + PortTrafficDevice, + TopoDiscoverJob, + UmeCliOverride, + UmeSyncJob, +) + + +def _iso(dt: datetime | None) -> str | None: + if dt is None: + return None + try: + return dt.isoformat() + except Exception: + return str(dt) + + +def _item( + *, + kind: str, + id: str, + title: str, + status: str, + trigger: str = "", + actor: str = "", + started_at: datetime | None = None, + updated_at: datetime | None = None, + progress: str = "", + detail: str = "", + href: str = "", +) -> dict[str, Any]: + return { + "kind": kind, + "id": id, + "title": title, + "status": status, + "trigger": trigger, + "actor": actor or "—", + "started_at": _iso(started_at), + "updated_at": _iso(updated_at), + "progress": progress, + "detail": detail, + "href": href, + } + + +def _audit_actor_map(db: Session, *, hours: int = 72) -> dict[str, str]: + """Best-effort map of resource id → latest actor username from audit detail.""" + since = datetime.utcnow() - timedelta(hours=max(1, hours)) + rows = ( + db.query(AuditLog) + .filter(AuditLog.ts >= since, AuditLog.status_code < 400) + .order_by(AuditLog.ts.desc()) + .limit(800) + .all() + ) + out: dict[str, str] = {} + for row in rows: + user = str(row.actor_username or "").strip() + if not user: + continue + detail = row.detail if isinstance(row.detail, dict) else {} + for key in ("id", "cycle_id", "job_id", "device_id", "session_id", "board_id"): + rid = str(detail.get(key) or "").strip() + if rid and rid not in out: + out[rid] = user + return out + + +def _port_traffic_items(db: Session, actors: dict[str, str]) -> list[dict[str, Any]]: + rows = ( + db.query(PortTrafficDevice) + .filter( + (PortTrafficDevice.collect_running.is_(True)) + | (PortTrafficDevice.status == "running") + ) + .order_by(PortTrafficDevice.updated_at.desc()) + .limit(200) + .all() + ) + items: list[dict[str, Any]] = [] + for row in rows: + did = str(row.id) + name = str(row.ne_name or row.ne_ip or did) + if bool(row.collect_running): + status = "collecting" + else: + status = str(row.status or "running") + actor = actors.get(did) or "scheduler" + items.append( + _item( + kind="port_traffic", + id=did, + title=f"端口流量 · {name}", + status=status, + trigger="schedule" if actor == "scheduler" else "manual", + actor=actor, + started_at=row.last_collect_started_at or row.updated_at, + updated_at=row.last_collect_ended_at or row.updated_at, + progress="", + detail=str(row.last_error or "")[:240], + href="/network/tasks/port-traffic", + ) + ) + return items + + +def _config_sync_items(db: Session, actors: dict[str, str]) -> list[dict[str, Any]]: + rows = ( + db.query(ConfigSyncCycle) + .filter(ConfigSyncCycle.status.in_(("pending", "running", "paused"))) + .order_by(ConfigSyncCycle.created_at.desc()) + .limit(50) + .all() + ) + items: list[dict[str, Any]] = [] + for row in rows: + cid = str(row.id) + running = ( + db.query(ConfigSyncTask) + .filter(ConfigSyncTask.cycle_id == cid, ConfigSyncTask.status == "running") + .count() + ) + done = int(row.success_count or 0) + int(row.fail_count or 0) + int(row.skip_count or 0) + planned = int(row.planned_count or 0) + trigger = str(row.trigger_mode or "schedule") + actor = actors.get(cid) or ("scheduler" if trigger == "schedule" else "—") + items.append( + _item( + kind="config_sync", + id=cid, + title=f"配置同步 · {cid[:8]}", + status=str(row.status or "pending"), + trigger=trigger, + actor=actor, + started_at=row.started_at or row.created_at, + updated_at=row.ended_at or row.started_at or row.created_at, + progress=f"{done}/{planned}" + (f" · running {running}" if running else ""), + detail=str(row.error_message or "")[:240], + href="/network/tasks/config-sync", + ) + ) + return items + + +def _collection_items(db: Session, actors: dict[str, str]) -> list[dict[str, Any]]: + rows = ( + db.query(NeCollectionJob) + .filter(NeCollectionJob.status.in_(("pending", "running", "paused"))) + .order_by(NeCollectionJob.created_at.desc()) + .limit(50) + .all() + ) + items: list[dict[str, Any]] = [] + for row in rows: + jid = str(row.id) + running = ( + db.query(NeCollectionRun) + .filter(NeCollectionRun.job_id == jid, NeCollectionRun.status == "running") + .count() + ) + title = str(row.title or "").strip() or f"采集任务 · {jid[:8]}" + actor = actors.get(jid) or "—" + items.append( + _item( + kind="ne_collect", + id=jid, + title=title, + status=str(row.status or "pending"), + trigger="manual", + actor=actor, + started_at=row.started_at or row.created_at, + updated_at=row.last_run_at or row.ended_at or row.started_at or row.created_at, + progress=( + f"ok {int(row.success_count or 0)} / fail {int(row.fail_count or 0)}" + f" / total {int(row.ne_count or 0)}" + + (f" · running {running}" if running else "") + ), + detail=str(row.error_message or "")[:240], + href="/network/tasks/collect", + ) + ) + return items + + +def _lldp_items(db: Session, actors: dict[str, str]) -> list[dict[str, Any]]: + rows = ( + db.query(TopoDiscoverJob) + .filter(TopoDiscoverJob.status.in_(("pending", "running"))) + .order_by(TopoDiscoverJob.created_at.desc()) + .limit(20) + .all() + ) + items: list[dict[str, Any]] = [] + for row in rows: + jid = str(row.id) + trigger = str(row.trigger_mode or "manual") + actor = actors.get(jid) or ( + "scheduler" if trigger == "schedule" else ("topology" if trigger == "topology" else "—") + ) + done = int(row.done or 0) + total = int(row.total or 0) + items.append( + _item( + kind="lldp_discover", + id=jid, + title=f"LLDP 发现 · {trigger}", + status=str(row.status or "pending"), + trigger=trigger, + actor=actor, + started_at=row.started_at or row.created_at, + updated_at=row.updated_at or row.ended_at or row.started_at, + progress=f"{done}/{total}", + detail=str(row.error or "")[:240], + href="/network/topology/lldp", + ) + ) + return items + + +def _ume_sync_items(db: Session, actors: dict[str, str]) -> list[dict[str, Any]]: + """In-flight UME inventory/alarm sync jobs (manual or scheduled).""" + rows = ( + db.query(UmeSyncJob) + .filter(UmeSyncJob.status == "running", UmeSyncJob.ended_at.is_(None)) + .order_by(UmeSyncJob.started_at.desc()) + .limit(20) + .all() + ) + items: list[dict[str, Any]] = [] + for row in rows: + jid = str(row.id) + domain = str(row.domain or "sync") + trigger = str(row.trigger_mode or "manual") + actor = actors.get(jid) or ("scheduler" if trigger == "schedule" else "—") + pulled = int(row.pulled_count or 0) + inserted = int(row.inserted_count or 0) + updated = int(row.updated_count or 0) + items.append( + _item( + kind="ume_sync", + id=jid, + title=f"UME 同步 · {domain}", + status="running", + trigger=trigger, + actor=actor, + started_at=row.started_at, + updated_at=row.started_at, + progress=f"pull {pulled} · +{inserted} ~{updated}", + detail=str(row.error_message or "")[:240], + href="/ume", + ) + ) + return items + + +def _ne_connect_items(db: Session, actors: dict[str, str]) -> list[dict[str, Any]]: + """CLI connect-test pool work marked as testing on NE / UME override rows.""" + items: list[dict[str, Any]] = [] + managed = ( + db.query(ManagedNE) + .filter(ManagedNE.connect_status == "testing") + .order_by(ManagedNE.updated_at.desc()) + .limit(100) + .all() + ) + for row in managed: + nid = str(row.id) + name = str(row.name or row.ip_address or nid[:8]) + items.append( + _item( + kind="ne_connect", + id=f"managed:{nid}", + title=f"连通性测试 · {name}", + status="testing", + trigger="manual", + actor=actors.get(nid) or "—", + started_at=row.connect_tested_at or row.updated_at, + updated_at=row.updated_at, + progress=str(row.ip_address or ""), + detail=str(row.connect_message or "")[:240], + href="/network/devices", + ) + ) + overrides = ( + db.query(UmeCliOverride) + .filter(UmeCliOverride.connect_status == "testing") + .order_by(UmeCliOverride.updated_at.desc()) + .limit(100) + .all() + ) + for row in overrides: + uid = str(row.ume_ne_id) + items.append( + _item( + kind="ne_connect", + id=f"ume:{uid}", + title=f"连通性测试 · UME {uid[:12]}", + status="testing", + trigger="manual", + actor=actors.get(uid) or "—", + started_at=row.connect_tested_at or row.updated_at, + updated_at=row.updated_at, + progress="", + detail=str(row.connect_message or "")[:240], + href="/ume", + ) + ) + return items + + +def _ume_runtime_items() -> list[dict[str, Any]]: + try: + from .main import _list_runtime_tasks + except Exception: + return [] + items: list[dict[str, Any]] = [] + for row in _list_runtime_tasks(): + task = str(row.get("task") or "") + status = str(row.get("status") or "unknown") + # Always show UME background loops so ops can see paused/idle too. + items.append( + _item( + kind="ume_runtime", + id=task, + title=f"UME · {task}", + status=status, + trigger="system", + actor="system", + started_at=None, + updated_at=None, + progress=str(row.get("interval_label") or ""), + detail=str(row.get("last_error") or "")[:240], + href="/ume", + ) + ) + # Fix updated_at from last_run_at string if present + if row.get("last_run_at") and items: + items[-1]["updated_at"] = row.get("last_run_at") + return items + + +def _webcrt_items(actors: dict[str, str]) -> list[dict[str, Any]]: + try: + from .webcrt_service import list_sessions + except Exception: + return [] + data = list_sessions() + items: list[dict[str, Any]] = [] + now = datetime.utcnow().timestamp() + for row in data.get("items") or []: + sid = str(row.get("session_id") or "") + name = str(row.get("ne_name") or row.get("ne_ip") or sid[:8]) + lifecycle = str(row.get("lifecycle") or row.get("state") or "unknown") + actor = actors.get(sid) or "—" + detail = "" + progress = "" + if lifecycle == "connecting": + elapsed_ms = row.get("elapsed_ms") + progress = f"{int(elapsed_ms)} ms" if isinstance(elapsed_ms, int) else "logging in" + detail = "authenticating" + elif lifecycle == "ready": + progress = "attached" + if row.get("connect_ms") is not None: + detail = f"connect {int(row['connect_ms'])} ms" + elif lifecycle == "detached": + progress = "detached" + deadline = row.get("detach_deadline") + if isinstance(deadline, (int, float)) and deadline > 0: + left = max(0, int(deadline - now)) + detail = f"grace {left}s" + else: + detail = "awaiting reconnect / close" + elif lifecycle == "error": + progress = "error" + detail = str(row.get("connect_error") or "")[:240] + items.append( + _item( + kind="webcrt", + id=sid, + title=f"WebCRT · {name}", + status=lifecycle, + trigger="manual", + actor=actor, + started_at=None, + updated_at=None, + progress=progress, + detail=detail, + href="/webcrt", + ) + ) + if row.get("created_at"): + items[-1]["started_at"] = row.get("created_at") + if row.get("last_activity"): + items[-1]["updated_at"] = row.get("last_activity") + return items + + +def list_ops_tasks(db: Session) -> dict[str, Any]: + actors = _audit_actor_map(db) + items: list[dict[str, Any]] = [] + items.extend(_port_traffic_items(db, actors)) + items.extend(_config_sync_items(db, actors)) + items.extend(_collection_items(db, actors)) + items.extend(_lldp_items(db, actors)) + items.extend(_ume_sync_items(db, actors)) + items.extend(_ne_connect_items(db, actors)) + items.extend(_ume_runtime_items()) + items.extend(_webcrt_items(actors)) + + by_kind: dict[str, int] = {} + by_status: dict[str, int] = {} + active = 0 + for it in items: + kind = str(it.get("kind") or "") + status = str(it.get("status") or "") + by_kind[kind] = int(by_kind.get(kind, 0)) + 1 + by_status[status] = int(by_status.get(status, 0)) + 1 + if status in ( + "running", + "collecting", + "pending", + "paused", + "connecting", + "ready", + "testing", + ): + active += 1 + + # Sort: active-ish first, then kind/title + rank = { + "collecting": 0, + "running": 1, + "testing": 2, + "connecting": 3, + "pending": 4, + "paused": 5, + "ready": 6, + "detached": 7, + "error": 8, + } + items.sort( + key=lambda x: ( + rank.get(str(x.get("status") or ""), 50), + str(x.get("kind") or ""), + str(x.get("title") or ""), + ) + ) + + return { + "generated_at": datetime.utcnow().isoformat() + "Z", + "total": len(items), + "active": active, + "by_kind": by_kind, + "by_status": by_status, + "items": items, + } diff --git a/netx_api/port_traffic_board_service.py b/netx_api/port_traffic_board_service.py index 4d961d4..0d910d0 100644 --- a/netx_api/port_traffic_board_service.py +++ b/netx_api/port_traffic_board_service.py @@ -38,6 +38,7 @@ def _panel_out(db: Session, row: PortTrafficPanel) -> PortTrafficPanelOut: range_hours=int(row.range_hours or 24), baseline=str(row.baseline or "off"), offset_hours=int(row.offset_hours or 0), + ahead_hours=max(0, int(row.ahead_hours if getattr(row, "ahead_hours", None) is not None else 1)), baseline_target_id=str(row.baseline_target_id or ""), y_mode=str(row.y_mode or "auto"), ord=int(row.ord or 0), @@ -120,6 +121,7 @@ def _apply_panels( range_hours=int(item.range_hours), baseline=str(item.baseline or "off"), offset_hours=int(item.offset_hours or 0), + ahead_hours=max(0, min(24, int(item.ahead_hours if item.ahead_hours is not None else 1))), baseline_target_id=str(item.baseline_target_id or "").strip(), y_mode=str(item.y_mode or "auto"), ord=int(item.ord if item.ord is not None else i), diff --git a/netx_api/port_traffic_migrate.py b/netx_api/port_traffic_migrate.py index e500030..c2fe92a 100644 --- a/netx_api/port_traffic_migrate.py +++ b/netx_api/port_traffic_migrate.py @@ -154,6 +154,7 @@ def ensure_port_traffic_series_schema(conn) -> None: range_hours INTEGER DEFAULT 24, baseline VARCHAR(16) DEFAULT 'off', offset_hours INTEGER DEFAULT 0, + ahead_hours INTEGER DEFAULT 1, baseline_target_id VARCHAR(64) DEFAULT '', y_mode VARCHAR(16) DEFAULT 'auto', ord INTEGER DEFAULT 0, @@ -164,6 +165,9 @@ def ensure_port_traffic_series_schema(conn) -> None: ) """ ) + conn.exec_driver_sql( + "ALTER TABLE port_traffic_panel ADD COLUMN IF NOT EXISTS ahead_hours INTEGER DEFAULT 1" + ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS ix_port_traffic_panel_board_id ON port_traffic_panel (board_id)" ) diff --git a/netx_api/port_traffic_router.py b/netx_api/port_traffic_router.py index cfea399..ac853cb 100644 --- a/netx_api/port_traffic_router.py +++ b/netx_api/port_traffic_router.py @@ -440,6 +440,12 @@ def api_compare( 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), + ahead_hours: float = Query( + default=0, + ge=0, + le=24, + description="extend chart end past now so baseline can show upcoming trend", + ), baseline_target_id: str | None = Query( default=None, description="optional mapped interface for baseline overlay", @@ -454,5 +460,6 @@ def api_compare( baseline=baseline, offset_hours=offset_hours, baseline_target_id=baseline_target_id, + ahead_hours=ahead_hours, to_ts=to_ts, ).model_dump() diff --git a/netx_api/port_traffic_schemas.py b/netx_api/port_traffic_schemas.py index 95fbd0f..20e1568 100644 --- a/netx_api/port_traffic_schemas.py +++ b/netx_api/port_traffic_schemas.py @@ -160,21 +160,22 @@ class PortTrafficSamplePoint(BaseModel): ts_raw: datetime | None = None -class PortTrafficSamplesOut(BaseModel): - target: PortTrafficTargetOut - points: list[PortTrafficSamplePoint] = Field(default_factory=list) - - class PortTrafficCompareMeta(BaseModel): target_id: str baseline: str = "off" offset_hours: float = 0 range_hours: float = 24 + ahead_hours: float = 0 current_target: PortTrafficTargetOut | None = None baseline_target: PortTrafficTargetOut | None = None baseline_target_id: str = "" +class PortTrafficSamplesOut(BaseModel): + target: PortTrafficTargetOut + points: list[PortTrafficSamplePoint] = Field(default_factory=list) + + class PortTrafficCompareOut(BaseModel): meta: PortTrafficCompareMeta current: list[PortTrafficSamplePoint] = Field(default_factory=list) @@ -214,6 +215,7 @@ class PortTrafficPanelIn(BaseModel): range_hours: int = Field(default=24, ge=1, le=24 * 90) baseline: Literal["off", "day", "week", "shift", "custom"] = "off" offset_hours: int = Field(default=0, ge=0, le=24 * 90) + ahead_hours: int = Field(default=1, ge=0, le=24) baseline_target_id: str = "" y_mode: Literal["auto", "current", "util"] = "auto" ord: int = 0 @@ -229,6 +231,7 @@ class PortTrafficPanelOut(BaseModel): range_hours: int = 24 baseline: str = "off" offset_hours: int = 0 + ahead_hours: int = 1 baseline_target_id: str = "" y_mode: str = "auto" ord: int = 0 diff --git a/netx_api/port_traffic_service.py b/netx_api/port_traffic_service.py index 75a2c14..1f329a9 100644 --- a/netx_api/port_traffic_service.py +++ b/netx_api/port_traffic_service.py @@ -720,6 +720,7 @@ def compare_targets( baseline: str = "off", offset_hours: float | None = None, baseline_target_id: str | None = None, + ahead_hours: float = 0, to_ts: datetime | None = None, ) -> PortTrafficCompareOut: target = db.get(PortTrafficTarget, target_row_id) @@ -736,10 +737,14 @@ def compare_targets( raise HTTPException(status_code=404, detail="baseline_target_not_found") now = _utcnow() - to_ts = _as_naive_utc(to_ts) or now + # Anchor = "now" (or explicit to). Lookback is from anchor; ahead extends past it + # so period compare can show baseline trend after the current clock time. + anchor = _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) + ahead_h = max(0.0, min(24.0, float(ahead_hours or 0))) + from_ts = anchor - timedelta(hours=range_h) + to_end = anchor + timedelta(hours=ahead_h) + to_q = to_end + timedelta(seconds=5) current = _sample_points( _query_target_samples(db, target_row_id=str(target.id), from_ts=from_ts, to_ts=to_q) ) @@ -770,6 +775,7 @@ def compare_targets( baseline=str(baseline or "off"), offset_hours=float(off_h or 0), range_hours=range_h, + ahead_hours=ahead_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 "", diff --git a/netx_api/topology_lldp.py b/netx_api/topology_lldp.py index ff4cb06..5a52384 100644 --- a/netx_api/topology_lldp.py +++ b/netx_api/topology_lldp.py @@ -77,7 +77,7 @@ VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = { key="cisco", lldp_command="show lldp neighbors detail", cdp_command="show cdp neighbors detail", - notes="device_type cisco_*; use detail form for System Name / Port id.", + notes="device_type cisco_*; prefer detail for System Name / Port id / mgmt IP.", ), "huawei": VendorLldpProfile( key="huawei", diff --git a/netx_api/webcrt_service.py b/netx_api/webcrt_service.py index d2d89f4..6474c7a 100644 --- a/netx_api/webcrt_service.py +++ b/netx_api/webcrt_service.py @@ -471,8 +471,7 @@ class WebcrtSession: detach_deadline: float | None = None closed: bool = False close_reason: str = "" - # connecting | ready | error | closed - state: str = "ready" + state: str = "connecting" connect_error: str = "" connect_started_at: float = field(default_factory=time.time) connect_finished_at: float | None = None @@ -1444,32 +1443,54 @@ def close_session(session_id: str, *, reason: str = "closed", client: str = "") def list_sessions() -> dict[str, Any]: with _sessions_lock: - items = [ - { - "session_id": s.session_id, - "ne_id": s.ne_id, - "ne_name": s.ne_name, - "ne_ip": s.ne_ip, - "protocol": s.protocol, - "encoding": s.encoding, - "keepalive_sec": int(s.keepalive_sec or 0), - "state": s.state, - "attached": s.attached, - "created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(), - "last_activity": datetime.fromtimestamp(s.last_activity, tz=timezone.utc).isoformat(), - "bytes_in": s.bytes_in, - "bytes_out": s.bytes_out, - "queue_depth": s.out_queue.qsize(), - "queue_dropped": getattr(s.out_queue, "dropped", 0), - "connect_ms": ( - int((s.connect_finished_at - s.connect_started_at) * 1000) - if s.connect_finished_at - else None - ), - } - for s in _sessions.values() - if not s.closed - ] + items = [] + for s in _sessions.values(): + if s.closed: + continue + state = str(s.state or "unknown") + attached = bool(s.attached) + # Lifecycle for ops UI: distinguish login vs live vs grace-period detach. + if state == "connecting": + lifecycle = "connecting" + elif state == "error": + lifecycle = "error" + elif state == "ready" and attached: + lifecycle = "ready" + elif state == "ready" and not attached: + lifecycle = "detached" + else: + lifecycle = state + elapsed_ms = None + if state == "connecting": + elapsed_ms = int(max(0.0, time.time() - float(s.connect_started_at or time.time())) * 1000) + items.append( + { + "session_id": s.session_id, + "ne_id": s.ne_id, + "ne_name": s.ne_name, + "ne_ip": s.ne_ip, + "protocol": s.protocol, + "encoding": s.encoding, + "keepalive_sec": int(s.keepalive_sec or 0), + "state": state, + "lifecycle": lifecycle, + "attached": attached, + "detach_deadline": s.detach_deadline, + "connect_error": str(s.connect_error or "")[:500], + "elapsed_ms": elapsed_ms, + "created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(), + "last_activity": datetime.fromtimestamp(s.last_activity, tz=timezone.utc).isoformat(), + "bytes_in": s.bytes_in, + "bytes_out": s.bytes_out, + "queue_depth": s.out_queue.qsize(), + "queue_dropped": getattr(s.out_queue, "dropped", 0), + "connect_ms": ( + int((s.connect_finished_at - s.connect_started_at) * 1000) + if s.connect_finished_at + else None + ), + } + ) return { "total": len(items), "max_sessions": max(1, int(settings.webcrt_max_sessions or 20)), diff --git a/tests/test_port_traffic_compare.py b/tests/test_port_traffic_compare.py index 04fbac6..156eab9 100644 --- a/tests/test_port_traffic_compare.py +++ b/tests/test_port_traffic_compare.py @@ -236,6 +236,46 @@ class CompareTargetsTests(unittest.TestCase): self.assertGreaterEqual(len(out.baseline), 2) self.assertEqual(out.meta.offset_hours, 2.0) + def test_ahead_hours_extends_baseline_past_now(self): + # Yesterday sample 30min after "now" clock — only visible when ahead_hours > 0. + future_raw = self.now - timedelta(days=1) + timedelta(minutes=30) + self.db.add( + PortTrafficSample( + id=uuid4().hex, + target_row_id=self.target_id, + series_id=self.series_id, + ts=future_raw, + in_bps=777.0, + out_bps=888.0, + raw_ok=True, + ) + ) + self.db.commit() + + without = compare_targets( + self.db, + target_row_id=self.target_id, + range_hours=2, + baseline="day", + ahead_hours=0, + to_ts=self.now, + ) + self.assertFalse(any(abs((p.ts_raw or p.ts) - future_raw).total_seconds() < 1 for p in without.baseline)) + + with_ahead = compare_targets( + self.db, + target_row_id=self.target_id, + range_hours=2, + baseline="day", + ahead_hours=1, + to_ts=self.now, + ) + self.assertEqual(with_ahead.meta.ahead_hours, 1.0) + hit = [p for p in with_ahead.baseline if p.in_bps == 777.0] + self.assertEqual(len(hit), 1) + self.assertGreater(hit[0].ts, self.now) + self.assertLessEqual(hit[0].ts, self.now + timedelta(hours=1, seconds=5)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_topology.py b/tests/test_topology.py index 96b62eb..2d3736a 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -34,12 +34,15 @@ from netx_api.topology_schemas import ( CISCO_LLDP_BRIEF = """ +R2#show lldp neighbors Capability codes: - (R) Router, (B) Bridge + (R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device + (W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other Device ID Local Intf Hold-time Capability Port ID -R1 Gi0/0 120 R Gi0/1 -R3 Gi0/1 120 R Gi0/0 +r1 Gi0/1 120 B,R Ethernet1/0/1 + +Total entries displayed: 1 """ CISCO_LLDP_DETAIL = """ @@ -72,8 +75,16 @@ gei-0/1/0/1 0011.2233.4455 gei-0/1/0/2 R1 class LldpParserTests(unittest.TestCase): def test_cisco_brief(self) -> None: hits = lldp.parse_cisco_lldp(CISCO_LLDP_BRIEF) - self.assertGreaterEqual(len(hits), 1) - self.assertTrue(any(h.remote_name.upper().startswith("R1") for h in hits)) + self.assertEqual(len(hits), 1) + self.assertEqual(hits[0].remote_name.lower(), "r1") + self.assertEqual(hits[0].local_port, "Gi0/1") + self.assertEqual(hits[0].remote_port, "Ethernet1/0/1") + + def test_cisco_detail_fallback(self) -> None: + hits = lldp.parse_cisco_lldp(CISCO_LLDP_DETAIL) + self.assertEqual(len(hits), 1) + self.assertEqual(hits[0].remote_name.lower(), "r1") + self.assertEqual(hits[0].remote_ip, "192.168.0.1") def test_huawei(self) -> None: hits = lldp.parse_huawei_lldp(HUAWEI_LLDP) @@ -83,7 +94,7 @@ class LldpParserTests(unittest.TestCase): def test_pick_command_lldp_only(self) -> None: cmd, tag = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios") self.assertEqual(tag, "lldp") - self.assertIn("lldp", cmd.lower()) + self.assertEqual(cmd, "show lldp neighbors detail") class FabricTopologyTests(unittest.TestCase): diff --git a/tests/test_webcrt.py b/tests/test_webcrt.py index 3266133..d6c02d9 100644 --- a/tests/test_webcrt.py +++ b/tests/test_webcrt.py @@ -503,6 +503,8 @@ class WebcrtServiceTests(unittest.TestCase): rows=24, conn=conn, # type: ignore[arg-type] ) + sess.state = "ready" + sess.connect_finished_at = time.time() sess.attached = True with svc._sessions_lock: svc._sessions["grace"] = sess @@ -522,6 +524,42 @@ class WebcrtServiceTests(unittest.TestCase): svc._reap_sessions() self.assertIsNone(svc.get_session("grace")) + @patch.object(svc, "_audit") + def test_list_sessions_lifecycle_ready_vs_detached(self, _mock_audit: MagicMock) -> None: + conn = _FakeConn() + sess = svc.WebcrtSession( + session_id="life", + ne_id="ne1", + ne_name="lab", + ne_ip="1.2.3.4", + protocol="ssh", + cols=80, + rows=24, + conn=conn, # type: ignore[arg-type] + ) + sess.state = "ready" + sess.attached = True + with svc._sessions_lock: + svc._sessions["life"] = sess + ready_rows = {r["session_id"]: r for r in svc.list_sessions()["items"]} + self.assertEqual(ready_rows["life"]["lifecycle"], "ready") + self.assertTrue(ready_rows["life"]["attached"]) + + svc.detach_session("life", grace_sec=120.0, attach_gen=0) + detached_rows = {r["session_id"]: r for r in svc.list_sessions()["items"]} + self.assertEqual(detached_rows["life"]["lifecycle"], "detached") + self.assertFalse(detached_rows["life"]["attached"]) + self.assertIsNotNone(detached_rows["life"]["detach_deadline"]) + + sess.state = "connecting" + sess.attached = False + connecting_rows = {r["session_id"]: r for r in svc.list_sessions()["items"]} + self.assertEqual(connecting_rows["life"]["lifecycle"], "connecting") + self.assertIsInstance(connecting_rows["life"]["elapsed_ms"], int) + + svc.close_session("life", reason="test") + self.assertEqual(svc.list_sessions()["total"], 0) + @patch.object(svc, "_audit") def test_attach_timeout_reaper(self, _mock_audit: MagicMock) -> None: conn = _FakeConn() diff --git a/web/src/App.tsx b/web/src/App.tsx index 6ec612c..ea4d1b5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -20,6 +20,8 @@ import { PortTrafficBoardListPage } from "./pages/network/PortTrafficBoardListPa import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage"; import { LoginPage } from "./pages/LoginPage"; import { UsersPage } from "./pages/UsersPage"; +import { AuditLayout } from "./pages/audit/AuditLayout"; +import { TaskOverviewPage } from "./pages/audit/TaskOverviewPage"; import { AuditPage } from "./pages/AuditPage"; import { ApiTokensPage } from "./pages/ApiTokensPage"; import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage"; @@ -117,7 +119,11 @@ function ProtectedApp() { } /> } /> - } /> + }> + } /> + } /> + } /> + } /> } /> diff --git a/web/src/config/auditNav.ts b/web/src/config/auditNav.ts new file mode 100644 index 0000000..84d2923 --- /dev/null +++ b/web/src/config/auditNav.ts @@ -0,0 +1,10 @@ +export type AuditNavItem = { + id: string; + path: string; + labelKey: string; +}; + +export const AUDIT_NAV: readonly AuditNavItem[] = [ + { id: "tasks", path: "/audit/tasks", labelKey: "audit.nav.tasks" }, + { id: "logs", path: "/audit/logs", labelKey: "audit.nav.logs" }, +] as const; diff --git a/web/src/config/modules.ts b/web/src/config/modules.ts index bca63fd..4cda705 100644 --- a/web/src/config/modules.ts +++ b/web/src/config/modules.ts @@ -118,6 +118,8 @@ export function moduleIdFromPath(pathname: string): string | null { } export function getPageTitleKey(pathname: string): string { + if (pathname.startsWith("/audit/tasks")) return "audit.tasks.title"; + if (pathname.startsWith("/audit/logs") || pathname === "/audit") return "audit.logsTitle"; const moduleId = moduleIdFromPath(pathname); if (moduleId) { const mod = getModuleById(moduleId); diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index fab81c3..1ac3f82 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -96,5 +96,7 @@ export const queryKeys = { baseline: string, offsetHours: number, baselineTargetId: string, - ) => ["portTrafficCompare", targetId, rangeHours, baseline, offsetHours, baselineTargetId] as const, + aheadHours = 0, + ) => + ["portTrafficCompare", targetId, rangeHours, baseline, offsetHours, baselineTargetId, aheadHours] as const, }; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 41cbf22..292380c 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -38,7 +38,7 @@ const en = { users: "Users", usersDesc: "Admin: create, disable users and reset passwords", audit: "Audit", - auditDesc: "View login and operation records", + auditDesc: "Live task overview and operation logs", apiKeys: "API Keys", apiKeysDesc: "Issue MCP/script tokens per user with expiry", }, @@ -170,6 +170,11 @@ const en = { compareShift: "Previous window", compareCustom: "Custom offset", offsetHours: "Offset (hours)", + aheadHours: "Extend past now (hours)", + aheadHoursHint: + "Push the chart end past now. With period compare enabled, the overlay can show trend after the current clock; 0 ends at now.", + mapBaselineHint: + "Mapped baseline can be combined with period compare; turn on Yesterday yourself when you need that contrast.", baseline: "Baseline", baselineAt: "Baseline time", baselineDevice: "Baseline device", @@ -1474,6 +1479,55 @@ const en = { sliceStats: "{{maps}} map(s) planned, {{overlap}} NE(s) on multiple maps", nodeCount: "{{count}} NE(s)", }, + audit: { + title: "Audit & ops", + logsTitle: "Operation logs", + nav: { + tasks: "Task overview", + logs: "Operation logs", + }, + tasks: { + title: "Task overview", + hint: "Live view of running or pending background work. Actor prefers recent audit entries; scheduled work shows as scheduler/system.", + empty: "No tasks to show right now.", + open: "Open", + kpiActive: "Active / in-flight", + kpiTotal: "Total rows", + kpiKinds: "By kind", + kpiStatuses: "By status", + colKind: "Kind", + colTitle: "Task", + colStatus: "Status", + colActor: "Actor", + colTrigger: "Trigger", + colProgress: "Progress", + colStarted: "Started", + colUpdated: "Updated", + colLink: "Open", + kind: { + port_traffic: "Port traffic", + config_sync: "Config sync", + ne_collect: "Batch collect", + lldp_discover: "LLDP discover", + ume_sync: "UME sync", + ne_connect: "Connect test", + ume_runtime: "UME runtime", + webcrt: "WebCRT", + }, + status: { + collecting: "Collecting", + running: "Running", + testing: "Testing", + connecting: "Logging in", + ready: "Connected", + detached: "Detached (grace)", + pending: "Pending", + paused: "Paused", + error: "Error", + failed: "Failed", + }, + }, + }, }; export default en; diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 7b004aa..06c5f27 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -38,7 +38,7 @@ const zh = { users: "用户管理", usersDesc: "管理员添加、禁用用户并重置密码", audit: "操作审计", - auditDesc: "查看登录与操作记录", + auditDesc: "任务概览与操作日志", apiKeys: "API Key", apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期", }, @@ -170,6 +170,10 @@ const zh = { compareShift: "前一窗", compareCustom: "自定义偏移", offsetHours: "偏移(小时)", + aheadHours: "向后延长(小时)", + aheadHoursHint: + "图表终点相对当前时刻再往后延长。开启周期对比时可看到对照曲线在「现在」之后的走势;0 表示截止到现在。", + mapBaselineHint: "映射基线可与周期对比叠加使用;需要昨日对照时请自行打开「昨日同时刻」。", baseline: "基线", baselineAt: "基线原时刻", baselineDevice: "基线网元", @@ -1466,6 +1470,55 @@ const zh = { sliceStats: "计划 {{maps}} 张图,重复上图网元 {{overlap}} 个", nodeCount: "{{count}} 个网元", }, + audit: { + title: "操作审计", + logsTitle: "操作日志", + nav: { + tasks: "任务概览", + logs: "操作日志", + }, + tasks: { + title: "任务概览", + hint: "汇总当前系统中正在运行或挂起的各类后台任务;发起人优先取近期审计记录,调度类任务显示为 scheduler/system。", + empty: "当前没有可展示的任务。", + open: "打开", + kpiActive: "活跃/在途", + kpiTotal: "条目总数", + kpiKinds: "按类型", + kpiStatuses: "按状态", + colKind: "类型", + colTitle: "任务", + colStatus: "状态", + colActor: "发起人", + colTrigger: "触发", + colProgress: "进度", + colStarted: "开始", + colUpdated: "更新", + colLink: "入口", + kind: { + port_traffic: "端口流量", + config_sync: "配置同步", + ne_collect: "批量采集", + lldp_discover: "LLDP 发现", + ume_sync: "UME 同步", + ne_connect: "连通性测试", + ume_runtime: "UME 后台", + webcrt: "WebCRT", + }, + status: { + collecting: "采集中", + running: "运行中", + testing: "测试中", + connecting: "登录中", + ready: "已连接", + detached: "已断开(宽限)", + pending: "排队", + paused: "暂停", + error: "错误", + failed: "失败", + }, + }, + }, }; export default zh; diff --git a/web/src/index.css b/web/src/index.css index 7ef4e7a..cad84b4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -5957,6 +5957,23 @@ pre { border-left-color: var(--nm-accent); } +.network-nav__brand { + padding: 14px 16px 10px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + color: var(--nm-brand); + border-bottom: 1px solid var(--nm-nav-border); +} + +.network-nav__list--flat { + padding: 8px 10px 12px; +} + +.audit-shell .network-main { + overflow: auto; +} + .network-main { flex: 1; min-width: 0; diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx index a30a052..c2c70e3 100644 --- a/web/src/pages/AuditPage.tsx +++ b/web/src/pages/AuditPage.tsx @@ -55,7 +55,7 @@ export function AuditPage() {
-

{t("auth.auditTitle")}

+

{t("audit.logsTitle")}

{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}

diff --git a/web/src/pages/audit/AuditLayout.tsx b/web/src/pages/audit/AuditLayout.tsx new file mode 100644 index 0000000..debac18 --- /dev/null +++ b/web/src/pages/audit/AuditLayout.tsx @@ -0,0 +1,32 @@ +import { NavLink, Outlet } from "react-router-dom"; +import { AUDIT_NAV } from "../../config/auditNav"; +import { useI18n } from "../../i18n"; + +export function AuditLayout() { + const { t } = useI18n(); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/web/src/pages/audit/TaskOverviewPage.tsx b/web/src/pages/audit/TaskOverviewPage.tsx new file mode 100644 index 0000000..11417c7 --- /dev/null +++ b/web/src/pages/audit/TaskOverviewPage.tsx @@ -0,0 +1,149 @@ +import { useMemo } from "react"; +import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { useI18n } from "../../i18n"; +import { fetchOpsTasks } from "../../services/api"; +import { formatSystemTime } from "../../utils/time"; + +const POLL_MS = 4000; + +function statusTone(status: string): string { + const s = status.toLowerCase(); + if (s === "collecting" || s === "running" || s === "connecting" || s === "testing") return "running"; + if (s === "ready") return "ok"; + if (s === "paused" || s === "pending" || s === "detached") return "paused"; + if (s === "failed" || s === "error") return "stopped"; + return "other"; +} + +function kindLabel(kind: string, t: (k: string) => string): string { + const key = `audit.tasks.kind.${kind}`; + const tr = t(key); + return tr === key ? kind : tr; +} + +function statusLabel(status: string, t: (k: string) => string): string { + const key = `audit.tasks.status.${status}`; + const tr = t(key); + return tr === key ? status : tr; +} + +export function TaskOverviewPage() { + const { t } = useI18n(); + const query = useQuery({ + queryKey: ["opsTasks"], + queryFn: fetchOpsTasks, + refetchInterval: POLL_MS, + staleTime: 1500, + }); + + const data = query.data; + const items = useMemo(() => data?.items || [], [data]); + const byKind = data?.by_kind || {}; + const byStatus = data?.by_status || {}; + + return ( +
+
+
+

{t("audit.tasks.title")}

+ +
+

{t("audit.tasks.hint")}

+ +
+
+
{t("audit.tasks.kpiActive")}
+
{data?.active ?? "—"}
+
+
+
{t("audit.tasks.kpiTotal")}
+
{data?.total ?? "—"}
+
+
+
{t("audit.tasks.kpiKinds")}
+
+ {Object.keys(byKind).length + ? Object.entries(byKind) + .map(([k, n]) => `${kindLabel(k, t)} ${n}`) + .join(" · ") + : "—"} +
+
+
+
{t("audit.tasks.kpiStatuses")}
+
+ {Object.keys(byStatus).length + ? Object.entries(byStatus) + .map(([k, n]) => `${statusLabel(k, t)} ${n}`) + .join(" · ") + : "—"} +
+
+
+ + {query.isLoading ?

{t("common.refreshing")}

: null} + + {!items.length && !query.isLoading ? ( +
+

{t("audit.tasks.empty")}

+
+ ) : ( +
+ + + + + + + + + + + + + + + + {items.map((row) => ( + + + + + + + + + + + + ))} + +
{t("audit.tasks.colKind")}{t("audit.tasks.colTitle")}{t("audit.tasks.colStatus")}{t("audit.tasks.colActor")}{t("audit.tasks.colTrigger")}{t("audit.tasks.colProgress")}{t("audit.tasks.colStarted")}{t("audit.tasks.colUpdated")}{t("audit.tasks.colLink")}
{kindLabel(row.kind, t)} +
{row.title}
+ {row.detail ? ( +
+ {row.detail} +
+ ) : null} +
+ + {statusLabel(row.status, t)} + + {row.actor || "—"}{row.trigger || "—"}{row.progress || "—"}{formatSystemTime(row.started_at) || "—"}{formatSystemTime(row.updated_at) || "—"} + {row.href ? ( + + {t("audit.tasks.open")} + + ) : ( + "—" + )} +
+
+ )} +
+
+ ); +} diff --git a/web/src/pages/network/PortTrafficBoardPanelCell.tsx b/web/src/pages/network/PortTrafficBoardPanelCell.tsx index 5f93824..0442932 100644 --- a/web/src/pages/network/PortTrafficBoardPanelCell.tsx +++ b/web/src/pages/network/PortTrafficBoardPanelCell.tsx @@ -29,6 +29,10 @@ export function PortTrafficBoardPanelCell({ }: Props) { const { t } = useI18n(); const baseline = panel.baseline || "off"; + const aheadHours = + baseline === "off" && !panel.baseline_target_id + ? 0 + : Math.max(0, Number(panel.ahead_hours ?? 1) || 0); const compareQuery = useQuery({ queryKey: [ ...queryKeys.portTrafficCompare( @@ -37,6 +41,7 @@ export function PortTrafficBoardPanelCell({ baseline, baseline === "custom" ? panel.offset_hours : 0, panel.baseline_target_id || "", + aheadHours, ), boardId, panel.id, @@ -47,6 +52,7 @@ export function PortTrafficBoardPanelCell({ rangeHours: panel.range_hours, baseline, offsetHours: baseline === "custom" ? panel.offset_hours : undefined, + aheadHours: aheadHours || undefined, baselineTargetId: panel.baseline_target_id || undefined, }), enabled: Boolean(panel.target_id) && !panel.stale, diff --git a/web/src/pages/network/PortTrafficWallPage.tsx b/web/src/pages/network/PortTrafficWallPage.tsx index cf3e8ba..f349b07 100644 --- a/web/src/pages/network/PortTrafficWallPage.tsx +++ b/web/src/pages/network/PortTrafficWallPage.tsx @@ -41,6 +41,7 @@ function panelToIn(p: PortTrafficBoardPanel): PortTrafficBoardPanelIn { range_hours: p.range_hours, baseline: p.baseline || "off", offset_hours: p.offset_hours || 0, + ahead_hours: p.ahead_hours ?? 1, baseline_target_id: p.baseline_target_id || "", y_mode: p.y_mode || "auto", ord: p.ord, @@ -49,6 +50,10 @@ function panelToIn(p: PortTrafficBoardPanel): PortTrafficBoardPanelIn { }; } +function normalizePanel(p: PortTrafficBoardPanel): PortTrafficBoardPanel { + return { ...p, ahead_hours: p.ahead_hours ?? 1 }; +} + /** Dedicated board wall tab at `/port-traffic/wall/:boardId` (outside Network shell). */ export function PortTrafficWallPage() { const { t } = useI18n(); @@ -136,7 +141,7 @@ export function PortTrafficWallPage() { useEffect(() => { if (!board || editing) return; - setDraftPanels(board.panels || []); + setDraftPanels((board.panels || []).map(normalizePanel)); setDraftName(board.name || ""); setDraftCols(board.cols || 2); }, [board, editing]); @@ -188,7 +193,7 @@ export function PortTrafficWallPage() { const startEdit = () => { if (!board) return; - setDraftPanels([...(board.panels || [])]); + setDraftPanels((board.panels || []).map(normalizePanel)); setDraftName(board.name || ""); setDraftCols(board.cols || 2); setEditing(true); @@ -201,7 +206,7 @@ export function PortTrafficWallPage() { setDirty(false); closePanelSettings(); if (board) { - setDraftPanels(board.panels || []); + setDraftPanels((board.panels || []).map(normalizePanel)); setDraftName(board.name || ""); setDraftCols(board.cols || 2); } @@ -249,6 +254,7 @@ export function PortTrafficWallPage() { range_hours: 24, baseline: "off", offset_hours: 0, + ahead_hours: 1, baseline_target_id: "", y_mode: "auto", ord: draftPanels.length, @@ -571,9 +577,15 @@ export function PortTrafficWallPage() { {t("portTraffic.compare")} + {editPanel.baseline !== "off" || editPanel.baseline_target_id ? ( + + ) : null}
{editPanel.baseline === "week" ? (

{t("portTraffic.retentionHint")}

) : null} + {editPanel.baseline_target_id ? ( +

+ {t("portTraffic.mapBaselineHint")} +

+ ) : null}