mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
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:
parent
282dbe75af
commit
042c015045
36 changed files with 3695 additions and 17 deletions
|
|
@ -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
1
.gitignore
vendored
|
|
@ -7,3 +7,4 @@ build/
|
||||||
dist/
|
dist/
|
||||||
scripts/.run/
|
scripts/.run/
|
||||||
ume/
|
ume/
|
||||||
|
data/ne_collections/
|
||||||
|
|
|
||||||
78
netx_api/collection_job_state.py
Normal file
78
netx_api/collection_job_state.py
Normal 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
|
||||||
52
netx_api/collection_recovery.py
Normal file
52
netx_api/collection_recovery.py
Normal 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
|
||||||
100
netx_api/collection_router.py
Normal file
100
netx_api/collection_router.py
Normal 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)
|
||||||
41
netx_api/collection_schemas.py
Normal file
41
netx_api/collection_schemas.py
Normal 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
|
||||||
385
netx_api/collection_service.py
Normal file
385
netx_api/collection_service.py
Normal 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
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
45
netx_api/device_types.py
Normal 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",
|
||||||
|
)
|
||||||
|
|
@ -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 '资源名称'")
|
||||||
|
|
|
||||||
92
netx_api/managed_ne_router.py
Normal file
92
netx_api/managed_ne_router.py
Normal 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}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
174
netx_api/ne_collect_runner.py
Normal file
174
netx_api/ne_collect_runner.py
Normal 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
|
||||||
26
netx_api/ne_collection_paths.py
Normal file
26
netx_api/ne_collection_paths.py
Normal 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
193
netx_api/ne_connect.py
Normal 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
40
netx_api/ne_crypto.py
Normal 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
17
netx_api/ne_netmiko.py
Normal 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
93
netx_api/ne_schemas.py
Normal 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
280
netx_api/ne_service.py
Normal 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 ""),
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
35
tests/test_collection_job_state.py
Normal file
35
tests/test_collection_job_state.py
Normal 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
175
tests/test_managed_ne.py
Normal 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()
|
||||||
15
tests/test_ne_collection.py
Normal file
15
tests/test_ne_collection.py
Normal 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()
|
||||||
15
web/WEB.md
15
web/WEB.md
|
|
@ -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()`
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 状态",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
531
web/src/pages/CollectPage.tsx
Normal file
531
web/src/pages/CollectPage.tsx
Normal 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
493
web/src/pages/NePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue