mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
Add industrial config sync separate from collection tasks.
Periodic vendor-aware CLI sync stores zlib snapshots in Postgres with dashboard, retry, and config viewer under Network Management. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2fc47e5135
commit
0eead0e662
23 changed files with 2993 additions and 9 deletions
|
|
@ -72,6 +72,9 @@ class Settings(BaseSettings):
|
|||
ne_collect_pending_stale_sec: int = 180
|
||||
ne_collect_run_timeout_cap_sec: int = 600
|
||||
ne_collection_data_dir: str = "data/ne_collections"
|
||||
# Config sync (periodic running-config backup into DB)
|
||||
config_sync_scheduler_enabled: bool = True
|
||||
config_sync_scheduler_tick_sec: int = 60
|
||||
# Managed NE exec: max CLI commands per request (lab can raise; hard-capped in ne_exec).
|
||||
ne_exec_max_commands: int = 5
|
||||
# WebCRT interactive terminal sessions
|
||||
|
|
|
|||
20
netx_api/config_sync_codec.py
Normal file
20
netx_api/config_sync_codec.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""zlib + sha256 helpers for config blobs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import zlib
|
||||
|
||||
|
||||
def compress_text(text: str) -> tuple[bytes, str, int, int]:
|
||||
"""Return (zlib_bytes, sha256_hex, plain_size, zlib_size)."""
|
||||
raw = str(text or "").encode("utf-8", errors="replace")
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
blob = zlib.compress(raw, level=6)
|
||||
return blob, digest, len(raw), len(blob)
|
||||
|
||||
|
||||
def decompress_text(blob: bytes | None) -> str:
|
||||
if not blob:
|
||||
return ""
|
||||
return zlib.decompress(bytes(blob)).decode("utf-8", errors="replace")
|
||||
59
netx_api/config_sync_commands.py
Normal file
59
netx_api/config_sync_commands.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Vendor → config-collection CLI command matrix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConfigCommands:
|
||||
"""primary → config_zlib; alt → config_alt_zlib (Juniper only)."""
|
||||
|
||||
primary: str
|
||||
alt: str | None = None
|
||||
vendor_key: str = "other"
|
||||
|
||||
|
||||
def normalize_vendor_key(vendor: str, device_type: str = "") -> str:
|
||||
blob = f"{vendor} {device_type}".strip().lower()
|
||||
if "juniper" in blob or "junos" in blob:
|
||||
return "juniper"
|
||||
if "nokia" in blob or "alcatel" in blob or "sros" in blob or "tiomos" in blob:
|
||||
return "nokia"
|
||||
if "ericsson" in blob:
|
||||
return "ericsson"
|
||||
if "huawei" in blob or "vrp" in blob:
|
||||
return "huawei"
|
||||
if "h3c" in blob or "comware" in blob:
|
||||
return "h3c"
|
||||
if "zte" in blob or "zxros" in blob:
|
||||
return "zte"
|
||||
if "cisco" in blob or "ios" in blob or "nx-os" in blob or "xr" in blob:
|
||||
return "cisco"
|
||||
return "other"
|
||||
|
||||
|
||||
def commands_for_vendor(vendor: str, device_type: str = "") -> ConfigCommands | None:
|
||||
key = normalize_vendor_key(vendor, device_type)
|
||||
if key in ("cisco", "zte"):
|
||||
return ConfigCommands(primary="show running-config", vendor_key=key)
|
||||
if key in ("huawei", "h3c"):
|
||||
return ConfigCommands(primary="display current-configuration", vendor_key=key)
|
||||
if key == "juniper":
|
||||
return ConfigCommands(
|
||||
primary="show configuration | display set",
|
||||
alt="show configuration | no-more",
|
||||
vendor_key=key,
|
||||
)
|
||||
if key == "nokia":
|
||||
return ConfigCommands(primary="admin display-config", vendor_key=key)
|
||||
if key == "ericsson":
|
||||
return ConfigCommands(primary="show configuration", vendor_key=key)
|
||||
return None
|
||||
|
||||
|
||||
def command_list(cmds: ConfigCommands) -> list[str]:
|
||||
out = [cmds.primary]
|
||||
if cmds.alt:
|
||||
out.append(cmds.alt)
|
||||
return out
|
||||
68
netx_api/config_sync_recovery.py
Normal file
68
netx_api/config_sync_recovery.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
"""Startup recovery for interrupted config-sync cycles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config_sync_runner import dispatch_cycle
|
||||
from .config_sync_service import finalize_cycle, sync_cycle_progress
|
||||
from .models import ConfigSyncCycle, ConfigSyncTask
|
||||
from datetime import datetime
|
||||
|
||||
_log = logging.getLogger("netx.config_sync.recovery")
|
||||
|
||||
|
||||
def recover_config_sync_on_startup(db: Session) -> int:
|
||||
"""
|
||||
Mark orphaned running tasks as fail(orphan_recovered), then resume pending
|
||||
tasks for cycles still marked running/paused.
|
||||
"""
|
||||
cycles = (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.status.in_(("running", "paused", "pending")))
|
||||
.all()
|
||||
)
|
||||
resumed = 0
|
||||
for cycle in cycles:
|
||||
cycle_id = str(cycle.id)
|
||||
orphans = (
|
||||
db.query(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "running")
|
||||
.all()
|
||||
)
|
||||
for task in orphans:
|
||||
task.status = "fail"
|
||||
task.message = "orphan_recovered"
|
||||
task.ended_at = datetime.utcnow()
|
||||
if orphans:
|
||||
db.commit()
|
||||
_log.info("config_sync recovery cycle=%s orphaned_tasks=%s", cycle_id, len(orphans))
|
||||
|
||||
sync_cycle_progress(db, cycle_id)
|
||||
db.refresh(cycle)
|
||||
|
||||
if str(cycle.status) == "paused":
|
||||
continue
|
||||
|
||||
pending = (
|
||||
db.query(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "pending")
|
||||
.count()
|
||||
)
|
||||
if pending <= 0:
|
||||
if str(cycle.status) in ("running", "pending"):
|
||||
finalize_cycle(db, cycle_id)
|
||||
continue
|
||||
|
||||
if str(cycle.status) == "pending":
|
||||
cycle.status = "running"
|
||||
if not cycle.started_at:
|
||||
cycle.started_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
n = dispatch_cycle(cycle_id)
|
||||
resumed += n
|
||||
_log.info("config_sync recovery resumed cycle=%s pending=%s", cycle_id, n)
|
||||
return resumed
|
||||
168
netx_api/config_sync_router.py
Normal file
168
netx_api/config_sync_router.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
"""HTTP API for config sync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_service import write_audit
|
||||
from .config_sync_runner import dispatch_cycle
|
||||
from .config_sync_schemas import ConfigSyncCycleCreate, ConfigSyncPolicyUpdate
|
||||
from .config_sync_service import (
|
||||
create_cycle,
|
||||
dashboard,
|
||||
get_cycle,
|
||||
get_policy,
|
||||
get_snapshot_detail,
|
||||
list_cycle_tasks,
|
||||
list_cycles,
|
||||
list_snapshot_history,
|
||||
list_snapshots,
|
||||
pause_cycle,
|
||||
resume_cycle,
|
||||
update_policy,
|
||||
)
|
||||
from .db import get_db
|
||||
|
||||
router = APIRouter(prefix="/v1/config-sync", tags=["config-sync"])
|
||||
|
||||
|
||||
def _actor(request: Request) -> tuple[str, str]:
|
||||
user = getattr(request.state, "auth_user", None)
|
||||
if not user:
|
||||
return "", ""
|
||||
return str(getattr(user, "id", "") or ""), str(getattr(user, "username", "") or "")
|
||||
|
||||
|
||||
@router.get("/policy")
|
||||
def api_get_policy(db: Session = Depends(get_db)):
|
||||
return get_policy(db).model_dump()
|
||||
|
||||
|
||||
@router.put("/policy")
|
||||
def api_put_policy(
|
||||
body: ConfigSyncPolicyUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
out = update_policy(db, body)
|
||||
uid, uname = _actor(request)
|
||||
write_audit(
|
||||
db,
|
||||
action="config_sync.policy.update",
|
||||
actor_user_id=uid,
|
||||
actor_username=uname,
|
||||
method="PUT",
|
||||
path="/v1/config-sync/policy",
|
||||
status_code=200,
|
||||
detail=body.model_dump(exclude_unset=True),
|
||||
)
|
||||
return out.model_dump()
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
def api_dashboard(db: Session = Depends(get_db)):
|
||||
return dashboard(db).model_dump()
|
||||
|
||||
|
||||
@router.get("/cycles")
|
||||
def api_list_cycles(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_cycles(db, page=page, page_size=page_size)
|
||||
|
||||
|
||||
@router.post("/cycles")
|
||||
def api_create_cycle(
|
||||
body: ConfigSyncCycleCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
out = create_cycle(db, body)
|
||||
background_tasks.add_task(dispatch_cycle, out.id)
|
||||
uid, uname = _actor(request)
|
||||
action = "config_sync.retry_failed" if body.mode == "retry_failed" else "config_sync.start"
|
||||
write_audit(
|
||||
db,
|
||||
action=action,
|
||||
actor_user_id=uid,
|
||||
actor_username=uname,
|
||||
method="POST",
|
||||
path="/v1/config-sync/cycles",
|
||||
status_code=200,
|
||||
detail={"mode": body.mode, "cycle_id": out.id},
|
||||
)
|
||||
return out.model_dump()
|
||||
|
||||
|
||||
@router.get("/cycles/{cycle_id}")
|
||||
def api_get_cycle(cycle_id: str, db: Session = Depends(get_db)):
|
||||
return get_cycle(db, cycle_id).model_dump()
|
||||
|
||||
|
||||
@router.get("/cycles/{cycle_id}/tasks")
|
||||
def api_list_cycle_tasks(
|
||||
cycle_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=200),
|
||||
status: str = Query(default=""),
|
||||
keyword: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_cycle_tasks(
|
||||
db, cycle_id, page=page, page_size=page_size, status=status, keyword=keyword
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cycles/{cycle_id}/pause")
|
||||
def api_pause_cycle(cycle_id: str, db: Session = Depends(get_db)):
|
||||
return pause_cycle(db, cycle_id).model_dump()
|
||||
|
||||
|
||||
@router.post("/cycles/{cycle_id}/resume")
|
||||
def api_resume_cycle(
|
||||
cycle_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
out = resume_cycle(db, cycle_id)
|
||||
background_tasks.add_task(dispatch_cycle, out.id)
|
||||
return out.model_dump()
|
||||
|
||||
|
||||
@router.get("/snapshots")
|
||||
def api_list_snapshots(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
keyword: str = Query(default=""),
|
||||
source: str = Query(default=""),
|
||||
vendor: str = Query(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_snapshots(
|
||||
db, page=page, page_size=page_size, keyword=keyword, source=source, vendor=vendor
|
||||
)
|
||||
|
||||
|
||||
@router.get("/snapshots/{source}/{target_id}")
|
||||
def api_get_snapshot(
|
||||
source: str,
|
||||
target_id: str,
|
||||
field: str = Query(default="both"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return get_snapshot_detail(db, source, target_id, field=field).model_dump()
|
||||
|
||||
|
||||
@router.get("/snapshots/{source}/{target_id}/history")
|
||||
def api_snapshot_history(
|
||||
source: str,
|
||||
target_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_snapshot_history(db, source, target_id, page=page, page_size=page_size)
|
||||
429
netx_api/config_sync_runner.py
Normal file
429
netx_api/config_sync_runner.py
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
"""Config sync worker: claim tasks, collect vendor configs, store zlib snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
from datetime import datetime
|
||||
from threading import Lock
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .cli_resolve import resolve_cli_target
|
||||
from .config import settings
|
||||
from .config_sync_codec import compress_text
|
||||
from .config_sync_commands import command_list, commands_for_vendor
|
||||
from .config_sync_service import finalize_cycle, sync_cycle_progress
|
||||
from .db import SessionLocal
|
||||
from .models import ConfigSyncCycle, ConfigSyncPolicy, ConfigSyncTask, NeConfigHistory, NeConfigSnapshot
|
||||
from .ne_session_factory import close_netmiko_connection, open_netmiko_connection
|
||||
|
||||
_log = logging.getLogger("netx.config_sync.runner")
|
||||
_pools: dict[str, ThreadPoolExecutor] = {}
|
||||
_pools_lock = Lock()
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def _format_error(exc: BaseException) -> str:
|
||||
head = f"{type(exc).__name__}: {exc}"
|
||||
return head[:1020]
|
||||
|
||||
|
||||
def _pool_for_cycle(cycle_id: str, concurrency: int) -> ThreadPoolExecutor:
|
||||
with _pools_lock:
|
||||
pool = _pools.get(cycle_id)
|
||||
if pool is None:
|
||||
workers = max(1, min(30, int(concurrency or 5)))
|
||||
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix=f"cfg-sync-{cycle_id[:8]}")
|
||||
_pools[cycle_id] = pool
|
||||
return pool
|
||||
|
||||
|
||||
def _release_pool(cycle_id: str) -> None:
|
||||
with _pools_lock:
|
||||
pool = _pools.pop(cycle_id, None)
|
||||
if pool is not None:
|
||||
try:
|
||||
pool.shutdown(wait=False, cancel_futures=False)
|
||||
except TypeError:
|
||||
pool.shutdown(wait=False)
|
||||
except Exception:
|
||||
_log.exception("config_sync pool shutdown failed cycle=%s", cycle_id)
|
||||
|
||||
|
||||
def _update_task(task_id: str, **fields: Any) -> None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = db.get(ConfigSyncTask, task_id)
|
||||
if not row:
|
||||
return
|
||||
for key, val in fields.items():
|
||||
setattr(row, key, val)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _update_task(task_id: str, **fields: Any) -> None:
|
||||
for attempt in range(10):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.get(ConfigSyncTask, task_id)
|
||||
cycle = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not task or not cycle:
|
||||
time.sleep(0.05 * (attempt + 1))
|
||||
continue
|
||||
cycle_status = str(cycle.status or "")
|
||||
task_status = str(task.status or "")
|
||||
if cycle_status == "paused":
|
||||
return False
|
||||
if cycle_status != "running":
|
||||
return False
|
||||
if task_status == "running":
|
||||
return False
|
||||
if task_status != "pending":
|
||||
return False
|
||||
task.status = "running"
|
||||
task.message = "collecting"
|
||||
task.started_at = _utcnow()
|
||||
db.commit()
|
||||
return True
|
||||
finally:
|
||||
db.close()
|
||||
time.sleep(0.05 * (attempt + 1))
|
||||
return False
|
||||
|
||||
|
||||
def _collect_commands(creds: dict[str, Any], commands: list[str]) -> list[str]:
|
||||
per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
|
||||
session_timeout = per_cmd * max(1, len(commands)) + 60
|
||||
conn = open_netmiko_connection(creds, session_timeout=session_timeout)
|
||||
try:
|
||||
outputs: list[str] = []
|
||||
for command in commands:
|
||||
out = conn.send_command(command_string=command, read_timeout=per_cmd)
|
||||
outputs.append(str(out or ""))
|
||||
return outputs
|
||||
finally:
|
||||
close_netmiko_connection(conn)
|
||||
|
||||
|
||||
def _collect_with_timeout(creds: dict[str, Any], commands: list[str]) -> list[str]:
|
||||
per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
|
||||
cap = int(settings.ne_collect_run_timeout_cap_sec or 600)
|
||||
budget = min(cap, per_cmd * max(1, len(commands)) + 90)
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
fut = pool.submit(_collect_commands, creds, commands)
|
||||
try:
|
||||
return fut.result(timeout=budget)
|
||||
except FuturesTimeout as exc:
|
||||
raise TimeoutError(f"config_sync_timeout ({budget}s)") from exc
|
||||
|
||||
|
||||
def _history_keep(db) -> int:
|
||||
pol = db.get(ConfigSyncPolicy, 1)
|
||||
if not pol:
|
||||
return 3
|
||||
return max(0, min(30, int(pol.history_keep if pol.history_keep is not None else 3)))
|
||||
|
||||
|
||||
def _save_success_snapshot(
|
||||
*,
|
||||
source: str,
|
||||
target_id: str,
|
||||
vendor: str,
|
||||
device_type: str,
|
||||
ne_name: str,
|
||||
ne_ip: str,
|
||||
primary_text: str,
|
||||
alt_text: str | None,
|
||||
commands: list[str],
|
||||
cycle_id: str,
|
||||
task_id: str,
|
||||
) -> None:
|
||||
primary_blob, primary_sha, plain_size, zlib_size = compress_text(primary_text)
|
||||
alt_blob = None
|
||||
alt_sha = ""
|
||||
plain_alt = 0
|
||||
zlib_alt = 0
|
||||
if alt_text is not None:
|
||||
alt_blob, alt_sha, plain_alt, zlib_alt = compress_text(alt_text)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
existing = db.get(NeConfigSnapshot, {"source": source, "target_id": target_id})
|
||||
changed = True
|
||||
if existing is not None:
|
||||
changed = (
|
||||
str(existing.config_sha256 or "") != primary_sha
|
||||
or str(existing.config_alt_sha256 or "") != alt_sha
|
||||
)
|
||||
if changed:
|
||||
keep = _history_keep(db)
|
||||
if keep > 0:
|
||||
# Archive previous successful snapshot before overwrite.
|
||||
db.add(
|
||||
NeConfigHistory(
|
||||
id=uuid4().hex,
|
||||
source=existing.source,
|
||||
target_id=existing.target_id,
|
||||
vendor=str(existing.vendor or ""),
|
||||
device_type=str(existing.device_type or ""),
|
||||
ne_name=str(existing.ne_name or ""),
|
||||
ne_ip=str(existing.ne_ip or ""),
|
||||
config_zlib=existing.config_zlib or b"",
|
||||
config_alt_zlib=existing.config_alt_zlib,
|
||||
config_sha256=str(existing.config_sha256 or ""),
|
||||
config_alt_sha256=str(existing.config_alt_sha256 or ""),
|
||||
plain_size=int(existing.plain_size or 0),
|
||||
plain_alt_size=int(existing.plain_alt_size or 0),
|
||||
zlib_size=int(existing.zlib_size or 0),
|
||||
zlib_alt_size=int(existing.zlib_alt_size or 0),
|
||||
commands_json=existing.commands_json if isinstance(existing.commands_json, list) else [],
|
||||
collected_at=existing.collected_at or _utcnow(),
|
||||
cycle_id=str(existing.last_cycle_id or ""),
|
||||
task_id=str(existing.last_task_id or ""),
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
old_rows = (
|
||||
db.query(NeConfigHistory)
|
||||
.filter(NeConfigHistory.source == source, NeConfigHistory.target_id == target_id)
|
||||
.order_by(NeConfigHistory.collected_at.desc())
|
||||
.all()
|
||||
)
|
||||
for stale in old_rows[keep:]:
|
||||
db.delete(stale)
|
||||
|
||||
existing.vendor = vendor
|
||||
existing.device_type = device_type
|
||||
existing.ne_name = ne_name
|
||||
existing.ne_ip = ne_ip
|
||||
existing.config_zlib = primary_blob
|
||||
existing.config_alt_zlib = alt_blob
|
||||
existing.config_sha256 = primary_sha
|
||||
existing.config_alt_sha256 = alt_sha
|
||||
existing.plain_size = plain_size
|
||||
existing.plain_alt_size = plain_alt
|
||||
existing.zlib_size = zlib_size
|
||||
existing.zlib_alt_size = zlib_alt
|
||||
existing.commands_json = list(commands)
|
||||
existing.collected_at = _utcnow()
|
||||
existing.last_cycle_id = cycle_id
|
||||
existing.last_task_id = task_id
|
||||
else:
|
||||
db.add(
|
||||
NeConfigSnapshot(
|
||||
source=source,
|
||||
target_id=target_id,
|
||||
vendor=vendor,
|
||||
device_type=device_type,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
config_zlib=primary_blob,
|
||||
config_alt_zlib=alt_blob,
|
||||
config_sha256=primary_sha,
|
||||
config_alt_sha256=alt_sha,
|
||||
plain_size=plain_size,
|
||||
plain_alt_size=plain_alt,
|
||||
zlib_size=zlib_size,
|
||||
zlib_alt_size=zlib_alt,
|
||||
commands_json=list(commands),
|
||||
collected_at=_utcnow(),
|
||||
last_cycle_id=cycle_id,
|
||||
last_task_id=task_id,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _run_single(cycle_id: str, task_id: str) -> None:
|
||||
if not _claim_task(cycle_id, task_id):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
sync_cycle_progress(db, cycle_id)
|
||||
finalize_cycle(db, cycle_id)
|
||||
finally:
|
||||
db.close()
|
||||
return
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
task = db.get(ConfigSyncTask, task_id)
|
||||
if not task:
|
||||
return
|
||||
source = str(task.source or "").strip().lower()
|
||||
target_id = str(task.target_id or "").strip()
|
||||
vendor_hint = str(task.vendor or "")
|
||||
try:
|
||||
if source == "managed":
|
||||
creds, device = resolve_cli_target(db, managed_ne_id=target_id)
|
||||
elif source == "ume":
|
||||
creds, device = resolve_cli_target(db, ume_ne_id=target_id)
|
||||
else:
|
||||
_update_task(task_id, status="fail", message="invalid_source", ended_at=_utcnow())
|
||||
return
|
||||
except HTTPException as exc:
|
||||
detail = str(exc.detail) if exc.detail else "resolve_failed"
|
||||
_update_task(task_id, status="fail", message=detail[:1020], ended_at=_utcnow())
|
||||
return
|
||||
except Exception as exc:
|
||||
_update_task(task_id, status="fail", message=_format_error(exc), ended_at=_utcnow())
|
||||
return
|
||||
|
||||
vendor = str(device.get("vendor") or vendor_hint or "")
|
||||
device_type = str(device.get("device_type") or "")
|
||||
ne_name = str(device.get("name") or task.ne_name or "")
|
||||
ne_ip = str(device.get("ip_address") or task.ne_ip or "")
|
||||
cmds = commands_for_vendor(vendor, device_type)
|
||||
if cmds is None:
|
||||
_update_task(
|
||||
task_id,
|
||||
status="fail",
|
||||
message="unsupported_vendor",
|
||||
vendor=vendor,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
ended_at=_utcnow(),
|
||||
)
|
||||
return
|
||||
|
||||
cmd_names = command_list(cmds)
|
||||
try:
|
||||
outputs = _collect_with_timeout(creds, cmd_names)
|
||||
except Exception as exc:
|
||||
_log.warning("config_sync collect failed task=%s: %s", task_id, _format_error(exc))
|
||||
_update_task(
|
||||
task_id,
|
||||
status="fail",
|
||||
message=_format_error(exc),
|
||||
vendor=vendor,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
ended_at=_utcnow(),
|
||||
)
|
||||
return
|
||||
|
||||
if not outputs or not str(outputs[0] or "").strip():
|
||||
_update_task(
|
||||
task_id,
|
||||
status="fail",
|
||||
message="empty_config_output",
|
||||
vendor=vendor,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
ended_at=_utcnow(),
|
||||
)
|
||||
return
|
||||
|
||||
primary = outputs[0]
|
||||
alt = outputs[1] if cmds.alt and len(outputs) > 1 else None
|
||||
try:
|
||||
_save_success_snapshot(
|
||||
source=source,
|
||||
target_id=target_id,
|
||||
vendor=vendor,
|
||||
device_type=device_type,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
primary_text=primary,
|
||||
alt_text=alt,
|
||||
commands=cmd_names,
|
||||
cycle_id=cycle_id,
|
||||
task_id=task_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
_log.exception("config_sync snapshot save failed task=%s", task_id)
|
||||
_update_task(task_id, status="fail", message=_format_error(exc), ended_at=_utcnow())
|
||||
return
|
||||
|
||||
_update_task(
|
||||
task_id,
|
||||
status="success",
|
||||
message="synced",
|
||||
vendor=vendor,
|
||||
ne_name=ne_name,
|
||||
ne_ip=ne_ip,
|
||||
ended_at=_utcnow(),
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
sync_cycle_progress(db2, cycle_id)
|
||||
finalize_cycle(db2, cycle_id)
|
||||
cycle = db2.get(ConfigSyncCycle, cycle_id)
|
||||
if cycle and str(cycle.status or "") in ("success", "fail", "cancelled"):
|
||||
_release_pool(cycle_id)
|
||||
finally:
|
||||
db2.close()
|
||||
|
||||
|
||||
def _run_safe(cycle_id: str, task_id: str) -> None:
|
||||
try:
|
||||
_run_single(cycle_id, task_id)
|
||||
except Exception:
|
||||
_log.exception("config_sync worker crashed cycle=%s task=%s", cycle_id, task_id)
|
||||
_update_task(
|
||||
task_id,
|
||||
status="fail",
|
||||
message="config_sync_worker_crashed",
|
||||
ended_at=_utcnow(),
|
||||
)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
sync_cycle_progress(db, cycle_id)
|
||||
finalize_cycle(db, cycle_id)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def schedule_cycle_tasks(cycle_id: str, task_ids: list[str], concurrency: int) -> int:
|
||||
if not task_ids:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
finalize_cycle(db, cycle_id)
|
||||
finally:
|
||||
db.close()
|
||||
return 0
|
||||
pool = _pool_for_cycle(cycle_id, concurrency)
|
||||
submitted = 0
|
||||
for tid in task_ids:
|
||||
pool.submit(_run_safe, cycle_id, str(tid))
|
||||
submitted += 1
|
||||
_log.info("scheduled config_sync cycle=%s tasks=%s concurrency=%s", cycle_id, submitted, concurrency)
|
||||
return submitted
|
||||
|
||||
|
||||
def dispatch_cycle(cycle_id: str) -> int:
|
||||
"""Load pending tasks for a cycle and schedule workers."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
cycle = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not cycle:
|
||||
return 0
|
||||
if str(cycle.status or "") not in ("running", "pending"):
|
||||
return 0
|
||||
if str(cycle.status or "") == "pending":
|
||||
cycle.status = "running"
|
||||
if not cycle.started_at:
|
||||
cycle.started_at = _utcnow()
|
||||
db.commit()
|
||||
pending = (
|
||||
db.query(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "pending")
|
||||
.all()
|
||||
)
|
||||
task_ids = [str(t.id) for t in pending]
|
||||
concurrency = max(1, min(30, int(cycle.concurrency or 5)))
|
||||
finally:
|
||||
db.close()
|
||||
return schedule_cycle_tasks(cycle_id, task_ids, concurrency)
|
||||
113
netx_api/config_sync_scheduler.py
Normal file
113
netx_api/config_sync_scheduler.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""Background scheduler for periodic config sync."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from .config import settings
|
||||
from .config_sync_runner import dispatch_cycle
|
||||
from .config_sync_service import (
|
||||
ensure_policy,
|
||||
expand_targets,
|
||||
has_running_cycle,
|
||||
next_due_at,
|
||||
)
|
||||
from .db import SessionLocal
|
||||
from .models import ConfigSyncCycle, ConfigSyncTask
|
||||
|
||||
_log = logging.getLogger("netx.config_sync.scheduler")
|
||||
_stop = threading.Event()
|
||||
_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def try_start_scheduled_cycle() -> str | None:
|
||||
"""Create and dispatch a scheduled cycle if policy is due. Returns cycle id or None."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
policy = ensure_policy(db)
|
||||
if not policy.enabled:
|
||||
return None
|
||||
if has_running_cycle(db):
|
||||
return None
|
||||
due = next_due_at(db, policy)
|
||||
if due is not None and due > _utcnow():
|
||||
return None
|
||||
targets = expand_targets(db, policy)
|
||||
if not targets:
|
||||
_log.info("config_sync schedule skip: no targets")
|
||||
return None
|
||||
concurrency = max(1, min(30, int(policy.concurrency or 5)))
|
||||
cycle = ConfigSyncCycle(
|
||||
id=uuid4().hex,
|
||||
trigger_mode="schedule",
|
||||
status="running",
|
||||
concurrency=concurrency,
|
||||
planned_count=len(targets),
|
||||
started_at=_utcnow(),
|
||||
created_at=_utcnow(),
|
||||
)
|
||||
db.add(cycle)
|
||||
db.flush()
|
||||
for t in targets:
|
||||
db.add(
|
||||
ConfigSyncTask(
|
||||
id=uuid4().hex,
|
||||
cycle_id=cycle.id,
|
||||
source=t["source"],
|
||||
target_id=t["id"],
|
||||
ne_name=t.get("ne_name") or "",
|
||||
ne_ip=t.get("ne_ip") or "",
|
||||
vendor=t.get("vendor") or "",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
cycle_id = str(cycle.id)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
_log.exception("config_sync schedule create failed")
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
dispatch_cycle(cycle_id)
|
||||
_log.info("config_sync scheduled cycle started id=%s", cycle_id)
|
||||
return cycle_id
|
||||
|
||||
|
||||
def _loop() -> None:
|
||||
tick = max(15, int(settings.config_sync_scheduler_tick_sec or 60))
|
||||
_log.info("config_sync scheduler started tick=%ss", tick)
|
||||
while not _stop.is_set():
|
||||
try:
|
||||
if bool(settings.config_sync_scheduler_enabled):
|
||||
try_start_scheduled_cycle()
|
||||
except Exception:
|
||||
_log.exception("config_sync scheduler tick failed")
|
||||
_stop.wait(tick)
|
||||
_log.info("config_sync scheduler stopped")
|
||||
|
||||
|
||||
def start_config_sync_scheduler() -> None:
|
||||
global _thread
|
||||
if not bool(settings.config_sync_scheduler_enabled):
|
||||
_log.info("config_sync scheduler disabled by settings")
|
||||
return
|
||||
if _thread and _thread.is_alive():
|
||||
return
|
||||
_stop.clear()
|
||||
_thread = threading.Thread(target=_loop, name="config-sync-scheduler", daemon=True)
|
||||
_thread.start()
|
||||
_log.info("started thread %s alive=%s", _thread.name, _thread.is_alive())
|
||||
|
||||
|
||||
def stop_config_sync_scheduler() -> None:
|
||||
_stop.set()
|
||||
104
netx_api/config_sync_schemas.py
Normal file
104
netx_api/config_sync_schemas.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Pydantic schemas for config sync API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ConfigSyncTargetRef(BaseModel):
|
||||
source: Literal["managed", "ume"]
|
||||
id: str
|
||||
|
||||
|
||||
class ConfigSyncPolicyOut(BaseModel):
|
||||
enabled: bool
|
||||
interval_days: int
|
||||
concurrency: int
|
||||
scope_mode: str
|
||||
selected_targets: list[ConfigSyncTargetRef] = Field(default_factory=list)
|
||||
history_keep: int
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class ConfigSyncPolicyUpdate(BaseModel):
|
||||
enabled: bool | None = None
|
||||
interval_days: int | None = Field(default=None, ge=1, le=365)
|
||||
concurrency: int | None = Field(default=None, ge=1, le=30)
|
||||
scope_mode: Literal["all", "selected"] | None = None
|
||||
selected_targets: list[ConfigSyncTargetRef] | None = None
|
||||
history_keep: int | None = Field(default=None, ge=0, le=30)
|
||||
|
||||
|
||||
class ConfigSyncCycleCreate(BaseModel):
|
||||
mode: Literal["full", "retry_failed"] = "full"
|
||||
cycle_id: str | None = None
|
||||
|
||||
|
||||
class ConfigSyncCycleOut(BaseModel):
|
||||
id: str
|
||||
trigger_mode: str
|
||||
status: str
|
||||
concurrency: int
|
||||
planned_count: int
|
||||
success_count: int
|
||||
fail_count: int
|
||||
skip_count: int
|
||||
error_message: str = ""
|
||||
started_at: datetime | None = None
|
||||
ended_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class ConfigSyncTaskOut(BaseModel):
|
||||
id: str
|
||||
cycle_id: str
|
||||
source: str
|
||||
target_id: str
|
||||
ne_name: str
|
||||
ne_ip: str
|
||||
vendor: str
|
||||
status: str
|
||||
message: str = ""
|
||||
started_at: datetime | None = None
|
||||
ended_at: datetime | None = None
|
||||
|
||||
|
||||
class ConfigSyncDashboardOut(BaseModel):
|
||||
policy: ConfigSyncPolicyOut
|
||||
snapshot_count: int
|
||||
last_cycle: ConfigSyncCycleOut | None = None
|
||||
running_cycle: ConfigSyncCycleOut | None = None
|
||||
next_due_at: datetime | None = None
|
||||
fail_by_vendor: dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class NeConfigSnapshotMetaOut(BaseModel):
|
||||
source: str
|
||||
target_id: str
|
||||
vendor: str
|
||||
device_type: str
|
||||
ne_name: str
|
||||
ne_ip: str
|
||||
config_sha256: str
|
||||
config_alt_sha256: str
|
||||
plain_size: int
|
||||
plain_alt_size: int
|
||||
zlib_size: int
|
||||
zlib_alt_size: int
|
||||
has_alt: bool
|
||||
commands: list[str] = Field(default_factory=list)
|
||||
collected_at: datetime | None = None
|
||||
last_cycle_id: str = ""
|
||||
|
||||
|
||||
class NeConfigSnapshotDetailOut(NeConfigSnapshotMetaOut):
|
||||
config_text: str = ""
|
||||
config_alt_text: str = ""
|
||||
|
||||
|
||||
class NeConfigHistoryOut(NeConfigSnapshotMetaOut):
|
||||
id: str
|
||||
cycle_id: str = ""
|
||||
600
netx_api/config_sync_service.py
Normal file
600
netx_api/config_sync_service.py
Normal file
|
|
@ -0,0 +1,600 @@
|
|||
"""Config sync policy, cycles, dashboard, and snapshot queries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config_sync_codec import decompress_text
|
||||
from .config_sync_schemas import (
|
||||
ConfigSyncCycleCreate,
|
||||
ConfigSyncCycleOut,
|
||||
ConfigSyncDashboardOut,
|
||||
ConfigSyncPolicyOut,
|
||||
ConfigSyncPolicyUpdate,
|
||||
ConfigSyncTargetRef,
|
||||
ConfigSyncTaskOut,
|
||||
NeConfigHistoryOut,
|
||||
NeConfigSnapshotDetailOut,
|
||||
NeConfigSnapshotMetaOut,
|
||||
)
|
||||
from .models import (
|
||||
ConfigSyncCycle,
|
||||
ConfigSyncPolicy,
|
||||
ConfigSyncTask,
|
||||
ManagedNE,
|
||||
NeConfigHistory,
|
||||
NeConfigSnapshot,
|
||||
UmeInventoryNE,
|
||||
)
|
||||
from .cli_resolve import cli_profile_ready
|
||||
|
||||
_log = logging.getLogger("netx.config_sync")
|
||||
|
||||
POLICY_ID = 1
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
def ensure_policy(db: Session) -> ConfigSyncPolicy:
|
||||
row = db.get(ConfigSyncPolicy, POLICY_ID)
|
||||
if row is None:
|
||||
row = ConfigSyncPolicy(id=POLICY_ID)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return row
|
||||
|
||||
|
||||
def _targets_from_json(raw: Any) -> list[ConfigSyncTargetRef]:
|
||||
items: list[ConfigSyncTargetRef] = []
|
||||
if not isinstance(raw, list):
|
||||
return items
|
||||
for x in raw:
|
||||
if not isinstance(x, dict):
|
||||
continue
|
||||
src = str(x.get("source") or "").strip().lower()
|
||||
tid = str(x.get("id") or "").strip()
|
||||
if src not in ("managed", "ume") or not tid:
|
||||
continue
|
||||
items.append(ConfigSyncTargetRef(source=src, id=tid)) # type: ignore[arg-type]
|
||||
return items
|
||||
|
||||
|
||||
def policy_to_out(row: ConfigSyncPolicy) -> ConfigSyncPolicyOut:
|
||||
return ConfigSyncPolicyOut(
|
||||
enabled=bool(row.enabled),
|
||||
interval_days=max(1, int(row.interval_days or 3)),
|
||||
concurrency=max(1, min(30, int(row.concurrency or 5))),
|
||||
scope_mode=str(row.scope_mode or "all"),
|
||||
selected_targets=_targets_from_json(row.selected_targets),
|
||||
history_keep=max(0, min(30, int(row.history_keep if row.history_keep is not None else 3))),
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def get_policy(db: Session) -> ConfigSyncPolicyOut:
|
||||
return policy_to_out(ensure_policy(db))
|
||||
|
||||
|
||||
def update_policy(db: Session, body: ConfigSyncPolicyUpdate) -> ConfigSyncPolicyOut:
|
||||
row = ensure_policy(db)
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "enabled" in data and data["enabled"] is not None:
|
||||
row.enabled = bool(data["enabled"])
|
||||
if "interval_days" in data and data["interval_days"] is not None:
|
||||
row.interval_days = int(data["interval_days"])
|
||||
if "concurrency" in data and data["concurrency"] is not None:
|
||||
row.concurrency = max(1, min(30, int(data["concurrency"])))
|
||||
if "scope_mode" in data and data["scope_mode"] is not None:
|
||||
row.scope_mode = str(data["scope_mode"])
|
||||
if "selected_targets" in data and data["selected_targets"] is not None:
|
||||
refs = data["selected_targets"]
|
||||
row.selected_targets = [
|
||||
{"source": r.source if hasattr(r, "source") else r["source"], "id": r.id if hasattr(r, "id") else r["id"]}
|
||||
for r in refs
|
||||
]
|
||||
if "history_keep" in data and data["history_keep"] is not None:
|
||||
row.history_keep = max(0, min(30, int(data["history_keep"])))
|
||||
row.updated_at = _utcnow()
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return policy_to_out(row)
|
||||
|
||||
|
||||
def cycle_to_out(row: ConfigSyncCycle) -> ConfigSyncCycleOut:
|
||||
return ConfigSyncCycleOut(
|
||||
id=str(row.id),
|
||||
trigger_mode=str(row.trigger_mode or ""),
|
||||
status=str(row.status or ""),
|
||||
concurrency=int(row.concurrency or 0),
|
||||
planned_count=int(row.planned_count or 0),
|
||||
success_count=int(row.success_count or 0),
|
||||
fail_count=int(row.fail_count or 0),
|
||||
skip_count=int(row.skip_count or 0),
|
||||
error_message=str(row.error_message or ""),
|
||||
started_at=row.started_at,
|
||||
ended_at=row.ended_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
def task_to_out(row: ConfigSyncTask) -> ConfigSyncTaskOut:
|
||||
return ConfigSyncTaskOut(
|
||||
id=str(row.id),
|
||||
cycle_id=str(row.cycle_id),
|
||||
source=str(row.source),
|
||||
target_id=str(row.target_id),
|
||||
ne_name=str(row.ne_name or ""),
|
||||
ne_ip=str(row.ne_ip or ""),
|
||||
vendor=str(row.vendor or ""),
|
||||
status=str(row.status or ""),
|
||||
message=str(row.message or ""),
|
||||
started_at=row.started_at,
|
||||
ended_at=row.ended_at,
|
||||
)
|
||||
|
||||
|
||||
def expand_targets(db: Session, policy: ConfigSyncPolicy) -> list[dict[str, str]]:
|
||||
"""Return list of {source, id, ne_name, ne_ip, vendor, device_type}."""
|
||||
mode = str(policy.scope_mode or "all").strip().lower()
|
||||
out: list[dict[str, str]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
def _add(source: str, tid: str, name: str, ip: str, vendor: str, device_type: str) -> None:
|
||||
key = (source, tid)
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
out.append(
|
||||
{
|
||||
"source": source,
|
||||
"id": tid,
|
||||
"ne_name": name,
|
||||
"ne_ip": ip,
|
||||
"vendor": vendor,
|
||||
"device_type": device_type,
|
||||
}
|
||||
)
|
||||
|
||||
if mode == "selected":
|
||||
for ref in _targets_from_json(policy.selected_targets):
|
||||
if ref.source == "managed":
|
||||
ne = db.get(ManagedNE, ref.id)
|
||||
if ne:
|
||||
_add("managed", str(ne.id), str(ne.name or ""), str(ne.ip_address or ""), str(ne.vendor or ""), str(ne.device_type or ""))
|
||||
else:
|
||||
inv = db.get(UmeInventoryNE, ref.id)
|
||||
if inv:
|
||||
_add(
|
||||
"ume",
|
||||
str(inv.ne_id),
|
||||
str(inv.host_name or inv.ne_name or inv.user_label or inv.ne_id or ""),
|
||||
str(inv.ip_address or ""),
|
||||
str(inv.vendor or ""),
|
||||
str(inv.ne_type or ""),
|
||||
)
|
||||
return out
|
||||
|
||||
for ne in db.query(ManagedNE).order_by(ManagedNE.updated_at.desc()).all():
|
||||
_add("managed", str(ne.id), str(ne.name or ""), str(ne.ip_address or ""), str(ne.vendor or ""), str(ne.device_type or ""))
|
||||
|
||||
if cli_profile_ready(db):
|
||||
for inv in db.query(UmeInventoryNE).order_by(UmeInventoryNE.ne_id.asc()).all():
|
||||
if not str(inv.ip_address or "").strip():
|
||||
continue
|
||||
_add(
|
||||
"ume",
|
||||
str(inv.ne_id),
|
||||
str(inv.host_name or inv.ne_name or inv.user_label or inv.ne_id or ""),
|
||||
str(inv.ip_address or ""),
|
||||
str(inv.vendor or ""),
|
||||
str(inv.ne_type or ""),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def has_running_cycle(db: Session) -> ConfigSyncCycle | None:
|
||||
return (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.status.in_(("running", "pending")))
|
||||
.order_by(ConfigSyncCycle.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def last_finished_cycle(db: Session) -> ConfigSyncCycle | None:
|
||||
return (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.status.in_(("success", "fail", "cancelled")))
|
||||
.order_by(ConfigSyncCycle.ended_at.desc().nullslast(), ConfigSyncCycle.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def next_due_at(db: Session, policy: ConfigSyncPolicy | None = None) -> datetime | None:
|
||||
pol = policy or ensure_policy(db)
|
||||
if not pol.enabled:
|
||||
return None
|
||||
last = (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.status == "success", ConfigSyncCycle.ended_at.isnot(None))
|
||||
.order_by(ConfigSyncCycle.ended_at.desc())
|
||||
.first()
|
||||
)
|
||||
days = max(1, int(pol.interval_days or 3))
|
||||
if last and last.ended_at:
|
||||
return last.ended_at + timedelta(days=days)
|
||||
return _utcnow()
|
||||
|
||||
|
||||
def create_cycle(db: Session, body: ConfigSyncCycleCreate) -> ConfigSyncCycleOut:
|
||||
if has_running_cycle(db):
|
||||
raise HTTPException(status_code=409, detail="config_sync_cycle_already_running")
|
||||
policy = ensure_policy(db)
|
||||
mode = str(body.mode or "full").strip().lower()
|
||||
trigger = "retry_failed" if mode == "retry_failed" else "manual"
|
||||
concurrency = max(1, min(30, int(policy.concurrency or 5)))
|
||||
|
||||
targets: list[dict[str, str]] = []
|
||||
if mode == "retry_failed":
|
||||
src_cycle_id = str(body.cycle_id or "").strip()
|
||||
src = None
|
||||
if src_cycle_id:
|
||||
src = db.get(ConfigSyncCycle, src_cycle_id)
|
||||
if src is None:
|
||||
src = (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.fail_count > 0)
|
||||
.order_by(ConfigSyncCycle.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
if src is None:
|
||||
raise HTTPException(status_code=404, detail="no_failed_cycle")
|
||||
fails = (
|
||||
db.query(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == src.id, ConfigSyncTask.status == "fail")
|
||||
.all()
|
||||
)
|
||||
for t in fails:
|
||||
targets.append(
|
||||
{
|
||||
"source": str(t.source),
|
||||
"id": str(t.target_id),
|
||||
"ne_name": str(t.ne_name or ""),
|
||||
"ne_ip": str(t.ne_ip or ""),
|
||||
"vendor": str(t.vendor or ""),
|
||||
"device_type": "",
|
||||
}
|
||||
)
|
||||
if not targets:
|
||||
raise HTTPException(status_code=400, detail="no_failed_tasks")
|
||||
else:
|
||||
targets = expand_targets(db, policy)
|
||||
if not targets:
|
||||
raise HTTPException(status_code=400, detail="no_targets")
|
||||
|
||||
cycle = ConfigSyncCycle(
|
||||
id=uuid4().hex,
|
||||
trigger_mode=trigger,
|
||||
status="running",
|
||||
concurrency=concurrency,
|
||||
planned_count=len(targets),
|
||||
started_at=_utcnow(),
|
||||
created_at=_utcnow(),
|
||||
)
|
||||
db.add(cycle)
|
||||
db.flush()
|
||||
for t in targets:
|
||||
db.add(
|
||||
ConfigSyncTask(
|
||||
id=uuid4().hex,
|
||||
cycle_id=cycle.id,
|
||||
source=t["source"],
|
||||
target_id=t["id"],
|
||||
ne_name=t.get("ne_name") or "",
|
||||
ne_ip=t.get("ne_ip") or "",
|
||||
vendor=t.get("vendor") or "",
|
||||
status="pending",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(cycle)
|
||||
return cycle_to_out(cycle)
|
||||
|
||||
|
||||
def list_cycles(db: Session, *, page: int, page_size: int) -> dict[str, Any]:
|
||||
q = db.query(ConfigSyncCycle).order_by(ConfigSyncCycle.created_at.desc())
|
||||
total = int(q.count())
|
||||
rows = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": [cycle_to_out(r) for r in rows]}
|
||||
|
||||
|
||||
def get_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
|
||||
row = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="cycle_not_found")
|
||||
return cycle_to_out(row)
|
||||
|
||||
|
||||
def list_cycle_tasks(
|
||||
db: Session,
|
||||
cycle_id: str,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
status: str = "",
|
||||
keyword: str = "",
|
||||
) -> dict[str, Any]:
|
||||
if not db.get(ConfigSyncCycle, cycle_id):
|
||||
raise HTTPException(status_code=404, detail="cycle_not_found")
|
||||
q = db.query(ConfigSyncTask).filter(ConfigSyncTask.cycle_id == cycle_id)
|
||||
st = str(status or "").strip()
|
||||
if st:
|
||||
q = q.filter(ConfigSyncTask.status == st)
|
||||
kw = str(keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
ConfigSyncTask.ne_name.ilike(like),
|
||||
ConfigSyncTask.ne_ip.ilike(like),
|
||||
ConfigSyncTask.target_id.ilike(like),
|
||||
ConfigSyncTask.message.ilike(like),
|
||||
)
|
||||
)
|
||||
total = int(q.count())
|
||||
rows = q.order_by(ConfigSyncTask.ne_name.asc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": [task_to_out(r) for r in rows]}
|
||||
|
||||
|
||||
def pause_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
|
||||
row = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="cycle_not_found")
|
||||
if str(row.status) != "running":
|
||||
raise HTTPException(status_code=400, detail="cycle_not_running")
|
||||
row.status = "paused"
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return cycle_to_out(row)
|
||||
|
||||
|
||||
def resume_cycle(db: Session, cycle_id: str) -> ConfigSyncCycleOut:
|
||||
row = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="cycle_not_found")
|
||||
if str(row.status) != "paused":
|
||||
raise HTTPException(status_code=400, detail="cycle_not_paused")
|
||||
pending = (
|
||||
db.query(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "pending")
|
||||
.count()
|
||||
)
|
||||
if pending <= 0:
|
||||
raise HTTPException(status_code=400, detail="no_pending_tasks")
|
||||
other = has_running_cycle(db)
|
||||
if other and str(other.id) != cycle_id:
|
||||
raise HTTPException(status_code=409, detail="config_sync_cycle_already_running")
|
||||
row.status = "running"
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return cycle_to_out(row)
|
||||
|
||||
|
||||
def dashboard(db: Session) -> ConfigSyncDashboardOut:
|
||||
policy = ensure_policy(db)
|
||||
snap_count = int(db.query(func.count()).select_from(NeConfigSnapshot).scalar() or 0)
|
||||
running = (
|
||||
db.query(ConfigSyncCycle)
|
||||
.filter(ConfigSyncCycle.status.in_(("running", "paused", "pending")))
|
||||
.order_by(ConfigSyncCycle.created_at.desc())
|
||||
.first()
|
||||
)
|
||||
last = last_finished_cycle(db)
|
||||
fail_by_vendor: dict[str, int] = {}
|
||||
if last:
|
||||
rows = (
|
||||
db.query(ConfigSyncTask.vendor, func.count())
|
||||
.filter(ConfigSyncTask.cycle_id == last.id, ConfigSyncTask.status == "fail")
|
||||
.group_by(ConfigSyncTask.vendor)
|
||||
.all()
|
||||
)
|
||||
for vendor, cnt in rows:
|
||||
fail_by_vendor[str(vendor or "unknown") or "unknown"] = int(cnt)
|
||||
return ConfigSyncDashboardOut(
|
||||
policy=policy_to_out(policy),
|
||||
snapshot_count=snap_count,
|
||||
last_cycle=cycle_to_out(last) if last else None,
|
||||
running_cycle=cycle_to_out(running) if running else None,
|
||||
next_due_at=next_due_at(db, policy),
|
||||
fail_by_vendor=fail_by_vendor,
|
||||
)
|
||||
|
||||
|
||||
def _snap_meta(row: NeConfigSnapshot) -> NeConfigSnapshotMetaOut:
|
||||
cmds = row.commands_json if isinstance(row.commands_json, list) else []
|
||||
return NeConfigSnapshotMetaOut(
|
||||
source=str(row.source),
|
||||
target_id=str(row.target_id),
|
||||
vendor=str(row.vendor or ""),
|
||||
device_type=str(row.device_type or ""),
|
||||
ne_name=str(row.ne_name or ""),
|
||||
ne_ip=str(row.ne_ip or ""),
|
||||
config_sha256=str(row.config_sha256 or ""),
|
||||
config_alt_sha256=str(row.config_alt_sha256 or ""),
|
||||
plain_size=int(row.plain_size or 0),
|
||||
plain_alt_size=int(row.plain_alt_size or 0),
|
||||
zlib_size=int(row.zlib_size or 0),
|
||||
zlib_alt_size=int(row.zlib_alt_size or 0),
|
||||
has_alt=bool(row.config_alt_zlib),
|
||||
commands=[str(c) for c in cmds],
|
||||
collected_at=row.collected_at,
|
||||
last_cycle_id=str(row.last_cycle_id or ""),
|
||||
)
|
||||
|
||||
|
||||
def list_snapshots(
|
||||
db: Session,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
keyword: str = "",
|
||||
source: str = "",
|
||||
vendor: str = "",
|
||||
) -> dict[str, Any]:
|
||||
q = db.query(NeConfigSnapshot)
|
||||
src = str(source or "").strip().lower()
|
||||
if src in ("managed", "ume"):
|
||||
q = q.filter(NeConfigSnapshot.source == src)
|
||||
vend = str(vendor or "").strip()
|
||||
if vend:
|
||||
q = q.filter(NeConfigSnapshot.vendor.ilike(f"%{vend}%"))
|
||||
kw = str(keyword or "").strip()
|
||||
if kw:
|
||||
like = f"%{kw}%"
|
||||
q = q.filter(
|
||||
or_(
|
||||
NeConfigSnapshot.ne_name.ilike(like),
|
||||
NeConfigSnapshot.ne_ip.ilike(like),
|
||||
NeConfigSnapshot.target_id.ilike(like),
|
||||
)
|
||||
)
|
||||
total = int(q.count())
|
||||
rows = q.order_by(NeConfigSnapshot.collected_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": [_snap_meta(r) for r in rows]}
|
||||
|
||||
|
||||
def get_snapshot_detail(
|
||||
db: Session,
|
||||
source: str,
|
||||
target_id: str,
|
||||
*,
|
||||
field: str = "both",
|
||||
) -> NeConfigSnapshotDetailOut:
|
||||
src = str(source or "").strip().lower()
|
||||
tid = str(target_id or "").strip()
|
||||
row = db.get(NeConfigSnapshot, {"source": src, "target_id": tid})
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="snapshot_not_found")
|
||||
meta = _snap_meta(row)
|
||||
primary = ""
|
||||
alt = ""
|
||||
f = str(field or "both").strip().lower()
|
||||
if f in ("primary", "both", ""):
|
||||
primary = decompress_text(row.config_zlib)
|
||||
if f in ("alt", "both") and row.config_alt_zlib:
|
||||
alt = decompress_text(row.config_alt_zlib)
|
||||
return NeConfigSnapshotDetailOut(**meta.model_dump(), config_text=primary, config_alt_text=alt)
|
||||
|
||||
|
||||
def list_snapshot_history(
|
||||
db: Session,
|
||||
source: str,
|
||||
target_id: str,
|
||||
*,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> dict[str, Any]:
|
||||
src = str(source or "").strip().lower()
|
||||
tid = str(target_id or "").strip()
|
||||
q = (
|
||||
db.query(NeConfigHistory)
|
||||
.filter(NeConfigHistory.source == src, NeConfigHistory.target_id == tid)
|
||||
.order_by(NeConfigHistory.collected_at.desc())
|
||||
)
|
||||
total = int(q.count())
|
||||
rows = q.offset((page - 1) * page_size).limit(page_size).all()
|
||||
items: list[NeConfigHistoryOut] = []
|
||||
for row in rows:
|
||||
cmds = row.commands_json if isinstance(row.commands_json, list) else []
|
||||
items.append(
|
||||
NeConfigHistoryOut(
|
||||
id=str(row.id),
|
||||
source=str(row.source),
|
||||
target_id=str(row.target_id),
|
||||
vendor=str(row.vendor or ""),
|
||||
device_type=str(row.device_type or ""),
|
||||
ne_name=str(row.ne_name or ""),
|
||||
ne_ip=str(row.ne_ip or ""),
|
||||
config_sha256=str(row.config_sha256 or ""),
|
||||
config_alt_sha256=str(row.config_alt_sha256 or ""),
|
||||
plain_size=int(row.plain_size or 0),
|
||||
plain_alt_size=int(row.plain_alt_size or 0),
|
||||
zlib_size=int(row.zlib_size or 0),
|
||||
zlib_alt_size=int(row.zlib_alt_size or 0),
|
||||
has_alt=bool(row.config_alt_zlib),
|
||||
commands=[str(c) for c in cmds],
|
||||
collected_at=row.collected_at,
|
||||
last_cycle_id=str(row.cycle_id or ""),
|
||||
cycle_id=str(row.cycle_id or ""),
|
||||
)
|
||||
)
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
||||
|
||||
|
||||
def sync_cycle_progress(db: Session, cycle_id: str) -> None:
|
||||
cycle = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not cycle:
|
||||
return
|
||||
success = (
|
||||
db.query(func.count())
|
||||
.select_from(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "success")
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
fail = (
|
||||
db.query(func.count())
|
||||
.select_from(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status == "fail")
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
skip = (
|
||||
db.query(func.count())
|
||||
.select_from(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status.in_(("skipped", "cancelled")))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
cycle.success_count = int(success)
|
||||
cycle.fail_count = int(fail)
|
||||
cycle.skip_count = int(skip)
|
||||
db.commit()
|
||||
|
||||
|
||||
def finalize_cycle(db: Session, cycle_id: str) -> None:
|
||||
cycle = db.get(ConfigSyncCycle, cycle_id)
|
||||
if not cycle:
|
||||
return
|
||||
if str(cycle.status) == "paused":
|
||||
return
|
||||
pending = (
|
||||
db.query(func.count())
|
||||
.select_from(ConfigSyncTask)
|
||||
.filter(ConfigSyncTask.cycle_id == cycle_id, ConfigSyncTask.status.in_(("pending", "running")))
|
||||
.scalar()
|
||||
or 0
|
||||
)
|
||||
if int(pending) > 0:
|
||||
return
|
||||
sync_cycle_progress(db, cycle_id)
|
||||
db.refresh(cycle)
|
||||
if int(cycle.fail_count or 0) > 0 and int(cycle.success_count or 0) == 0:
|
||||
cycle.status = "fail"
|
||||
elif int(cycle.fail_count or 0) > 0:
|
||||
cycle.status = "fail"
|
||||
cycle.error_message = cycle.error_message or "completed_with_failures"
|
||||
else:
|
||||
cycle.status = "success"
|
||||
cycle.ended_at = _utcnow()
|
||||
db.commit()
|
||||
|
|
@ -26,6 +26,7 @@ from .config import settings
|
|||
from .db import Base, SessionLocal, engine, get_db
|
||||
from .collection_router import router as collection_router
|
||||
from .cli_router import router as cli_router
|
||||
from .config_sync_router import router as config_sync_router
|
||||
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
|
||||
|
|
@ -133,6 +134,7 @@ app.include_router(auth_router)
|
|||
app.include_router(managed_ne_router)
|
||||
app.include_router(cli_router)
|
||||
app.include_router(collection_router)
|
||||
app.include_router(config_sync_router)
|
||||
app.include_router(webcrt_router)
|
||||
app.include_router(topology_router)
|
||||
parser_cfg = load_parser_config()
|
||||
|
|
@ -832,10 +834,23 @@ def on_startup() -> None:
|
|||
resumed = recover_collection_jobs_on_startup(db)
|
||||
if resumed:
|
||||
_schedule_log.info("startup: resumed %s pending ne collection runs", resumed)
|
||||
from .config_sync_recovery import recover_config_sync_on_startup
|
||||
from .config_sync_service import ensure_policy
|
||||
|
||||
ensure_policy(db)
|
||||
cfg_resumed = recover_config_sync_on_startup(db)
|
||||
if cfg_resumed:
|
||||
_schedule_log.info("startup: resumed %s pending config_sync tasks", cfg_resumed)
|
||||
except Exception:
|
||||
_schedule_log.exception("startup: ne collection job recovery failed")
|
||||
_schedule_log.exception("startup: ne collection / config_sync recovery failed")
|
||||
finally:
|
||||
db.close()
|
||||
try:
|
||||
from .config_sync_scheduler import start_config_sync_scheduler
|
||||
|
||||
start_config_sync_scheduler()
|
||||
except Exception:
|
||||
_schedule_log.exception("startup: config_sync scheduler init failed")
|
||||
# Best-effort schema evolution for new columns (no migrations framework).
|
||||
# Safe for Postgres (IF NOT EXISTS); ignored on failure.
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from __future__ import annotations
|
|||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
|
|
@ -484,3 +484,107 @@ class ApiToken(Base):
|
|||
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class ConfigSyncPolicy(Base):
|
||||
"""Singleton policy for periodic config sync (id=1)."""
|
||||
|
||||
__tablename__ = "config_sync_policy"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
interval_days: Mapped[int] = mapped_column(Integer, default=3)
|
||||
concurrency: Mapped[int] = mapped_column(Integer, default=5)
|
||||
scope_mode: Mapped[str] = mapped_column(String(32), default="all") # all | selected
|
||||
selected_targets: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
history_keep: Mapped[int] = mapped_column(Integer, default=3)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ConfigSyncCycle(Base):
|
||||
"""One config-sync cycle over many NEs."""
|
||||
|
||||
__tablename__ = "config_sync_cycle"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
trigger_mode: Mapped[str] = mapped_column(String(32), default="schedule", index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
concurrency: Mapped[int] = mapped_column(Integer, default=5)
|
||||
planned_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
success_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
fail_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
skip_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error_message: Mapped[str] = mapped_column(String(1024), default="")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class ConfigSyncTask(Base):
|
||||
"""Per-NE work item inside a config sync cycle."""
|
||||
|
||||
__tablename__ = "config_sync_task"
|
||||
__table_args__ = (UniqueConstraint("cycle_id", "source", "target_id", name="uq_config_sync_task_target"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
cycle_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||
source: Mapped[str] = mapped_column(String(32), default="managed", index=True) # managed | ume
|
||||
target_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
ne_name: Mapped[str] = mapped_column(String(256), default="")
|
||||
ne_ip: Mapped[str] = mapped_column(String(128), default="")
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
|
||||
message: Mapped[str] = mapped_column(String(1024), default="")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
|
||||
class NeConfigSnapshot(Base):
|
||||
"""Latest successful config snapshot per NE (zlib-compressed)."""
|
||||
|
||||
__tablename__ = "ne_config_snapshot"
|
||||
|
||||
source: Mapped[str] = mapped_column(String(32), primary_key=True) # managed | ume
|
||||
target_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||
device_type: Mapped[str] = mapped_column(String(64), default="")
|
||||
ne_name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
ne_ip: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
config_zlib: Mapped[bytes] = mapped_column(LargeBinary, default=b"")
|
||||
config_alt_zlib: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
config_sha256: Mapped[str] = mapped_column(String(64), default="")
|
||||
config_alt_sha256: Mapped[str] = mapped_column(String(64), default="")
|
||||
plain_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
plain_alt_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
zlib_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
zlib_alt_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
commands_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
collected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
last_cycle_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
last_task_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
||||
|
||||
class NeConfigHistory(Base):
|
||||
"""Historical config versions when content changes."""
|
||||
|
||||
__tablename__ = "ne_config_history"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
source: Mapped[str] = mapped_column(String(32), index=True)
|
||||
target_id: Mapped[str] = mapped_column(String(128), index=True)
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||
device_type: Mapped[str] = mapped_column(String(64), default="")
|
||||
ne_name: Mapped[str] = mapped_column(String(256), default="")
|
||||
ne_ip: Mapped[str] = mapped_column(String(128), default="")
|
||||
config_zlib: Mapped[bytes] = mapped_column(LargeBinary, default=b"")
|
||||
config_alt_zlib: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
config_sha256: Mapped[str] = mapped_column(String(64), default="")
|
||||
config_alt_sha256: Mapped[str] = mapped_column(String(64), default="")
|
||||
plain_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
plain_alt_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
zlib_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
zlib_alt_size: Mapped[int] = mapped_column(Integer, default=0)
|
||||
commands_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||
collected_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
cycle_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
task_id: Mapped[str] = mapped_column(String(64), default="")
|
||||
|
|
|
|||
243
tests/test_config_sync.py
Normal file
243
tests/test_config_sync.py
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
"""Unit tests for config sync commands, codec, snapshot overwrite rules, recovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from netx_api.config_sync_codec import compress_text, decompress_text
|
||||
from netx_api.config_sync_commands import command_list, commands_for_vendor, normalize_vendor_key
|
||||
from netx_api.config_sync_recovery import recover_config_sync_on_startup
|
||||
from netx_api.config_sync_runner import _claim_task, _save_success_snapshot
|
||||
|
||||
|
||||
class ConfigSyncCommandsTests(unittest.TestCase):
|
||||
def test_normalize_vendor_keys(self):
|
||||
self.assertEqual(normalize_vendor_key("Cisco", "ios"), "cisco")
|
||||
self.assertEqual(normalize_vendor_key("ZTE", "zxros"), "zte")
|
||||
self.assertEqual(normalize_vendor_key("Huawei", "vrp"), "huawei")
|
||||
self.assertEqual(normalize_vendor_key("H3C", "comware"), "h3c")
|
||||
self.assertEqual(normalize_vendor_key("Juniper", "junos"), "juniper")
|
||||
self.assertEqual(normalize_vendor_key("Nokia", "sros"), "nokia")
|
||||
self.assertEqual(normalize_vendor_key("Ericsson", ""), "ericsson")
|
||||
self.assertEqual(normalize_vendor_key("Acme", "router"), "other")
|
||||
|
||||
def test_command_matrix(self):
|
||||
self.assertEqual(commands_for_vendor("Cisco", "").primary, "show running-config")
|
||||
self.assertEqual(commands_for_vendor("ZTE", "").primary, "show running-config")
|
||||
self.assertEqual(commands_for_vendor("Huawei", "").primary, "display current-configuration")
|
||||
self.assertEqual(commands_for_vendor("H3C", "").primary, "display current-configuration")
|
||||
self.assertEqual(commands_for_vendor("Nokia", "").primary, "admin display-config")
|
||||
self.assertEqual(commands_for_vendor("Ericsson", "").primary, "show configuration")
|
||||
self.assertIsNone(commands_for_vendor("UnknownVendor", ""))
|
||||
|
||||
def test_juniper_dual_commands(self):
|
||||
cmds = commands_for_vendor("Juniper", "junos")
|
||||
assert cmds is not None
|
||||
self.assertEqual(cmds.primary, "show configuration | display set")
|
||||
self.assertEqual(cmds.alt, "show configuration | no-more")
|
||||
self.assertEqual(
|
||||
command_list(cmds),
|
||||
["show configuration | display set", "show configuration | no-more"],
|
||||
)
|
||||
|
||||
|
||||
class ConfigSyncCodecTests(unittest.TestCase):
|
||||
def test_zlib_roundtrip(self):
|
||||
text = "hostname router1\ninterface GigabitEthernet0/0\n"
|
||||
blob, digest, plain_size, zlib_size = compress_text(text)
|
||||
self.assertEqual(plain_size, len(text.encode("utf-8")))
|
||||
self.assertGreater(zlib_size, 0)
|
||||
self.assertEqual(len(digest), 64)
|
||||
self.assertEqual(decompress_text(blob), text)
|
||||
|
||||
def test_empty_decompress(self):
|
||||
self.assertEqual(decompress_text(None), "")
|
||||
self.assertEqual(decompress_text(b""), "")
|
||||
|
||||
|
||||
class ConfigSyncSnapshotOverwriteTests(unittest.TestCase):
|
||||
@patch("netx_api.config_sync_runner.SessionLocal")
|
||||
def test_success_updates_snapshot(self, session_local):
|
||||
db = MagicMock()
|
||||
session_local.return_value = db
|
||||
existing = MagicMock()
|
||||
existing.source = "managed"
|
||||
existing.target_id = "ne1"
|
||||
existing.config_sha256 = "old"
|
||||
existing.config_alt_sha256 = ""
|
||||
existing.config_zlib = b"old"
|
||||
existing.config_alt_zlib = None
|
||||
existing.plain_size = 1
|
||||
existing.plain_alt_size = 0
|
||||
existing.zlib_size = 1
|
||||
existing.zlib_alt_size = 0
|
||||
existing.commands_json = []
|
||||
existing.collected_at = datetime.now(timezone.utc)
|
||||
existing.last_cycle_id = "c0"
|
||||
existing.last_task_id = "t0"
|
||||
existing.vendor = "Cisco"
|
||||
existing.device_type = "ios"
|
||||
existing.ne_name = "r1"
|
||||
existing.ne_ip = "1.1.1.1"
|
||||
db.get.side_effect = lambda model, key: existing if "Snapshot" in str(model) else MagicMock(history_keep=3)
|
||||
|
||||
_save_success_snapshot(
|
||||
source="managed",
|
||||
target_id="ne1",
|
||||
vendor="Cisco",
|
||||
device_type="ios",
|
||||
ne_name="r1",
|
||||
ne_ip="1.1.1.1",
|
||||
primary_text="hostname r1\n",
|
||||
alt_text=None,
|
||||
commands=["show running-config"],
|
||||
cycle_id="c1",
|
||||
task_id="t1",
|
||||
)
|
||||
self.assertEqual(existing.last_cycle_id, "c1")
|
||||
self.assertEqual(existing.last_task_id, "t1")
|
||||
self.assertTrue(existing.config_sha256)
|
||||
self.assertNotEqual(existing.config_sha256, "old")
|
||||
db.commit.assert_called()
|
||||
|
||||
@patch("netx_api.config_sync_runner._save_success_snapshot")
|
||||
@patch("netx_api.config_sync_runner._collect_with_timeout", side_effect=TimeoutError("boom"))
|
||||
@patch("netx_api.config_sync_runner.resolve_cli_target")
|
||||
@patch("netx_api.config_sync_runner._claim_task", return_value=True)
|
||||
@patch("netx_api.config_sync_runner.SessionLocal")
|
||||
def test_fail_does_not_overwrite_snapshot(self, session_local, _claim, resolve, _collect, save_snap):
|
||||
from netx_api.config_sync_runner import _run_single
|
||||
|
||||
db = MagicMock()
|
||||
session_local.return_value = db
|
||||
task = MagicMock()
|
||||
task.source = "managed"
|
||||
task.target_id = "ne1"
|
||||
task.vendor = "Cisco"
|
||||
task.ne_name = "r1"
|
||||
task.ne_ip = "1.1.1.1"
|
||||
db.get.return_value = task
|
||||
resolve.return_value = (
|
||||
{"host": "1.1.1.1"},
|
||||
{"vendor": "Cisco", "device_type": "ios", "name": "r1", "ip_address": "1.1.1.1"},
|
||||
)
|
||||
|
||||
with patch("netx_api.config_sync_runner._update_task") as update_task, patch(
|
||||
"netx_api.config_sync_runner.sync_cycle_progress"
|
||||
), patch("netx_api.config_sync_runner.finalize_cycle"):
|
||||
_run_single("c1", "t1")
|
||||
save_snap.assert_not_called()
|
||||
args = update_task.call_args
|
||||
self.assertEqual(args[0][0], "t1")
|
||||
self.assertEqual(args[1]["status"], "fail")
|
||||
|
||||
@patch("netx_api.config_sync_runner._save_success_snapshot")
|
||||
@patch(
|
||||
"netx_api.config_sync_runner._collect_with_timeout",
|
||||
return_value=["set system host-name r1", "system {\n host-name r1;\n}"],
|
||||
)
|
||||
@patch("netx_api.config_sync_runner.resolve_cli_target")
|
||||
@patch("netx_api.config_sync_runner._claim_task", return_value=True)
|
||||
@patch("netx_api.config_sync_runner.SessionLocal")
|
||||
def test_juniper_dual_fields_passed_to_save(self, session_local, _claim, resolve, _collect, save_snap):
|
||||
from netx_api.config_sync_runner import _run_single
|
||||
|
||||
db = MagicMock()
|
||||
session_local.return_value = db
|
||||
task = MagicMock()
|
||||
task.source = "managed"
|
||||
task.target_id = "ne1"
|
||||
task.vendor = "Juniper"
|
||||
task.ne_name = "r1"
|
||||
task.ne_ip = "1.1.1.1"
|
||||
db.get.return_value = task
|
||||
resolve.return_value = (
|
||||
{"host": "1.1.1.1"},
|
||||
{"vendor": "Juniper", "device_type": "junos", "name": "r1", "ip_address": "1.1.1.1"},
|
||||
)
|
||||
|
||||
with patch("netx_api.config_sync_runner._update_task"), patch(
|
||||
"netx_api.config_sync_runner.sync_cycle_progress"
|
||||
), patch("netx_api.config_sync_runner.finalize_cycle"):
|
||||
_run_single("c1", "t1")
|
||||
save_snap.assert_called_once()
|
||||
kwargs = save_snap.call_args.kwargs
|
||||
self.assertEqual(kwargs["primary_text"], "set system host-name r1")
|
||||
self.assertIn("host-name r1", kwargs["alt_text"])
|
||||
self.assertEqual(
|
||||
kwargs["commands"],
|
||||
["show configuration | display set", "show configuration | no-more"],
|
||||
)
|
||||
|
||||
|
||||
class ConfigSyncClaimTests(unittest.TestCase):
|
||||
@patch("netx_api.config_sync_runner.SessionLocal")
|
||||
def test_claim_pending_to_running(self, session_local):
|
||||
db = MagicMock()
|
||||
session_local.return_value = db
|
||||
task = MagicMock()
|
||||
task.status = "pending"
|
||||
cycle = MagicMock()
|
||||
cycle.status = "running"
|
||||
|
||||
def get_side(model, _id):
|
||||
name = getattr(model, "__name__", str(model))
|
||||
if "Task" in name:
|
||||
return task
|
||||
return cycle
|
||||
|
||||
db.get.side_effect = get_side
|
||||
ok = _claim_task("c1", "t1")
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(task.status, "running")
|
||||
db.commit.assert_called()
|
||||
|
||||
@patch("netx_api.config_sync_runner.SessionLocal")
|
||||
def test_claim_skipped_when_paused(self, session_local):
|
||||
db = MagicMock()
|
||||
session_local.return_value = db
|
||||
task = MagicMock()
|
||||
task.status = "pending"
|
||||
cycle = MagicMock()
|
||||
cycle.status = "paused"
|
||||
db.get.side_effect = lambda model, _id: task if "Task" in getattr(model, "__name__", "") else cycle
|
||||
self.assertFalse(_claim_task("c1", "t1"))
|
||||
self.assertEqual(task.status, "pending")
|
||||
|
||||
|
||||
class ConfigSyncRecoveryTests(unittest.TestCase):
|
||||
@patch("netx_api.config_sync_recovery.dispatch_cycle", return_value=2)
|
||||
@patch("netx_api.config_sync_recovery.finalize_cycle")
|
||||
@patch("netx_api.config_sync_recovery.sync_cycle_progress")
|
||||
def test_orphan_running_tasks_marked_fail(self, _sync, _fin, dispatch):
|
||||
cycle = MagicMock()
|
||||
cycle.id = "c1"
|
||||
cycle.status = "running"
|
||||
cycle.started_at = datetime.now(timezone.utc)
|
||||
|
||||
orphan = MagicMock()
|
||||
orphan.status = "running"
|
||||
orphan.message = ""
|
||||
orphan.ended_at = None
|
||||
|
||||
db = MagicMock()
|
||||
db.query.side_effect = [
|
||||
MagicMock(filter=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[cycle])))),
|
||||
MagicMock(filter=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[orphan])))),
|
||||
MagicMock(filter=MagicMock(return_value=MagicMock(count=MagicMock(return_value=2)))),
|
||||
]
|
||||
db.refresh = MagicMock()
|
||||
|
||||
resumed = recover_config_sync_on_startup(db)
|
||||
|
||||
self.assertEqual(orphan.status, "fail")
|
||||
self.assertEqual(orphan.message, "orphan_recovered")
|
||||
self.assertIsNotNone(orphan.ended_at)
|
||||
self.assertEqual(resumed, 2)
|
||||
dispatch.assert_called_once_with("c1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
14
web/WEB.md
14
web/WEB.md
|
|
@ -37,11 +37,13 @@ src/
|
|||
| `/network/devices` | 设备列表 | `network` |
|
||||
| `/network/topology` | 拓扑信息 | `network` |
|
||||
| `/network/alarms` | 告警信息 | `network` |
|
||||
| `/network/configs` | 配置信息(同步快照) | `network` |
|
||||
| `/network/webcrt` | WebCRT 终端 | `network` |
|
||||
| `/network/tasks/config-sync` | 采集任务(原批量采集) | `network` |
|
||||
| `/network/tasks/collect` | 采集任务 | `network` |
|
||||
| `/network/tasks/config-sync` | 配置同步 | `network` |
|
||||
| `/network/tasks/port-traffic` | 端口流量(占位) | `network` |
|
||||
| `/ne` | redirect → `/network/devices` | — |
|
||||
| `/collect` | redirect → `/network/tasks/config-sync` | — |
|
||||
| `/collect` | redirect → `/network/tasks/collect` | — |
|
||||
| `/topology` | redirect → `/network/topology` | — |
|
||||
| `/webcrt` | redirect → `/network/webcrt`(保留 query) | — |
|
||||
|
||||
|
|
@ -90,6 +92,14 @@ src/
|
|||
- 采集日志目录:`NETX_NE_COLLECTION_DATA_DIR`(默认 `data/ne_collections`)
|
||||
- 命令每行一条,`#` 为注释;输出格式与旧版 NetX 采集 `.txt` 一致
|
||||
|
||||
## 配置同步
|
||||
|
||||
- API:`/v1/config-sync/*`(策略、看板、周期、快照)
|
||||
- 存储:PostgreSQL `ne_config_snapshot` / `ne_config_history`(zlib BYTEA)
|
||||
- 范围:ManagedNE + UME CLI 目标;厂商固定只读命令矩阵
|
||||
- 调度:`NETX_CONFIG_SYNC_SCHEDULER_ENABLED`(默认开),周期天数策略可配(默认 3 天)
|
||||
- 前端:`/network/tasks/config-sync`(看板)+ `/network/configs`(查看)
|
||||
|
||||
## WebCRT
|
||||
|
||||
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ import { AppLayout } from "./layout/AppLayout";
|
|||
import { queryKeys } from "./constants/queryKeys";
|
||||
import { WorkbenchPage } from "./pages/WorkbenchPage";
|
||||
import { CollectPage } from "./pages/CollectPage";
|
||||
import { ConfigSyncPage } from "./pages/ConfigSyncPage";
|
||||
import { NePage } from "./pages/NePage";
|
||||
import { UmePage } from "./pages/UmePage";
|
||||
import { WebcrtPage } from "./pages/WebcrtPage";
|
||||
import { TopologyPage } from "./pages/TopologyPage";
|
||||
import { NetworkLayout } from "./pages/network/NetworkLayout";
|
||||
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
|
||||
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
|
||||
import { NetworkPlaceholderPage } from "./pages/network/NetworkPlaceholderPage";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { UsersPage } from "./pages/UsersPage";
|
||||
|
|
@ -85,12 +87,14 @@ function ProtectedApp() {
|
|||
<Route path="devices" element={<NePage />} />
|
||||
<Route path="topology" element={<TopologyPage />} />
|
||||
<Route path="alarms" element={<NetworkAlarmsPage />} />
|
||||
<Route path="configs" element={<NetworkConfigsPage />} />
|
||||
<Route path="webcrt" element={<WebcrtPage />} />
|
||||
<Route path="tasks/config-sync" element={<CollectPage />} />
|
||||
<Route path="tasks/collect" element={<CollectPage />} />
|
||||
<Route path="tasks/config-sync" element={<ConfigSyncPage />} />
|
||||
<Route path="tasks/port-traffic" element={<NetworkPlaceholderPage kind="port-traffic" />} />
|
||||
</Route>
|
||||
<Route path="/ne" element={<Navigate to="/network/devices" replace />} />
|
||||
<Route path="/collect" element={<Navigate to="/network/tasks/config-sync" replace />} />
|
||||
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />
|
||||
<Route path="/topology" element={<Navigate to="/network/topology" replace />} />
|
||||
<Route path="/webcrt" element={<WebcrtLegacyRedirect />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [
|
|||
{ id: "devices", path: "/network/devices", labelKey: "network.nav.devices", group: "detail" },
|
||||
{ id: "topology", path: "/network/topology", labelKey: "network.nav.topology", group: "detail" },
|
||||
{ id: "alarms", path: "/network/alarms", labelKey: "network.nav.alarms", group: "detail" },
|
||||
{ id: "configs", path: "/network/configs", labelKey: "network.nav.configs", group: "detail" },
|
||||
{ id: "webcrt", path: "/network/webcrt", labelKey: "network.nav.webcrt", group: "detail" },
|
||||
],
|
||||
},
|
||||
|
|
@ -33,6 +34,12 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [
|
|||
id: "tasks",
|
||||
labelKey: "network.nav.tasks",
|
||||
items: [
|
||||
{
|
||||
id: "collect",
|
||||
path: "/network/tasks/collect",
|
||||
labelKey: "network.nav.collectTasks",
|
||||
group: "tasks",
|
||||
},
|
||||
{
|
||||
id: "config-sync",
|
||||
path: "/network/tasks/config-sync",
|
||||
|
|
|
|||
|
|
@ -48,4 +48,15 @@ export const queryKeys = {
|
|||
) => ["umeCurrentAlarms", severity, cleared, hostName, keyword, page, pageSize] as const,
|
||||
topologyMaps: ["topologyMaps"] as const,
|
||||
topologyGraph: (mapId: string) => ["topologyGraph", mapId] as const,
|
||||
configSyncDashboard: ["configSyncDashboard"] as const,
|
||||
configSyncPolicy: ["configSyncPolicy"] as const,
|
||||
configSyncCyclesAll: ["configSyncCycles"] as const,
|
||||
configSyncCycles: (page: number) => ["configSyncCycles", page] as const,
|
||||
configSyncCycleTasksAll: ["configSyncCycleTasks"] as const,
|
||||
configSyncCycleTasks: (cycleId: string, page: number, status: string, keyword: string) =>
|
||||
["configSyncCycleTasks", cycleId, page, status, keyword] as const,
|
||||
networkConfigsAll: ["networkConfigs"] as const,
|
||||
networkConfigs: (page: number, keyword: string, source: string, vendor: string) =>
|
||||
["networkConfigs", page, keyword, source, vendor] as const,
|
||||
networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -49,8 +49,10 @@ const en = {
|
|||
devices: "Device list",
|
||||
topology: "Topology",
|
||||
alarms: "Alarms",
|
||||
configs: "Configurations",
|
||||
webcrt: "WebCRT",
|
||||
configSync: "Collection tasks",
|
||||
collectTasks: "Collection tasks",
|
||||
configSync: "Config sync",
|
||||
portTraffic: "Port traffic",
|
||||
},
|
||||
placeholder: {
|
||||
|
|
@ -64,6 +66,71 @@ const en = {
|
|||
portTrafficStep4: "UI: task list + per-port trend charts",
|
||||
},
|
||||
},
|
||||
configSync: {
|
||||
title: "Config sync",
|
||||
syncNow: "Sync now",
|
||||
retryFailed: "Retry failed",
|
||||
pause: "Pause",
|
||||
resume: "Resume",
|
||||
started: "Sync cycle started",
|
||||
paused: "Paused",
|
||||
resumed: "Resumed",
|
||||
policyTitle: "Sync policy",
|
||||
policySaved: "Policy saved",
|
||||
enabled: "Enable scheduled sync",
|
||||
intervalDays: "Interval (days)",
|
||||
concurrency: "Concurrency",
|
||||
historyKeep: "History keep",
|
||||
scope: "Scope",
|
||||
scopeAll: "All NEs",
|
||||
scopeSelected: "Selected NEs",
|
||||
savePolicy: "Save policy",
|
||||
selectedCount: "{{count}} selected",
|
||||
targetKeywordPh: "Name / IP / vendor",
|
||||
cyclesTitle: "Sync cycles",
|
||||
cyclesEmpty: "No sync cycles yet",
|
||||
tasksTitle: "Cycle tasks",
|
||||
taskKeywordPh: "Name / IP / message",
|
||||
allStatus: "All statuses",
|
||||
expand: "Expand",
|
||||
collapse: "Collapse",
|
||||
kpi: {
|
||||
snapshots: "Snapshots",
|
||||
running: "In progress",
|
||||
idle: "Idle",
|
||||
last: "Last cycle",
|
||||
nextDue: "Next due",
|
||||
},
|
||||
col: {
|
||||
source: "Source",
|
||||
name: "Device",
|
||||
vendor: "Vendor",
|
||||
trigger: "Trigger",
|
||||
status: "Status",
|
||||
progress: "Progress",
|
||||
started: "Started",
|
||||
ended: "Ended",
|
||||
message: "Message",
|
||||
},
|
||||
},
|
||||
networkConfigs: {
|
||||
title: "Configurations",
|
||||
keywordPh: "Name / IP / ID",
|
||||
vendorPh: "Vendor",
|
||||
allSource: "All sources",
|
||||
empty: "No synced configs yet. Run Config sync first.",
|
||||
view: "View",
|
||||
close: "Close",
|
||||
tabSet: "Set format",
|
||||
tabHier: "Hierarchical",
|
||||
col: {
|
||||
name: "Device",
|
||||
vendor: "Vendor",
|
||||
source: "Source",
|
||||
size: "Size",
|
||||
collected: "Collected",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
title: "UME Integration",
|
||||
titleUme: "UME Sync",
|
||||
|
|
|
|||
|
|
@ -49,8 +49,10 @@ const zh = {
|
|||
devices: "设备列表",
|
||||
topology: "拓扑信息",
|
||||
alarms: "告警信息",
|
||||
configs: "配置信息",
|
||||
webcrt: "WebCRT",
|
||||
configSync: "采集任务",
|
||||
collectTasks: "采集任务",
|
||||
configSync: "配置同步",
|
||||
portTraffic: "端口流量监控",
|
||||
},
|
||||
placeholder: {
|
||||
|
|
@ -64,6 +66,71 @@ const zh = {
|
|||
portTrafficStep4: "界面:任务列表 + 单端口趋势图",
|
||||
},
|
||||
},
|
||||
configSync: {
|
||||
title: "配置同步",
|
||||
syncNow: "立即全量同步",
|
||||
retryFailed: "一键重试失败",
|
||||
pause: "暂停",
|
||||
resume: "继续",
|
||||
started: "已启动同步周期",
|
||||
paused: "已暂停",
|
||||
resumed: "已继续",
|
||||
policyTitle: "同步策略",
|
||||
policySaved: "策略已保存",
|
||||
enabled: "启用周期调度",
|
||||
intervalDays: "周期(天)",
|
||||
concurrency: "并发",
|
||||
historyKeep: "历史保留",
|
||||
scope: "范围",
|
||||
scopeAll: "全部网元",
|
||||
scopeSelected: "指定网元",
|
||||
savePolicy: "保存策略",
|
||||
selectedCount: "已选 {{count}} 台",
|
||||
targetKeywordPh: "名称 / IP / 厂商",
|
||||
cyclesTitle: "同步周期",
|
||||
cyclesEmpty: "暂无同步周期",
|
||||
tasksTitle: "周期任务",
|
||||
taskKeywordPh: "名称 / IP / 消息",
|
||||
allStatus: "全部状态",
|
||||
expand: "展开",
|
||||
collapse: "收起",
|
||||
kpi: {
|
||||
snapshots: "配置覆盖数",
|
||||
running: "进行中",
|
||||
idle: "空闲",
|
||||
last: "最近一轮",
|
||||
nextDue: "下次调度",
|
||||
},
|
||||
col: {
|
||||
source: "来源",
|
||||
name: "设备",
|
||||
vendor: "厂商",
|
||||
trigger: "触发",
|
||||
status: "状态",
|
||||
progress: "进度",
|
||||
started: "开始",
|
||||
ended: "结束",
|
||||
message: "消息",
|
||||
},
|
||||
},
|
||||
networkConfigs: {
|
||||
title: "配置信息",
|
||||
keywordPh: "名称 / IP / ID",
|
||||
vendorPh: "厂商",
|
||||
allSource: "全部来源",
|
||||
empty: "暂无已同步配置。请先在「配置同步」执行采集。",
|
||||
view: "查看",
|
||||
close: "关闭",
|
||||
tabSet: "Set 格式",
|
||||
tabHier: "层级格式",
|
||||
col: {
|
||||
name: "设备",
|
||||
vendor: "厂商",
|
||||
source: "来源",
|
||||
size: "大小",
|
||||
collected: "采集时间",
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
title: "UME 对接",
|
||||
titleUme: "UME同步",
|
||||
|
|
|
|||
|
|
@ -758,7 +758,46 @@ pre {
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── NE stats card ── */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border: 1px solid var(--border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
background: var(--panel-bg, #fff);
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ne-stats-card {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
|
|
|||
496
web/src/pages/ConfigSyncPage.tsx
Normal file
496
web/src/pages/ConfigSyncPage.tsx
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createConfigSyncCycle,
|
||||
fetchCliTargets,
|
||||
fetchConfigSyncCycleTasks,
|
||||
fetchConfigSyncCycles,
|
||||
fetchConfigSyncDashboard,
|
||||
pauseConfigSyncCycle,
|
||||
resumeConfigSyncCycle,
|
||||
updateConfigSyncPolicy,
|
||||
} from "../services/api";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import type { CliTargetItem, ConfigSyncTargetRef } from "../types";
|
||||
import { pageCount } from "../utils/display";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
const POLL_MS = 2500;
|
||||
const TARGET_PAGE_SIZE = 20;
|
||||
|
||||
export function ConfigSyncPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [cyclePage, setCyclePage] = useState(1);
|
||||
const [expandedCycleId, setExpandedCycleId] = useState("");
|
||||
const [taskPage, setTaskPage] = useState(1);
|
||||
const [taskStatus, setTaskStatus] = useState("");
|
||||
const [taskKeyword, setTaskKeyword] = useState("");
|
||||
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [intervalDays, setIntervalDays] = useState(3);
|
||||
const [concurrency, setConcurrency] = useState(5);
|
||||
const [scopeMode, setScopeMode] = useState<"all" | "selected">("all");
|
||||
const [historyKeep, setHistoryKeep] = useState(3);
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, ConfigSyncTargetRef>>({});
|
||||
const [policyHydrated, setPolicyHydrated] = useState(false);
|
||||
|
||||
const [targetKeyword, setTargetKeyword] = useState("");
|
||||
const [targetPage, setTargetPage] = useState(1);
|
||||
|
||||
const dashQuery = useQuery({
|
||||
queryKey: queryKeys.configSyncDashboard,
|
||||
queryFn: fetchConfigSyncDashboard,
|
||||
staleTime: 1000,
|
||||
refetchInterval: (q) => {
|
||||
const running = q.state.data?.running_cycle;
|
||||
return running && (running.status === "running" || running.status === "paused") ? POLL_MS : false;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashQuery.data || policyHydrated) return;
|
||||
const p = dashQuery.data.policy;
|
||||
setEnabled(Boolean(p.enabled));
|
||||
setIntervalDays(Number(p.interval_days || 3));
|
||||
setConcurrency(Number(p.concurrency || 5));
|
||||
setScopeMode(p.scope_mode === "selected" ? "selected" : "all");
|
||||
setHistoryKeep(Number(p.history_keep ?? 3));
|
||||
const map: Record<string, ConfigSyncTargetRef> = {};
|
||||
for (const ref of p.selected_targets || []) {
|
||||
map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id };
|
||||
}
|
||||
setSelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
}, [dashQuery.data, policyHydrated]);
|
||||
|
||||
const cyclesQuery = useQuery({
|
||||
queryKey: queryKeys.configSyncCycles(cyclePage),
|
||||
queryFn: () => fetchConfigSyncCycles({ page: cyclePage, pageSize: 10 }),
|
||||
staleTime: 1000,
|
||||
refetchInterval: () => (dashQuery.data?.running_cycle ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: queryKeys.configSyncCycleTasks(expandedCycleId, taskPage, taskStatus, taskKeyword),
|
||||
queryFn: () =>
|
||||
fetchConfigSyncCycleTasks({
|
||||
cycleId: expandedCycleId,
|
||||
page: taskPage,
|
||||
pageSize: 20,
|
||||
status: taskStatus,
|
||||
keyword: taskKeyword,
|
||||
}),
|
||||
enabled: Boolean(expandedCycleId),
|
||||
staleTime: 800,
|
||||
refetchInterval: () => (dashQuery.data?.running_cycle?.id === expandedCycleId ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const targetsQuery = useQuery({
|
||||
queryKey: queryKeys.cliTargets(targetKeyword, targetPage, TARGET_PAGE_SIZE),
|
||||
queryFn: () =>
|
||||
fetchCliTargets({ source: "all", keyword: targetKeyword, page: targetPage, pageSize: TARGET_PAGE_SIZE }),
|
||||
enabled: scopeMode === "selected",
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.configSyncDashboard }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.configSyncCyclesAll }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.configSyncCycleTasksAll }),
|
||||
]);
|
||||
};
|
||||
|
||||
const savePolicyMut = useMutation({
|
||||
mutationFn: () =>
|
||||
updateConfigSyncPolicy({
|
||||
enabled,
|
||||
interval_days: intervalDays,
|
||||
concurrency,
|
||||
scope_mode: scopeMode,
|
||||
history_keep: historyKeep,
|
||||
selected_targets: Object.values(selectedMap),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
showOk(t("configSync.policySaved"));
|
||||
setPolicyHydrated(false);
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const startMut = useMutation({
|
||||
mutationFn: (mode: "full" | "retry_failed") => createConfigSyncCycle({ mode }),
|
||||
onSuccess: async () => {
|
||||
showOk(t("configSync.started"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const pauseMut = useMutation({
|
||||
mutationFn: (id: string) => pauseConfigSyncCycle(id),
|
||||
onSuccess: async () => {
|
||||
showOk(t("configSync.paused"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const resumeMut = useMutation({
|
||||
mutationFn: (id: string) => resumeConfigSyncCycle(id),
|
||||
onSuccess: async () => {
|
||||
showOk(t("configSync.resumed"));
|
||||
await refresh();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const dash = dashQuery.data;
|
||||
const running = dash?.running_cycle;
|
||||
const last = dash?.last_cycle;
|
||||
const cycles = cyclesQuery.data?.items ?? [];
|
||||
const cycleTotal = Number(cyclesQuery.data?.total || 0);
|
||||
const cyclePages = pageCount(cycleTotal, 10);
|
||||
const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]);
|
||||
|
||||
const toggleTarget = (row: CliTargetItem) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
const key = `${source}:${row.id}`;
|
||||
setSelectedMap((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[key]) delete next[key];
|
||||
else next[key] = { source, id: row.id };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("configSync.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<button type="button" onClick={() => void refresh()} disabled={dashQuery.isFetching}>
|
||||
{t("common.refresh")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={Boolean(running) || startMut.isPending}
|
||||
onClick={() => startMut.mutate("full")}
|
||||
>
|
||||
{t("configSync.syncNow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={Boolean(running) || startMut.isPending || !(last && last.fail_count > 0)}
|
||||
onClick={() => startMut.mutate("retry_failed")}
|
||||
>
|
||||
{t("configSync.retryFailed")}
|
||||
</button>
|
||||
{running?.status === "running" ? (
|
||||
<button type="button" onClick={() => pauseMut.mutate(running.id)} disabled={pauseMut.isPending}>
|
||||
{t("configSync.pause")}
|
||||
</button>
|
||||
) : null}
|
||||
{running?.status === "paused" ? (
|
||||
<button type="button" onClick={() => resumeMut.mutate(running.id)} disabled={resumeMut.isPending}>
|
||||
{t("configSync.resume")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid" style={{ marginBottom: 16 }}>
|
||||
<div className="stat-card">
|
||||
<div className="muted">{t("configSync.kpi.snapshots")}</div>
|
||||
<strong>{dash?.snapshot_count ?? "-"}</strong>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="muted">{t("configSync.kpi.running")}</div>
|
||||
<strong>
|
||||
{running
|
||||
? `${running.status} · ${running.success_count}/${running.planned_count}`
|
||||
: t("configSync.kpi.idle")}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="muted">{t("configSync.kpi.last")}</div>
|
||||
<strong>
|
||||
{last
|
||||
? `${last.status} · ok ${last.success_count} / fail ${last.fail_count}`
|
||||
: t("common.empty")}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="muted">{t("configSync.kpi.nextDue")}</div>
|
||||
<strong>{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel" style={{ marginBottom: 16 }}>
|
||||
<h3>{t("configSync.policyTitle")}</h3>
|
||||
<div className="filter-inline" style={{ flexWrap: "wrap", gap: 12 }}>
|
||||
<label>
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />{" "}
|
||||
{t("configSync.enabled")}
|
||||
</label>
|
||||
<label>
|
||||
{t("configSync.intervalDays")}{" "}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(Math.max(1, Number(e.target.value) || 1))}
|
||||
style={{ width: 72 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("configSync.concurrency")}{" "}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
value={concurrency}
|
||||
onChange={(e) => setConcurrency(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
|
||||
style={{ width: 72 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("configSync.historyKeep")}{" "}
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={30}
|
||||
value={historyKeep}
|
||||
onChange={(e) => setHistoryKeep(Math.max(0, Math.min(30, Number(e.target.value) || 0)))}
|
||||
style={{ width: 72 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("configSync.scope")}{" "}
|
||||
<select
|
||||
value={scopeMode}
|
||||
onChange={(e) => setScopeMode(e.target.value === "selected" ? "selected" : "all")}
|
||||
>
|
||||
<option value="all">{t("configSync.scopeAll")}</option>
|
||||
<option value="selected">{t("configSync.scopeSelected")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={savePolicyMut.isPending}
|
||||
onClick={() => savePolicyMut.mutate()}
|
||||
>
|
||||
{t("configSync.savePolicy")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{scopeMode === "selected" ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p className="muted">{t("configSync.selectedCount", { count: String(selectedCount) })}</p>
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={targetKeyword}
|
||||
placeholder={t("configSync.targetKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setTargetKeyword(e.target.value);
|
||||
setTargetPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t("configSync.col.source")}</th>
|
||||
<th>{t("configSync.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("configSync.col.vendor")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(targetsQuery.data?.items ?? []).map((row) => {
|
||||
const source = row.source === "ume" ? "ume" : "managed";
|
||||
const key = `${source}:${row.id}`;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(selectedMap[key])}
|
||||
onChange={() => toggleTarget(row)}
|
||||
/>
|
||||
</td>
|
||||
<td>{source}</td>
|
||||
<td>{row.name}</td>
|
||||
<td>{row.ip_address}</td>
|
||||
<td>{row.vendor || "-"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={targetPage <= 1} onClick={() => setTargetPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(targetsQuery.data?.total ?? 0),
|
||||
page: String(targetPage),
|
||||
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
|
||||
onClick={() => setTargetPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h3>{t("configSync.cyclesTitle")}</h3>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>ID</th>
|
||||
<th>{t("configSync.col.trigger")}</th>
|
||||
<th>{t("configSync.col.status")}</th>
|
||||
<th>{t("configSync.col.progress")}</th>
|
||||
<th>{t("configSync.col.started")}</th>
|
||||
<th>{t("configSync.col.ended")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cycles.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setExpandedCycleId((id) => (id === c.id ? "" : c.id));
|
||||
setTaskPage(1);
|
||||
}}
|
||||
>
|
||||
{expandedCycleId === c.id ? t("configSync.collapse") : t("configSync.expand")}
|
||||
</button>
|
||||
</td>
|
||||
<td title={c.id}>{c.id.slice(0, 8)}</td>
|
||||
<td>{c.trigger_mode}</td>
|
||||
<td>{c.status}</td>
|
||||
<td>
|
||||
{c.success_count}/{c.planned_count} · fail {c.fail_count}
|
||||
</td>
|
||||
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
|
||||
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!cycles.length ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
{t("configSync.cyclesEmpty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={cyclePage <= 1} onClick={() => setCyclePage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", { total: String(cycleTotal), page: String(cyclePage), pages: String(cyclePages) })}
|
||||
</span>
|
||||
<button type="button" disabled={cyclePage >= cyclePages} onClick={() => setCyclePage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expandedCycleId ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h3>{t("configSync.tasksTitle")}</h3>
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={taskKeyword}
|
||||
placeholder={t("configSync.taskKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setTaskKeyword(e.target.value);
|
||||
setTaskPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={taskStatus}
|
||||
onChange={(e) => {
|
||||
setTaskStatus(e.target.value);
|
||||
setTaskPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("configSync.allStatus")}</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="running">running</option>
|
||||
<option value="success">success</option>
|
||||
<option value="fail">fail</option>
|
||||
<option value="skipped">skipped</option>
|
||||
</select>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("configSync.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("configSync.col.vendor")}</th>
|
||||
<th>{t("configSync.col.source")}</th>
|
||||
<th>{t("configSync.col.status")}</th>
|
||||
<th>{t("configSync.col.message")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(tasksQuery.data?.items ?? []).map((task) => (
|
||||
<tr key={task.id}>
|
||||
<td>{task.ne_name || task.target_id}</td>
|
||||
<td>{task.ne_ip}</td>
|
||||
<td>{task.vendor || "-"}</td>
|
||||
<td>{task.source}</td>
|
||||
<td>{task.status}</td>
|
||||
<td title={task.message}>{task.message || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pager">
|
||||
<button type="button" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", {
|
||||
total: String(tasksQuery.data?.total ?? 0),
|
||||
page: String(taskPage),
|
||||
pages: String(pageCount(Number(tasksQuery.data?.total || 0), 20)),
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={taskPage >= pageCount(Number(tasksQuery.data?.total || 0), 20)}
|
||||
onClick={() => setTaskPage((p) => p + 1)}
|
||||
>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
203
web/src/pages/network/NetworkConfigsPage.tsx
Normal file
203
web/src/pages/network/NetworkConfigsPage.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchNeConfigSnapshotDetail, fetchNeConfigSnapshots } from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { formatSystemTime } from "../../utils/time";
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return "0 B";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function NetworkConfigsPage() {
|
||||
const { t } = useI18n();
|
||||
const [page, setPage] = useState(1);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [vendor, setVendor] = useState("");
|
||||
const [selected, setSelected] = useState<{ source: string; id: string } | null>(null);
|
||||
const [tab, setTab] = useState<"primary" | "alt">("primary");
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.networkConfigs(page, keyword, source, vendor),
|
||||
queryFn: () =>
|
||||
fetchNeConfigSnapshots({ page, pageSize: 20, keyword, source, vendor }),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: queryKeys.networkConfigDetail(selected?.source || "", selected?.id || ""),
|
||||
queryFn: () => fetchNeConfigSnapshotDetail(selected!.source, selected!.id, "both"),
|
||||
enabled: Boolean(selected),
|
||||
staleTime: 10000,
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const total = Number(listQuery.data?.total || 0);
|
||||
const pages = pageCount(total, 20);
|
||||
const detail = detailQuery.data;
|
||||
const showAlt = Boolean(detail?.has_alt);
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("networkConfigs.title")}</h2>
|
||||
</div>
|
||||
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={keyword}
|
||||
placeholder={t("networkConfigs.keywordPh")}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("networkConfigs.allSource")}</option>
|
||||
<option value="managed">managed</option>
|
||||
<option value="ume">ume</option>
|
||||
</select>
|
||||
<input
|
||||
value={vendor}
|
||||
placeholder={t("networkConfigs.vendorPh")}
|
||||
onChange={(e) => {
|
||||
setVendor(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!keyword && !source && !vendor}
|
||||
onClick={() => {
|
||||
setKeyword("");
|
||||
setSource("");
|
||||
setVendor("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("networkConfigs.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("networkConfigs.col.vendor")}</th>
|
||||
<th>{t("networkConfigs.col.source")}</th>
|
||||
<th>{t("networkConfigs.col.size")}</th>
|
||||
<th>{t("networkConfigs.col.collected")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={`${row.source}:${row.target_id}`}>
|
||||
<td>{row.ne_name || row.target_id}</td>
|
||||
<td>{row.ne_ip}</td>
|
||||
<td>{row.vendor || "-"}</td>
|
||||
<td>{row.source}</td>
|
||||
<td>{fmtBytes(row.plain_size)}</td>
|
||||
<td>{row.collected_at ? formatSystemTime(row.collected_at) : "-"}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelected({ source: row.source, id: row.target_id });
|
||||
setTab("primary");
|
||||
}}
|
||||
>
|
||||
{t("networkConfigs.view")}
|
||||
</button>
|
||||
<Link
|
||||
to={`/network/webcrt?${row.source === "ume" ? "ume_ne_id" : "ne_id"}=${encodeURIComponent(row.target_id)}`}
|
||||
style={{ marginLeft: 8 }}
|
||||
>
|
||||
WebCRT
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!items.length && !listQuery.isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="muted">
|
||||
{t("networkConfigs.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="pager">
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t("common.prevPage")}
|
||||
</button>
|
||||
<span className="muted">
|
||||
{t("common.pagerMeta", { total: String(total), page: String(page), pages: String(pages) })}
|
||||
</span>
|
||||
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t("common.nextPage")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selected ? (
|
||||
<div className="panel" style={{ marginTop: 16 }}>
|
||||
<div className="panel__toolbar">
|
||||
<h3>
|
||||
{detail?.ne_name || selected.id}{" "}
|
||||
<span className="muted">
|
||||
({selected.source} / {detail?.ne_ip || "-"})
|
||||
</span>
|
||||
</h3>
|
||||
<button type="button" onClick={() => setSelected(null)}>
|
||||
{t("networkConfigs.close")}
|
||||
</button>
|
||||
</div>
|
||||
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{showAlt ? (
|
||||
<div className="btn-row" style={{ marginBottom: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "primary" ? "btn-primary" : undefined}
|
||||
onClick={() => setTab("primary")}
|
||||
>
|
||||
{t("networkConfigs.tabSet")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "alt" ? "btn-primary" : undefined}
|
||||
onClick={() => setTab("alt")}
|
||||
>
|
||||
{t("networkConfigs.tabHier")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
readOnly
|
||||
value={tab === "alt" ? detail?.config_alt_text || "" : detail?.config_text || ""}
|
||||
style={{ width: "100%", minHeight: 420, fontFamily: "ui-monospace, monospace", fontSize: 12 }}
|
||||
/>
|
||||
<p className="muted">
|
||||
SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
|
||||
{fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,12 @@ import type {
|
|||
TopologyDiscoverStreamHandlers,
|
||||
TopologyGraph,
|
||||
TopologyMapItem,
|
||||
ConfigSyncCycle,
|
||||
ConfigSyncDashboard,
|
||||
ConfigSyncPolicy,
|
||||
ConfigSyncTask,
|
||||
NeConfigSnapshotDetail,
|
||||
NeConfigSnapshotMeta,
|
||||
} from "../types";
|
||||
|
||||
export const AUTH_TOKEN_KEY = "netx_access_token";
|
||||
|
|
@ -669,3 +675,73 @@ export async function discoverTopologyNeighborsStream(
|
|||
if (!finalReport) throw new Error("discover_stream_incomplete");
|
||||
return finalReport;
|
||||
}
|
||||
|
||||
export const fetchConfigSyncDashboard = () =>
|
||||
apiGet<ConfigSyncDashboard>("/v1/config-sync/dashboard");
|
||||
|
||||
export const fetchConfigSyncPolicy = () => apiGet<ConfigSyncPolicy>("/v1/config-sync/policy");
|
||||
|
||||
export const updateConfigSyncPolicy = (body: Partial<ConfigSyncPolicy>) =>
|
||||
apiPut<ConfigSyncPolicy>("/v1/config-sync/policy", body);
|
||||
|
||||
export const fetchConfigSyncCycles = (params: { page?: number; pageSize?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncCycle[] }>(
|
||||
`/v1/config-sync/cycles?${p.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const createConfigSyncCycle = (body: { mode: "full" | "retry_failed"; cycle_id?: string }) =>
|
||||
apiPost<ConfigSyncCycle>("/v1/config-sync/cycles", body);
|
||||
|
||||
export const pauseConfigSyncCycle = (cycleId: string) =>
|
||||
apiPost<ConfigSyncCycle>(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/pause`, {});
|
||||
|
||||
export const resumeConfigSyncCycle = (cycleId: string) =>
|
||||
apiPost<ConfigSyncCycle>(`/v1/config-sync/cycles/${encodeURIComponent(cycleId)}/resume`, {});
|
||||
|
||||
export const fetchConfigSyncCycleTasks = (params: {
|
||||
cycleId: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(200, Number(params.pageSize || 20)))));
|
||||
if (params.status) p.set("status", params.status);
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: ConfigSyncTask[] }>(
|
||||
`/v1/config-sync/cycles/${encodeURIComponent(params.cycleId)}/tasks?${p.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchNeConfigSnapshots = (params: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
source?: string;
|
||||
vendor?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
|
||||
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
|
||||
if (params.source) p.set("source", params.source);
|
||||
if (params.vendor?.trim()) p.set("vendor", params.vendor.trim());
|
||||
return apiGet<{ total: number; page: number; page_size: number; items: NeConfigSnapshotMeta[] }>(
|
||||
`/v1/config-sync/snapshots?${p.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const fetchNeConfigSnapshotDetail = (
|
||||
source: string,
|
||||
targetId: string,
|
||||
field: "primary" | "alt" | "both" = "both",
|
||||
) =>
|
||||
apiGet<NeConfigSnapshotDetail>(
|
||||
`/v1/config-sync/snapshots/${encodeURIComponent(source)}/${encodeURIComponent(targetId)}?field=${field}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -429,3 +429,81 @@ export type TopologyDiscoverStreamHandlers = {
|
|||
onDone?: (report: TopologyDiscoverOut) => void;
|
||||
onError?: (detail: string) => void;
|
||||
};
|
||||
|
||||
export type ConfigSyncTargetRef = {
|
||||
source: "managed" | "ume";
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type ConfigSyncPolicy = {
|
||||
enabled: boolean;
|
||||
interval_days: number;
|
||||
concurrency: number;
|
||||
scope_mode: string;
|
||||
selected_targets: ConfigSyncTargetRef[];
|
||||
history_keep: number;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type ConfigSyncCycle = {
|
||||
id: string;
|
||||
trigger_mode: string;
|
||||
status: string;
|
||||
concurrency: number;
|
||||
planned_count: number;
|
||||
success_count: number;
|
||||
fail_count: number;
|
||||
skip_count: number;
|
||||
error_message: string;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type ConfigSyncTask = {
|
||||
id: string;
|
||||
cycle_id: string;
|
||||
source: string;
|
||||
target_id: string;
|
||||
ne_name: string;
|
||||
ne_ip: string;
|
||||
vendor: string;
|
||||
status: string;
|
||||
message: string;
|
||||
started_at?: string | null;
|
||||
ended_at?: string | null;
|
||||
};
|
||||
|
||||
export type ConfigSyncDashboard = {
|
||||
policy: ConfigSyncPolicy;
|
||||
snapshot_count: number;
|
||||
last_cycle: ConfigSyncCycle | null;
|
||||
running_cycle: ConfigSyncCycle | null;
|
||||
next_due_at?: string | null;
|
||||
fail_by_vendor: Record<string, number>;
|
||||
};
|
||||
|
||||
export type NeConfigSnapshotMeta = {
|
||||
source: string;
|
||||
target_id: string;
|
||||
vendor: string;
|
||||
device_type: string;
|
||||
ne_name: string;
|
||||
ne_ip: string;
|
||||
config_sha256: string;
|
||||
config_alt_sha256: string;
|
||||
plain_size: number;
|
||||
plain_alt_size: number;
|
||||
zlib_size: number;
|
||||
zlib_alt_size: number;
|
||||
has_alt: boolean;
|
||||
commands: string[];
|
||||
collected_at?: string | null;
|
||||
last_cycle_id: string;
|
||||
};
|
||||
|
||||
export type NeConfigSnapshotDetail = NeConfigSnapshotMeta & {
|
||||
config_text: string;
|
||||
config_alt_text: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue