mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 08:10:47 +08:00
feat: initialize netx ops tool repository
Set up netx as a standalone git-managed project with isolated Python dependencies, alarm ingestion/query APIs, oclaw bridge integration, and ops-focused UI enhancements including history and raw field access. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
f49c0b5bad
56 changed files with 6974 additions and 0 deletions
3
netx_api/__init__.py
Normal file
3
netx_api/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
50
netx_api/ap_client.py
Normal file
50
netx_api/ap_client.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def _analyze_httpx_timeout() -> httpx.Timeout:
|
||||
read_s = max(30.0, float(settings.oclaw_analyze_read_timeout_sec))
|
||||
connect_s = max(3.0, float(settings.oclaw_connect_timeout_sec))
|
||||
return httpx.Timeout(
|
||||
connect=connect_s,
|
||||
read=read_s,
|
||||
write=min(120.0, read_s),
|
||||
pool=connect_s,
|
||||
)
|
||||
|
||||
|
||||
def analyze_with_oclaw(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
headers = {"content-type": "application/json", "accept": "application/json"}
|
||||
token = str(settings.oclaw_analyze_token or "").strip()
|
||||
if token:
|
||||
headers["authorization"] = f"Bearer {token}"
|
||||
with httpx.Client(timeout=_analyze_httpx_timeout()) as client:
|
||||
resp = client.post(settings.oclaw_analyze_url, json=payload, headers=headers)
|
||||
text = resp.text
|
||||
if not resp.is_success:
|
||||
raise RuntimeError(f"oclaw analyze failed: {resp.status_code} {text[:300]}")
|
||||
if not text:
|
||||
return {}
|
||||
return resp.json()
|
||||
|
||||
|
||||
def health_with_oclaw() -> dict[str, Any]:
|
||||
headers = {"accept": "application/json"}
|
||||
token = str(settings.oclaw_analyze_token or "").strip()
|
||||
if token:
|
||||
headers["authorization"] = f"Bearer {token}"
|
||||
health_s = max(3.0, float(settings.oclaw_health_timeout_sec))
|
||||
with httpx.Client(timeout=health_s) as client:
|
||||
resp = client.get(settings.oclaw_health_url, headers=headers)
|
||||
status = int(resp.status_code)
|
||||
text = resp.text
|
||||
if not resp.is_success:
|
||||
raise RuntimeError(f"oclaw health failed: {status} {text[:200]}")
|
||||
if not text:
|
||||
return {"status_code": status, "data": {}}
|
||||
return {"status_code": status, "data": resp.json()}
|
||||
25
netx_api/config.py
Normal file
25
netx_api/config.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_prefix="NETX_", extra="ignore")
|
||||
|
||||
database_url: str = "postgresql+psycopg://netx:netx@127.0.0.1:5432/netx"
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8890
|
||||
vendor: str = "ZTE"
|
||||
source_type: str = "gateway_export_excel"
|
||||
parser_config: str = "netx_api/config/parsers/zte_alarm_monitor_v1.yaml"
|
||||
frontend_url: str = "http://127.0.0.1:5173"
|
||||
oclaw_analyze_url: str = "http://127.0.0.1:8787/admin/api/ops-ai/analyze-sync"
|
||||
oclaw_analyze_token: str = ""
|
||||
oclaw_health_url: str = "http://127.0.0.1:8787/admin/api/ops-ai/health"
|
||||
# analyze-sync runs a full gateway + LLM turn; 35s is often too short (was hardcoded).
|
||||
oclaw_connect_timeout_sec: float = 15.0
|
||||
oclaw_analyze_read_timeout_sec: float = 180.0
|
||||
oclaw_health_timeout_sec: float = 8.0
|
||||
|
||||
|
||||
settings = Settings()
|
||||
95
netx_api/config/parsers/zte_alarm_monitor_v1.yaml
Normal file
95
netx_api/config/parsers/zte_alarm_monitor_v1.yaml
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
parser_version: zte_alarm_monitor_v1
|
||||
dict_version: v1
|
||||
|
||||
aliases:
|
||||
alarm_time:
|
||||
- Occurrence Time
|
||||
- Alarm Time
|
||||
- Occur Time
|
||||
- Event Time
|
||||
- 告警时间
|
||||
- 发生时间
|
||||
clear_time:
|
||||
- Clear Time
|
||||
- Restore Time
|
||||
- 恢复时间
|
||||
- 清除时间
|
||||
severity_raw:
|
||||
- Alarm Severity
|
||||
- Severity
|
||||
- Level
|
||||
- 告警级别
|
||||
- 级别
|
||||
alarm_code:
|
||||
- Alarm Code
|
||||
- Alarm ID
|
||||
- Alarm Code Name
|
||||
- 告警码
|
||||
- 告警ID
|
||||
ne_name:
|
||||
- ME
|
||||
- NE Name
|
||||
- Node Name
|
||||
- 网元名称
|
||||
- 设备名称
|
||||
ne_id:
|
||||
- Aid
|
||||
- NE ID
|
||||
- 网元ID
|
||||
- 设备ID
|
||||
site_name:
|
||||
- Site
|
||||
- Site Name
|
||||
- 站点
|
||||
description:
|
||||
- Position
|
||||
- Description
|
||||
- Additional Info
|
||||
- 告警描述
|
||||
- 详细信息
|
||||
relevancy:
|
||||
- Relevancy
|
||||
- 相关性
|
||||
l3vpn_peer_ne:
|
||||
- L3VPN Peer NE
|
||||
- 对端网元
|
||||
service:
|
||||
- Service
|
||||
- 业务
|
||||
affected_client_service_number:
|
||||
- Affected Client Service Number
|
||||
- 影响用户数
|
||||
intermittence_count:
|
||||
- Intermittence Count
|
||||
- 间歇次数
|
||||
me_level:
|
||||
- ME Level
|
||||
- 网元级别
|
||||
ack_state:
|
||||
- Ack State
|
||||
- Ack
|
||||
- 确认状态
|
||||
clear_state:
|
||||
- Clear Type
|
||||
- Alarm State
|
||||
- 告警状态
|
||||
- 清除状态
|
||||
|
||||
severity_map:
|
||||
critical: critical
|
||||
major: major
|
||||
minor: minor
|
||||
warning: warning
|
||||
info: info
|
||||
紧急: critical
|
||||
危急: critical
|
||||
一级: critical
|
||||
重要: major
|
||||
严重: major
|
||||
二级: major
|
||||
次要: minor
|
||||
三级: minor
|
||||
警告: warning
|
||||
四级: warning
|
||||
信息: info
|
||||
通知: info
|
||||
14
netx_api/db.py
Normal file
14
netx_api/db.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_engine(settings.database_url, future=True, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||
202
netx_api/importer.py
Normal file
202
netx_api/importer.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import settings
|
||||
from .models import AlarmBatch, AlarmNorm, ImportErrorRow
|
||||
from .parser_config import ParserConfig
|
||||
|
||||
|
||||
def _dt(v: Any) -> datetime | None:
|
||||
if v is None:
|
||||
return None
|
||||
text = str(v).strip()
|
||||
if not text or text.lower() == "nan":
|
||||
return None
|
||||
parsed = pd.to_datetime(text, errors="coerce")
|
||||
if pd.isna(parsed):
|
||||
return None
|
||||
dt = parsed.to_pydatetime()
|
||||
# Convention: Excel source time is local timezone time.
|
||||
# Convert to UTC before persisting; keep DB as naive UTC for compatibility.
|
||||
if dt.tzinfo is None:
|
||||
local_tz = datetime.now().astimezone().tzinfo or timezone.utc
|
||||
dt = dt.replace(tzinfo=local_tz)
|
||||
else:
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
return dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _str(v: Any) -> str:
|
||||
if v is None:
|
||||
return ""
|
||||
text = str(v).strip()
|
||||
if text.lower() == "nan":
|
||||
return ""
|
||||
return text
|
||||
|
||||
|
||||
def _int(v: Any) -> int:
|
||||
if v is None:
|
||||
return 0
|
||||
text = str(v).strip()
|
||||
if not text or text.lower() == "nan":
|
||||
return 0
|
||||
try:
|
||||
return int(float(text))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def import_alarm_excel(db: Session, filename: str, content: bytes, parser: ParserConfig) -> AlarmBatch:
|
||||
df = pd.read_excel(BytesIO(content))
|
||||
headers = [str(h) for h in df.columns]
|
||||
|
||||
col_alarm_time = parser.resolve_col(headers, "alarm_time")
|
||||
col_clear_time = parser.resolve_col(headers, "clear_time")
|
||||
col_severity = parser.resolve_col(headers, "severity_raw")
|
||||
col_alarm_code = parser.resolve_col(headers, "alarm_code")
|
||||
col_ne_name = parser.resolve_col(headers, "ne_name")
|
||||
col_ne_id = parser.resolve_col(headers, "ne_id")
|
||||
col_site_name = parser.resolve_col(headers, "site_name")
|
||||
col_desc = parser.resolve_col(headers, "description")
|
||||
col_ack = parser.resolve_col(headers, "ack_state")
|
||||
col_state = parser.resolve_col(headers, "clear_state")
|
||||
col_relevancy = parser.resolve_col(headers, "relevancy")
|
||||
col_l3vpn_peer_ne = parser.resolve_col(headers, "l3vpn_peer_ne")
|
||||
col_service = parser.resolve_col(headers, "service")
|
||||
col_affected = parser.resolve_col(headers, "affected_client_service_number")
|
||||
col_intermittence = parser.resolve_col(headers, "intermittence_count")
|
||||
col_me_level = parser.resolve_col(headers, "me_level")
|
||||
|
||||
batch = AlarmBatch(
|
||||
source_file=filename,
|
||||
parser_version=parser.parser_version,
|
||||
dict_version=parser.dict_version,
|
||||
total_rows=int(len(df.index)),
|
||||
status="processing",
|
||||
)
|
||||
db.add(batch)
|
||||
db.flush()
|
||||
|
||||
success_rows = 0
|
||||
failed_rows = 0
|
||||
|
||||
for idx, row in df.iterrows():
|
||||
row_no = int(idx) + 2 # Excel header is row 1
|
||||
alarm_time = _dt(row.get(col_alarm_time)) if col_alarm_time else None
|
||||
raw_severity = _str(row.get(col_severity)) if col_severity else ""
|
||||
alarm_code = _str(row.get(col_alarm_code)) if col_alarm_code else ""
|
||||
ne_name = _str(row.get(col_ne_name)) if col_ne_name else ""
|
||||
ne_id = _str(row.get(col_ne_id)) if col_ne_id else ""
|
||||
description = _str(row.get(col_desc)) if col_desc else ""
|
||||
relevancy = _str(row.get(col_relevancy)) if col_relevancy else ""
|
||||
l3vpn_peer_ne = _str(row.get(col_l3vpn_peer_ne)) if col_l3vpn_peer_ne else ""
|
||||
service = _str(row.get(col_service)) if col_service else ""
|
||||
affected = _int(row.get(col_affected)) if col_affected else 0
|
||||
intermittence = _int(row.get(col_intermittence)) if col_intermittence else 0
|
||||
me_level = _str(row.get(col_me_level)) if col_me_level else ""
|
||||
|
||||
if not alarm_time:
|
||||
failed_rows += 1
|
||||
db.add(
|
||||
ImportErrorRow(
|
||||
batch_id=batch.batch_id,
|
||||
row_no=row_no,
|
||||
reason="missing_or_invalid_alarm_time",
|
||||
raw_json=json.dumps(row.to_dict(), ensure_ascii=False, default=str),
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not (ne_name or ne_id):
|
||||
failed_rows += 1
|
||||
db.add(
|
||||
ImportErrorRow(
|
||||
batch_id=batch.batch_id,
|
||||
row_no=row_no,
|
||||
reason="missing_ne_name_and_ne_id",
|
||||
raw_json=json.dumps(row.to_dict(), ensure_ascii=False, default=str),
|
||||
)
|
||||
)
|
||||
continue
|
||||
db.add(
|
||||
AlarmNorm(
|
||||
batch_id=batch.batch_id,
|
||||
row_no=row_no,
|
||||
alarm_time=alarm_time,
|
||||
clear_time=_dt(row.get(col_clear_time)) if col_clear_time else None,
|
||||
severity_raw=raw_severity,
|
||||
severity_norm=parser.normalize_severity(raw_severity),
|
||||
ne_name=ne_name,
|
||||
ne_id=ne_id,
|
||||
site_name=_str(row.get(col_site_name)) if col_site_name else "",
|
||||
alarm_code=alarm_code,
|
||||
alarm_name="",
|
||||
description=description,
|
||||
ack_state=_str(row.get(col_ack)) if col_ack else "",
|
||||
clear_state=_str(row.get(col_state)) if col_state else "",
|
||||
relevancy=relevancy,
|
||||
l3vpn_peer_ne=l3vpn_peer_ne,
|
||||
service=service,
|
||||
affected_client_service_number=affected,
|
||||
intermittence_count=intermittence,
|
||||
me_level=me_level,
|
||||
vendor=settings.vendor,
|
||||
source_type=settings.source_type,
|
||||
source_file=filename,
|
||||
raw_json=json.dumps(row.to_dict(), ensure_ascii=False, default=str),
|
||||
)
|
||||
)
|
||||
success_rows += 1
|
||||
|
||||
batch.success_rows = success_rows
|
||||
batch.failed_rows = failed_rows
|
||||
batch.status = "done"
|
||||
db.commit()
|
||||
db.refresh(batch)
|
||||
return batch
|
||||
|
||||
|
||||
def query_alarms(
|
||||
db: Session,
|
||||
*,
|
||||
batch_id: str | None,
|
||||
severity: str | None,
|
||||
alarm_code: str | None,
|
||||
ne_name: str | None,
|
||||
page: int,
|
||||
page_size: int,
|
||||
) -> tuple[int, list[AlarmNorm]]:
|
||||
stmt = select(AlarmNorm)
|
||||
if batch_id:
|
||||
stmt = stmt.where(AlarmNorm.batch_id == batch_id)
|
||||
if severity:
|
||||
stmt = stmt.where(AlarmNorm.severity_norm == severity)
|
||||
if alarm_code:
|
||||
stmt = stmt.where(AlarmNorm.alarm_code.contains(alarm_code))
|
||||
if ne_name:
|
||||
stmt = stmt.where(AlarmNorm.ne_name.contains(ne_name))
|
||||
total = int(db.scalar(select(func.count()).select_from(stmt.subquery())) or 0)
|
||||
rows = list(
|
||||
db.scalars(
|
||||
stmt.order_by(AlarmNorm.alarm_time.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
)
|
||||
)
|
||||
return total, rows
|
||||
|
||||
|
||||
def aggregate_alarms(db: Session, *, group_by: str, batch_id: str | None) -> list[tuple[str, int]]:
|
||||
if group_by not in {"severity_norm", "alarm_code", "ne_name"}:
|
||||
raise ValueError("unsupported_group_by")
|
||||
col = getattr(AlarmNorm, group_by)
|
||||
stmt = select(col, func.count()).group_by(col).order_by(func.count().desc())
|
||||
if batch_id:
|
||||
stmt = stmt.where(AlarmNorm.batch_id == batch_id)
|
||||
return [(str(key or ""), int(count)) for key, count in db.execute(stmt).all()]
|
||||
670
netx_api/main.py
Normal file
670
netx_api/main.py
Normal file
|
|
@ -0,0 +1,670 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from io import StringIO
|
||||
import time
|
||||
import re
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import text as sql_text
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Any
|
||||
import uvicorn
|
||||
|
||||
from .ap_client import analyze_with_oclaw, health_with_oclaw
|
||||
from .config import settings
|
||||
from .db import Base, SessionLocal, engine
|
||||
from .importer import aggregate_alarms, import_alarm_excel, query_alarms
|
||||
from .models import AiAnalyzeHistory, AlarmBatch, AlarmNorm, ImportErrorRow
|
||||
from .models import ImportJob
|
||||
from .parser_config import load_parser_config
|
||||
from .schemas import (
|
||||
AlarmAggregateBucket,
|
||||
AlarmAggregateResponse,
|
||||
AiAnalyzeHistoryItem,
|
||||
AiAnalyzeHistoryResponse,
|
||||
AlarmItem,
|
||||
AlarmQueryResponse,
|
||||
BatchSummary,
|
||||
ImportJobItem,
|
||||
ImportJobListResponse,
|
||||
)
|
||||
|
||||
app = FastAPI(title="netx ops tool", version="0.1.0")
|
||||
parser_cfg = load_parser_config()
|
||||
|
||||
_SQL_FORBIDDEN_RE = re.compile(
|
||||
r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|call|copy|vacuum|analyze)\b",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_utc(dt: datetime | None) -> datetime | None:
|
||||
if dt is None:
|
||||
return None
|
||||
# All timestamps are stored as UTC in DB (naive). Treat naive as UTC.
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
return dt.astimezone(timezone.utc)
|
||||
except Exception:
|
||||
return dt
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.post("/v1/sql/query")
|
||||
def sql_query(payload: dict[str, Any] | None = None, db: Session = Depends(get_db)) -> dict:
|
||||
"""
|
||||
Read-only SQL query endpoint for AI power users.
|
||||
|
||||
Safety constraints:
|
||||
- SELECT only, single statement (no ';')
|
||||
- forbid DDL/DML keywords
|
||||
- enforce max rows (server-side LIMIT wrapper)
|
||||
- require batch_id param and require SQL contains ':batch_id'
|
||||
"""
|
||||
payload = payload or {}
|
||||
sql = str(payload.get("sql") or "").strip()
|
||||
batch_id = str(payload.get("batch_id") or "").strip()
|
||||
limit = int(payload.get("limit") or 200)
|
||||
limit = max(1, min(limit, 2000))
|
||||
if not sql:
|
||||
raise HTTPException(status_code=400, detail="sql_required")
|
||||
if ";" in sql:
|
||||
raise HTTPException(status_code=400, detail="single_statement_only")
|
||||
low = sql.lower().lstrip()
|
||||
if not low.startswith("select"):
|
||||
raise HTTPException(status_code=400, detail="select_only")
|
||||
if _SQL_FORBIDDEN_RE.search(sql):
|
||||
raise HTTPException(status_code=400, detail="forbidden_keyword")
|
||||
if not batch_id:
|
||||
raise HTTPException(status_code=400, detail="batch_id_required")
|
||||
if ":batch_id" not in sql:
|
||||
raise HTTPException(status_code=400, detail="batch_id_param_required(:batch_id)")
|
||||
wrapped = f"select * from ({sql}) as q limit {limit}"
|
||||
try:
|
||||
res = db.execute(sql_text(wrapped), {"batch_id": batch_id})
|
||||
cols = list(res.keys())
|
||||
raw_rows = res.fetchall()
|
||||
rows: list[list[Any]] = []
|
||||
for r in raw_rows:
|
||||
out_row: list[Any] = []
|
||||
for v in list(r):
|
||||
if isinstance(v, datetime):
|
||||
out_row.append(((_ensure_utc(v) or v).isoformat().replace("+00:00", "Z")))
|
||||
else:
|
||||
out_row.append(v)
|
||||
rows.append(out_row)
|
||||
return {"ok": True, "columns": cols, "rows": rows, "limit": limit}
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"sql_failed:{str(exc)[:240]}") from exc
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
Base.metadata.create_all(bind=engine)
|
||||
# Best-effort schema evolution for new columns (no migrations framework).
|
||||
# Safe for Postgres (IF NOT EXISTS); ignored on failure.
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
conn.exec_driver_sql("ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS relevancy VARCHAR(128) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS l3vpn_peer_ne VARCHAR(256) DEFAULT ''")
|
||||
conn.exec_driver_sql("ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS service VARCHAR(256) DEFAULT ''")
|
||||
conn.exec_driver_sql(
|
||||
"ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS affected_client_service_number INTEGER DEFAULT 0"
|
||||
)
|
||||
conn.exec_driver_sql("ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS intermittence_count INTEGER DEFAULT 0")
|
||||
conn.exec_driver_sql("ALTER TABLE alarms_norm ADD COLUMN IF NOT EXISTS me_level VARCHAR(128) DEFAULT ''")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/v1/integrations/status")
|
||||
def integrations_status(db: Session = Depends(get_db)) -> dict:
|
||||
# netx api is up if this handler executes; still verify DB + oclaw bridge separately.
|
||||
netx_api = {"status": "up"}
|
||||
|
||||
db_status: dict = {"status": "unknown"}
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
db.execute(sql_text("select 1"))
|
||||
db_status = {"status": "up", "latency_ms": int((time.monotonic() - t0) * 1000)}
|
||||
except Exception as exc:
|
||||
db_status = {"status": "down", "error": str(exc)[:240]}
|
||||
|
||||
oclaw_status: dict = {"status": "unknown"}
|
||||
try:
|
||||
t0 = time.monotonic()
|
||||
data = health_with_oclaw()
|
||||
oclaw_status = {
|
||||
"status": "up",
|
||||
"latency_ms": int((time.monotonic() - t0) * 1000),
|
||||
"http_status": int(data.get("status_code") or 200),
|
||||
"detail": data.get("data") or {},
|
||||
}
|
||||
except Exception as exc:
|
||||
msg = str(exc)
|
||||
http_status = None
|
||||
kind = "unknown"
|
||||
if " 401 " in msg or "401" in msg:
|
||||
kind = "auth"
|
||||
http_status = 401
|
||||
elif " 404 " in msg or "404" in msg:
|
||||
kind = "not_found"
|
||||
http_status = 404
|
||||
elif "timeout" in msg.lower():
|
||||
kind = "timeout"
|
||||
elif "connect" in msg.lower():
|
||||
kind = "connect"
|
||||
else:
|
||||
kind = "other"
|
||||
oclaw_status = {"status": "down", "error_kind": kind, "http_status": http_status, "error": msg[:240]}
|
||||
|
||||
return {"netx_api": netx_api, "db": db_status, "oclaw_bridge": oclaw_status}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict:
|
||||
return {
|
||||
"ok": True,
|
||||
"mode": "api_only",
|
||||
"message": "netx UI is served by Vite frontend only",
|
||||
"frontend_url": settings.frontend_url,
|
||||
"api_health": "/health",
|
||||
"api_status": "/v1/integrations/status",
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/alarms/import", response_model=BatchSummary)
|
||||
async def import_alarms(file: UploadFile = File(...), db: Session = Depends(get_db)) -> BatchSummary:
|
||||
filename = str(file.filename or "alarm.xlsx")
|
||||
if not filename.lower().endswith((".xlsx", ".xls")):
|
||||
raise HTTPException(status_code=400, detail="only_excel_supported_in_phase1")
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="empty_file")
|
||||
batch = import_alarm_excel(db, filename=filename, content=content, parser=parser_cfg)
|
||||
|
||||
try:
|
||||
job = ImportJob(
|
||||
kind="alarms",
|
||||
file_name=filename,
|
||||
batch_id=str(batch.batch_id),
|
||||
ok=1,
|
||||
summary=f"success={int(batch.success_rows)} failed={int(batch.failed_rows)}",
|
||||
)
|
||||
db.add(job)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
return BatchSummary(
|
||||
batch_id=str(batch.batch_id),
|
||||
total_rows=int(batch.total_rows or 0),
|
||||
success_rows=int(batch.success_rows or 0),
|
||||
failed_rows=int(batch.failed_rows or 0),
|
||||
status=str(batch.status or ""),
|
||||
created_at=_ensure_utc(batch.created_at) or datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
@app.post("/v1/logs/import")
|
||||
async def import_logs(file: UploadFile = File(...)) -> dict:
|
||||
# Placeholder for Phase 2: logs parsing + storage + query.
|
||||
filename = str(file.filename or "logs.zip")
|
||||
if not filename:
|
||||
raise HTTPException(status_code=400, detail="filename_required")
|
||||
raise HTTPException(status_code=501, detail="logs_import_not_implemented")
|
||||
|
||||
|
||||
@app.get("/v1/jobs", response_model=ImportJobListResponse)
|
||||
def list_jobs(limit: int = Query(default=20, ge=1, le=100), db: Session = Depends(get_db)) -> ImportJobListResponse:
|
||||
rows = db.query(ImportJob).order_by(ImportJob.created_at.desc()).limit(limit).all()
|
||||
items = [
|
||||
ImportJobItem(
|
||||
id=int(x.id),
|
||||
kind=str(x.kind),
|
||||
file_name=str(x.file_name or ""),
|
||||
batch_id=str(x.batch_id) if x.batch_id else None,
|
||||
ok=bool(int(x.ok or 0)),
|
||||
summary=str(x.summary or ""),
|
||||
created_at=_ensure_utc(x.created_at) or datetime.now(timezone.utc),
|
||||
)
|
||||
for x in rows
|
||||
]
|
||||
return ImportJobListResponse(items=items)
|
||||
|
||||
|
||||
@app.get("/v1/batches")
|
||||
def list_batches(limit: int = Query(default=20, ge=1, le=100), db: Session = Depends(get_db)) -> dict:
|
||||
rows = db.query(AlarmBatch).order_by(AlarmBatch.created_at.desc()).limit(limit).all()
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"batch_id": x.batch_id,
|
||||
"source_file": x.source_file,
|
||||
"status": x.status,
|
||||
"total_rows": x.total_rows,
|
||||
"success_rows": x.success_rows,
|
||||
"failed_rows": x.failed_rows,
|
||||
"created_at": (_ensure_utc(x.created_at) or datetime.now(timezone.utc)).isoformat(),
|
||||
}
|
||||
for x in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/batches/{batch_id}/errors.csv")
|
||||
def download_batch_errors(batch_id: str, db: Session = Depends(get_db)):
|
||||
rows = (
|
||||
db.query(ImportErrorRow)
|
||||
.filter(ImportErrorRow.batch_id == batch_id)
|
||||
.order_by(ImportErrorRow.id.asc())
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
raise HTTPException(status_code=404, detail="batch_or_errors_not_found")
|
||||
buf = StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["row_no", "reason", "raw_json"])
|
||||
for r in rows:
|
||||
writer.writerow([r.row_no, r.reason, r.raw_json])
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="text/csv",
|
||||
headers={"content-disposition": f'attachment; filename="batch_{batch_id}_errors.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/v1/batches/{batch_id}")
|
||||
def delete_batch(batch_id: str, db: Session = Depends(get_db)) -> dict:
|
||||
batch = db.get(AlarmBatch, batch_id)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||
try:
|
||||
alarms_deleted = int(
|
||||
db.query(AlarmNorm).filter(AlarmNorm.batch_id == batch_id).delete(synchronize_session=False)
|
||||
)
|
||||
errors_deleted = int(
|
||||
db.query(ImportErrorRow).filter(ImportErrorRow.batch_id == batch_id).delete(synchronize_session=False)
|
||||
)
|
||||
jobs_deleted = int(
|
||||
db.query(ImportJob).filter(ImportJob.batch_id == batch_id).delete(synchronize_session=False)
|
||||
)
|
||||
db.delete(batch)
|
||||
db.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"batch_id": batch_id,
|
||||
"deleted": {
|
||||
"batch": 1,
|
||||
"alarms": alarms_deleted,
|
||||
"errors": errors_deleted,
|
||||
"jobs": jobs_deleted,
|
||||
},
|
||||
}
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"delete_batch_failed: {exc}") from exc
|
||||
|
||||
|
||||
@app.delete("/v1/batches")
|
||||
def delete_all_batches(db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
alarms_deleted = int(db.query(AlarmNorm).delete(synchronize_session=False))
|
||||
errors_deleted = int(db.query(ImportErrorRow).delete(synchronize_session=False))
|
||||
jobs_deleted = int(db.query(ImportJob).delete(synchronize_session=False))
|
||||
batches_deleted = int(db.query(AlarmBatch).delete(synchronize_session=False))
|
||||
db.commit()
|
||||
return {
|
||||
"ok": True,
|
||||
"deleted": {
|
||||
"batches": batches_deleted,
|
||||
"alarms": alarms_deleted,
|
||||
"errors": errors_deleted,
|
||||
"jobs": jobs_deleted,
|
||||
},
|
||||
}
|
||||
except Exception as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"delete_all_batches_failed: {exc}") from exc
|
||||
|
||||
|
||||
@app.get("/v1/diagnostics")
|
||||
def diagnostics(
|
||||
batch_id: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
sev_rows = aggregate_alarms(db, group_by="severity_norm", batch_id=batch_id)
|
||||
code_rows = aggregate_alarms(db, group_by="alarm_code", batch_id=batch_id)[:10]
|
||||
ne_rows = aggregate_alarms(db, group_by="ne_name", batch_id=batch_id)[:10]
|
||||
total = sum(count for _, count in sev_rows)
|
||||
|
||||
def _protocol_bucket(text: str) -> str:
|
||||
t = (text or "").upper()
|
||||
# IP/MPLS control plane
|
||||
if any(x in t for x in ("BGP", "OSPF", "ISIS", "LDP", "MPLS", "L3VPN", "VPN")):
|
||||
return "IP/MPLS"
|
||||
# Ethernet / packet
|
||||
if any(x in t for x in ("ETH", "GE", "10GE", "25GE", "40GE", "100GE", "XGE")):
|
||||
return "ETH"
|
||||
# OTN / optical
|
||||
if any(x in t for x in ("OTN", "ODU", "OCH", "OMS", "OSC", "DWDM", "WDM", "ROADM")):
|
||||
return "OTN/光"
|
||||
# Timing / clock
|
||||
if any(x in t for x in ("CLOCK", "SYNC", "PTP", "1588", "BITS", "TOD")):
|
||||
return "时钟"
|
||||
# Power
|
||||
if any(x in t for x in ("PWR", "POWER", "PSU", "BAT", "BATT")):
|
||||
return "电源"
|
||||
return "其他"
|
||||
|
||||
proto_counts: dict[str, int] = {}
|
||||
for name, desc, code, raw in (
|
||||
db.query(AlarmNorm.alarm_name, AlarmNorm.description, AlarmNorm.alarm_code, AlarmNorm.raw_json)
|
||||
.filter(AlarmNorm.batch_id == batch_id)
|
||||
.all()
|
||||
):
|
||||
blob = " | ".join([str(code or ""), str(name or ""), str(desc or ""), str(raw or "")])
|
||||
k = _protocol_bucket(blob)
|
||||
proto_counts[k] = int(proto_counts.get(k, 0)) + 1
|
||||
protocol_summary = sorted(proto_counts.items(), key=lambda x: x[1], reverse=True)[:10]
|
||||
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"total_alarms": int(total),
|
||||
"severity_summary": [{"key": k, "count": v} for k, v in sev_rows],
|
||||
"top_alarm_codes": [{"key": k, "count": v} for k, v in code_rows],
|
||||
"top_ne": [{"key": k, "count": v} for k, v in ne_rows],
|
||||
"protocol_summary": [{"key": k, "count": v} for k, v in protocol_summary],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/ap/analyze")
|
||||
def ap_analyze(payload: dict, db: Session = Depends(get_db)) -> dict:
|
||||
batch_id = str(payload.get("batch_id") or "").strip()
|
||||
question = str(payload.get("question") or "").strip()
|
||||
if not batch_id or not question:
|
||||
raise HTTPException(status_code=400, detail="batch_id_and_question_required")
|
||||
diag = diagnostics(batch_id=batch_id, db=db)
|
||||
analysis_request_id = str(payload.get("analysis_request_id") or "").strip()
|
||||
filters_obj = payload.get("filters") if isinstance(payload.get("filters"), dict) else {}
|
||||
req = {
|
||||
"analysis_request_id": analysis_request_id,
|
||||
"question": question,
|
||||
"dataset_ref": {
|
||||
"batch_id": batch_id,
|
||||
"filters": filters_obj or {},
|
||||
},
|
||||
"context": {
|
||||
"severity_summary": diag["severity_summary"],
|
||||
"top_alarm_codes": diag["top_alarm_codes"],
|
||||
"top_ne": diag["top_ne"],
|
||||
"protocol_summary": diag.get("protocol_summary", []),
|
||||
"findings": diag.get("findings", []),
|
||||
},
|
||||
"constraints": payload.get("constraints") or {"language": "zh-CN", "max_points": 6},
|
||||
"interaction_mode": "expert",
|
||||
"specialist": "ops",
|
||||
}
|
||||
ok = False
|
||||
err = ""
|
||||
oclaw_resp: dict[str, Any] | None = None
|
||||
try:
|
||||
oclaw_resp = analyze_with_oclaw(req)
|
||||
ok = bool(oclaw_resp.get("ok")) if isinstance(oclaw_resp, dict) else False
|
||||
except Exception as exc:
|
||||
err = str(exc)
|
||||
# Persist Q&A history (best-effort; never block response).
|
||||
try:
|
||||
answer = ""
|
||||
if isinstance(oclaw_resp, dict):
|
||||
answer = str(oclaw_resp.get("answer") or "").strip()
|
||||
row = AiAnalyzeHistory(
|
||||
analysis_request_id=analysis_request_id,
|
||||
batch_id=batch_id,
|
||||
question=question,
|
||||
filters_json=json.dumps(filters_obj or {}, ensure_ascii=False),
|
||||
ok=1 if ok else 0,
|
||||
answer=answer,
|
||||
error=err,
|
||||
evidence_json=json.dumps(diag or {}, ensure_ascii=False),
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
if not ok:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": err or "oclaw_bridge_unavailable",
|
||||
"fallback_diagnostics": diag,
|
||||
"batch_id": batch_id,
|
||||
"question": question,
|
||||
}
|
||||
return {"ok": True, "batch_id": batch_id, "question": question, "diagnostics": diag, "oclaw": oclaw_resp}
|
||||
|
||||
|
||||
@app.get("/v1/ap/history", response_model=AiAnalyzeHistoryResponse)
|
||||
def ap_history(
|
||||
batch_id: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AiAnalyzeHistoryResponse:
|
||||
q = db.query(AiAnalyzeHistory)
|
||||
if batch_id and str(batch_id).strip():
|
||||
q = q.filter(AiAnalyzeHistory.batch_id == str(batch_id).strip())
|
||||
total = int(q.count())
|
||||
rows = (
|
||||
q.order_by(AiAnalyzeHistory.id.desc())
|
||||
.offset((int(page) - 1) * int(page_size))
|
||||
.limit(int(page_size))
|
||||
.all()
|
||||
)
|
||||
items: list[AiAnalyzeHistoryItem] = []
|
||||
for r in rows:
|
||||
try:
|
||||
filters = json.loads(str(r.filters_json or "{}"))
|
||||
except Exception:
|
||||
filters = {}
|
||||
items.append(
|
||||
AiAnalyzeHistoryItem(
|
||||
id=int(r.id),
|
||||
analysis_request_id=str(r.analysis_request_id or ""),
|
||||
batch_id=str(r.batch_id or ""),
|
||||
question=str(r.question or ""),
|
||||
filters=filters if isinstance(filters, dict) else {},
|
||||
ok=bool(int(r.ok or 0) == 1),
|
||||
answer=str(r.answer or ""),
|
||||
error=str(r.error or ""),
|
||||
created_at=_ensure_utc(r.created_at) or datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
return AiAnalyzeHistoryResponse(total=total, page=page, page_size=page_size, items=items)
|
||||
|
||||
|
||||
@app.get("/v1/alarms", response_model=AlarmQueryResponse)
|
||||
def list_alarms(
|
||||
batch_id: str | None = Query(default=None),
|
||||
alarm_code: str | None = Query(default=None),
|
||||
severity: str | None = Query(default=None),
|
||||
ne_name: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AlarmQueryResponse:
|
||||
total, rows = query_alarms(
|
||||
db,
|
||||
batch_id=batch_id,
|
||||
alarm_code=alarm_code,
|
||||
severity=severity,
|
||||
ne_name=ne_name,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
items = [
|
||||
AlarmItem(
|
||||
id=x.id,
|
||||
batch_id=x.batch_id,
|
||||
row_no=x.row_no,
|
||||
alarm_time=_ensure_utc(x.alarm_time) or datetime.now(timezone.utc),
|
||||
severity_norm=x.severity_norm,
|
||||
severity_raw=x.severity_raw,
|
||||
ne_name=x.ne_name,
|
||||
alarm_code=x.alarm_code,
|
||||
description=x.description,
|
||||
ack_state=x.ack_state,
|
||||
)
|
||||
for x in rows
|
||||
]
|
||||
return AlarmQueryResponse(total=total, page=page, page_size=page_size, items=items)
|
||||
|
||||
|
||||
@app.get("/v1/alarms/fields")
|
||||
def alarms_fields() -> dict:
|
||||
"""List all columns in alarms_norm for power querying."""
|
||||
cols = []
|
||||
try:
|
||||
cols = [str(c.name) for c in AlarmNorm.__table__.columns] # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
cols = []
|
||||
return {"items": cols}
|
||||
|
||||
|
||||
def _serialize_alarm_row(row: AlarmNorm) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for c in AlarmNorm.__table__.columns: # type: ignore[attr-defined]
|
||||
name = str(c.name)
|
||||
v = getattr(row, name, None)
|
||||
if hasattr(v, "isoformat"):
|
||||
try:
|
||||
if isinstance(v, datetime):
|
||||
out[name] = (_ensure_utc(v) or v).isoformat()
|
||||
else:
|
||||
out[name] = v.isoformat() # datetime/date
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
out[name] = v
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/v1/alarms/raw")
|
||||
def alarms_raw(
|
||||
batch_id: str | None = Query(default=None),
|
||||
severity: str | None = Query(default=None),
|
||||
alarm_code: str | None = Query(default=None),
|
||||
ne_name: str | None = Query(default=None),
|
||||
q: str | None = Query(default=None, description="free text contains on alarm_code/ne_name/description/service"),
|
||||
order_by: str = Query(default="alarm_time"),
|
||||
order: str = Query(default="desc"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""
|
||||
Power query: return **all columns** for alarms_norm rows.
|
||||
|
||||
Safety constraints:
|
||||
- batch_id is required (avoid unbounded scans)
|
||||
- order_by is whitelisted
|
||||
- page_size capped
|
||||
"""
|
||||
bid = str(batch_id or "").strip()
|
||||
if not bid:
|
||||
raise HTTPException(status_code=400, detail="batch_id_required")
|
||||
stmt = db.query(AlarmNorm).filter(AlarmNorm.batch_id == bid)
|
||||
if severity and str(severity).strip():
|
||||
stmt = stmt.filter(AlarmNorm.severity_norm == str(severity).strip())
|
||||
if alarm_code and str(alarm_code).strip():
|
||||
stmt = stmt.filter(AlarmNorm.alarm_code.contains(str(alarm_code).strip()))
|
||||
if ne_name and str(ne_name).strip():
|
||||
stmt = stmt.filter(AlarmNorm.ne_name.contains(str(ne_name).strip()))
|
||||
if q and str(q).strip():
|
||||
qw = str(q).strip()
|
||||
stmt = stmt.filter(
|
||||
(AlarmNorm.alarm_code.contains(qw))
|
||||
| (AlarmNorm.ne_name.contains(qw))
|
||||
| (AlarmNorm.description.contains(qw))
|
||||
| (AlarmNorm.service.contains(qw))
|
||||
)
|
||||
allowed_order_by = {
|
||||
"id": AlarmNorm.id,
|
||||
"alarm_time": AlarmNorm.alarm_time,
|
||||
"severity_norm": AlarmNorm.severity_norm,
|
||||
"ne_name": AlarmNorm.ne_name,
|
||||
"alarm_code": AlarmNorm.alarm_code,
|
||||
}
|
||||
col = allowed_order_by.get(str(order_by or "").strip(), AlarmNorm.alarm_time)
|
||||
if str(order or "").strip().lower() == "asc":
|
||||
stmt = stmt.order_by(col.asc())
|
||||
else:
|
||||
stmt = stmt.order_by(col.desc())
|
||||
total = int(stmt.count())
|
||||
rows = (
|
||||
stmt.offset((int(page) - 1) * int(page_size))
|
||||
.limit(int(page_size))
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"total": total,
|
||||
"page": int(page),
|
||||
"page_size": int(page_size),
|
||||
"items": [_serialize_alarm_row(r) for r in rows],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/v1/alarms/aggregate", response_model=AlarmAggregateResponse)
|
||||
def alarms_aggregate(
|
||||
group_by: str = Query(default="severity_norm"),
|
||||
batch_id: str | None = Query(default=None),
|
||||
db: Session = Depends(get_db),
|
||||
) -> AlarmAggregateResponse:
|
||||
try:
|
||||
rows = aggregate_alarms(db, group_by=group_by, batch_id=batch_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
return AlarmAggregateResponse(
|
||||
group_by=group_by,
|
||||
buckets=[AlarmAggregateBucket(key=k, count=v) for k, v in rows],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/batches/{batch_id}")
|
||||
def get_batch(batch_id: str, db: Session = Depends(get_db)) -> dict:
|
||||
batch = db.get(AlarmBatch, batch_id)
|
||||
if not batch:
|
||||
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||
errors = (
|
||||
db.query(ImportErrorRow)
|
||||
.filter(ImportErrorRow.batch_id == batch_id)
|
||||
.order_by(ImportErrorRow.id.asc())
|
||||
.limit(20)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"batch": BatchSummary.model_validate(batch, from_attributes=True).model_dump(),
|
||||
"errors_preview": [
|
||||
{"row_no": e.row_no, "reason": e.reason, "raw_json": e.raw_json}
|
||||
for e in errors
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("netx_api.main:app", host=settings.host, port=settings.port, reload=False)
|
||||
207
netx_api/mcp_server.py
Normal file
207
netx_api/mcp_server.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from .db import SessionLocal
|
||||
from .importer import aggregate_alarms, query_alarms
|
||||
from .models import AlarmBatch
|
||||
|
||||
|
||||
def _ok(rid: Any, result: dict[str, Any]) -> None:
|
||||
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _err(rid: Any, code: int, message: str) -> None:
|
||||
sys.stdout.write(
|
||||
json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False)
|
||||
+ "\n"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _tool_list() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "queryAlarms",
|
||||
"description": "Query normalized alarms with filters and pagination.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"batch_id": {"type": "string"},
|
||||
"alarm_code": {"type": "string"},
|
||||
"severity": {"type": "string"},
|
||||
"ne_name": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1},
|
||||
"page_size": {"type": "integer", "minimum": 1, "maximum": 200},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "aggregateAlarms",
|
||||
"description": "Aggregate alarms by severity_norm, alarm_code or ne_name.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_by": {
|
||||
"type": "string",
|
||||
"enum": ["severity_norm", "alarm_code", "ne_name"],
|
||||
},
|
||||
"batch_id": {"type": "string"},
|
||||
},
|
||||
"required": ["group_by"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "getImportBatch",
|
||||
"description": "Get imported batch summary by batch_id.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"batch_id": {"type": "string"}},
|
||||
"required": ["batch_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "runDiagnostics",
|
||||
"description": "Generate quick diagnostics summary by batch.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"batch_id": {"type": "string"}},
|
||||
"required": ["batch_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
with SessionLocal() as db:
|
||||
if name == "queryAlarms":
|
||||
total, rows = query_alarms(
|
||||
db,
|
||||
batch_id=str(args.get("batch_id") or "").strip() or None,
|
||||
alarm_code=str(args.get("alarm_code") or "").strip() or None,
|
||||
severity=str(args.get("severity") or "").strip() or None,
|
||||
ne_name=str(args.get("ne_name") or "").strip() or None,
|
||||
page=max(1, int(args.get("page") or 1)),
|
||||
page_size=min(200, max(1, int(args.get("page_size") or 50))),
|
||||
)
|
||||
payload = {
|
||||
"total": total,
|
||||
"items": [
|
||||
{
|
||||
"id": r.id,
|
||||
"batch_id": r.batch_id,
|
||||
"alarm_time": r.alarm_time.isoformat(),
|
||||
"severity_norm": r.severity_norm,
|
||||
"ne_name": r.ne_name,
|
||||
"alarm_code": r.alarm_code,
|
||||
"alarm_name": r.alarm_name,
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if name == "aggregateAlarms":
|
||||
group_by = str(args.get("group_by") or "").strip()
|
||||
rows = aggregate_alarms(
|
||||
db,
|
||||
group_by=group_by,
|
||||
batch_id=str(args.get("batch_id") or "").strip() or None,
|
||||
)
|
||||
payload = {"group_by": group_by, "buckets": [{"key": k, "count": v} for k, v in rows]}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if name == "getImportBatch":
|
||||
batch_id = str(args.get("batch_id") or "").strip()
|
||||
batch = db.get(AlarmBatch, batch_id)
|
||||
if not batch:
|
||||
return {"content": [{"type": "text", "text": json.dumps({"error": "batch_not_found"})}], "isError": True}
|
||||
payload = {
|
||||
"batch_id": batch.batch_id,
|
||||
"total_rows": batch.total_rows,
|
||||
"success_rows": batch.success_rows,
|
||||
"failed_rows": batch.failed_rows,
|
||||
"status": batch.status,
|
||||
"created_at": batch.created_at.isoformat(),
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
if name == "runDiagnostics":
|
||||
batch_id = str(args.get("batch_id") or "").strip()
|
||||
sev_rows = aggregate_alarms(db, group_by="severity_norm", batch_id=batch_id)
|
||||
code_rows = aggregate_alarms(db, group_by="alarm_code", batch_id=batch_id)[:5]
|
||||
ne_rows = aggregate_alarms(db, group_by="ne_name", batch_id=batch_id)[:5]
|
||||
sev_map = {k: v for k, v in sev_rows}
|
||||
findings: list[str] = []
|
||||
actions: list[str] = []
|
||||
risk_level = "low"
|
||||
if int(sev_map.get("critical", 0)) > 0:
|
||||
findings.append("critical 告警存在,建议优先确认核心网元影响。")
|
||||
actions.append("优先处理 critical 告警,确认影响面并升级。")
|
||||
risk_level = "high"
|
||||
if int(sev_map.get("warning", 0)) > int(sev_map.get("major", 0)):
|
||||
findings.append("warning 占比较高,疑似阈值型告警风暴。")
|
||||
actions.append("检查高频 warning 告警码是否集中在单一阈值策略。")
|
||||
if risk_level != "high":
|
||||
risk_level = "medium"
|
||||
if not findings:
|
||||
findings.append("分布相对均衡,建议按 top 告警码进一步排查。")
|
||||
actions.append("按 top 告警码和网元继续下钻分析。")
|
||||
payload = {
|
||||
"batch_id": batch_id,
|
||||
"risk_level": risk_level,
|
||||
"severity_summary": [{"key": k, "count": v} for k, v in sev_rows],
|
||||
"top_alarm_codes": [{"key": k, "count": v} for k, v in code_rows],
|
||||
"top_ne": [{"key": k, "count": v} for k, v in ne_rows],
|
||||
"findings": findings,
|
||||
"actions": actions,
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||
raise ValueError(f"unknown tool: {name}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for line in sys.stdin:
|
||||
raw = line.strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(raw)
|
||||
except Exception:
|
||||
continue
|
||||
rid = req.get("id")
|
||||
method = str(req.get("method") or "")
|
||||
params = req.get("params") if isinstance(req.get("params"), dict) else {}
|
||||
|
||||
try:
|
||||
if method == "initialize":
|
||||
_ok(
|
||||
rid,
|
||||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "netx-mcp", "version": "0.1.0"},
|
||||
},
|
||||
)
|
||||
continue
|
||||
if method == "notifications/initialized":
|
||||
continue
|
||||
if method == "tools/list":
|
||||
_ok(rid, {"tools": _tool_list()})
|
||||
continue
|
||||
if method == "tools/call":
|
||||
name = str(params.get("name") or "")
|
||||
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
|
||||
_ok(rid, _call_tool(name, args))
|
||||
continue
|
||||
_err(rid, -32601, f"method not found: {method}")
|
||||
except Exception as exc:
|
||||
_err(rid, -32000, str(exc))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
101
netx_api/models.py
Normal file
101
netx_api/models.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .db import Base
|
||||
|
||||
|
||||
class AlarmBatch(Base):
|
||||
__tablename__ = "alarm_batches"
|
||||
|
||||
batch_id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||
source_file: Mapped[str] = mapped_column(String(512))
|
||||
parser_version: Mapped[str] = mapped_column(String(64), default="zte_alarm_monitor_v1")
|
||||
dict_version: Mapped[str] = mapped_column(String(64), default="v1")
|
||||
total_rows: Mapped[int] = mapped_column(Integer, default=0)
|
||||
success_rows: Mapped[int] = mapped_column(Integer, default=0)
|
||||
failed_rows: Mapped[int] = mapped_column(Integer, default=0)
|
||||
status: Mapped[str] = mapped_column(String(32), default="done")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
alarms: Mapped[list["AlarmNorm"]] = relationship(back_populates="batch")
|
||||
errors: Mapped[list["ImportErrorRow"]] = relationship(back_populates="batch")
|
||||
|
||||
|
||||
class AlarmNorm(Base):
|
||||
__tablename__ = "alarms_norm"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
batch_id: Mapped[str] = mapped_column(ForeignKey("alarm_batches.batch_id"), index=True)
|
||||
row_no: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
alarm_time: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
clear_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
severity_raw: Mapped[str] = mapped_column(String(64), default="")
|
||||
severity_norm: Mapped[str] = mapped_column(String(32), index=True, default="unknown")
|
||||
ne_name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
site_name: Mapped[str] = mapped_column(String(256), default="")
|
||||
alarm_code: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||
# NOTE: historically we stored `alarm_name` separately, but for ZTE Alarm Monitor exports
|
||||
# "Alarm Code Name" is a single column. We keep the DB column for backward compatibility,
|
||||
# but APIs/UI are unified on `alarm_code`.
|
||||
alarm_name: Mapped[str] = mapped_column(String(512), default="")
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
ack_state: Mapped[str] = mapped_column(String(64), default="")
|
||||
clear_state: Mapped[str] = mapped_column(String(64), default="")
|
||||
relevancy: Mapped[str] = mapped_column(String(128), default="")
|
||||
l3vpn_peer_ne: Mapped[str] = mapped_column(String(256), default="")
|
||||
service: Mapped[str] = mapped_column(String(256), default="")
|
||||
affected_client_service_number: Mapped[int] = mapped_column(Integer, default=0)
|
||||
intermittence_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
me_level: Mapped[str] = mapped_column(String(128), default="")
|
||||
vendor: Mapped[str] = mapped_column(String(64), default="ZTE")
|
||||
source_type: Mapped[str] = mapped_column(String(64), default="gateway_export_excel")
|
||||
source_file: Mapped[str] = mapped_column(String(512), default="")
|
||||
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
|
||||
batch: Mapped[AlarmBatch] = relationship(back_populates="alarms")
|
||||
|
||||
|
||||
class ImportErrorRow(Base):
|
||||
__tablename__ = "import_errors"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
batch_id: Mapped[str] = mapped_column(ForeignKey("alarm_batches.batch_id"), index=True)
|
||||
row_no: Mapped[int] = mapped_column(Integer)
|
||||
reason: Mapped[str] = mapped_column(String(512))
|
||||
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
|
||||
batch: Mapped[AlarmBatch] = relationship(back_populates="errors")
|
||||
|
||||
|
||||
class ImportJob(Base):
|
||||
__tablename__ = "import_jobs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
kind: Mapped[str] = mapped_column(String(32), index=True) # alarms/logs/config
|
||||
file_name: Mapped[str] = mapped_column(String(512), default="")
|
||||
batch_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
ok: Mapped[int] = mapped_column(Integer, default=1) # 1 ok, 0 error
|
||||
summary: Mapped[str] = mapped_column(String(1024), default="")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class AiAnalyzeHistory(Base):
|
||||
__tablename__ = "ai_analyze_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
analysis_request_id: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||
batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||
question: Mapped[str] = mapped_column(Text, default="")
|
||||
filters_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
ok: Mapped[int] = mapped_column(Integer, default=1) # 1 ok, 0 error
|
||||
answer: Mapped[str] = mapped_column(Text, default="")
|
||||
error: Mapped[str] = mapped_column(Text, default="")
|
||||
evidence_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||
46
netx_api/parser_config.py
Normal file
46
netx_api/parser_config.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
def _normalize_key(s: str) -> str:
|
||||
return "".join(ch for ch in str(s).strip().lower() if ch not in {" ", "_", "-"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParserConfig:
|
||||
parser_version: str
|
||||
dict_version: str
|
||||
aliases: dict[str, list[str]]
|
||||
severity_map: dict[str, str]
|
||||
|
||||
def resolve_col(self, headers: list[str], field: str) -> str | None:
|
||||
alias_set = {_normalize_key(x) for x in self.aliases.get(field, [])}
|
||||
for h in headers:
|
||||
if _normalize_key(h) in alias_set:
|
||||
return h
|
||||
return None
|
||||
|
||||
def normalize_severity(self, raw: str) -> str:
|
||||
key = _normalize_key(raw)
|
||||
return self.severity_map.get(key, "unknown")
|
||||
|
||||
|
||||
def load_parser_config(path: str | None = None) -> ParserConfig:
|
||||
cfg_path = Path(path or settings.parser_config)
|
||||
data: dict[str, Any] = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
|
||||
aliases = data.get("aliases") or {}
|
||||
severity = data.get("severity_map") or {}
|
||||
severity_map = {_normalize_key(k): str(v) for k, v in severity.items()}
|
||||
return ParserConfig(
|
||||
parser_version=str(data.get("parser_version") or "zte_alarm_monitor_v1"),
|
||||
dict_version=str(data.get("dict_version") or "v1"),
|
||||
aliases={str(k): [str(x) for x in (v or [])] for k, v in aliases.items()},
|
||||
severity_map=severity_map,
|
||||
)
|
||||
77
netx_api/schemas.py
Normal file
77
netx_api/schemas.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class BatchSummary(BaseModel):
|
||||
batch_id: str
|
||||
total_rows: int
|
||||
success_rows: int
|
||||
failed_rows: int
|
||||
status: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AlarmItem(BaseModel):
|
||||
id: int
|
||||
batch_id: str
|
||||
row_no: int
|
||||
alarm_time: datetime
|
||||
severity_norm: str
|
||||
severity_raw: str
|
||||
ne_name: str
|
||||
alarm_code: str
|
||||
description: str
|
||||
ack_state: str
|
||||
|
||||
|
||||
class AlarmQueryResponse(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
items: list[AlarmItem]
|
||||
|
||||
|
||||
class AlarmAggregateBucket(BaseModel):
|
||||
key: str
|
||||
count: int
|
||||
|
||||
|
||||
class AlarmAggregateResponse(BaseModel):
|
||||
group_by: str = Field(pattern="^(severity_norm|alarm_code|ne_name)$")
|
||||
buckets: list[AlarmAggregateBucket]
|
||||
|
||||
|
||||
class ImportJobItem(BaseModel):
|
||||
id: int
|
||||
kind: str
|
||||
file_name: str
|
||||
batch_id: str | None = None
|
||||
ok: bool
|
||||
summary: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ImportJobListResponse(BaseModel):
|
||||
items: list[ImportJobItem]
|
||||
|
||||
|
||||
class AiAnalyzeHistoryItem(BaseModel):
|
||||
id: int
|
||||
analysis_request_id: str = ""
|
||||
batch_id: str = ""
|
||||
question: str = ""
|
||||
filters: dict = Field(default_factory=dict)
|
||||
ok: bool
|
||||
answer: str = ""
|
||||
error: str = ""
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AiAnalyzeHistoryResponse(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
items: list[AiAnalyzeHistoryItem]
|
||||
Loading…
Add table
Add a link
Reference in a new issue