feat(ops): add managed NE management and batch CLI collection

Introduce workbench operations for multi-vendor NE CRUD/connect-test and Netmiko batch collection with job lifecycle controls, log downloads, and paginated run filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-27 23:34:29 +08:00
parent 282dbe75af
commit 042c015045
36 changed files with 3695 additions and 17 deletions

View file

@ -1,4 +1,8 @@
NETX_DATABASE_URL=postgresql+psycopg://netx:netx@127.0.0.1:5432/netx NETX_DATABASE_URL=postgresql+psycopg://netx:netx@127.0.0.1:5432/netx
# Fernet key for managed NE passwords (python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
NETX_CREDENTIAL_SECRET_KEY=
NETX_NE_CONNECT_MAX_WORKERS=5
NETX_NE_CONNECT_TIMEOUT_SEC=30
NETX_HOST=127.0.0.1 NETX_HOST=127.0.0.1
NETX_PORT=8890 NETX_PORT=8890
NETX_VENDOR=ZTE NETX_VENDOR=ZTE

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ build/
dist/ dist/
scripts/.run/ scripts/.run/
ume/ ume/
data/ne_collections/

View file

@ -0,0 +1,78 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy.orm import Session
from .config import settings
from .models import NeCollectionJob, NeCollectionRun
_TERMINAL = frozenset({"success", "fail", "cancelled"})
def _sync_job_counts(job: NeCollectionJob, runs: list[NeCollectionRun]) -> None:
job.success_count = sum(1 for r in runs if str(r.status) == "success")
job.fail_count = sum(1 for r in runs if str(r.status) in ("fail", "cancelled"))
def finalize_collection_job(db: Session, job_id: str) -> None:
runs = db.query(NeCollectionRun).filter(NeCollectionRun.job_id == job_id).all()
if not runs:
return
if any(str(r.status or "") not in _TERMINAL for r in runs):
return
job = db.get(NeCollectionJob, job_id)
if not job:
return
_sync_job_counts(job, runs)
job_status = str(job.status or "")
finish_at = datetime.now()
if job_status == "paused":
if not job.ended_at:
job.ended_at = finish_at
else:
success = int(job.success_count or 0)
job.status = "done" if success > 0 else "failed"
if not job.ended_at:
job.ended_at = finish_at
job.last_run_at = job.ended_at or finish_at
db.commit()
def reconcile_stale_collection_job(db: Session, job_id: str) -> bool:
"""Mark long-running pending/running rows as failed and finalize job if possible."""
job = db.get(NeCollectionJob, job_id)
if not job:
return False
if str(job.status or "") in ("done", "failed", "paused"):
return False
run_stale_sec = max(60, int(settings.ne_collect_stale_run_sec or 900))
pending_stale_sec = max(30, int(settings.ne_collect_pending_stale_sec or 180))
now = datetime.now()
runs = db.query(NeCollectionRun).filter(NeCollectionRun.job_id == job_id).all()
changed = False
for run in runs:
st = str(run.status or "")
if st in _TERMINAL:
continue
if st == "pending":
anchor = job.started_at or job.created_at
limit = pending_stale_sec
reason = "collection_pending_stale"
else:
anchor = run.started_at or job.started_at or job.created_at
limit = run_stale_sec
reason = "collection_timeout_stale"
if not anchor:
continue
age = (now - anchor).total_seconds()
if age < limit:
continue
run.status = "fail"
run.message = f"{reason} ({int(age)}s)"
run.ended_at = now
changed = True
if changed:
db.commit()
finalize_collection_job(db, job_id)
return changed

View file

@ -0,0 +1,52 @@
from __future__ import annotations
import logging
from sqlalchemy.orm import Session
from .collection_job_state import finalize_collection_job, reconcile_stale_collection_job
from .models import NeCollectionJob, NeCollectionRun
from .ne_collect_runner import schedule_collection_runs
_log = logging.getLogger("netx.collection.recovery")
def _parse_commands(text: str) -> list[str]:
lines: list[str] = []
for raw in str(text or "").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
lines.append(line)
return lines
def recover_collection_jobs_on_startup(db: Session) -> int:
"""Re-queue pending runs for running jobs after API restart; reconcile stale rows."""
jobs = db.query(NeCollectionJob).filter(NeCollectionJob.status == "running").all()
resumed = 0
for job in jobs:
job_id = str(job.id)
reconcile_stale_collection_job(db, job_id)
db.refresh(job)
if str(job.status or "") in ("done", "failed"):
continue
commands = _parse_commands(str(job.commands or ""))
if not commands:
job.status = "failed"
job.error_message = "commands_empty_on_recovery"
db.commit()
continue
pending = (
db.query(NeCollectionRun)
.filter(NeCollectionRun.job_id == job_id, NeCollectionRun.status == "pending")
.all()
)
if not pending:
finalize_collection_job(db, job_id)
continue
run_ids = [str(r.id) for r in pending]
schedule_collection_runs(job_id, run_ids, commands)
resumed += len(run_ids)
_log.info("resumed collection job=%s pending_runs=%s", job_id, len(run_ids))
return resumed

View file

@ -0,0 +1,100 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session
from .collection_service import (
build_collection_job_zip,
create_and_start_collection,
delete_collection_job,
get_collection_job,
list_collection_jobs,
list_collection_runs,
list_eligible_ne,
pause_collection_job,
resolve_run_output_path,
restart_collection_job,
)
from .collection_schemas import CollectionJobCreate
from .db import get_db
from .models import NeCollectionRun
router = APIRouter(prefix="/v1/ne-collections", tags=["ne-collections"])
@router.get("/eligible-ne")
def api_eligible_ne(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
):
return list_eligible_ne(db, page=page, page_size=page_size)
@router.post("")
def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)):
return create_and_start_collection(db, body).model_dump()
@router.get("")
def api_list_collections(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
):
return list_collection_jobs(db, page=page, page_size=page_size)
@router.get("/runs/{run_id}/download")
def api_download_run_output(run_id: str, db: Session = Depends(get_db)):
run = db.get(NeCollectionRun, run_id)
if not run:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="collection_run_not_found")
path = resolve_run_output_path(str(run.output_rel_path or ""))
filename = path.name
return FileResponse(path, media_type="text/plain; charset=utf-8", filename=filename)
@router.get("/{job_id}/download")
def api_download_collection_job(job_id: str, db: Session = Depends(get_db)):
filename, payload = build_collection_job_zip(db, job_id)
return Response(
content=payload,
media_type="application/zip",
headers={"content-disposition": f'attachment; filename="{filename}"'},
)
@router.get("/{job_id}/runs")
def api_list_collection_runs(
job_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_collection_runs(db, job_id, page=page, page_size=page_size, status=status, keyword=keyword)
@router.get("/{job_id}")
def api_get_collection(job_id: str, db: Session = Depends(get_db)):
return get_collection_job(db, job_id)
@router.post("/{job_id}/pause")
def api_pause_collection(job_id: str, db: Session = Depends(get_db)):
return pause_collection_job(db, job_id).model_dump()
@router.post("/{job_id}/restart")
def api_restart_collection(job_id: str, db: Session = Depends(get_db)):
return restart_collection_job(db, job_id).model_dump()
@router.delete("/{job_id}")
def api_delete_collection(job_id: str, db: Session = Depends(get_db)):
return delete_collection_job(db, job_id)

View file

@ -0,0 +1,41 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class CollectionJobCreate(BaseModel):
title: str = ""
commands: str = Field(min_length=1)
ne_ids: list[str] = Field(min_length=1)
class CollectionRunOut(BaseModel):
id: str
job_id: str
ne_id: str
ne_name: str
ne_ip: str
status: str
message: str
output_rel_path: str
has_output: bool
started_at: datetime | None
ended_at: datetime | None
class CollectionJobOut(BaseModel):
id: str
title: str
commands: str
status: str
ne_count: int
success_count: int
fail_count: int
output_count: int = 0
error_message: str
created_at: datetime
started_at: datetime | None
ended_at: datetime | None
last_run_at: datetime | None = None

View file

@ -0,0 +1,385 @@
from __future__ import annotations
import io
import logging
import re
import shutil
import zipfile
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from .models import ManagedNE, NeCollectionJob, NeCollectionRun
from .collection_job_state import finalize_collection_job, reconcile_stale_collection_job, _sync_job_counts
from .collection_schemas import CollectionJobCreate, CollectionJobOut, CollectionRunOut
from .ne_collect_runner import schedule_collection_runs
from .ne_collection_paths import clear_run_output_files, collection_data_root
_log = logging.getLogger("netx.collection")
def _now() -> datetime:
return datetime.utcnow()
def _parse_commands(text: str) -> list[str]:
lines = []
for raw in str(text or "").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
lines.append(line)
return lines
def _output_counts_for_jobs(db: Session, job_ids: list[str]) -> dict[str, int]:
if not job_ids:
return {}
rows = (
db.query(NeCollectionRun.job_id, func.count())
.filter(
NeCollectionRun.job_id.in_(job_ids),
NeCollectionRun.output_rel_path != "",
NeCollectionRun.output_rel_path.isnot(None),
)
.group_by(NeCollectionRun.job_id)
.all()
)
return {str(job_id): int(count) for job_id, count in rows}
def _output_count_for_job(db: Session, job_id: str) -> int:
return int(
db.query(func.count())
.select_from(NeCollectionRun)
.filter(
NeCollectionRun.job_id == job_id,
NeCollectionRun.output_rel_path != "",
NeCollectionRun.output_rel_path.isnot(None),
)
.scalar()
or 0
)
def job_to_out(row: NeCollectionJob, *, output_count: int | None = None) -> CollectionJobOut:
return CollectionJobOut(
id=str(row.id),
title=str(row.title or ""),
commands=str(row.commands or ""),
status=str(row.status or "pending"),
ne_count=int(row.ne_count or 0),
success_count=int(row.success_count or 0),
fail_count=int(row.fail_count or 0),
output_count=int(output_count if output_count is not None else 0),
error_message=str(row.error_message or "")[:1000],
created_at=row.created_at,
started_at=row.started_at,
ended_at=row.ended_at,
last_run_at=row.last_run_at,
)
def run_to_out(row: NeCollectionRun) -> CollectionRunOut:
rel = str(row.output_rel_path or "").strip()
return CollectionRunOut(
id=str(row.id),
job_id=str(row.job_id),
ne_id=str(row.ne_id),
ne_name=str(row.ne_name or ""),
ne_ip=str(row.ne_ip or ""),
status=str(row.status or "pending"),
message=str(row.message or "")[:1000],
output_rel_path=rel,
has_output=bool(rel),
started_at=row.started_at,
ended_at=row.ended_at,
)
def list_eligible_ne(db: Session, *, page: int = 1, page_size: int = 200) -> dict[str, Any]:
stmt = db.query(ManagedNE).filter(ManagedNE.connect_status == "pass")
total = int(stmt.count())
rows = (
stmt.order_by(ManagedNE.name.asc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
items = [
{
"id": str(x.id),
"name": str(x.name or ""),
"vendor": str(x.vendor or ""),
"device_type": str(x.device_type or ""),
"ip_address": str(x.ip_address or ""),
"connect_status": str(x.connect_status or ""),
"connect_tested_at": x.connect_tested_at.isoformat() if x.connect_tested_at else None,
}
for x in rows
]
return {"total": total, "page": page, "page_size": page_size, "items": items}
def create_and_start_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOut:
commands = _parse_commands(body.commands)
if not commands:
raise HTTPException(status_code=400, detail="commands_empty")
ne_ids = [str(x).strip() for x in body.ne_ids if str(x).strip()]
if not ne_ids:
raise HTTPException(status_code=400, detail="ne_ids_required")
ne_rows: list[ManagedNE] = []
missing: list[str] = []
not_pass: list[str] = []
for ne_id in ne_ids:
row = db.get(ManagedNE, ne_id)
if not row:
missing.append(ne_id)
continue
if str(row.connect_status or "") != "pass":
not_pass.append(ne_id)
continue
ne_rows.append(row)
if missing:
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
if not_pass:
raise HTTPException(status_code=400, detail=f"ne_connect_not_pass: {','.join(not_pass[:5])}")
if not ne_rows:
raise HTTPException(status_code=400, detail="no_eligible_ne")
now = _now()
job = NeCollectionJob(
title=str(body.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}",
commands="\n".join(commands),
status="running",
ne_count=len(ne_rows),
created_at=now,
started_at=now,
last_run_at=now,
)
db.add(job)
db.flush()
run_ids: list[str] = []
for ne in ne_rows:
run = NeCollectionRun(
job_id=str(job.id),
ne_id=str(ne.id),
ne_name=str(ne.name or ne.ip_address or ""),
ne_ip=str(ne.ip_address or ""),
status="pending",
)
db.add(run)
run_ids.append(str(run.id))
db.commit()
db.refresh(job)
schedule_collection_runs(str(job.id), run_ids, commands)
return job_to_out(job, output_count=0)
def list_collection_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]:
stmt = db.query(NeCollectionJob)
total = int(stmt.count())
rows = stmt.order_by(NeCollectionJob.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
for row in rows:
if str(row.status or "") not in ("done", "failed"):
reconcile_stale_collection_job(db, str(row.id))
db.refresh(row)
job_ids = [str(x.id) for x in rows]
output_counts = _output_counts_for_jobs(db, job_ids)
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [job_to_out(x, output_count=output_counts.get(str(x.id), 0)).model_dump() for x in rows],
}
def get_collection_job(db: Session, job_id: str) -> dict[str, Any]:
job = db.get(NeCollectionJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="collection_job_not_found")
if str(job.status or "") not in ("done", "failed"):
reconcile_stale_collection_job(db, job_id)
db.refresh(job)
return {
"job": job_to_out(job, output_count=_output_count_for_job(db, job_id)).model_dump(),
}
def list_collection_runs(
db: Session,
job_id: str,
*,
page: int = 1,
page_size: int = 20,
status: str = "",
keyword: str = "",
) -> dict[str, Any]:
job = db.get(NeCollectionJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="collection_job_not_found")
stmt = db.query(NeCollectionRun).filter(NeCollectionRun.job_id == job_id)
st = str(status or "").strip()
if st:
stmt = stmt.filter(NeCollectionRun.status == st)
kw = str(keyword or "").strip()
if kw:
like = f"%{kw}%"
stmt = stmt.filter(or_(NeCollectionRun.ne_name.ilike(like), NeCollectionRun.ne_ip.ilike(like)))
total = int(stmt.count())
rows = (
stmt.order_by(NeCollectionRun.ne_name.asc(), NeCollectionRun.ne_ip.asc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [run_to_out(x).model_dump() for x in rows],
}
def _active_runs(runs: list[NeCollectionRun]) -> bool:
return any(str(r.status or "") in ("pending", "running") for r in runs)
def pause_collection_job(db: Session, job_id: str) -> CollectionJobOut:
job = db.get(NeCollectionJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="collection_job_not_found")
if str(job.status or "") != "running":
raise HTTPException(status_code=400, detail="collection_job_not_running")
now = _now()
runs = db.query(NeCollectionRun).filter(NeCollectionRun.job_id == job_id).all()
for run in runs:
if str(run.status or "") != "pending":
continue
run.status = "cancelled"
run.message = "paused"
run.ended_at = now
job.status = "paused"
_sync_job_counts(job, runs)
if not any(str(r.status or "") not in {"success", "fail", "cancelled"} for r in runs):
job.ended_at = now
db.commit()
db.refresh(job)
finalize_collection_job(db, job_id)
db.refresh(job)
return job_to_out(job, output_count=_output_count_for_job(db, job_id))
def restart_collection_job(db: Session, job_id: str) -> CollectionJobOut:
job = db.get(NeCollectionJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="collection_job_not_found")
runs = db.query(NeCollectionRun).filter(NeCollectionRun.job_id == job_id).all()
if not runs:
raise HTTPException(status_code=400, detail="collection_no_runs")
if str(job.status or "") == "running" or _active_runs(runs):
raise HTTPException(status_code=400, detail="collection_job_running")
commands = _parse_commands(str(job.commands or ""))
if not commands:
raise HTTPException(status_code=400, detail="commands_empty")
retry_ids: list[str] = []
for run in runs:
st = str(run.status or "")
if st in ("pending", "running"):
continue
clear_run_output_files(job_id, str(run.id))
run.status = "pending"
run.message = ""
run.output_rel_path = ""
run.started_at = None
run.ended_at = None
retry_ids.append(str(run.id))
if not retry_ids:
raise HTTPException(status_code=400, detail="collection_nothing_to_retry")
now = _now()
job.status = "running"
job.ended_at = None
job.error_message = ""
job.success_count = 0
job.fail_count = 0
job.started_at = now
job.last_run_at = now
db.commit()
db.refresh(job)
schedule_collection_runs(job_id, retry_ids, commands)
return job_to_out(job, output_count=0)
def delete_collection_job(db: Session, job_id: str) -> dict[str, bool]:
job = db.get(NeCollectionJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="collection_job_not_found")
runs = db.query(NeCollectionRun).filter(NeCollectionRun.job_id == job_id).all()
if str(job.status or "") == "running" or _active_runs(runs):
raise HTTPException(status_code=400, detail="collection_job_running")
db.query(NeCollectionRun).filter(NeCollectionRun.job_id == job_id).delete()
db.delete(job)
db.commit()
job_dir = (collection_data_root() / job_id).resolve()
root = collection_data_root().resolve()
if str(job_dir).startswith(str(root)) and job_dir.is_dir():
shutil.rmtree(job_dir, ignore_errors=True)
return {"ok": True}
def _safe_archive_part(text: str, fallback: str = "device") -> str:
s = re.sub(r'[<>:"/\\|?*]', "_", str(text or "").strip())
return (s[:80] or fallback).strip("._") or fallback
def build_collection_job_zip(db: Session, job_id: str) -> tuple[str, bytes]:
job = db.get(NeCollectionJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="collection_job_not_found")
runs = (
db.query(NeCollectionRun)
.filter(NeCollectionRun.job_id == job_id)
.order_by(NeCollectionRun.ne_name.asc())
.all()
)
files: list[tuple[str, Path]] = []
used_names: set[str] = set()
for run in runs:
rel = str(run.output_rel_path or "").strip()
if not rel:
continue
path = resolve_run_output_path(rel)
arcname = path.name
if arcname in used_names:
arcname = f"{str(run.id)[:8]}_{path.name}"
used_names.add(arcname)
files.append((arcname, path))
if not files:
raise HTTPException(status_code=404, detail="collection_outputs_not_found")
buf = io.BytesIO()
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for arcname, path in files:
zf.write(path, arcname=arcname)
title = _safe_archive_part(str(job.title or "collect"), "collect")
zip_name = f"{title}_{job_id[:8]}.zip"
return zip_name, buf.getvalue()
def resolve_run_output_path(rel_path: str) -> Path:
rel = str(rel_path or "").strip().replace("\\", "/")
if not rel or ".." in rel.split("/"):
raise HTTPException(status_code=400, detail="invalid_output_path")
full = (collection_data_root() / rel).resolve()
root = collection_data_root()
if not str(full).startswith(str(root)):
raise HTTPException(status_code=400, detail="invalid_output_path")
if not full.is_file():
raise HTTPException(status_code=404, detail="output_file_not_found")
return full

View file

@ -57,6 +57,16 @@ class Settings(BaseSettings):
ume_ne_path: str = "/restconf/data/zte-resources-module:network-elements" ume_ne_path: str = "/restconf/data/zte-resources-module:network-elements"
ume_alarms_path: str = "/restconf/data/zte-alarms:alarms/alarm-list" ume_alarms_path: str = "/restconf/data/zte-alarms:alarms/alarm-list"
ume_sync_alarms_history_every_hours: int = 24 ume_sync_alarms_history_every_hours: int = 24
# Managed NE credentials (Fernet key; generate with cryptography.fernet.Fernet.generate_key())
credential_secret_key: str = ""
ne_connect_max_workers: int = 5
ne_connect_timeout_sec: int = 30
ne_collect_max_workers: int = 5
ne_collect_read_timeout_sec: int = 120
ne_collect_stale_run_sec: int = 900
ne_collect_pending_stale_sec: int = 180
ne_collect_run_timeout_cap_sec: int = 600
ne_collection_data_dir: str = "data/ne_collections"
settings = Settings() settings = Settings()

View file

@ -12,3 +12,11 @@ class Base(DeclarativeBase):
engine = create_engine(settings.database_url, future=True, pool_pre_ping=True) engine = create_engine(settings.database_url, future=True, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

45
netx_api/device_types.py Normal file
View file

@ -0,0 +1,45 @@
"""Netmiko device_type values supported for managed network elements."""
from __future__ import annotations
SUPPORTED_DEVICE_TYPES: tuple[str, ...] = (
"zte_zxros",
"alcatel_aos",
"alcatel_sros",
"cisco_asa",
"cisco_ftd",
"cisco_ios",
"cisco_nxos",
"cisco_s200",
"cisco_s300",
"cisco_tp",
"cisco_viptela",
"cisco_wlc",
"cisco_xe",
"cisco_xr",
"ericsson_ipos",
"ericsson_mltn63",
"ericsson_mltn66",
"huawei",
"huawei_smartax",
"huawei_olt",
"huawei_vrp",
"huawei_vrpv8",
"juniper",
"juniper_junos",
"juniper_screenos",
"mikrotik_routeros",
"mikrotik_switchos",
"nokia_sros",
"nokia_srl",
"ruijie_os",
)
SUPPORTED_VENDORS: tuple[str, ...] = (
"ZTE",
"Huawei",
"Cisco",
"Juniper",
"Nokia",
"Other",
)

View file

@ -19,13 +19,18 @@ import uvicorn
from .ap_client import analyze_with_oclaw, health_with_oclaw from .ap_client import analyze_with_oclaw, health_with_oclaw
from .config import settings from .config import settings
from .db import Base, SessionLocal, engine from .db import Base, SessionLocal, engine, get_db
from .collection_router import router as collection_router
from .managed_ne_router import router as managed_ne_router
from .importer import aggregate_alarms, import_alarm_excel, query_alarms from .importer import aggregate_alarms, import_alarm_excel, query_alarms
from .models import ( from .models import (
AiAnalyzeHistory, AiAnalyzeHistory,
AlarmBatch, AlarmBatch,
AlarmNorm, AlarmNorm,
ImportErrorRow, ImportErrorRow,
ManagedNE,
NeCollectionJob,
NeCollectionRun,
UmeAlarmCurrent, UmeAlarmCurrent,
UmeAlarmHistory, UmeAlarmHistory,
UmeInventoryNE, UmeInventoryNE,
@ -72,6 +77,8 @@ from .schemas import (
) )
app = FastAPI(title="netx ops tool", version="0.1.0") app = FastAPI(title="netx ops tool", version="0.1.0")
app.include_router(managed_ne_router)
app.include_router(collection_router)
parser_cfg = load_parser_config() parser_cfg = load_parser_config()
_UME_CLIENT_SINGLETON = UMEClient( _UME_CLIENT_SINGLETON = UMEClient(
token_loader=lambda: load_shared_token(), token_loader=lambda: load_shared_token(),
@ -418,14 +425,6 @@ def _maybe_wait_for_sync_interval(
_sleep_or_until_paused(task_id, wait_s) _sleep_or_until_paused(task_id, wait_s)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def _parse_time(text: str | None) -> datetime | None: def _parse_time(text: str | None) -> datetime | None:
s = str(text or "").strip() s = str(text or "").strip()
if not s: if not s:
@ -658,6 +657,17 @@ def on_startup() -> None:
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_reset_runtime_pause_flags() _reset_runtime_pause_flags()
_fail_stale_running_sync_jobs_on_startup() _fail_stale_running_sync_jobs_on_startup()
db = SessionLocal()
try:
from .collection_recovery import recover_collection_jobs_on_startup
resumed = recover_collection_jobs_on_startup(db)
if resumed:
_schedule_log.info("startup: resumed %s pending ne collection runs", resumed)
except Exception:
_schedule_log.exception("startup: ne collection job recovery failed")
finally:
db.close()
# Best-effort schema evolution for new columns (no migrations framework). # Best-effort schema evolution for new columns (no migrations framework).
# Safe for Postgres (IF NOT EXISTS); ignored on failure. # Safe for Postgres (IF NOT EXISTS); ignored on failure.
try: try:
@ -730,6 +740,13 @@ def on_startup() -> None:
conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS user_label") conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS user_label")
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS ne_name") conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS ne_name")
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS user_label") conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS user_label")
conn.exec_driver_sql(
"ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP"
)
conn.exec_driver_sql(
"UPDATE ne_collection_job SET last_run_at = COALESCE(ended_at, started_at, created_at) "
"WHERE last_run_at IS NULL"
)
conn.exec_driver_sql("COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'") conn.exec_driver_sql("COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'")

View file

@ -0,0 +1,92 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy.orm import Session
from .db import get_db
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS
from .ne_connect import schedule_connect_tests
from .ne_crypto import credentials_configured
from .ne_schemas import ConnectTestRequest, ManagedNeCreate, ManagedNeUpdate
from .ne_service import (
create_managed_ne,
delete_managed_ne,
get_managed_ne,
import_managed_ne,
list_managed_ne,
update_managed_ne,
)
from .models import ManagedNE
router = APIRouter(prefix="/v1/managed-ne", tags=["managed-ne"])
@router.get("")
def api_list_managed_ne(
keyword: str | None = Query(default=None),
vendor: str | None = Query(default=None),
connect_status: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
db: Session = Depends(get_db),
):
return list_managed_ne(
db,
keyword=keyword,
vendor=vendor,
connect_status=connect_status,
page=page,
page_size=page_size,
)
@router.get("/meta/device-types")
def api_device_types():
return {"device_types": list(SUPPORTED_DEVICE_TYPES), "vendors": list(SUPPORTED_VENDORS)}
@router.get("/meta/credentials-configured")
def api_credentials_configured():
return {"configured": credentials_configured()}
@router.post("")
def api_create_managed_ne(body: ManagedNeCreate, db: Session = Depends(get_db)):
return create_managed_ne(db, body).model_dump()
@router.get("/{ne_id}")
def api_get_managed_ne(ne_id: str, db: Session = Depends(get_db)):
return get_managed_ne(db, ne_id).model_dump()
@router.patch("/{ne_id}")
def api_update_managed_ne(ne_id: str, body: ManagedNeUpdate, db: Session = Depends(get_db)):
return update_managed_ne(db, ne_id, body).model_dump()
@router.delete("/{ne_id}")
def api_delete_managed_ne(ne_id: str, db: Session = Depends(get_db)):
return delete_managed_ne(db, ne_id)
@router.post("/import")
async def api_import_managed_ne(file: UploadFile = File(...), db: Session = Depends(get_db)):
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="empty_file")
return import_managed_ne(db, content, file.filename or "import.xlsx").model_dump()
@router.post("/connect-test")
def api_connect_test(body: ConnectTestRequest, db: Session = Depends(get_db)):
ids = [str(x).strip() for x in body.ids if str(x).strip()]
if not ids:
raise HTTPException(status_code=400, detail="ids_required")
rows = db.query(ManagedNE).filter(ManagedNE.id.in_(ids)).all()
found_ids = {str(r.id) for r in rows}
missing = [x for x in ids if x not in found_ids]
if missing:
raise HTTPException(status_code=404, detail=f"managed_ne_not_found: {','.join(missing[:5])}")
submitted = schedule_connect_tests(ids)
return {"ok": True, "submitted": submitted}

View file

@ -221,3 +221,64 @@ class UmeTokenCache(Base):
lock_owner: Mapped[str] = mapped_column(String(128), default="", index=True) lock_owner: Mapped[str] = mapped_column(String(128), default="", index=True)
lock_expires_at_epoch_s: Mapped[int] = mapped_column(Integer, default=0, index=True) lock_expires_at_epoch_s: Mapped[int] = mapped_column(Integer, default=0, index=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class ManagedNE(Base):
"""Locally managed network element (SSH/Telnet), independent of UME inventory."""
__tablename__ = "managed_ne"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
name: Mapped[str] = mapped_column(String(256), default="", index=True)
vendor: Mapped[str] = mapped_column(String(64), default="Other", index=True)
device_type: Mapped[str] = mapped_column(String(128), default="")
ip_address: Mapped[str] = mapped_column(String(128), unique=True, index=True)
port: Mapped[int] = mapped_column(Integer, default=22)
protocol: Mapped[str] = mapped_column(String(16), default="ssh")
username: Mapped[str] = mapped_column(String(128), default="")
password_enc: Mapped[str] = mapped_column(Text, default="")
enable_secret_enc: Mapped[str] = mapped_column(Text, default="")
connect_status: Mapped[str] = mapped_column(String(32), default="unknown", index=True)
connect_message: Mapped[str] = mapped_column(String(512), default="")
connect_tested_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
site: Mapped[str] = mapped_column(String(256), default="")
tags: Mapped[str] = mapped_column(String(512), default="")
remark: Mapped[str] = mapped_column(String(1024), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class NeCollectionJob(Base):
"""Batch CLI collection job over managed NEs."""
__tablename__ = "ne_collection_job"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
title: Mapped[str] = mapped_column(String(256), default="")
commands: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
ne_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)
error_message: Mapped[str] = mapped_column(String(1024), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True)
class NeCollectionRun(Base):
"""Per-NE execution within a collection job."""
__tablename__ = "ne_collection_run"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
job_id: Mapped[str] = mapped_column(String(64), index=True)
ne_id: Mapped[str] = mapped_column(String(64), index=True)
ne_name: Mapped[str] = mapped_column(String(256), default="")
ne_ip: Mapped[str] = mapped_column(String(128), default="")
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
message: Mapped[str] = mapped_column(String(1024), default="")
output_rel_path: 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)

View file

@ -0,0 +1,174 @@
from __future__ import annotations
import logging
import re
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from datetime import datetime
from pathlib import Path
from typing import Any
from netmiko import ConnectHandler
from .collection_job_state import finalize_collection_job
from .config import settings
from .db import SessionLocal
from .models import ManagedNE, NeCollectionJob, NeCollectionRun
from .ne_collection_paths import clear_run_output_files, run_output_dir
from .ne_crypto import CredentialCryptoError
from .ne_netmiko import normalize_netmiko_device_type
from .ne_service import get_device_credentials
_log = logging.getLogger("netx.ne.collect")
_executor: ThreadPoolExecutor | None = None
def _executor_pool() -> ThreadPoolExecutor:
global _executor
if _executor is None:
workers = max(1, int(settings.ne_collect_max_workers or 5))
_executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="ne-collect")
return _executor
def _safe_filename_part(text: str) -> str:
s = re.sub(r'[<>:"/\\|?*]', "_", str(text or "").strip())
return s[:80] or "device"
def _collect_on_device(creds: dict[str, Any], commands: list[str]) -> str:
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
per_cmd = int(settings.ne_collect_read_timeout_sec or 120)
dev: dict[str, Any] = {
"device_type": device_type,
"host": creds["ip_address"],
"username": creds["username"],
"password": creds["password"],
"port": int(creds["port"] or 22),
"conn_timeout": int(settings.ne_connect_timeout_sec or 30),
"auth_timeout": int(settings.ne_connect_timeout_sec or 30),
"banner_timeout": int(settings.ne_connect_timeout_sec or 30),
"session_timeout": per_cmd * max(1, len(commands)) + 60,
}
secret = str(creds.get("enable_secret") or "").strip()
if secret:
dev["secret"] = secret
chunks: list[str] = []
with ConnectHandler(**dev) as conn:
prompt = str(conn.find_prompt() or "")
for command in commands:
ts = datetime.now().isoformat(timespec="seconds")
chunks.append(f'>>> [{ts}] {{"String":"{command}", "Match":"{prompt}", "Timeout":0}}\n')
out = conn.send_command(command_string=command, read_timeout=per_cmd)
chunks.append(str(out or ""))
chunks.append("\n")
return "".join(chunks)
def _collect_with_timeout(creds: dict[str, Any], commands: list[str]) -> 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_on_device, creds, commands)
try:
return fut.result(timeout=budget)
except FuturesTimeout as exc:
raise TimeoutError(f"collection_timeout ({budget}s)") from exc
def _update_run(run_id: str, **fields: Any) -> None:
db = SessionLocal()
try:
row = db.get(NeCollectionRun, run_id)
if not row:
return
for key, val in fields.items():
setattr(row, key, val)
db.commit()
finally:
db.close()
def _collection_aborted(job_id: str, run_id: str) -> bool:
db = SessionLocal()
try:
job = db.get(NeCollectionJob, job_id)
run = db.get(NeCollectionRun, run_id)
if not job or not run:
return True
if str(job.status or "") == "paused":
if str(run.status or "") == "pending":
run.status = "cancelled"
run.message = "paused"
run.ended_at = datetime.now()
db.commit()
return True
if str(run.status or "") in ("cancelled", "success", "fail"):
return True
return False
finally:
db.close()
def _run_single(job_id: str, run_id: str, commands: list[str]) -> None:
if _collection_aborted(job_id, run_id):
db = SessionLocal()
try:
finalize_collection_job(db, job_id)
finally:
db.close()
return
db = SessionLocal()
try:
run = db.get(NeCollectionRun, run_id)
if not run:
return
ne = db.get(ManagedNE, str(run.ne_id))
if not ne:
_update_run(run_id, status="fail", message="managed_ne_not_found", ended_at=datetime.now())
return
started_at = datetime.now()
_update_run(run_id, status="running", message="collecting", started_at=started_at)
try:
creds = get_device_credentials(ne)
output = _collect_with_timeout(creds, commands)
finished_at = datetime.now()
name_part = _safe_filename_part(str(run.ne_name or creds.get("name") or "ne"))
ip_part = _safe_filename_part(str(run.ne_ip or creds.get("ip_address") or "ip"))
ts = finished_at.strftime("%Y%m%d%H%M%S")
rel_dir = Path(job_id) / run_id
out_dir = run_output_dir(job_id, run_id)
clear_run_output_files(job_id, run_id)
out_dir.mkdir(parents=True, exist_ok=True)
filename = f"{name_part}-{ip_part}-{ts}.txt"
full_path = out_dir / filename
full_path.write_text(output, encoding="utf-8", errors="replace")
rel_path = str(rel_dir / filename).replace("\\", "/")
_update_run(
run_id,
status="success",
message="collected",
output_rel_path=rel_path,
ended_at=finished_at,
)
except CredentialCryptoError as exc:
_update_run(run_id, status="fail", message=str(exc)[:1000], ended_at=datetime.now())
except Exception as exc:
_log.exception("collection failed run=%s", run_id)
_update_run(run_id, status="fail", message=str(exc).split("\n")[0][:1000], ended_at=datetime.now())
finally:
db.close()
db2 = SessionLocal()
try:
finalize_collection_job(db2, job_id)
finally:
db2.close()
def schedule_collection_runs(job_id: str, run_ids: list[str], commands: list[str]) -> int:
pool = _executor_pool()
submitted = 0
for run_id in run_ids:
pool.submit(_run_single, job_id, run_id, list(commands))
submitted += 1
return submitted

View file

@ -0,0 +1,26 @@
from __future__ import annotations
from pathlib import Path
from .config import settings
def collection_data_root() -> Path:
root = Path(str(settings.ne_collection_data_dir or "data/ne_collections"))
root.mkdir(parents=True, exist_ok=True)
return root.resolve()
def run_output_dir(job_id: str, run_id: str) -> Path:
return collection_data_root() / job_id / run_id
def clear_run_output_files(job_id: str, run_id: str) -> None:
"""Remove prior log files for this run so re-collection overwrites in place."""
out_dir = run_output_dir(job_id, run_id).resolve()
root = collection_data_root()
if not str(out_dir).startswith(str(root)) or not out_dir.is_dir():
return
for path in out_dir.iterdir():
if path.is_file():
path.unlink(missing_ok=True)

193
netx_api/ne_connect.py Normal file
View file

@ -0,0 +1,193 @@
from __future__ import annotations
import logging
import re
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from typing import Any
from netmiko import ConnectHandler
from .config import settings
from .db import SessionLocal
from .models import ManagedNE
from .ne_crypto import CredentialCryptoError, decrypt_secret
from .ne_netmiko import normalize_netmiko_device_type
from .ne_service import get_device_credentials
_log = logging.getLogger("netx.ne.connect")
_executor: ThreadPoolExecutor | None = None
def _executor_pool() -> ThreadPoolExecutor:
global _executor
if _executor is None:
workers = max(1, int(settings.ne_connect_max_workers or 5))
_executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="ne-connect")
return _executor
def hostname_probe_command(device_type: str, vendor: str) -> str | None:
"""
Per-vendor CLI to read system name (ported from legacy connect.extract_dev_command).
ZTE: rely on login prompt / empty command path.
"""
dt = str(device_type or "").lower()
v = str(vendor or "").lower()
if "huawei" in dt or v == "huawei":
return "display current-configuration | include sysname"
if "juniper" in dt or v == "juniper":
return "show system host-name"
if "cisco" in dt or v == "cisco":
return "show hostname"
return None
def parse_hostname_from_output(
device_type: str,
vendor: str,
output: str,
prompt: str = "",
) -> str | None:
"""
Parse device name from command output or prompt (legacy connect.extract_hostname).
"""
dt = str(device_type or "").lower()
v = str(vendor or "").lower()
text = str(output or "")
if "huawei" in dt or v == "huawei":
m = re.search(r"sysname\s+(\S+)", text, re.IGNORECASE)
if m:
return m.group(1).strip()
if "juniper" in dt or v == "juniper":
m = re.search(r"host-name\s+(\S+)", text, re.IGNORECASE)
if m:
return m.group(1).strip().rstrip(";")
m = re.search(r"^\s*name\s+(\S+)", text, re.IGNORECASE | re.MULTILINE)
if m:
return m.group(1).strip().rstrip(";")
if "cisco" in dt or v == "cisco":
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
for ln in reversed(lines):
if ln.startswith("%") or "invalid" in ln.lower():
continue
token = ln.split()[0].strip("<>[]")
if token:
return token
if "zte" in dt or v == "zte":
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
if lines:
last = lines[-1].strip()
if last and len(last) <= 128 and not last.startswith("%"):
return last
cleaned = _clean_prompt_hostname(prompt)
if cleaned:
return cleaned
return None
def _clean_prompt_hostname(prompt: str) -> str | None:
p = str(prompt or "").strip()
if not p:
return None
p = re.sub(r"[\s#>$]+\s*$", "", p).strip()
p = re.sub(r"^[<\[]|[>\]]$", "", p).strip()
if not p or p.lower() in (">", "#"):
return None
return p[:256]
def _probe_device(creds: dict[str, Any]) -> tuple[str, str, str | None]:
"""Login via Netmiko, probe hostname, return (status, message, discovered_name)."""
device_type = normalize_netmiko_device_type(creds["device_type"], creds["protocol"])
vendor = str(creds.get("vendor") or "")
dev: dict[str, Any] = {
"device_type": device_type,
"host": creds["ip_address"],
"username": creds["username"],
"password": creds["password"],
"port": int(creds["port"] or 22),
"conn_timeout": int(settings.ne_connect_timeout_sec or 30),
"auth_timeout": int(settings.ne_connect_timeout_sec or 30),
"banner_timeout": int(settings.ne_connect_timeout_sec or 30),
}
secret = str(creds.get("enable_secret") or "").strip()
if secret:
dev["secret"] = secret
try:
with ConnectHandler(**dev) as conn:
prompt = str(conn.find_prompt() or "")
command = hostname_probe_command(creds["device_type"], vendor)
output = ""
if command:
output = conn.send_command(command_string=command, read_timeout=30)
hostname = parse_hostname_from_output(creds["device_type"], vendor, output, prompt)
if hostname:
return "pass", f"connected: {hostname}", hostname
if command:
return "pass", "connected (hostname not parsed)", None
fallback = _clean_prompt_hostname(prompt)
if fallback:
return "pass", f"connected: {fallback}", fallback
return "pass", "connected", None
except Exception as exc:
msg = str(exc).split("\n")[0][:480]
return "fail", msg, None
def _update_row(ne_id: str, status: str, message: str, discovered_name: str | None = None) -> None:
db = SessionLocal()
try:
row = db.get(ManagedNE, ne_id)
if not row:
return
row.connect_status = status
row.connect_message = str(message or "")[:500]
row.connect_tested_at = datetime.utcnow()
if discovered_name:
row.name = discovered_name[:256]
row.updated_at = datetime.utcnow()
db.commit()
finally:
db.close()
def _run_single(ne_id: str) -> None:
db = SessionLocal()
try:
row = db.get(ManagedNE, ne_id)
if not row:
return
row.connect_status = "testing"
row.connect_message = ""
row.updated_at = datetime.utcnow()
db.commit()
try:
creds = get_device_credentials(row)
except CredentialCryptoError as exc:
_update_row(ne_id, "fail", str(exc))
return
status, message, discovered = _probe_device(creds)
_update_row(ne_id, status, message, discovered)
except Exception as exc:
_log.exception("connect test failed for %s", ne_id)
_update_row(ne_id, "fail", str(exc)[:480])
finally:
db.close()
def schedule_connect_tests(ne_ids: list[str]) -> int:
pool = _executor_pool()
submitted = 0
for ne_id in ne_ids:
ne_id = str(ne_id or "").strip()
if not ne_id:
continue
pool.submit(_run_single, ne_id)
submitted += 1
return submitted

40
netx_api/ne_crypto.py Normal file
View file

@ -0,0 +1,40 @@
from __future__ import annotations
from cryptography.fernet import Fernet, InvalidToken
from .config import settings
class CredentialCryptoError(RuntimeError):
pass
def _fernet() -> Fernet:
key = str(settings.credential_secret_key or "").strip()
if not key:
raise CredentialCryptoError("credential_secret_key_not_configured")
try:
return Fernet(key.encode("ascii"))
except Exception as exc:
raise CredentialCryptoError("credential_secret_key_invalid") from exc
def encrypt_secret(value: str) -> str:
plain = str(value or "")
if not plain:
return ""
return _fernet().encrypt(plain.encode("utf-8")).decode("ascii")
def decrypt_secret(value: str) -> str:
enc = str(value or "").strip()
if not enc:
return ""
try:
return _fernet().decrypt(enc.encode("ascii")).decode("utf-8")
except InvalidToken as exc:
raise CredentialCryptoError("credential_decrypt_failed") from exc
def credentials_configured() -> bool:
return bool(str(settings.credential_secret_key or "").strip())

17
netx_api/ne_netmiko.py Normal file
View file

@ -0,0 +1,17 @@
"""Shared Netmiko helpers for managed NE connect/collect."""
from __future__ import annotations
def normalize_netmiko_device_type(device_type: str, protocol: str) -> str:
dt = str(device_type or "").strip()
proto = str(protocol or "ssh").strip().lower()
if "zte" in dt.lower():
if dt == "zte":
return f"zte_zxros_{proto}"
if "telnet" not in dt and "ssh" not in dt:
return f"{dt}_{proto}"
return dt
if "telnet" not in dt and "ssh" not in dt:
return f"{dt}_{proto}"
return dt

93
netx_api/ne_schemas.py Normal file
View file

@ -0,0 +1,93 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, field_validator
from .device_types import SUPPORTED_VENDORS
ConnectStatus = Literal["unknown", "testing", "pass", "fail"]
class ManagedNeCreate(BaseModel):
name: str = ""
vendor: str
device_type: str
ip_address: str
port: int = 22
protocol: str = "ssh"
username: str
password: str
tags: str = ""
remark: str = ""
@field_validator("vendor")
@classmethod
def normalize_vendor(cls, v: str) -> str:
raw = str(v or "").strip()
if not raw:
raise ValueError("vendor_required")
for item in SUPPORTED_VENDORS:
if item.lower() == raw.lower():
return item
return "Other"
class ManagedNeUpdate(BaseModel):
name: str | None = None
vendor: str | None = None
device_type: str | None = None
ip_address: str | None = None
port: int | None = None
protocol: str | None = None
username: str | None = None
password: str | None = None
tags: str | None = None
remark: str | None = None
@field_validator("vendor")
@classmethod
def normalize_vendor_update(cls, v: str | None) -> str | None:
if v is None:
return None
raw = str(v).strip()
if not raw:
raise ValueError("vendor_required")
for item in SUPPORTED_VENDORS:
if item.lower() == raw.lower():
return item
return "Other"
class ManagedNeOut(BaseModel):
id: str
name: str
vendor: str
device_type: str
ip_address: str
port: int
protocol: str
username: str
connect_status: ConnectStatus
connect_message: str
connect_tested_at: datetime | None
tags: str
remark: str
created_at: datetime
updated_at: datetime
class ConnectTestRequest(BaseModel):
ids: list[str] = Field(min_length=1)
class ImportFailure(BaseModel):
row: int
reason: str
class ImportResult(BaseModel):
inserted: int
updated: int
failed: list[ImportFailure]

280
netx_api/ne_service.py Normal file
View file

@ -0,0 +1,280 @@
from __future__ import annotations
from datetime import datetime
from io import BytesIO
from typing import Any
import pandas as pd
from fastapi import HTTPException
from sqlalchemy.orm import Session
from .device_types import SUPPORTED_DEVICE_TYPES, SUPPORTED_VENDORS
from .models import ManagedNE
from .ne_crypto import CredentialCryptoError, credentials_configured, decrypt_secret, encrypt_secret
from .ne_schemas import ImportFailure, ImportResult, ManagedNeCreate, ManagedNeOut, ManagedNeUpdate
IMPORT_COLUMNS = (
"device_type",
"ip",
"username",
"password",
"port",
"protocol",
"name",
"vendor",
)
def _now() -> datetime:
return datetime.utcnow()
def _require_crypto() -> None:
if not credentials_configured():
raise HTTPException(status_code=503, detail="credential_secret_key_not_configured")
def _normalize_ip(ip: str) -> str:
return str(ip or "").strip()
def _normalize_protocol(protocol: str) -> str:
p = str(protocol or "ssh").strip().lower()
return p if p in ("ssh", "telnet") else "ssh"
def row_to_out(row: ManagedNE) -> ManagedNeOut:
status = str(row.connect_status or "unknown")
if status not in ("unknown", "testing", "pass", "fail"):
status = "unknown"
return ManagedNeOut(
id=str(row.id),
name=str(row.name or ""),
vendor=str(row.vendor or "Other"),
device_type=str(row.device_type or ""),
ip_address=str(row.ip_address or ""),
port=int(row.port or 22),
protocol=str(row.protocol or "ssh"),
username=str(row.username or ""),
connect_status=status, # type: ignore[arg-type]
connect_message=str(row.connect_message or "")[:500],
connect_tested_at=row.connect_tested_at,
tags=str(row.tags or ""),
remark=str(row.remark or ""),
created_at=row.created_at,
updated_at=row.updated_at,
)
def list_managed_ne(
db: Session,
*,
keyword: str | None = None,
vendor: str | None = None,
connect_status: str | None = None,
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
stmt = db.query(ManagedNE)
kw = str(keyword or "").strip()
if kw:
stmt = stmt.filter(
ManagedNE.name.contains(kw)
| ManagedNE.ip_address.contains(kw)
| ManagedNE.username.contains(kw)
| ManagedNE.tags.contains(kw)
)
v = str(vendor or "").strip()
if v:
stmt = stmt.filter(ManagedNE.vendor == v)
cs = str(connect_status or "").strip()
if cs:
stmt = stmt.filter(ManagedNE.connect_status == cs)
total = int(stmt.count())
rows = (
stmt.order_by(ManagedNE.updated_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [row_to_out(x).model_dump() for x in rows],
}
def get_managed_ne(db: Session, ne_id: str) -> ManagedNeOut:
row = db.get(ManagedNE, ne_id)
if not row:
raise HTTPException(status_code=404, detail="managed_ne_not_found")
return row_to_out(row)
def create_managed_ne(db: Session, body: ManagedNeCreate) -> ManagedNeOut:
_require_crypto()
ip = _normalize_ip(body.ip_address)
if not ip:
raise HTTPException(status_code=400, detail="ip_address_required")
if body.device_type not in SUPPORTED_DEVICE_TYPES:
raise HTTPException(status_code=400, detail="unsupported_device_type")
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
if existing:
raise HTTPException(status_code=400, detail="ip_address_exists")
now = _now()
row = ManagedNE(
name=str(body.name or "").strip() or ip,
vendor=body.vendor,
device_type=body.device_type,
ip_address=ip,
port=int(body.port or 22),
protocol=_normalize_protocol(body.protocol),
username=str(body.username or "").strip(),
password_enc=encrypt_secret(body.password),
enable_secret_enc="",
connect_status="unknown",
tags=str(body.tags or "").strip(),
remark=str(body.remark or "").strip(),
created_at=now,
updated_at=now,
)
db.add(row)
db.commit()
db.refresh(row)
return row_to_out(row)
def update_managed_ne(db: Session, ne_id: str, body: ManagedNeUpdate) -> ManagedNeOut:
row = db.get(ManagedNE, ne_id)
if not row:
raise HTTPException(status_code=404, detail="managed_ne_not_found")
data = body.model_dump(exclude_unset=True)
if "ip_address" in data:
ip = _normalize_ip(data["ip_address"])
if not ip:
raise HTTPException(status_code=400, detail="ip_address_required")
other = db.query(ManagedNE).filter(ManagedNE.ip_address == ip, ManagedNE.id != ne_id).first()
if other:
raise HTTPException(status_code=400, detail="ip_address_exists")
row.ip_address = ip
if "device_type" in data:
if data["device_type"] not in SUPPORTED_DEVICE_TYPES:
raise HTTPException(status_code=400, detail="unsupported_device_type")
row.device_type = data["device_type"]
if "vendor" in data:
v = str(data["vendor"] or "").strip()
row.vendor = v if v in SUPPORTED_VENDORS else "Other"
if "name" in data:
row.name = str(data["name"] or "").strip()
if "port" in data and data["port"] is not None:
row.port = int(data["port"])
if "protocol" in data and data["protocol"] is not None:
row.protocol = _normalize_protocol(data["protocol"])
if "username" in data and data["username"] is not None:
row.username = str(data["username"]).strip()
if "tags" in data and data["tags"] is not None:
row.tags = str(data["tags"]).strip()
if "remark" in data and data["remark"] is not None:
row.remark = str(data["remark"]).strip()
if "password" in data and data["password"]:
_require_crypto()
row.password_enc = encrypt_secret(str(data["password"]))
row.updated_at = _now()
db.commit()
db.refresh(row)
return row_to_out(row)
def delete_managed_ne(db: Session, ne_id: str) -> dict[str, bool]:
row = db.get(ManagedNE, ne_id)
if not row:
raise HTTPException(status_code=404, detail="managed_ne_not_found")
db.delete(row)
db.commit()
return {"ok": True}
def import_managed_ne(db: Session, content: bytes, filename: str) -> ImportResult:
_require_crypto()
name = str(filename or "").lower()
try:
if name.endswith(".csv"):
df = pd.read_csv(BytesIO(content))
else:
df = pd.read_excel(BytesIO(content))
except Exception as exc:
raise HTTPException(status_code=400, detail=f"import_parse_failed: {exc}") from exc
df.columns = [str(c).strip().lower() for c in df.columns]
missing = [c for c in IMPORT_COLUMNS if c not in df.columns]
if missing:
raise HTTPException(status_code=400, detail=f"import_missing_columns: {','.join(missing)}")
inserted = 0
updated = 0
failed: list[ImportFailure] = []
for idx, row in df.iterrows():
row_no = int(idx) + 2
try:
ip = _normalize_ip(str(row.get("ip", "")))
if not ip:
failed.append(ImportFailure(row=row_no, reason="ip_required"))
continue
device_type = str(row.get("device_type", "")).strip()
if device_type not in SUPPORTED_DEVICE_TYPES:
failed.append(ImportFailure(row=row_no, reason="unsupported_device_type"))
continue
username = str(row.get("username", "")).strip()
password = str(row.get("password", "")).strip()
if not username or not password:
failed.append(ImportFailure(row=row_no, reason="username_password_required"))
continue
port_raw = row.get("port", 22)
try:
port = int(port_raw)
except (TypeError, ValueError):
port = 22
protocol = _normalize_protocol(str(row.get("protocol", "ssh")))
display_name = str(row.get("name", "") or "").strip() or ip
vendor_raw = str(row.get("vendor", "") or "Other").strip()
vendor = "Other"
for v in SUPPORTED_VENDORS:
if v.lower() == vendor_raw.lower():
vendor = v
break
existing = db.query(ManagedNE).filter(ManagedNE.ip_address == ip).first()
now = _now()
if existing is None:
existing = ManagedNE(ip_address=ip, created_at=now)
db.add(existing)
inserted += 1
else:
updated += 1
existing.name = display_name
existing.vendor = vendor
existing.device_type = device_type
existing.port = port
existing.protocol = protocol
existing.username = username
existing.password_enc = encrypt_secret(password)
existing.updated_at = now
except CredentialCryptoError as exc:
failed.append(ImportFailure(row=row_no, reason=str(exc)))
except Exception as exc:
failed.append(ImportFailure(row=row_no, reason=str(exc)[:200]))
db.commit()
return ImportResult(inserted=inserted, updated=updated, failed=failed)
def get_device_credentials(row: ManagedNE) -> dict[str, Any]:
return {
"id": str(row.id),
"vendor": str(row.vendor or ""),
"device_type": str(row.device_type or ""),
"ip_address": str(row.ip_address or ""),
"port": int(row.port or 22),
"protocol": str(row.protocol or "ssh"),
"username": str(row.username or ""),
"password": decrypt_secret(row.password_enc),
"enable_secret": decrypt_secret(row.enable_secret_enc),
"name": str(row.name or ""),
}

View file

@ -10,3 +10,5 @@ pydantic>=2.8.0
pydantic-settings>=2.3.0 pydantic-settings>=2.3.0
python-multipart>=0.0.9 python-multipart>=0.0.9
websocket-client>=1.8.0 websocket-client>=1.8.0
cryptography>=42.0.0
netmiko>=4.3.0

View file

@ -0,0 +1,35 @@
from __future__ import annotations
import unittest
from datetime import datetime
from unittest.mock import MagicMock
from netx_api.collection_job_state import finalize_collection_job
class CollectionJobFinalizeTests(unittest.TestCase):
def test_paused_job_stays_paused_when_all_runs_terminal(self):
job = MagicMock()
job.status = "paused"
job.ended_at = None
job.success_count = 0
job.fail_count = 0
run_ok = MagicMock(status="success")
run_cancel = MagicMock(status="cancelled")
db = MagicMock()
db.query.return_value.filter.return_value.all.return_value = [run_ok, run_cancel]
db.get.return_value = job
finalize_collection_job(db, "job-1")
self.assertEqual(job.status, "paused")
self.assertEqual(job.success_count, 1)
self.assertEqual(job.fail_count, 1)
self.assertIsNotNone(job.ended_at)
db.commit.assert_called_once()
if __name__ == "__main__":
unittest.main()

175
tests/test_managed_ne.py Normal file
View file

@ -0,0 +1,175 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from netx_api.config import settings
from netx_api.db import Base, get_db
from netx_api.main import app
from netx_api.models import ManagedNE # noqa: F401 — register table on Base
from netx_api.ne_connect import hostname_probe_command, parse_hostname_from_output
from netx_api.ne_crypto import decrypt_secret, encrypt_secret
from netx_api.ne_service import create_managed_ne, import_managed_ne
from netx_api.ne_schemas import ManagedNeCreate
class ManagedNeHostnameParseTests(unittest.TestCase):
def test_huawei_sysname(self):
out = " sysname PE-CORE-01\n"
self.assertEqual(parse_hostname_from_output("huawei", "Huawei", out), "PE-CORE-01")
def test_juniper_hostname(self):
out = "host-name ROUTER-A;\nname ROUTER-A\n"
self.assertEqual(parse_hostname_from_output("juniper", "Juniper", out), "ROUTER-A")
def test_zte_last_line(self):
out = "line1\nZXR10-PE1#"
self.assertEqual(parse_hostname_from_output("zte_zxros", "ZTE", out), "ZXR10-PE1#")
def test_probe_commands(self):
self.assertIn("sysname", hostname_probe_command("huawei", "Huawei") or "")
self.assertEqual(hostname_probe_command("zte_zxros", "ZTE"), None)
class ManagedNeCryptoTests(unittest.TestCase):
def setUp(self):
self._orig = settings.credential_secret_key
settings.credential_secret_key = Fernet.generate_key().decode()
def tearDown(self):
settings.credential_secret_key = self._orig
def test_encrypt_roundtrip(self):
enc = encrypt_secret("secret-pass")
self.assertEqual(decrypt_secret(enc), "secret-pass")
class ManagedNeApiTests(unittest.TestCase):
def setUp(self):
self._orig_key = settings.credential_secret_key
settings.credential_secret_key = Fernet.generate_key().decode()
self.engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
ManagedNE.__table__.create(bind=self.engine, checkfirst=True)
self.Session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False)
def override_get_db():
db = self.Session()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
self._session_patch = patch("netx_api.ne_connect.SessionLocal", self.Session)
self._session_patch.start()
self.client = TestClient(app)
def tearDown(self):
app.dependency_overrides.clear()
self._session_patch.stop()
settings.credential_secret_key = self._orig_key
def test_crud_flow(self):
r = self.client.post(
"/v1/managed-ne",
json={
"name": "PE-01",
"vendor": "ZTE",
"device_type": "zte_zxros",
"ip_address": "10.0.0.1",
"port": 22,
"protocol": "ssh",
"username": "admin",
"password": "pass123",
},
)
self.assertEqual(r.status_code, 200, r.text)
ne_id = r.json()["id"]
self.assertNotIn("password", r.json())
r2 = self.client.get(f"/v1/managed-ne/{ne_id}")
self.assertEqual(r2.status_code, 200)
self.assertEqual(r2.json()["ip_address"], "10.0.0.1")
r3 = self.client.patch(f"/v1/managed-ne/{ne_id}", json={"name": "PE-01-upd"})
self.assertEqual(r3.status_code, 200)
self.assertEqual(r3.json()["name"], "PE-01-upd")
r4 = self.client.get("/v1/managed-ne", params={"keyword": "10.0.0"})
self.assertEqual(r4.status_code, 200)
self.assertEqual(r4.json()["total"], 1)
r5 = self.client.delete(f"/v1/managed-ne/{ne_id}")
self.assertEqual(r5.status_code, 200)
def test_create_without_crypto_key(self):
settings.credential_secret_key = ""
r = self.client.post(
"/v1/managed-ne",
json={
"vendor": "ZTE",
"device_type": "zte_zxros",
"ip_address": "10.0.0.2",
"username": "admin",
"password": "x",
},
)
self.assertEqual(r.status_code, 503, r.text)
@patch("netx_api.ne_connect._probe_device", return_value=("pass", "ok", None))
def test_connect_test(self, _mock_probe):
r = self.client.post(
"/v1/managed-ne",
json={
"vendor": "Huawei",
"device_type": "huawei",
"ip_address": "10.0.0.3",
"username": "admin",
"password": "pass",
},
)
ne_id = r.json()["id"]
r2 = self.client.post("/v1/managed-ne/connect-test", json={"ids": [ne_id]})
self.assertEqual(r2.status_code, 200)
self.assertEqual(r2.json()["submitted"], 1)
class ManagedNeServiceImportTests(unittest.TestCase):
def setUp(self):
self._orig = settings.credential_secret_key
settings.credential_secret_key = Fernet.generate_key().decode()
self.engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
ManagedNE.__table__.create(bind=self.engine, checkfirst=True)
self.db = sessionmaker(bind=self.engine)()
def tearDown(self):
self.db.close()
settings.credential_secret_key = self._orig
def test_csv_import(self):
csv = (
"device_type,ip,username,password,port,protocol,name,vendor\n"
"zte_zxros,10.1.1.1,u1,p1,22,ssh,NE-A,ZTE\n"
).encode("utf-8")
result = import_managed_ne(self.db, csv, "devices.csv")
self.assertEqual(result.inserted, 1)
self.assertEqual(result.updated, 0)
self.assertEqual(len(result.failed), 0)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,15 @@
from __future__ import annotations
import unittest
from netx_api.collection_service import _parse_commands
class NeCollectionParseTests(unittest.TestCase):
def test_parse_commands_skips_comments(self):
cmds = _parse_commands("display version\n# comment\ndisplay ip int brief\n")
self.assertEqual(cmds, ["display version", "display ip int brief"])
if __name__ == "__main__":
unittest.main()

View file

@ -33,6 +33,8 @@ src/
|------|------|----------| |------|------|----------|
| `/` | 工作台 | — | | `/` | 工作台 | — |
| `/ume` | UME 同步 | `ume` | | `/ume` | UME 同步 | `ume` |
| `/ne` | 网元管理 | `managed-ne` |
| `/collect` | 批量采集 | `collect` |
**新增模块只需改 `config/modules.ts`:** **新增模块只需改 `config/modules.ts`:**
@ -60,6 +62,19 @@ src/
- 失效缓存用 prefix key(如 `queryKeys.umeSyncStatusAll`) - 失效缓存用 prefix key(如 `queryKeys.umeSyncStatusAll`)
- 顶栏连接状态:`App` 轮询 `GET /v1/integrations/status`(5s),展示 **netx api** 与 **oclaw bridge**(含延迟 / 错误类型) - 顶栏连接状态:`App` 轮询 `GET /v1/integrations/status`(5s),展示 **netx api** 与 **oclaw bridge**(含延迟 / 错误类型)
## 网元管理(独立于 UME)
- API:`/v1/managed-ne/*`(CRUD、导入、连通性测试)
- 环境变量:`NETX_CREDENTIAL_SECRET_KEY`(Fernet,用于加密存储 SSH 密码)
- 导入列:`device_type,ip,username,password,port,protocol,name,vendor`
- 连通性测试成功后会**始终**用探测到的设备名覆盖「名称」
## 批量采集
- API:`/v1/ne-collections/*`(仅 `connect_status=pass` 的网元可参与)
- 采集日志目录:`NETX_NE_COLLECTION_DATA_DIR`(默认 `data/ne_collections`)
- 命令每行一条,`#` 为注释;输出格式与旧版 NetX 采集 `.txt` 一致
## Toast ## Toast
- 使用 `ToastProvider`(`main.tsx`)+ `useToast()` - 使用 `ToastProvider`(`main.tsx`)+ `useToast()`

View file

@ -3,6 +3,8 @@ import { useQuery } from "@tanstack/react-query";
import { AppLayout } from "./layout/AppLayout"; import { AppLayout } from "./layout/AppLayout";
import { queryKeys } from "./constants/queryKeys"; import { queryKeys } from "./constants/queryKeys";
import { WorkbenchPage } from "./pages/WorkbenchPage"; import { WorkbenchPage } from "./pages/WorkbenchPage";
import { CollectPage } from "./pages/CollectPage";
import { NePage } from "./pages/NePage";
import { UmePage } from "./pages/UmePage"; import { UmePage } from "./pages/UmePage";
import { fetchIntegrationStatus } from "./services/api"; import { fetchIntegrationStatus } from "./services/api";
@ -40,6 +42,8 @@ function App() {
<Route path="/" element={<WorkbenchPage />} /> <Route path="/" element={<WorkbenchPage />} />
<Route path="/workbench" element={<Navigate to="/" replace />} /> <Route path="/workbench" element={<Navigate to="/" replace />} />
<Route path="/ume" element={<UmePage />} /> <Route path="/ume" element={<UmePage />} />
<Route path="/ne" element={<NePage />} />
<Route path="/collect" element={<CollectPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</AppLayout> </AppLayout>

View file

@ -3,7 +3,7 @@
*/ */
export type ModuleIconTone = "blue" | "green" | "amber"; export type ModuleIconTone = "blue" | "green" | "amber";
export type WorkbenchSection = "monitoring"; export type WorkbenchSection = "monitoring" | "operations";
export type ModuleDefinition = { export type ModuleDefinition = {
moduleId: string; moduleId: string;
@ -25,6 +25,24 @@ export const MODULES: readonly ModuleDefinition[] = [
iconTone: "blue", iconTone: "blue",
titleKey: "layout.titleUme", titleKey: "layout.titleUme",
}, },
{
moduleId: "managed-ne",
path: "/ne",
section: "operations",
labelKey: "workbench.cards.managedNe",
descKey: "workbench.cards.managedNeDesc",
iconTone: "green",
titleKey: "layout.titleManagedNe",
},
{
moduleId: "collect",
path: "/collect",
section: "operations",
labelKey: "workbench.cards.collect",
descKey: "workbench.cards.collectDesc",
iconTone: "amber",
titleKey: "layout.titleCollect",
},
] as const; ] as const;
export function getModuleById(moduleId: string): ModuleDefinition | undefined { export function getModuleById(moduleId: string): ModuleDefinition | undefined {

View file

@ -10,6 +10,18 @@ export const queryKeys = {
umeCurrentAlarmsAll: ["umeCurrentAlarms"] as const, umeCurrentAlarmsAll: ["umeCurrentAlarms"] as const,
umeSyncStatus: (page: number, pageSize: number) => ["umeSyncStatus", page, pageSize] as const, umeSyncStatus: (page: number, pageSize: number) => ["umeSyncStatus", page, pageSize] as const,
umeNE: (keyword: string, page: number, pageSize: number) => ["umeNE", keyword, page, pageSize] as const, umeNE: (keyword: string, page: number, pageSize: number) => ["umeNE", keyword, page, pageSize] as const,
managedNeMeta: ["managedNeMeta"] as const,
managedNeAll: ["managedNe"] as const,
managedNe: (keyword: string, vendor: string, connectStatus: string, page: number, pageSize: number) =>
["managedNe", keyword, vendor, connectStatus, page, pageSize] as const,
collectionEligibleNeAll: ["collectionEligibleNe"] as const,
collectionEligibleNe: (page: number) => ["collectionEligibleNe", page] as const,
neCollectionsAll: ["neCollections"] as const,
neCollections: (page: number) => ["neCollections", page] as const,
neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const,
neCollectionRunsAll: ["neCollectionRuns"] as const,
neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) =>
["neCollectionRuns", jobId, page, status, keyword] as const,
umeCurrentAlarms: ( umeCurrentAlarms: (
severity: string, severity: string,
cleared: string, cleared: string,

View file

@ -18,14 +18,21 @@ const en = {
openInNewTab: "Open in new tab", openInNewTab: "Open in new tab",
openModule: "Open or switch to this module tab", openModule: "Open or switch to this module tab",
monitoring: "Monitoring", monitoring: "Monitoring",
operations: "Operations",
cards: { cards: {
umeSync: "UME Sync", umeSync: "UME Sync",
umeSyncDesc: "UME alarms, subscription & inventory", umeSyncDesc: "UME alarms, subscription & inventory",
managedNe: "NE Management",
managedNeDesc: "Multi-vendor SSH/Telnet NEs & connectivity test",
collect: "Batch Collect",
collectDesc: "Run custom CLI commands on connected NEs and save logs",
}, },
}, },
layout: { layout: {
title: "UME Integration", title: "UME Integration",
titleUme: "UME Sync", titleUme: "UME Sync",
titleManagedNe: "NE Management",
titleCollect: "Batch Collect",
navUme: "UME", navUme: "UME",
netxApi: "netx api", netxApi: "netx api",
oclawBridge: "oclaw bridge", oclawBridge: "oclaw bridge",
@ -34,6 +41,112 @@ const en = {
langZh: "中文", langZh: "中文",
langEn: "English", langEn: "English",
}, },
collect: {
eligible: {
title: "Eligible NEs (connectivity passed)",
hint: "Only NEs with connect_status=pass. Run connectivity test in NE Management first.",
empty: "No NEs with passed connectivity test.",
},
form: {
title: "Collection setup",
jobTitle: "Job title",
jobTitlePh: "Auto-generated if empty",
commands: "Commands (one per line, # for comments)",
commandsPh: "display version\ndisplay current-configuration",
commandsHint: "Commands run in order on each device; output is saved as a text log (legacy NetX format).",
meta: "{{ne}} NE(s) selected · {{cmd}} command(s)",
start: "Start collection",
starting: "Submitting…",
},
started: "Collection job started: {{id}}",
paused: "Job paused",
restarted: "Collection restarted",
deleted: "Job deleted",
nothingToRetry: "No failed devices to retry",
confirmDelete: "Delete this collection job and its log files?",
jobs: {
title: "Collection jobs",
col: {
title: "Job",
status: "Status",
progress: "Progress",
created: "Created",
lastRun: "Last run",
actions: "Actions",
},
ok: "ok",
fail: "fail",
expand: "Details",
collapse: "Hide",
pause: "Pause",
restart: "Restart",
downloadResults: "Download results",
delete: "Delete",
autoRefresh: "Auto-refresh every 2s while jobs are running",
runsInProgress: "Some devices are still being collected…",
},
runs: {
status: "Status",
message: "Message",
download: "Log",
downloadFile: "Download",
filterStatus: "Status filter",
filterDevice: "Device filter",
deviceKeywordPh: "Name / IP",
allStatus: "All statuses",
},
},
managedNe: {
title: "Network Elements",
credsNotConfigured:
"NETX_CREDENTIAL_SECRET_KEY is not set. Configure a Fernet key in .env and restart the API to store passwords.",
add: "Add NE",
importBtn: "Import",
importing: "Importing…",
edit: "Edit",
delete: "Delete",
confirmDelete: "Delete this network element?",
keywordPh: "name / IP / user / tags",
allVendors: "All vendors",
allConnectStatus: "All connect status",
col: {
name: "Name",
vendor: "Vendor",
deviceType: "Device type",
ip: "Management IP",
port: "Port",
protocol: "Protocol",
user: "Username",
password: "Password",
tags: "Tags",
remark: "Remark",
connect: "Connect",
testedAt: "Tested at",
actions: "Actions",
},
connect: {
run: "Connectivity test",
running: "Testing…",
submitted: "Submitted tests for {{n}} device(s)",
},
importResult: {
done: "Import done: {{inserted}} inserted, {{updated}} updated, {{failed}} failed row(s)",
},
form: {
requiredHint: "Fields marked with * are required",
nameConnectHint: "After a successful connectivity test, name is replaced with the device hostname",
createTitle: "Add network element",
editTitle: "Edit network element",
save: "Save",
saving: "Saving…",
cancel: "Cancel",
created: "Network element created",
updated: "Network element updated",
deleted: "Network element deleted",
passwordRequired: "Password is required",
passwordOptional: "leave blank to keep unchanged",
},
},
ume: { ume: {
token: { token: {
title: "UME Token", title: "UME Token",

View file

@ -18,14 +18,21 @@ const zh = {
openInNewTab: "在新标签页中打开", openInNewTab: "在新标签页中打开",
openModule: "打开或切换到该模块页签", openModule: "打开或切换到该模块页签",
monitoring: "监控", monitoring: "监控",
operations: "运维",
cards: { cards: {
umeSync: "UME同步", umeSync: "UME同步",
umeSyncDesc: "UME 告警同步、订阅与清单", umeSyncDesc: "UME 告警同步、订阅与清单",
managedNe: "网元管理",
managedNeDesc: "多厂商 SSH/Telnet 网元与连通性测试",
collect: "批量采集",
collectDesc: "对已连通网元执行定制 CLI 命令并保存日志",
}, },
}, },
layout: { layout: {
title: "UME 对接", title: "UME 对接",
titleUme: "UME同步", titleUme: "UME同步",
titleManagedNe: "网元管理",
titleCollect: "批量采集",
navUme: "UME 对接", navUme: "UME 对接",
netxApi: "netx api", netxApi: "netx api",
oclawBridge: "oclaw bridge", oclawBridge: "oclaw bridge",
@ -34,6 +41,111 @@ const zh = {
langZh: "中文", langZh: "中文",
langEn: "English", langEn: "English",
}, },
collect: {
eligible: {
title: "可选网元(连通性已通过)",
hint: "仅展示 connect_status=pass 的网元。请先在「网元管理」完成连通性测试。",
empty: "暂无已通过连通性测试的网元。",
},
form: {
title: "采集配置",
jobTitle: "任务名称",
jobTitlePh: "留空则自动生成",
commands: "采集命令(每行一条,# 开头为注释)",
commandsPh: "display version\ndisplay current-configuration",
commandsHint: "命令将按顺序在每台设备上执行,输出合并为一份文本日志(格式与旧版 NetX 采集一致)。",
meta: "已选 {{ne}} 台网元 · {{cmd}} 条命令",
start: "开始采集",
starting: "提交中…",
},
started: "采集任务已启动:{{id}}",
paused: "任务已暂停",
restarted: "已重新开始采集",
deleted: "任务已删除",
nothingToRetry: "没有可重试的失败网元",
confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。",
jobs: {
title: "采集任务",
col: {
title: "任务",
status: "状态",
progress: "进度",
created: "创建时间",
lastRun: "最近执行",
actions: "操作",
},
ok: "成功",
fail: "失败",
expand: "详情",
collapse: "收起",
pause: "暂停",
restart: "重新开始",
downloadResults: "下载结果",
delete: "删除",
autoRefresh: "任务进行中,每 2 秒自动刷新",
runsInProgress: "部分网元仍在采集中,请稍候…",
},
runs: {
status: "状态",
message: "说明",
download: "日志",
downloadFile: "下载",
filterStatus: "状态筛选",
filterDevice: "设备筛选",
deviceKeywordPh: "名称 / IP",
allStatus: "全部状态",
},
},
managedNe: {
title: "网元管理",
credsNotConfigured: "未配置 NETX_CREDENTIAL_SECRET_KEY,无法保存密码。请在 .env 中设置 Fernet 密钥后重启 API。",
add: "新增网元",
importBtn: "批量导入",
importing: "导入中…",
edit: "编辑",
delete: "删除",
confirmDelete: "确定删除该网元?",
keywordPh: "名称 / IP / 用户名 / 标签",
allVendors: "全部厂商",
allConnectStatus: "全部连通状态",
col: {
name: "名称",
vendor: "厂商",
deviceType: "设备类型",
ip: "管理地址",
port: "端口",
protocol: "协议",
user: "用户名",
password: "密码",
tags: "标签",
remark: "备注",
connect: "连通性",
testedAt: "测试时间",
actions: "操作",
},
connect: {
run: "连通性测试",
running: "测试中…",
submitted: "已提交 {{n}} 台设备测试",
},
importResult: {
done: "导入完成:新增 {{inserted}},更新 {{updated}},失败 {{failed}} 行",
},
form: {
requiredHint: "带 * 为必填项",
nameConnectHint: "连通性测试成功后将自动更新为设备名称",
createTitle: "新增网元",
editTitle: "编辑网元",
save: "保存",
saving: "保存中…",
cancel: "取消",
created: "网元已创建",
updated: "网元已更新",
deleted: "网元已删除",
passwordRequired: "请填写密码",
passwordOptional: "留空则不修改",
},
},
ume: { ume: {
token: { token: {
title: "UME Token 状态", title: "UME Token 状态",

View file

@ -511,6 +511,22 @@ pre {
cursor: pointer; cursor: pointer;
} }
.link-btn--danger {
color: #c62828;
}
.link-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.table-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.status { .status {
margin-top: 12px; margin-top: 12px;
color: #9db2cf; color: #9db2cf;
@ -555,21 +571,28 @@ pre {
position: fixed; position: fixed;
right: 20px; right: 20px;
bottom: 20px; bottom: 20px;
padding: 10px 12px; padding: 12px 16px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #2c405f; border: 1px solid #cbd5e1;
background: #0f1e34; background: #fff;
color: #1e293b;
font-size: 14px;
line-height: 1.45;
z-index: 60; z-index: 60;
max-width: 420px; max-width: 420px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); box-shadow: 0 10px 28px rgba(15, 23, 42, 0.14);
} }
.toast--ok { .toast--ok {
border-color: #1f5a3a; border-color: #81c784;
background: #e8f5e9;
color: #1b5e20;
} }
.toast--error { .toast--error {
border-color: #5f1f2a; border-color: #ef9a9a;
background: #ffebee;
color: #b71c1c;
} }
.inline-spinner { .inline-spinner {
@ -739,6 +762,201 @@ pre {
} }
} }
.page-stack {
display: flex;
flex-direction: column;
gap: 16px;
}
.panel--warn {
border-color: #ffe082;
background: #fff8e1;
color: #5d4037;
}
.panel__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.panel__toolbar h2 {
margin: 0;
}
.panel__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.table-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.modal {
width: min(720px, 100%);
max-height: 90vh;
overflow: auto;
background: #fff;
border-radius: 10px;
padding: 20px;
box-shadow: 0 16px 48px rgba(15, 23, 42, 0.2);
}
.modal h3 {
margin: 0 0 16px;
}
.modal__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.form-hint {
margin: 0 0 12px;
font-size: 12px;
color: #64748b;
}
.form-label__required {
color: #c62828;
font-weight: 600;
}
.form-label__optional {
color: #94a3b8;
font-weight: 400;
}
.form-field-hint {
font-size: 11px;
color: #94a3b8;
line-height: 1.35;
}
.form-grid label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #64748b;
}
.form-grid input,
.form-grid select {
font-size: 14px;
}
.form-grid__full {
grid-column: 1 / -1;
}
.form-grid--single {
grid-template-columns: 1fr;
}
.panel__hint {
margin: 0 0 12px;
font-size: 13px;
color: #64748b;
line-height: 1.45;
}
.panel__hint--live {
color: #1565c0;
}
.collect-commands {
width: 100%;
min-height: 160px;
font-family: ui-monospace, Consolas, monospace;
font-size: 13px;
line-height: 1.4;
padding: 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
resize: vertical;
}
.collect-meta {
font-size: 13px;
color: #64748b;
margin-right: auto;
}
.collect-cmd-preview {
font-size: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px;
max-height: 120px;
overflow: auto;
white-space: pre-wrap;
}
.collect-runs-panel {
display: flex;
flex-direction: column;
gap: 10px;
}
.collect-runs-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.collect-runs-filter {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #475569;
}
.collect-runs-filter select {
min-width: 140px;
padding: 6px 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
}
.collect-runs-filter input {
min-width: 180px;
padding: 6px 8px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
.cards { .cards {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -752,4 +970,7 @@ pre {
.split { .split {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.form-grid {
grid-template-columns: 1fr;
}
} }

View file

@ -0,0 +1,531 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createNeCollection,
deleteCollectionJob,
fetchCollectionJob,
fetchCollectionRuns,
fetchEligibleNe,
fetchNeCollections,
pauseCollectionJob,
restartCollectionJob,
collectionJobDownloadUrl,
collectionRunDownloadUrl,
} from "../services/api";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import type { CollectionJobDetail, CollectionJobItem, EligibleNeItem } from "../types";
import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
export function CollectPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [commands, setCommands] = useState("");
const [title, setTitle] = useState("");
const [selected, setSelected] = useState<string[]>([]);
const [nePage, setNePage] = useState(1);
const [jobPage, setJobPage] = useState(1);
const [expandedJobId, setExpandedJobId] = useState("");
const POLL_MS = 2000;
const ELIGIBLE_PAGE_SIZE = 20;
const eligibleQuery = useQuery({
queryKey: queryKeys.collectionEligibleNe(nePage),
queryFn: () => fetchEligibleNe({ page: nePage, pageSize: ELIGIBLE_PAGE_SIZE }),
staleTime: 5000,
});
const jobsQuery = useQuery({
queryKey: queryKeys.neCollections(jobPage),
queryFn: () => fetchNeCollections({ page: jobPage, pageSize: 20 }),
staleTime: 1000,
refetchInterval: (q) => {
const items = q.state.data?.items ?? [];
return items.some((j) => j.status === "running") ? POLL_MS : false;
},
});
const detailQuery = useQuery({
queryKey: queryKeys.neCollectionDetail(expandedJobId),
queryFn: () => fetchCollectionJob(expandedJobId),
enabled: Boolean(expandedJobId),
staleTime: 500,
refetchInterval: (q) => (q.state.data?.job.status === "running" ? POLL_MS : false),
});
const jobs = jobsQuery.data?.items ?? [];
const jobActive = jobs.some((j) => j.status === "running");
const expandedJobRunning =
Boolean(expandedJobId) &&
(jobs.find((j) => j.id === expandedJobId)?.status === "running" || detailQuery.data?.job.status === "running");
const autoPolling = jobActive || expandedJobRunning;
const refreshAll = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }),
expandedJobId
? queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(expandedJobId) })
: Promise.resolve(),
expandedJobId
? queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionRunsAll })
: Promise.resolve(),
]);
};
const invalidateJobs = async (jobId?: string) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
if (jobId) {
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(jobId) });
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionRunsAll });
}
};
const pauseMutation = useMutation({
mutationFn: pauseCollectionJob,
onSuccess: async (job) => {
showOk(t("collect.paused"));
await invalidateJobs(job.id);
},
onError: (err) => showError(String(err)),
});
const restartMutation = useMutation({
mutationFn: restartCollectionJob,
onSuccess: async (job) => {
showOk(t("collect.restarted"));
setExpandedJobId(job.id);
await invalidateJobs(job.id);
},
onError: (err) => showError(String(err)),
});
const deleteMutation = useMutation({
mutationFn: deleteCollectionJob,
onSuccess: async (_, jobId) => {
showOk(t("collect.deleted"));
if (expandedJobId === jobId) setExpandedJobId("");
await invalidateJobs();
},
onError: (err) => showError(String(err)),
});
const startMutation = useMutation({
mutationFn: () =>
createNeCollection({
title: title.trim(),
commands,
ne_ids: selected,
}),
onSuccess: async (job) => {
showOk(t("collect.started", { id: job.id }));
setExpandedJobId(job.id);
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDetail(job.id) });
},
onError: (err) => showError(String(err)),
});
const items = eligibleQuery.data?.items ?? [];
const allSelected = items.length > 0 && items.every((x) => selected.includes(x.id));
const toggleAll = () => {
if (allSelected) {
const ids = new Set(items.map((x) => x.id));
setSelected((prev) => prev.filter((id) => !ids.has(id)));
} else {
setSelected((prev) => [...new Set([...prev, ...items.map((x) => x.id)])]);
}
};
const neTotal = eligibleQuery.data?.total ?? 0;
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
const jobTotal = jobsQuery.data?.total ?? 0;
const jobPages = pageCount(jobTotal, 20);
const commandLines = useMemo(
() =>
commands
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#")).length,
[commands],
);
return (
<div className="page-stack">
<section className="panel">
<div className="panel__toolbar">
<div>
<h2>{t("collect.eligible.title")}</h2>
<p className="panel__hint">{t("collect.eligible.hint")}</p>
</div>
<button type="button" onClick={() => eligibleQuery.refetch()} disabled={eligibleQuery.isFetching}>
{eligibleQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
</div>
{eligibleQuery.isLoading ? <p>{t("common.refreshing")}</p> : null}
{!eligibleQuery.isLoading && items.length === 0 ? (
<p>{t("collect.eligible.empty")}</p>
) : (
<table>
<thead>
<tr>
<th>
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="select all" />
</th>
<th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.vendor")}</th>
<th>{t("managedNe.col.ip")}</th>
<th>{t("managedNe.col.connect")}</th>
</tr>
</thead>
<tbody>
{items.map((row: EligibleNeItem) => (
<tr key={row.id}>
<td>
<input
type="checkbox"
checked={selected.includes(row.id)}
onChange={() =>
setSelected((prev) =>
prev.includes(row.id) ? prev.filter((x) => x !== row.id) : [...prev, row.id],
)
}
/>
</td>
<td>{row.name || row.ip_address}</td>
<td>{row.vendor}</td>
<td>{row.ip_address}</td>
<td>
<span className="conn-pill conn-pill--up">{row.connect_status}</span>
</td>
</tr>
))}
</tbody>
</table>
)}
{neTotal > 0 ? (
<div className="pager">
<div className="pager__meta">{t("common.pagerMeta", { total: neTotal, page: nePage, pages: nePages })}</div>
<div className="pager__controls">
<button className="pager__btn" disabled={nePage <= 1} onClick={() => setNePage(nePage - 1)}>
{t("common.prevPage")}
</button>
<button className="pager__btn" disabled={nePage >= nePages} onClick={() => setNePage(nePage + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
) : null}
</section>
<section className="panel">
<h2>{t("collect.form.title")}</h2>
<p className="panel__hint">{t("collect.form.commandsHint")}</p>
<div className="form-grid form-grid--single">
<label className="form-grid__full">
{t("collect.form.jobTitle")}
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t("collect.form.jobTitlePh")} />
</label>
<label className="form-grid__full">
{t("collect.form.commands")}
<textarea
className="collect-commands"
rows={10}
value={commands}
onChange={(e) => setCommands(e.target.value)}
placeholder={t("collect.form.commandsPh")}
/>
</label>
</div>
<div className="panel__actions">
<span className="collect-meta">
{t("collect.form.meta", { ne: selected.length, cmd: commandLines })}
</span>
<button
type="button"
disabled={selected.length === 0 || commandLines === 0 || startMutation.isPending}
onClick={() => startMutation.mutate()}
>
{startMutation.isPending ? t("collect.form.starting") : t("collect.form.start")}
</button>
</div>
</section>
<section className="panel">
<div className="panel__toolbar">
<div>
<h2>{t("collect.jobs.title")}</h2>
{autoPolling ? (
<p className="panel__hint panel__hint--live">
{t("collect.jobs.autoRefresh")}
{jobsQuery.isFetching ? ` · ${t("common.refreshing")}` : ""}
</p>
) : null}
</div>
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching}>
{jobsQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
</div>
<table>
<thead>
<tr>
<th>{t("collect.jobs.col.title")}</th>
<th>{t("collect.jobs.col.status")}</th>
<th>{t("collect.jobs.col.progress")}</th>
<th>{t("collect.jobs.col.created")}</th>
<th>{t("collect.jobs.col.lastRun")}</th>
<th>{t("collect.jobs.col.actions")}</th>
</tr>
</thead>
<tbody>
{(jobsQuery.data?.items ?? []).map((job) => (
<JobRow
key={job.id}
job={job}
expanded={expandedJobId === job.id}
detail={expandedJobId === job.id ? detailQuery.data : undefined}
onToggle={() => setExpandedJobId(expandedJobId === job.id ? "" : job.id)}
onPause={() => pauseMutation.mutate(job.id)}
onRestart={() => restartMutation.mutate(job.id)}
onDelete={() => {
if (window.confirm(t("collect.confirmDelete"))) deleteMutation.mutate(job.id);
}}
actionPending={pauseMutation.isPending || restartMutation.isPending || deleteMutation.isPending}
/>
))}
</tbody>
</table>
<div className="pager">
<div className="pager__meta">{t("common.pagerMeta", { total: jobTotal, page: jobPage, pages: jobPages })}</div>
<div className="pager__controls">
<button className="pager__btn" disabled={jobPage <= 1} onClick={() => setJobPage(jobPage - 1)}>
{t("common.prevPage")}
</button>
<button className="pager__btn" disabled={jobPage >= jobPages} onClick={() => setJobPage(jobPage + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
</section>
</div>
);
}
function JobRow({
job,
expanded,
detail,
onToggle,
onPause,
onRestart,
onDelete,
actionPending,
}: {
job: CollectionJobItem;
expanded: boolean;
detail?: CollectionJobDetail;
onToggle: () => void;
onPause: () => void;
onRestart: () => void;
onDelete: () => void;
actionPending: boolean;
}) {
const { t } = useI18n();
const canPause = job.status === "running";
const canRestart = job.status !== "running";
const canDelete = job.status !== "running";
const hasResults = (job.output_count ?? 0) > 0;
const downloadResults = () => {
window.location.assign(collectionJobDownloadUrl(job.id));
};
return (
<>
<tr>
<td>{job.title}</td>
<td>{job.status}</td>
<td>
{job.success_count}/{job.ne_count} {t("collect.jobs.ok")}, {job.fail_count} {t("collect.jobs.fail")}
</td>
<td>{formatSystemTime(job.created_at)}</td>
<td>{job.last_run_at ? formatSystemTime(job.last_run_at) : t("common.empty")}</td>
<td className="table-actions">
<button type="button" className="link-btn" onClick={onToggle}>
{expanded ? t("collect.jobs.collapse") : t("collect.jobs.expand")}
</button>
{canPause ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onPause}>
{t("collect.jobs.pause")}
</button>
) : null}
{canRestart ? (
<button type="button" className="link-btn" disabled={actionPending} onClick={onRestart}>
{t("collect.jobs.restart")}
</button>
) : null}
<button
type="button"
className="link-btn"
disabled={actionPending || !hasResults}
onClick={downloadResults}
>
{t("collect.jobs.downloadResults")}
</button>
{canDelete ? (
<button type="button" className="link-btn link-btn--danger" disabled={actionPending} onClick={onDelete}>
{t("collect.jobs.delete")}
</button>
) : null}
</td>
</tr>
{expanded ? (
<tr>
<td colSpan={6}>
<JobRunsPanel jobId={job.id} jobStatus={job.status} commands={detail?.job.commands ?? ""} />
</td>
</tr>
) : null}
</>
);
}
const RUN_PAGE_SIZE = 20;
const RUN_STATUS_OPTIONS = ["pending", "running", "success", "fail", "cancelled"] as const;
function JobRunsPanel({
jobId,
jobStatus,
commands,
}: {
jobId: string;
jobStatus: string;
commands: string;
}) {
const { t } = useI18n();
const [runPage, setRunPage] = useState(1);
const [runStatus, setRunStatus] = useState("");
const [runKeyword, setRunKeyword] = useState("");
const runsQuery = useQuery({
queryKey: queryKeys.neCollectionRuns(jobId, runPage, runStatus, runKeyword),
queryFn: () =>
fetchCollectionRuns({
jobId,
page: runPage,
pageSize: RUN_PAGE_SIZE,
status: runStatus,
keyword: runKeyword,
}),
staleTime: 500,
refetchInterval: jobStatus === "running" ? 2000 : false,
});
const clearRunFilters = () => {
setRunStatus("");
setRunKeyword("");
setRunPage(1);
};
const runTotal = runsQuery.data?.total ?? 0;
const runPages = pageCount(runTotal, RUN_PAGE_SIZE);
const runs = runsQuery.data?.items ?? [];
return (
<div className="collect-runs-panel">
{jobStatus === "running" ? (
<p className="panel__hint panel__hint--live">{t("collect.jobs.runsInProgress")}</p>
) : null}
{commands ? <pre className="collect-cmd-preview">{commands}</pre> : null}
<div className="collect-runs-toolbar">
<label className="collect-runs-filter">
{t("collect.runs.filterDevice")}
<input
type="search"
value={runKeyword}
placeholder={t("collect.runs.deviceKeywordPh")}
onChange={(e) => {
setRunKeyword(e.target.value);
setRunPage(1);
}}
/>
</label>
<label className="collect-runs-filter">
{t("collect.runs.filterStatus")}
<select
value={runStatus}
onChange={(e) => {
setRunStatus(e.target.value);
setRunPage(1);
}}
>
<option value="">{t("collect.runs.allStatus")}</option>
{RUN_STATUS_OPTIONS.map((st) => (
<option key={st} value={st}>
{st}
</option>
))}
</select>
</label>
{runStatus || runKeyword ? (
<button type="button" className="link-btn" onClick={clearRunFilters}>
{t("common.clearFilters")}
</button>
) : null}
</div>
{runsQuery.isLoading ? <p>{t("common.refreshing")}</p> : null}
{!runsQuery.isLoading && runs.length === 0 ? <p>{t("common.empty")}</p> : null}
{runs.length > 0 ? (
<table>
<thead>
<tr>
<th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.ip")}</th>
<th>{t("collect.runs.status")}</th>
<th>{t("collect.runs.message")}</th>
<th>{t("collect.runs.download")}</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id}>
<td>{run.ne_name}</td>
<td>{run.ne_ip}</td>
<td>{run.status}</td>
<td title={run.message}>{run.message || t("common.empty")}</td>
<td>
{run.has_output ? (
<a className="link-btn" href={collectionRunDownloadUrl(run.id)} target="_blank" rel="noreferrer">
{t("collect.runs.downloadFile")}
</a>
) : (
t("common.empty")
)}
</td>
</tr>
))}
</tbody>
</table>
) : null}
{runTotal > 0 ? (
<div className="pager">
<div className="pager__meta">{t("common.pagerMeta", { total: runTotal, page: runPage, pages: runPages })}</div>
<div className="pager__controls">
<button className="pager__btn" disabled={runPage <= 1} onClick={() => setRunPage(runPage - 1)}>
{t("common.prevPage")}
</button>
<button className="pager__btn" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
) : null}
</div>
);
}

493
web/src/pages/NePage.tsx Normal file
View file

@ -0,0 +1,493 @@
import { useMemo, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
connectTestManagedNe,
createManagedNe,
deleteManagedNe,
fetchManagedNe,
fetchManagedNeMeta,
importManagedNe,
updateManagedNe,
} from "../services/api";
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import type { ManagedNeItem } from "../types";
import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
type FormState = {
name: string;
vendor: string;
device_type: string;
ip_address: string;
port: number;
protocol: string;
username: string;
password: string;
tags: string;
remark: string;
};
const emptyForm = (): FormState => ({
name: "",
vendor: "ZTE",
device_type: "zte_zxros",
ip_address: "",
port: 22,
protocol: "ssh",
username: "",
password: "",
tags: "",
remark: "",
});
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
return (
<span className="form-label">
{children}
{required ? (
<span className="form-label__required" title="required" aria-hidden="true">
{" "}
*
</span>
) : null}
</span>
);
}
function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" {
if (status === "pass") return "up";
if (status === "fail") return "down";
if (status === "testing") return "warn";
return "unknown";
}
export function NePage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const importRef = useRef<HTMLInputElement>(null);
const [keyword, setKeyword] = useState("");
const [vendorFilter, setVendorFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [selected, setSelected] = useState<string[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<ManagedNeItem | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const metaQuery = useQuery({
queryKey: queryKeys.managedNeMeta,
queryFn: fetchManagedNeMeta,
staleTime: 60_000,
});
const listQuery = useQuery({
queryKey: queryKeys.managedNe(keyword, vendorFilter, statusFilter, page, pageSize),
queryFn: () =>
fetchManagedNe({
keyword,
vendor: vendorFilter,
connectStatus: statusFilter,
page,
pageSize,
}),
refetchInterval: (q) => {
const items = q.state.data?.items || [];
return items.some((x) => x.connect_status === "testing") ? 2000 : false;
},
});
const total = listQuery.data?.total ?? 0;
const pages = pageCount(total, pageSize);
const perPage = (n: number) => t("common.perPage", { n });
const invalidateList = () => queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
const saveMutation = useMutation({
mutationFn: async () => {
const body = {
name: form.name,
vendor: form.vendor,
device_type: form.device_type,
ip_address: form.ip_address,
port: form.port,
protocol: form.protocol,
username: form.username,
tags: form.tags,
remark: form.remark,
...(form.password ? { password: form.password } : {}),
};
if (editing) {
if (!form.password) delete (body as { password?: string }).password;
return updateManagedNe(editing.id, body);
}
if (!form.password) throw new Error(t("managedNe.form.passwordRequired"));
return createManagedNe({ ...body, password: form.password });
},
onSuccess: async () => {
setModalOpen(false);
setEditing(null);
setForm(emptyForm());
showOk(editing ? t("managedNe.form.updated") : t("managedNe.form.created"));
await invalidateList();
},
onError: (err) => showError(String(err)),
});
const deleteMutation = useMutation({
mutationFn: deleteManagedNe,
onSuccess: async () => {
showOk(t("managedNe.form.deleted"));
await invalidateList();
},
onError: (err) => showError(String(err)),
});
const connectMutation = useMutation({
mutationFn: connectTestManagedNe,
onSuccess: async (res) => {
showOk(t("managedNe.connect.submitted", { n: res.submitted }));
await invalidateList();
},
onError: (err) => showError(String(err)),
});
const importMutation = useMutation({
mutationFn: importManagedNe,
onSuccess: async (res) => {
showOk(
t("managedNe.importResult.done", {
inserted: res.inserted,
updated: res.updated,
failed: res.failed.length,
}),
);
await invalidateList();
},
onError: (err) => showError(String(err)),
});
const vendors = metaQuery.data?.vendors ?? [];
const deviceTypes = metaQuery.data?.device_types ?? [];
const credsOk = metaQuery.data?.credentials_configured ?? false;
const allSelected = useMemo(() => {
const items = listQuery.data?.items ?? [];
return items.length > 0 && items.every((x) => selected.includes(x.id));
}, [listQuery.data?.items, selected]);
const openCreate = () => {
setEditing(null);
setForm(emptyForm());
setModalOpen(true);
};
const openEdit = (row: ManagedNeItem) => {
setEditing(row);
setForm({
name: row.name,
vendor: row.vendor,
device_type: row.device_type,
ip_address: row.ip_address,
port: row.port,
protocol: row.protocol,
username: row.username,
password: "",
tags: row.tags,
remark: row.remark,
});
setModalOpen(true);
};
const toggleSelect = (id: string) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
};
const toggleSelectAll = () => {
const items = listQuery.data?.items ?? [];
if (allSelected) {
const pageIds = new Set(items.map((x) => x.id));
setSelected((prev) => prev.filter((id) => !pageIds.has(id)));
} else {
const ids = items.map((x) => x.id);
setSelected((prev) => [...new Set([...prev, ...ids])]);
}
};
return (
<div className="page-stack">
{!credsOk ? (
<section className="panel panel--warn">
<p>{t("managedNe.credsNotConfigured")}</p>
</section>
) : null}
<section className="panel">
<div className="panel__toolbar">
<h2>{t("managedNe.title")}</h2>
<div className="panel__actions">
<button type="button" onClick={openCreate} disabled={!credsOk}>
{t("managedNe.add")}
</button>
<button
type="button"
onClick={() => importRef.current?.click()}
disabled={!credsOk || importMutation.isPending}
>
{importMutation.isPending ? t("managedNe.importing") : t("managedNe.importBtn")}
</button>
<input
ref={importRef}
type="file"
accept=".csv,.xlsx,.xls"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) importMutation.mutate(file);
}}
/>
<button
type="button"
disabled={selected.length === 0 || connectMutation.isPending}
onClick={() => connectMutation.mutate(selected)}
>
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.run")}
</button>
<button type="button" onClick={() => invalidateList()}>
{t("common.refresh")}
</button>
</div>
</div>
<div className="filter-inline">
<input
value={keyword}
placeholder={t("managedNe.keywordPh")}
onChange={(e) => setKeyword(e.target.value)}
/>
<select value={vendorFilter} onChange={(e) => setVendorFilter(e.target.value)}>
<option value="">{t("managedNe.allVendors")}</option>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">{t("managedNe.allConnectStatus")}</option>
<option value="unknown">unknown</option>
<option value="testing">testing</option>
<option value="pass">pass</option>
<option value="fail">fail</option>
</select>
<button type="button" onClick={() => setPage(1)}>
{t("common.query")}
</button>
<button
type="button"
onClick={() => {
setKeyword("");
setVendorFilter("");
setStatusFilter("");
setPage(1);
}}
>
{t("common.clearFilters")}
</button>
</div>
<table>
<thead>
<tr>
<th>
<input type="checkbox" checked={allSelected} onChange={toggleSelectAll} aria-label="select all" />
</th>
<th>{t("managedNe.col.name")}</th>
<th>{t("managedNe.col.vendor")}</th>
<th>{t("managedNe.col.deviceType")}</th>
<th>{t("managedNe.col.ip")}</th>
<th>{t("managedNe.col.user")}</th>
<th>{t("managedNe.col.connect")}</th>
<th>{t("managedNe.col.testedAt")}</th>
<th>{t("managedNe.col.actions")}</th>
</tr>
</thead>
<tbody>
{(listQuery.data?.items || []).map((row) => (
<tr key={row.id}>
<td>
<input
type="checkbox"
checked={selected.includes(row.id)}
onChange={() => toggleSelect(row.id)}
/>
</td>
<td>{row.name || row.ip_address}</td>
<td>{row.vendor}</td>
<td>{row.device_type}</td>
<td>
{row.ip_address}:{row.port}/{row.protocol}
</td>
<td>{row.username}</td>
<td>
<span
className={`conn-pill conn-pill--${connectPillLevel(row.connect_status)}`}
title={row.connect_message || undefined}
>
{row.connect_status}
</span>
</td>
<td>{row.connect_tested_at ? formatSystemTime(row.connect_tested_at) : t("common.empty")}</td>
<td className="table-actions">
<button type="button" className="link-btn" onClick={() => openEdit(row)}>
{t("managedNe.edit")}
</button>
<button
type="button"
className="link-btn"
onClick={() => {
if (window.confirm(t("managedNe.confirmDelete"))) deleteMutation.mutate(row.id);
}}
>
{t("managedNe.delete")}
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="pager">
<div className="pager__meta">{t("common.pagerMeta", { total, page, pages })}</div>
<div className="pager__controls">
<button className="pager__btn" onClick={() => setPage(Math.max(1, page - 1))} disabled={page <= 1}>
{t("common.prevPage")}
</button>
<button className="pager__btn" onClick={() => setPage(page + 1)} disabled={page >= pages}>
{t("common.nextPage")}
</button>
<select
className="pager__size"
value={String(pageSize)}
onChange={(e) => {
setPageSize(Number(e.target.value) || 50);
setPage(1);
}}
>
<option value="20">{perPage(20)}</option>
<option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option>
</select>
</div>
</div>
</section>
{modalOpen ? (
<div className="modal-backdrop" role="presentation" onClick={() => setModalOpen(false)}>
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}</h3>
<p className="form-hint">{t("managedNe.form.requiredHint")}</p>
<div className="form-grid">
<label>
<FormLabel>{t("managedNe.col.name")}</FormLabel>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span>
</label>
<label>
<FormLabel required>{t("managedNe.col.vendor")}</FormLabel>
<select required value={form.vendor} onChange={(e) => setForm({ ...form, vendor: e.target.value })}>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.deviceType")}</FormLabel>
<select
required
value={form.device_type}
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
{dt}
</option>
))}
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.ip")}</FormLabel>
<input
required
value={form.ip_address}
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
disabled={Boolean(editing)}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.port")}</FormLabel>
<input
type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 22 })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.protocol")}</FormLabel>
<select value={form.protocol} onChange={(e) => setForm({ ...form, protocol: e.target.value })}>
<option value="ssh">ssh</option>
<option value="telnet">telnet</option>
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.user")}</FormLabel>
<input
required
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</label>
<label>
<FormLabel required={!editing}>
{t("managedNe.col.password")}
{editing ? (
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
) : null}
</FormLabel>
<input
type="password"
required={!editing}
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.tags")}</FormLabel>
<input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
</label>
<label className="form-grid__full">
<FormLabel>{t("managedNe.col.remark")}</FormLabel>
<input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} />
</label>
</div>
<div className="modal__actions">
<button type="button" onClick={() => setModalOpen(false)}>
{t("managedNe.form.cancel")}
</button>
<button type="button" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}

View file

@ -3,7 +3,7 @@ import { WorkbenchCardIcon } from "../components/WorkbenchCardIcon";
import { modulesInSection, type WorkbenchSection } from "../config/modules"; import { modulesInSection, type WorkbenchSection } from "../config/modules";
import { openOrFocusModule } from "../utils/moduleWindows"; import { openOrFocusModule } from "../utils/moduleWindows";
const SECTIONS: WorkbenchSection[] = ["monitoring"]; const SECTIONS: WorkbenchSection[] = ["monitoring", "operations"];
export function WorkbenchPage() { export function WorkbenchPage() {
const { t } = useI18n(); const { t } = useI18n();

View file

@ -1,5 +1,13 @@
import type { import type {
CollectionJobDetail,
CollectionJobItem,
CollectionRunList,
EligibleNeItem,
IntegrationStatus, IntegrationStatus,
ManagedNeImportResult,
ManagedNeItem,
ManagedNeListResponse,
ManagedNeMeta,
UmeAlarmItem, UmeAlarmItem,
UmeAlarmSubscriptionStatus, UmeAlarmSubscriptionStatus,
UmeNeItem, UmeNeItem,
@ -25,6 +33,26 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
return data as T; return data as T;
}; };
export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
const res = await fetch(path, {
method: "PATCH",
headers: { "content-type": "application/json", accept: "application/json" },
body: JSON.stringify(body),
});
const text = await res.text();
const data = text ? JSON.parse(text) : {};
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
return data as T;
};
export const apiDelete = async <T,>(path: string): Promise<T> => {
const res = await fetch(path, { method: "DELETE", headers: { accept: "application/json" } });
const text = await res.text();
const data = text ? JSON.parse(text) : {};
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
return data as T;
};
export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status"); export const fetchIntegrationStatus = () => apiGet<IntegrationStatus>("/v1/integrations/status");
export const fetchUmeAlarmSubscriptionStatus = () => export const fetchUmeAlarmSubscriptionStatus = () =>
@ -64,6 +92,104 @@ export const fetchUmeNe = (params: { keyword: string; page: number; pageSize: nu
); );
}; };
export const fetchManagedNeMeta = () =>
Promise.all([
apiGet<ManagedNeMeta>("/v1/managed-ne/meta/device-types"),
apiGet<{ configured: boolean }>("/v1/managed-ne/meta/credentials-configured"),
]).then(([types, creds]) => ({
device_types: types.device_types,
vendors: types.vendors,
credentials_configured: creds.configured,
}));
export const fetchManagedNe = (params: {
keyword: string;
vendor: string;
connectStatus: string;
page: number;
pageSize: number;
}) => {
const p = new URLSearchParams();
if (params.keyword) p.set("keyword", params.keyword);
if (params.vendor) p.set("vendor", params.vendor);
if (params.connectStatus) p.set("connect_status", params.connectStatus);
p.set("page", String(Math.max(1, params.page)));
p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize))));
return apiGet<ManagedNeListResponse>(`/v1/managed-ne?${p.toString()}`);
};
export const createManagedNe = (body: Record<string, unknown>) =>
apiPost<ManagedNeItem>("/v1/managed-ne", body);
export const updateManagedNe = (id: string, body: Record<string, unknown>) =>
apiPatch<ManagedNeItem>(`/v1/managed-ne/${id}`, body);
export const deleteManagedNe = (id: string) => apiDelete<{ ok: boolean }>(`/v1/managed-ne/${id}`);
export const connectTestManagedNe = (ids: string[]) =>
apiPost<{ ok: boolean; submitted: number }>("/v1/managed-ne/connect-test", { ids });
export const importManagedNe = async (file: File): Promise<ManagedNeImportResult> => {
const form = new FormData();
form.append("file", file);
const res = await fetch("/v1/managed-ne/import", { method: "POST", body: form });
const text = await res.text();
const data = text ? JSON.parse(text) : {};
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} import`));
return data as ManagedNeImportResult;
};
export const fetchEligibleNe = (params: { page: number; pageSize: number }) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, params.page)));
p.set("page_size", String(Math.max(1, Math.min(500, params.pageSize))));
return apiGet<{ total: number; page: number; page_size: number; items: EligibleNeItem[] }>(
`/v1/ne-collections/eligible-ne?${p.toString()}`,
);
};
export const createNeCollection = (body: { title?: string; commands: string; ne_ids: string[] }) =>
apiPost<CollectionJobItem>("/v1/ne-collections", body);
export const fetchNeCollections = (params: { page: number; pageSize: number }) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, params.page)));
p.set("page_size", String(Math.max(1, Math.min(100, params.pageSize))));
return apiGet<{ total: number; page: number; page_size: number; items: CollectionJobItem[] }>(
`/v1/ne-collections?${p.toString()}`,
);
};
export const fetchCollectionJob = (jobId: string) => apiGet<CollectionJobDetail>(`/v1/ne-collections/${jobId}`);
export const fetchCollectionRuns = (params: {
jobId: string;
page: number;
pageSize: number;
status?: string;
keyword?: string;
}) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, params.page)));
p.set("page_size", String(Math.max(1, Math.min(200, params.pageSize))));
if (params.status) p.set("status", params.status);
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
return apiGet<CollectionRunList>(`/v1/ne-collections/${params.jobId}/runs?${p.toString()}`);
};
export const pauseCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/pause`, {});
export const restartCollectionJob = (jobId: string) =>
apiPost<CollectionJobItem>(`/v1/ne-collections/${jobId}/restart`, {});
export const deleteCollectionJob = (jobId: string) =>
apiDelete<{ ok: boolean }>(`/v1/ne-collections/${jobId}`);
export const collectionRunDownloadUrl = (runId: string) => `/v1/ne-collections/runs/${runId}/download`;
export const collectionJobDownloadUrl = (jobId: string) => `/v1/ne-collections/${jobId}/download`;
export const fetchUmeCurrentAlarms = (params: { export const fetchUmeCurrentAlarms = (params: {
severity: string; severity: string;
isCleared: string; isCleared: string;

View file

@ -119,6 +119,95 @@ export type UmeAlarmItem = {
last_seen_at?: string; last_seen_at?: string;
}; };
export type ConnectStatus = "unknown" | "testing" | "pass" | "fail";
export type ManagedNeItem = {
id: string;
name: string;
vendor: string;
device_type: string;
ip_address: string;
port: number;
protocol: string;
username: string;
connect_status: ConnectStatus;
connect_message: string;
connect_tested_at: string | null;
tags: string;
remark: string;
created_at: string;
updated_at: string;
};
export type ManagedNeListResponse = {
total: number;
page: number;
page_size: number;
items: ManagedNeItem[];
};
export type ManagedNeMeta = {
device_types: string[];
vendors: string[];
};
export type ManagedNeImportResult = {
inserted: number;
updated: number;
failed: Array<{ row: number; reason: string }>;
};
export type EligibleNeItem = {
id: string;
name: string;
vendor: string;
device_type: string;
ip_address: string;
connect_status: string;
connect_tested_at: string | null;
};
export type CollectionJobItem = {
id: string;
title: string;
commands: string;
status: string;
ne_count: number;
success_count: number;
fail_count: number;
output_count: number;
error_message: string;
created_at: string;
started_at: string | null;
ended_at: string | null;
last_run_at: string | null;
};
export type CollectionRunItem = {
id: string;
job_id: string;
ne_id: string;
ne_name: string;
ne_ip: string;
status: string;
message: string;
output_rel_path: string;
has_output: boolean;
started_at: string | null;
ended_at: string | null;
};
export type CollectionJobDetail = {
job: CollectionJobItem;
};
export type CollectionRunList = {
total: number;
page: number;
page_size: number;
items: CollectionRunItem[];
};
export type UmeTokenStatus = { export type UmeTokenStatus = {
ok?: boolean; ok?: boolean;
has_token: boolean; has_token: boolean;