mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
3f0787da11
commit
3908b78dbb
28 changed files with 1083 additions and 54 deletions
|
|
@ -32,6 +32,7 @@ from .managed_ne_router import router as managed_ne_router
|
||||||
from .webcrt_router import router as webcrt_router
|
from .webcrt_router import router as webcrt_router
|
||||||
from .topology_router import router as topology_router
|
from .topology_router import router as topology_router
|
||||||
from .lldp_collect_router import router as lldp_collect_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 .importer import aggregate_alarms, import_alarm_excel, query_alarms
|
||||||
from .models import (
|
from .models import (
|
||||||
AiAnalyzeHistory,
|
AiAnalyzeHistory,
|
||||||
|
|
@ -141,6 +142,7 @@ app.include_router(port_traffic_router)
|
||||||
app.include_router(webcrt_router)
|
app.include_router(webcrt_router)
|
||||||
app.include_router(topology_router)
|
app.include_router(topology_router)
|
||||||
app.include_router(lldp_collect_router)
|
app.include_router(lldp_collect_router)
|
||||||
|
app.include_router(ops_router)
|
||||||
parser_cfg = load_parser_config()
|
parser_cfg = load_parser_config()
|
||||||
_UME_CLIENT_SINGLETON = UMEClient(
|
_UME_CLIENT_SINGLETON = UMEClient(
|
||||||
token_loader=lambda: load_shared_token(),
|
token_loader=lambda: load_shared_token(),
|
||||||
|
|
|
||||||
|
|
@ -930,6 +930,7 @@ class PortTrafficPanel(Base):
|
||||||
range_hours: Mapped[int] = mapped_column(Integer, default=24)
|
range_hours: Mapped[int] = mapped_column(Integer, default=24)
|
||||||
baseline: Mapped[str] = mapped_column(String(16), default="off") # off|day|week|shift|custom
|
baseline: Mapped[str] = mapped_column(String(16), default="off") # off|day|week|shift|custom
|
||||||
offset_hours: Mapped[int] = mapped_column(Integer, default=0)
|
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="")
|
baseline_target_id: Mapped[str] = mapped_column(String(64), default="")
|
||||||
y_mode: Mapped[str] = mapped_column(String(16), default="auto") # auto|current|util
|
y_mode: Mapped[str] = mapped_column(String(16), default="auto") # auto|current|util
|
||||||
ord: Mapped[int] = mapped_column(Integer, default=0)
|
ord: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
|
|
||||||
23
netx_api/ops_router.py
Normal file
23
netx_api/ops_router.py
Normal file
|
|
@ -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)
|
||||||
472
netx_api/ops_tasks_service.py
Normal file
472
netx_api/ops_tasks_service.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,7 @@ def _panel_out(db: Session, row: PortTrafficPanel) -> PortTrafficPanelOut:
|
||||||
range_hours=int(row.range_hours or 24),
|
range_hours=int(row.range_hours or 24),
|
||||||
baseline=str(row.baseline or "off"),
|
baseline=str(row.baseline or "off"),
|
||||||
offset_hours=int(row.offset_hours or 0),
|
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 ""),
|
baseline_target_id=str(row.baseline_target_id or ""),
|
||||||
y_mode=str(row.y_mode or "auto"),
|
y_mode=str(row.y_mode or "auto"),
|
||||||
ord=int(row.ord or 0),
|
ord=int(row.ord or 0),
|
||||||
|
|
@ -120,6 +121,7 @@ def _apply_panels(
|
||||||
range_hours=int(item.range_hours),
|
range_hours=int(item.range_hours),
|
||||||
baseline=str(item.baseline or "off"),
|
baseline=str(item.baseline or "off"),
|
||||||
offset_hours=int(item.offset_hours or 0),
|
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(),
|
baseline_target_id=str(item.baseline_target_id or "").strip(),
|
||||||
y_mode=str(item.y_mode or "auto"),
|
y_mode=str(item.y_mode or "auto"),
|
||||||
ord=int(item.ord if item.ord is not None else i),
|
ord=int(item.ord if item.ord is not None else i),
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,7 @@ def ensure_port_traffic_series_schema(conn) -> None:
|
||||||
range_hours INTEGER DEFAULT 24,
|
range_hours INTEGER DEFAULT 24,
|
||||||
baseline VARCHAR(16) DEFAULT 'off',
|
baseline VARCHAR(16) DEFAULT 'off',
|
||||||
offset_hours INTEGER DEFAULT 0,
|
offset_hours INTEGER DEFAULT 0,
|
||||||
|
ahead_hours INTEGER DEFAULT 1,
|
||||||
baseline_target_id VARCHAR(64) DEFAULT '',
|
baseline_target_id VARCHAR(64) DEFAULT '',
|
||||||
y_mode VARCHAR(16) DEFAULT 'auto',
|
y_mode VARCHAR(16) DEFAULT 'auto',
|
||||||
ord INTEGER DEFAULT 0,
|
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(
|
conn.exec_driver_sql(
|
||||||
"CREATE INDEX IF NOT EXISTS ix_port_traffic_panel_board_id ON port_traffic_panel (board_id)"
|
"CREATE INDEX IF NOT EXISTS ix_port_traffic_panel_board_id ON port_traffic_panel (board_id)"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -440,6 +440,12 @@ def api_compare(
|
||||||
range_hours: float = Query(default=24, ge=0.25, le=24 * 90),
|
range_hours: float = Query(default=24, ge=0.25, le=24 * 90),
|
||||||
baseline: str = Query(default="off", description="off|shift|day|week|custom"),
|
baseline: str = Query(default="off", description="off|shift|day|week|custom"),
|
||||||
offset_hours: float | None = Query(default=None, ge=0.25, le=24 * 90),
|
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(
|
baseline_target_id: str | None = Query(
|
||||||
default=None,
|
default=None,
|
||||||
description="optional mapped interface for baseline overlay",
|
description="optional mapped interface for baseline overlay",
|
||||||
|
|
@ -454,5 +460,6 @@ def api_compare(
|
||||||
baseline=baseline,
|
baseline=baseline,
|
||||||
offset_hours=offset_hours,
|
offset_hours=offset_hours,
|
||||||
baseline_target_id=baseline_target_id,
|
baseline_target_id=baseline_target_id,
|
||||||
|
ahead_hours=ahead_hours,
|
||||||
to_ts=to_ts,
|
to_ts=to_ts,
|
||||||
).model_dump()
|
).model_dump()
|
||||||
|
|
|
||||||
|
|
@ -160,21 +160,22 @@ class PortTrafficSamplePoint(BaseModel):
|
||||||
ts_raw: datetime | None = None
|
ts_raw: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class PortTrafficSamplesOut(BaseModel):
|
|
||||||
target: PortTrafficTargetOut
|
|
||||||
points: list[PortTrafficSamplePoint] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class PortTrafficCompareMeta(BaseModel):
|
class PortTrafficCompareMeta(BaseModel):
|
||||||
target_id: str
|
target_id: str
|
||||||
baseline: str = "off"
|
baseline: str = "off"
|
||||||
offset_hours: float = 0
|
offset_hours: float = 0
|
||||||
range_hours: float = 24
|
range_hours: float = 24
|
||||||
|
ahead_hours: float = 0
|
||||||
current_target: PortTrafficTargetOut | None = None
|
current_target: PortTrafficTargetOut | None = None
|
||||||
baseline_target: PortTrafficTargetOut | None = None
|
baseline_target: PortTrafficTargetOut | None = None
|
||||||
baseline_target_id: str = ""
|
baseline_target_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PortTrafficSamplesOut(BaseModel):
|
||||||
|
target: PortTrafficTargetOut
|
||||||
|
points: list[PortTrafficSamplePoint] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class PortTrafficCompareOut(BaseModel):
|
class PortTrafficCompareOut(BaseModel):
|
||||||
meta: PortTrafficCompareMeta
|
meta: PortTrafficCompareMeta
|
||||||
current: list[PortTrafficSamplePoint] = Field(default_factory=list)
|
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)
|
range_hours: int = Field(default=24, ge=1, le=24 * 90)
|
||||||
baseline: Literal["off", "day", "week", "shift", "custom"] = "off"
|
baseline: Literal["off", "day", "week", "shift", "custom"] = "off"
|
||||||
offset_hours: int = Field(default=0, ge=0, le=24 * 90)
|
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 = ""
|
baseline_target_id: str = ""
|
||||||
y_mode: Literal["auto", "current", "util"] = "auto"
|
y_mode: Literal["auto", "current", "util"] = "auto"
|
||||||
ord: int = 0
|
ord: int = 0
|
||||||
|
|
@ -229,6 +231,7 @@ class PortTrafficPanelOut(BaseModel):
|
||||||
range_hours: int = 24
|
range_hours: int = 24
|
||||||
baseline: str = "off"
|
baseline: str = "off"
|
||||||
offset_hours: int = 0
|
offset_hours: int = 0
|
||||||
|
ahead_hours: int = 1
|
||||||
baseline_target_id: str = ""
|
baseline_target_id: str = ""
|
||||||
y_mode: str = "auto"
|
y_mode: str = "auto"
|
||||||
ord: int = 0
|
ord: int = 0
|
||||||
|
|
|
||||||
|
|
@ -720,6 +720,7 @@ def compare_targets(
|
||||||
baseline: str = "off",
|
baseline: str = "off",
|
||||||
offset_hours: float | None = None,
|
offset_hours: float | None = None,
|
||||||
baseline_target_id: str | None = None,
|
baseline_target_id: str | None = None,
|
||||||
|
ahead_hours: float = 0,
|
||||||
to_ts: datetime | None = None,
|
to_ts: datetime | None = None,
|
||||||
) -> PortTrafficCompareOut:
|
) -> PortTrafficCompareOut:
|
||||||
target = db.get(PortTrafficTarget, target_row_id)
|
target = db.get(PortTrafficTarget, target_row_id)
|
||||||
|
|
@ -736,10 +737,14 @@ def compare_targets(
|
||||||
raise HTTPException(status_code=404, detail="baseline_target_not_found")
|
raise HTTPException(status_code=404, detail="baseline_target_not_found")
|
||||||
|
|
||||||
now = _utcnow()
|
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))
|
range_h = max(0.25, float(range_hours or 24))
|
||||||
from_ts = to_ts - timedelta(hours=range_h)
|
ahead_h = max(0.0, min(24.0, float(ahead_hours or 0)))
|
||||||
to_q = to_ts + timedelta(seconds=5)
|
from_ts = anchor - timedelta(hours=range_h)
|
||||||
|
to_end = anchor + timedelta(hours=ahead_h)
|
||||||
|
to_q = to_end + timedelta(seconds=5)
|
||||||
current = _sample_points(
|
current = _sample_points(
|
||||||
_query_target_samples(db, target_row_id=str(target.id), from_ts=from_ts, to_ts=to_q)
|
_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"),
|
baseline=str(baseline or "off"),
|
||||||
offset_hours=float(off_h or 0),
|
offset_hours=float(off_h or 0),
|
||||||
range_hours=range_h,
|
range_hours=range_h,
|
||||||
|
ahead_hours=ahead_h,
|
||||||
current_target=_target_out(target),
|
current_target=_target_out(target),
|
||||||
baseline_target=_target_out(mapped) if mapped is not None else None,
|
baseline_target=_target_out(mapped) if mapped is not None else None,
|
||||||
baseline_target_id=str(mapped.id) if mapped is not None else "",
|
baseline_target_id=str(mapped.id) if mapped is not None else "",
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ VENDOR_LLDP_PROFILES: dict[str, VendorLldpProfile] = {
|
||||||
key="cisco",
|
key="cisco",
|
||||||
lldp_command="show lldp neighbors detail",
|
lldp_command="show lldp neighbors detail",
|
||||||
cdp_command="show cdp 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(
|
"huawei": VendorLldpProfile(
|
||||||
key="huawei",
|
key="huawei",
|
||||||
|
|
|
||||||
|
|
@ -471,8 +471,7 @@ class WebcrtSession:
|
||||||
detach_deadline: float | None = None
|
detach_deadline: float | None = None
|
||||||
closed: bool = False
|
closed: bool = False
|
||||||
close_reason: str = ""
|
close_reason: str = ""
|
||||||
# connecting | ready | error | closed
|
state: str = "connecting"
|
||||||
state: str = "ready"
|
|
||||||
connect_error: str = ""
|
connect_error: str = ""
|
||||||
connect_started_at: float = field(default_factory=time.time)
|
connect_started_at: float = field(default_factory=time.time)
|
||||||
connect_finished_at: float | None = None
|
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]:
|
def list_sessions() -> dict[str, Any]:
|
||||||
with _sessions_lock:
|
with _sessions_lock:
|
||||||
items = [
|
items = []
|
||||||
{
|
for s in _sessions.values():
|
||||||
"session_id": s.session_id,
|
if s.closed:
|
||||||
"ne_id": s.ne_id,
|
continue
|
||||||
"ne_name": s.ne_name,
|
state = str(s.state or "unknown")
|
||||||
"ne_ip": s.ne_ip,
|
attached = bool(s.attached)
|
||||||
"protocol": s.protocol,
|
# Lifecycle for ops UI: distinguish login vs live vs grace-period detach.
|
||||||
"encoding": s.encoding,
|
if state == "connecting":
|
||||||
"keepalive_sec": int(s.keepalive_sec or 0),
|
lifecycle = "connecting"
|
||||||
"state": s.state,
|
elif state == "error":
|
||||||
"attached": s.attached,
|
lifecycle = "error"
|
||||||
"created_at": datetime.fromtimestamp(s.created_at, tz=timezone.utc).isoformat(),
|
elif state == "ready" and attached:
|
||||||
"last_activity": datetime.fromtimestamp(s.last_activity, tz=timezone.utc).isoformat(),
|
lifecycle = "ready"
|
||||||
"bytes_in": s.bytes_in,
|
elif state == "ready" and not attached:
|
||||||
"bytes_out": s.bytes_out,
|
lifecycle = "detached"
|
||||||
"queue_depth": s.out_queue.qsize(),
|
else:
|
||||||
"queue_dropped": getattr(s.out_queue, "dropped", 0),
|
lifecycle = state
|
||||||
"connect_ms": (
|
elapsed_ms = None
|
||||||
int((s.connect_finished_at - s.connect_started_at) * 1000)
|
if state == "connecting":
|
||||||
if s.connect_finished_at
|
elapsed_ms = int(max(0.0, time.time() - float(s.connect_started_at or time.time())) * 1000)
|
||||||
else None
|
items.append(
|
||||||
),
|
{
|
||||||
}
|
"session_id": s.session_id,
|
||||||
for s in _sessions.values()
|
"ne_id": s.ne_id,
|
||||||
if not s.closed
|
"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 {
|
return {
|
||||||
"total": len(items),
|
"total": len(items),
|
||||||
"max_sessions": max(1, int(settings.webcrt_max_sessions or 20)),
|
"max_sessions": max(1, int(settings.webcrt_max_sessions or 20)),
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,46 @@ class CompareTargetsTests(unittest.TestCase):
|
||||||
self.assertGreaterEqual(len(out.baseline), 2)
|
self.assertGreaterEqual(len(out.baseline), 2)
|
||||||
self.assertEqual(out.meta.offset_hours, 2.0)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -34,12 +34,15 @@ from netx_api.topology_schemas import (
|
||||||
|
|
||||||
|
|
||||||
CISCO_LLDP_BRIEF = """
|
CISCO_LLDP_BRIEF = """
|
||||||
|
R2#show lldp neighbors
|
||||||
Capability codes:
|
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
|
Device ID Local Intf Hold-time Capability Port ID
|
||||||
R1 Gi0/0 120 R Gi0/1
|
r1 Gi0/1 120 B,R Ethernet1/0/1
|
||||||
R3 Gi0/1 120 R Gi0/0
|
|
||||||
|
Total entries displayed: 1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
CISCO_LLDP_DETAIL = """
|
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):
|
class LldpParserTests(unittest.TestCase):
|
||||||
def test_cisco_brief(self) -> None:
|
def test_cisco_brief(self) -> None:
|
||||||
hits = lldp.parse_cisco_lldp(CISCO_LLDP_BRIEF)
|
hits = lldp.parse_cisco_lldp(CISCO_LLDP_BRIEF)
|
||||||
self.assertGreaterEqual(len(hits), 1)
|
self.assertEqual(len(hits), 1)
|
||||||
self.assertTrue(any(h.remote_name.upper().startswith("R1") for h in hits))
|
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:
|
def test_huawei(self) -> None:
|
||||||
hits = lldp.parse_huawei_lldp(HUAWEI_LLDP)
|
hits = lldp.parse_huawei_lldp(HUAWEI_LLDP)
|
||||||
|
|
@ -83,7 +94,7 @@ class LldpParserTests(unittest.TestCase):
|
||||||
def test_pick_command_lldp_only(self) -> None:
|
def test_pick_command_lldp_only(self) -> None:
|
||||||
cmd, tag = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios")
|
cmd, tag = lldp.pick_neighbor_command(protocol="cdp", vendor="Cisco", device_type="cisco_ios")
|
||||||
self.assertEqual(tag, "lldp")
|
self.assertEqual(tag, "lldp")
|
||||||
self.assertIn("lldp", cmd.lower())
|
self.assertEqual(cmd, "show lldp neighbors detail")
|
||||||
|
|
||||||
|
|
||||||
class FabricTopologyTests(unittest.TestCase):
|
class FabricTopologyTests(unittest.TestCase):
|
||||||
|
|
|
||||||
|
|
@ -503,6 +503,8 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
rows=24,
|
rows=24,
|
||||||
conn=conn, # type: ignore[arg-type]
|
conn=conn, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
sess.state = "ready"
|
||||||
|
sess.connect_finished_at = time.time()
|
||||||
sess.attached = True
|
sess.attached = True
|
||||||
with svc._sessions_lock:
|
with svc._sessions_lock:
|
||||||
svc._sessions["grace"] = sess
|
svc._sessions["grace"] = sess
|
||||||
|
|
@ -522,6 +524,42 @@ class WebcrtServiceTests(unittest.TestCase):
|
||||||
svc._reap_sessions()
|
svc._reap_sessions()
|
||||||
self.assertIsNone(svc.get_session("grace"))
|
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")
|
@patch.object(svc, "_audit")
|
||||||
def test_attach_timeout_reaper(self, _mock_audit: MagicMock) -> None:
|
def test_attach_timeout_reaper(self, _mock_audit: MagicMock) -> None:
|
||||||
conn = _FakeConn()
|
conn = _FakeConn()
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ import { PortTrafficBoardListPage } from "./pages/network/PortTrafficBoardListPa
|
||||||
import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage";
|
import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage";
|
||||||
import { LoginPage } from "./pages/LoginPage";
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
import { UsersPage } from "./pages/UsersPage";
|
import { UsersPage } from "./pages/UsersPage";
|
||||||
|
import { AuditLayout } from "./pages/audit/AuditLayout";
|
||||||
|
import { TaskOverviewPage } from "./pages/audit/TaskOverviewPage";
|
||||||
import { AuditPage } from "./pages/AuditPage";
|
import { AuditPage } from "./pages/AuditPage";
|
||||||
import { ApiTokensPage } from "./pages/ApiTokensPage";
|
import { ApiTokensPage } from "./pages/ApiTokensPage";
|
||||||
import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage";
|
import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage";
|
||||||
|
|
@ -117,7 +119,11 @@ function ProtectedApp() {
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />
|
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
<Route path="/audit" element={<AuditPage />} />
|
<Route path="/audit" element={<AuditLayout />}>
|
||||||
|
<Route index element={<Navigate to="tasks" replace />} />
|
||||||
|
<Route path="tasks" element={<TaskOverviewPage />} />
|
||||||
|
<Route path="logs" element={<AuditPage />} />
|
||||||
|
</Route>
|
||||||
<Route path="/api-keys" element={<ApiTokensPage />} />
|
<Route path="/api-keys" element={<ApiTokensPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
10
web/src/config/auditNav.ts
Normal file
10
web/src/config/auditNav.ts
Normal file
|
|
@ -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;
|
||||||
|
|
@ -118,6 +118,8 @@ export function moduleIdFromPath(pathname: string): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPageTitleKey(pathname: string): string {
|
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);
|
const moduleId = moduleIdFromPath(pathname);
|
||||||
if (moduleId) {
|
if (moduleId) {
|
||||||
const mod = getModuleById(moduleId);
|
const mod = getModuleById(moduleId);
|
||||||
|
|
|
||||||
|
|
@ -96,5 +96,7 @@ export const queryKeys = {
|
||||||
baseline: string,
|
baseline: string,
|
||||||
offsetHours: number,
|
offsetHours: number,
|
||||||
baselineTargetId: string,
|
baselineTargetId: string,
|
||||||
) => ["portTrafficCompare", targetId, rangeHours, baseline, offsetHours, baselineTargetId] as const,
|
aheadHours = 0,
|
||||||
|
) =>
|
||||||
|
["portTrafficCompare", targetId, rangeHours, baseline, offsetHours, baselineTargetId, aheadHours] as const,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ const en = {
|
||||||
users: "Users",
|
users: "Users",
|
||||||
usersDesc: "Admin: create, disable users and reset passwords",
|
usersDesc: "Admin: create, disable users and reset passwords",
|
||||||
audit: "Audit",
|
audit: "Audit",
|
||||||
auditDesc: "View login and operation records",
|
auditDesc: "Live task overview and operation logs",
|
||||||
apiKeys: "API Keys",
|
apiKeys: "API Keys",
|
||||||
apiKeysDesc: "Issue MCP/script tokens per user with expiry",
|
apiKeysDesc: "Issue MCP/script tokens per user with expiry",
|
||||||
},
|
},
|
||||||
|
|
@ -170,6 +170,11 @@ const en = {
|
||||||
compareShift: "Previous window",
|
compareShift: "Previous window",
|
||||||
compareCustom: "Custom offset",
|
compareCustom: "Custom offset",
|
||||||
offsetHours: "Offset (hours)",
|
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",
|
baseline: "Baseline",
|
||||||
baselineAt: "Baseline time",
|
baselineAt: "Baseline time",
|
||||||
baselineDevice: "Baseline device",
|
baselineDevice: "Baseline device",
|
||||||
|
|
@ -1474,6 +1479,55 @@ const en = {
|
||||||
sliceStats: "{{maps}} map(s) planned, {{overlap}} NE(s) on multiple maps",
|
sliceStats: "{{maps}} map(s) planned, {{overlap}} NE(s) on multiple maps",
|
||||||
nodeCount: "{{count}} NE(s)",
|
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;
|
export default en;
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ const zh = {
|
||||||
users: "用户管理",
|
users: "用户管理",
|
||||||
usersDesc: "管理员添加、禁用用户并重置密码",
|
usersDesc: "管理员添加、禁用用户并重置密码",
|
||||||
audit: "操作审计",
|
audit: "操作审计",
|
||||||
auditDesc: "查看登录与操作记录",
|
auditDesc: "任务概览与操作日志",
|
||||||
apiKeys: "API Key",
|
apiKeys: "API Key",
|
||||||
apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期",
|
apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期",
|
||||||
},
|
},
|
||||||
|
|
@ -170,6 +170,10 @@ const zh = {
|
||||||
compareShift: "前一窗",
|
compareShift: "前一窗",
|
||||||
compareCustom: "自定义偏移",
|
compareCustom: "自定义偏移",
|
||||||
offsetHours: "偏移(小时)",
|
offsetHours: "偏移(小时)",
|
||||||
|
aheadHours: "向后延长(小时)",
|
||||||
|
aheadHoursHint:
|
||||||
|
"图表终点相对当前时刻再往后延长。开启周期对比时可看到对照曲线在「现在」之后的走势;0 表示截止到现在。",
|
||||||
|
mapBaselineHint: "映射基线可与周期对比叠加使用;需要昨日对照时请自行打开「昨日同时刻」。",
|
||||||
baseline: "基线",
|
baseline: "基线",
|
||||||
baselineAt: "基线原时刻",
|
baselineAt: "基线原时刻",
|
||||||
baselineDevice: "基线网元",
|
baselineDevice: "基线网元",
|
||||||
|
|
@ -1466,6 +1470,55 @@ const zh = {
|
||||||
sliceStats: "计划 {{maps}} 张图,重复上图网元 {{overlap}} 个",
|
sliceStats: "计划 {{maps}} 张图,重复上图网元 {{overlap}} 个",
|
||||||
nodeCount: "{{count}} 个网元",
|
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;
|
export default zh;
|
||||||
|
|
|
||||||
|
|
@ -5957,6 +5957,23 @@ pre {
|
||||||
border-left-color: var(--nm-accent);
|
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 {
|
.network-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ export function AuditPage() {
|
||||||
<div className="page-stack system-page">
|
<div className="page-stack system-page">
|
||||||
<section className="panel">
|
<section className="panel">
|
||||||
<div className="panel__toolbar">
|
<div className="panel__toolbar">
|
||||||
<h2>{t("auth.auditTitle")}</h2>
|
<h2>{t("audit.logsTitle")}</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="panel__hint">{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}</p>
|
<p className="panel__hint">{isAdmin ? t("auth.auditHintAdmin") : t("auth.auditHintUser")}</p>
|
||||||
|
|
||||||
|
|
|
||||||
32
web/src/pages/audit/AuditLayout.tsx
Normal file
32
web/src/pages/audit/AuditLayout.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="network-shell audit-shell">
|
||||||
|
<aside className="network-nav" aria-label={t("audit.title")}>
|
||||||
|
<div className="network-nav__brand">{t("audit.title")}</div>
|
||||||
|
<nav className="network-nav__scroll">
|
||||||
|
<ul className="network-nav__list network-nav__list--flat">
|
||||||
|
{AUDIT_NAV.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<NavLink
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) => `network-nav__link${isActive ? " is-active" : ""}`}
|
||||||
|
>
|
||||||
|
{t(item.labelKey)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
<div className="network-main">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
web/src/pages/audit/TaskOverviewPage.tsx
Normal file
149
web/src/pages/audit/TaskOverviewPage.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="page-stack system-page">
|
||||||
|
<section className="panel">
|
||||||
|
<div className="panel__toolbar">
|
||||||
|
<h2>{t("audit.tasks.title")}</h2>
|
||||||
|
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching}>
|
||||||
|
{query.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="panel__hint">{t("audit.tasks.hint")}</p>
|
||||||
|
|
||||||
|
<div className="pt-list-kpis">
|
||||||
|
<div className="pt-list-kpi pt-list-kpi--live">
|
||||||
|
<div className="pt-list-kpi__label">{t("audit.tasks.kpiActive")}</div>
|
||||||
|
<div className="pt-list-kpi__value">{data?.active ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-list-kpi">
|
||||||
|
<div className="pt-list-kpi__label">{t("audit.tasks.kpiTotal")}</div>
|
||||||
|
<div className="pt-list-kpi__value">{data?.total ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-list-kpi">
|
||||||
|
<div className="pt-list-kpi__label">{t("audit.tasks.kpiKinds")}</div>
|
||||||
|
<div className="pt-list-kpi__value" style={{ fontSize: 13, fontWeight: 500 }}>
|
||||||
|
{Object.keys(byKind).length
|
||||||
|
? Object.entries(byKind)
|
||||||
|
.map(([k, n]) => `${kindLabel(k, t)} ${n}`)
|
||||||
|
.join(" · ")
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-list-kpi">
|
||||||
|
<div className="pt-list-kpi__label">{t("audit.tasks.kpiStatuses")}</div>
|
||||||
|
<div className="pt-list-kpi__value" style={{ fontSize: 13, fontWeight: 500 }}>
|
||||||
|
{Object.keys(byStatus).length
|
||||||
|
? Object.entries(byStatus)
|
||||||
|
.map(([k, n]) => `${statusLabel(k, t)} ${n}`)
|
||||||
|
.join(" · ")
|
||||||
|
: "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{query.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||||
|
|
||||||
|
{!items.length && !query.isLoading ? (
|
||||||
|
<div className="pt-list-empty">
|
||||||
|
<p>{t("audit.tasks.empty")}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="pt-list-table-wrap">
|
||||||
|
<table className="data-table pt-list-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("audit.tasks.colKind")}</th>
|
||||||
|
<th>{t("audit.tasks.colTitle")}</th>
|
||||||
|
<th>{t("audit.tasks.colStatus")}</th>
|
||||||
|
<th>{t("audit.tasks.colActor")}</th>
|
||||||
|
<th>{t("audit.tasks.colTrigger")}</th>
|
||||||
|
<th>{t("audit.tasks.colProgress")}</th>
|
||||||
|
<th>{t("audit.tasks.colStarted")}</th>
|
||||||
|
<th>{t("audit.tasks.colUpdated")}</th>
|
||||||
|
<th>{t("audit.tasks.colLink")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((row) => (
|
||||||
|
<tr key={`${row.kind}:${row.id}`} title={row.detail || undefined}>
|
||||||
|
<td>{kindLabel(row.kind, t)}</td>
|
||||||
|
<td>
|
||||||
|
<div className="pt-list-task-name">{row.title}</div>
|
||||||
|
{row.detail ? (
|
||||||
|
<div className="muted" style={{ fontSize: 12 }}>
|
||||||
|
{row.detail}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`pt-list-status pt-list-status--${statusTone(row.status)}`}>
|
||||||
|
{statusLabel(row.status, t)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{row.actor || "—"}</td>
|
||||||
|
<td>{row.trigger || "—"}</td>
|
||||||
|
<td className="pt-list-num">{row.progress || "—"}</td>
|
||||||
|
<td className="pt-list-time">{formatSystemTime(row.started_at) || "—"}</td>
|
||||||
|
<td className="pt-list-time">{formatSystemTime(row.updated_at) || "—"}</td>
|
||||||
|
<td>
|
||||||
|
{row.href ? (
|
||||||
|
<Link to={row.href} className="link-btn">
|
||||||
|
{t("audit.tasks.open")}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -29,6 +29,10 @@ export function PortTrafficBoardPanelCell({
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const baseline = panel.baseline || "off";
|
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({
|
const compareQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
...queryKeys.portTrafficCompare(
|
...queryKeys.portTrafficCompare(
|
||||||
|
|
@ -37,6 +41,7 @@ export function PortTrafficBoardPanelCell({
|
||||||
baseline,
|
baseline,
|
||||||
baseline === "custom" ? panel.offset_hours : 0,
|
baseline === "custom" ? panel.offset_hours : 0,
|
||||||
panel.baseline_target_id || "",
|
panel.baseline_target_id || "",
|
||||||
|
aheadHours,
|
||||||
),
|
),
|
||||||
boardId,
|
boardId,
|
||||||
panel.id,
|
panel.id,
|
||||||
|
|
@ -47,6 +52,7 @@ export function PortTrafficBoardPanelCell({
|
||||||
rangeHours: panel.range_hours,
|
rangeHours: panel.range_hours,
|
||||||
baseline,
|
baseline,
|
||||||
offsetHours: baseline === "custom" ? panel.offset_hours : undefined,
|
offsetHours: baseline === "custom" ? panel.offset_hours : undefined,
|
||||||
|
aheadHours: aheadHours || undefined,
|
||||||
baselineTargetId: panel.baseline_target_id || undefined,
|
baselineTargetId: panel.baseline_target_id || undefined,
|
||||||
}),
|
}),
|
||||||
enabled: Boolean(panel.target_id) && !panel.stale,
|
enabled: Boolean(panel.target_id) && !panel.stale,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ function panelToIn(p: PortTrafficBoardPanel): PortTrafficBoardPanelIn {
|
||||||
range_hours: p.range_hours,
|
range_hours: p.range_hours,
|
||||||
baseline: p.baseline || "off",
|
baseline: p.baseline || "off",
|
||||||
offset_hours: p.offset_hours || 0,
|
offset_hours: p.offset_hours || 0,
|
||||||
|
ahead_hours: p.ahead_hours ?? 1,
|
||||||
baseline_target_id: p.baseline_target_id || "",
|
baseline_target_id: p.baseline_target_id || "",
|
||||||
y_mode: p.y_mode || "auto",
|
y_mode: p.y_mode || "auto",
|
||||||
ord: p.ord,
|
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). */
|
/** Dedicated board wall tab at `/port-traffic/wall/:boardId` (outside Network shell). */
|
||||||
export function PortTrafficWallPage() {
|
export function PortTrafficWallPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
@ -136,7 +141,7 @@ export function PortTrafficWallPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!board || editing) return;
|
if (!board || editing) return;
|
||||||
setDraftPanels(board.panels || []);
|
setDraftPanels((board.panels || []).map(normalizePanel));
|
||||||
setDraftName(board.name || "");
|
setDraftName(board.name || "");
|
||||||
setDraftCols(board.cols || 2);
|
setDraftCols(board.cols || 2);
|
||||||
}, [board, editing]);
|
}, [board, editing]);
|
||||||
|
|
@ -188,7 +193,7 @@ export function PortTrafficWallPage() {
|
||||||
|
|
||||||
const startEdit = () => {
|
const startEdit = () => {
|
||||||
if (!board) return;
|
if (!board) return;
|
||||||
setDraftPanels([...(board.panels || [])]);
|
setDraftPanels((board.panels || []).map(normalizePanel));
|
||||||
setDraftName(board.name || "");
|
setDraftName(board.name || "");
|
||||||
setDraftCols(board.cols || 2);
|
setDraftCols(board.cols || 2);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
|
|
@ -201,7 +206,7 @@ export function PortTrafficWallPage() {
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
closePanelSettings();
|
closePanelSettings();
|
||||||
if (board) {
|
if (board) {
|
||||||
setDraftPanels(board.panels || []);
|
setDraftPanels((board.panels || []).map(normalizePanel));
|
||||||
setDraftName(board.name || "");
|
setDraftName(board.name || "");
|
||||||
setDraftCols(board.cols || 2);
|
setDraftCols(board.cols || 2);
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +254,7 @@ export function PortTrafficWallPage() {
|
||||||
range_hours: 24,
|
range_hours: 24,
|
||||||
baseline: "off",
|
baseline: "off",
|
||||||
offset_hours: 0,
|
offset_hours: 0,
|
||||||
|
ahead_hours: 1,
|
||||||
baseline_target_id: "",
|
baseline_target_id: "",
|
||||||
y_mode: "auto",
|
y_mode: "auto",
|
||||||
ord: draftPanels.length,
|
ord: draftPanels.length,
|
||||||
|
|
@ -571,9 +577,15 @@ export function PortTrafficWallPage() {
|
||||||
{t("portTraffic.compare")}
|
{t("portTraffic.compare")}
|
||||||
<select
|
<select
|
||||||
value={editPanel.baseline}
|
value={editPanel.baseline}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
updatePanel(editPanel.id, { baseline: e.target.value as BaselineMode })
|
const next = e.target.value as BaselineMode;
|
||||||
}
|
updatePanel(editPanel.id, {
|
||||||
|
baseline: next,
|
||||||
|
...(next !== "off" && !(editPanel.ahead_hours > 0)
|
||||||
|
? { ahead_hours: 1 }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="off">{t("portTraffic.compareOff")}</option>
|
<option value="off">{t("portTraffic.compareOff")}</option>
|
||||||
<option value="day">{t("portTraffic.compareDay")}</option>
|
<option value="day">{t("portTraffic.compareDay")}</option>
|
||||||
|
|
@ -660,6 +672,7 @@ export function PortTrafficWallPage() {
|
||||||
updatePanel(editPanel.id, {
|
updatePanel(editPanel.id, {
|
||||||
baseline_target_id: tid,
|
baseline_target_id: tid,
|
||||||
baseline_target: tgt,
|
baseline_target: tgt,
|
||||||
|
...(tid && !(editPanel.ahead_hours > 0) ? { ahead_hours: 1 } : {}),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -671,12 +684,35 @@ export function PortTrafficWallPage() {
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{editPanel.baseline !== "off" || editPanel.baseline_target_id ? (
|
||||||
|
<label>
|
||||||
|
{t("portTraffic.aheadHours")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={24}
|
||||||
|
step={1}
|
||||||
|
title={t("portTraffic.aheadHoursHint")}
|
||||||
|
value={editPanel.ahead_hours ?? 1}
|
||||||
|
onChange={(e) =>
|
||||||
|
updatePanel(editPanel.id, {
|
||||||
|
ahead_hours: Math.max(0, Math.min(24, Number(e.target.value) || 0)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{editPanel.baseline === "week" ? (
|
{editPanel.baseline === "week" ? (
|
||||||
<p className="muted" style={{ marginTop: 10 }}>
|
<p className="muted" style={{ marginTop: 10 }}>
|
||||||
{t("portTraffic.retentionHint")}
|
{t("portTraffic.retentionHint")}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{editPanel.baseline_target_id ? (
|
||||||
|
<p className="muted" style={{ marginTop: 10 }}>
|
||||||
|
{t("portTraffic.mapBaselineHint")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="modal__actions">
|
<div className="modal__actions">
|
||||||
<button type="button" className="btn-primary" onClick={closePanelSettings}>
|
<button type="button" className="btn-primary" onClick={closePanelSettings}>
|
||||||
{t("portTraffic.logClose")}
|
{t("portTraffic.logClose")}
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,31 @@ export const apiPut = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||||
|
|
||||||
export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status");
|
export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status");
|
||||||
|
|
||||||
|
export type OpsTaskItem = {
|
||||||
|
kind: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
trigger: string;
|
||||||
|
actor: string;
|
||||||
|
started_at: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
progress: string;
|
||||||
|
detail: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OpsTasksResponse = {
|
||||||
|
generated_at: string;
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
by_kind: Record<string, number>;
|
||||||
|
by_status: Record<string, number>;
|
||||||
|
items: OpsTaskItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchOpsTasks = () => apiGet<OpsTasksResponse>("/v1/ops/tasks");
|
||||||
|
|
||||||
export const fetchUmeAlarmSubscriptionStatus = () =>
|
export const fetchUmeAlarmSubscriptionStatus = () =>
|
||||||
apiGet<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/status");
|
apiGet<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/status");
|
||||||
|
|
||||||
|
|
@ -1347,6 +1372,7 @@ export const fetchPortTrafficCompare = (params: {
|
||||||
rangeHours: number;
|
rangeHours: number;
|
||||||
baseline: string;
|
baseline: string;
|
||||||
offsetHours?: number;
|
offsetHours?: number;
|
||||||
|
aheadHours?: number;
|
||||||
baselineTargetId?: string;
|
baselineTargetId?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -1355,6 +1381,9 @@ export const fetchPortTrafficCompare = (params: {
|
||||||
p.set("range_hours", String(params.rangeHours));
|
p.set("range_hours", String(params.rangeHours));
|
||||||
p.set("baseline", params.baseline);
|
p.set("baseline", params.baseline);
|
||||||
if (params.offsetHours != null) p.set("offset_hours", String(params.offsetHours));
|
if (params.offsetHours != null) p.set("offset_hours", String(params.offsetHours));
|
||||||
|
if (params.aheadHours != null && params.aheadHours > 0) {
|
||||||
|
p.set("ahead_hours", String(params.aheadHours));
|
||||||
|
}
|
||||||
if (params.baselineTargetId) p.set("baseline_target_id", params.baselineTargetId);
|
if (params.baselineTargetId) p.set("baseline_target_id", params.baselineTargetId);
|
||||||
if (params.to) p.set("to", params.to);
|
if (params.to) p.set("to", params.to);
|
||||||
return apiGet<PortTrafficCompare>(`/v1/port-traffic/compare?${p.toString()}`);
|
return apiGet<PortTrafficCompare>(`/v1/port-traffic/compare?${p.toString()}`);
|
||||||
|
|
|
||||||
|
|
@ -798,6 +798,7 @@ export type PortTrafficBoardPanel = {
|
||||||
range_hours: number;
|
range_hours: number;
|
||||||
baseline: "off" | "day" | "week" | "shift" | "custom" | string;
|
baseline: "off" | "day" | "week" | "shift" | "custom" | string;
|
||||||
offset_hours: number;
|
offset_hours: number;
|
||||||
|
ahead_hours: number;
|
||||||
baseline_target_id: string;
|
baseline_target_id: string;
|
||||||
y_mode: "auto" | "current" | "util" | string;
|
y_mode: "auto" | "current" | "util" | string;
|
||||||
ord: number;
|
ord: number;
|
||||||
|
|
@ -817,6 +818,7 @@ export type PortTrafficBoardPanelIn = {
|
||||||
range_hours?: number;
|
range_hours?: number;
|
||||||
baseline?: string;
|
baseline?: string;
|
||||||
offset_hours?: number;
|
offset_hours?: number;
|
||||||
|
ahead_hours?: number;
|
||||||
baseline_target_id?: string;
|
baseline_target_id?: string;
|
||||||
y_mode?: string;
|
y_mode?: string;
|
||||||
ord?: number;
|
ord?: number;
|
||||||
|
|
@ -909,6 +911,7 @@ export type PortTrafficCompare = {
|
||||||
baseline: string;
|
baseline: string;
|
||||||
offset_hours: number;
|
offset_hours: number;
|
||||||
range_hours: number;
|
range_hours: number;
|
||||||
|
ahead_hours?: number;
|
||||||
current_target: PortTrafficTarget | null;
|
current_target: PortTrafficTarget | null;
|
||||||
baseline_target: PortTrafficTarget | null;
|
baseline_target: PortTrafficTarget | null;
|
||||||
baseline_target_id: string;
|
baseline_target_id: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue