from __future__ import annotations import csv import json import logging from datetime import datetime, timezone from io import StringIO import time import re import threading _schedule_log = logging.getLogger("netx.ume.schedule") _BOOT_MONO = time.monotonic() 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, 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 .models import ( AiAnalyzeHistory, AlarmBatch, AlarmNorm, ImportErrorRow, ManagedNE, NeCollectionJob, NeCollectionRun, UmeAlarmCurrent, UmeAlarmHistory, UmeInventoryNE, UmeKeyAlertRule, UmeKeyAlertForwardLog, UmeSyncJob, ) from .models import ImportJob from .parser_config import load_parser_config from .ume_client import UMEClient from .ume_alarm_ws import ( begin_startup_alarm_sync_gate, cancel_alarm_subscription_manual, clear_local_alarm_subscription_manual, complete_startup_alarm_sync_gate, establish_alarm_subscription_manual, get_alarms_coordination_status, get_subscription_status, get_ws_connection_status, get_ws_logs, is_startup_alarm_sync_pending, is_wss_active_for_current_alarms, load_persisted_subscription, request_ws_reconnect, shutdown_ws_consumer, start_ume_alarm_ws_consumer, ) from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full from .key_alert_matcher import ( invalidate_key_alert_rule_cache, normalize_match_type, rule_match_type, rule_match_value, rule_storage_key, ) from .oclaw_alarm_forwarder import forwarder_status, shutdown_oclaw_alarm_forwarder, start_oclaw_alarm_forwarder from .ume_token_store import ( clear_shared_token, load_shared_token, release_refresh_lock, save_shared_token, try_acquire_refresh_lock, wait_for_token_update, ) from .schemas import ( AlarmAggregateBucket, AlarmAggregateResponse, AiAnalyzeHistoryItem, AiAnalyzeHistoryResponse, AlarmItem, AlarmQueryResponse, BatchSummary, ImportJobItem, ImportJobListResponse, ) 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() _UME_CLIENT_SINGLETON = UMEClient( token_loader=lambda: load_shared_token(), token_saver=lambda token, exp: save_shared_token(token, exp), token_clearer=lambda: clear_shared_token(), lock_acquirer=lambda: try_acquire_refresh_lock(), lock_releaser=lambda: release_refresh_lock(), token_waiter=lambda min_exp: wait_for_token_update(min_expires_at_epoch_s=float(min_exp)), ) _SQL_FORBIDDEN_RE = re.compile( r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|call|copy|vacuum|analyze)\b", flags=re.IGNORECASE, ) _UME_RUNTIME_TASKS: dict[str, dict[str, Any]] = { "token_keepalive": {"task": "token_keepalive", "status": "init", "last_run_at": None, "last_error": ""}, "alarms_current_auto_sync": {"task": "alarms_current_auto_sync", "status": "init", "last_run_at": None, "last_error": ""}, "alarms_current_ws_consumer": {"task": "alarms_current_ws_consumer", "status": "init", "last_run_at": None, "last_error": ""}, "inventory_auto_sync": {"task": "inventory_auto_sync", "status": "init", "last_run_at": None, "last_error": ""}, } _UME_WS_STOP_EVENT: threading.Event | None = None _UME_RUNTIME_PAUSED: dict[str, bool] = {} UME_KNOWN_RUNTIME_TASKS: tuple[str, ...] = tuple(_UME_RUNTIME_TASKS.keys()) _UME_RUNTIME_LOCK = threading.Lock() # Debounce skip / wake for scheduled sync threads (resume should not wait full interval). _UME_DEBOUNCE_MUTEX = threading.Lock() _UME_SYNC_SKIP_DEBOUNCE: set[str] = set() _UME_DEBOUNCE_WAKE: dict[str, threading.Event] = {} def _debounce_wake_event(task_id: str) -> threading.Event: with _UME_DEBOUNCE_MUTEX: ev = _UME_DEBOUNCE_WAKE.get(task_id) if ev is None: ev = threading.Event() _UME_DEBOUNCE_WAKE[task_id] = ev return ev def _request_force_sync_after_resume(task_id: str) -> None: """Skip next debounce wait and interrupt an in-progress debounce sleep (UI 开始).""" with _UME_DEBOUNCE_MUTEX: _UME_SYNC_SKIP_DEBOUNCE.add(task_id) try: _debounce_wake_event(task_id).set() except Exception: pass def _clear_force_resume_hints(task_id: str) -> None: """Pause: drop pending skip/wake so state is predictable.""" with _UME_DEBOUNCE_MUTEX: _UME_SYNC_SKIP_DEBOUNCE.discard(task_id) try: _debounce_wake_event(task_id).clear() except Exception: pass def _reset_debounce_wakeup() -> None: with _UME_DEBOUNCE_MUTEX: _UME_SYNC_SKIP_DEBOUNCE.clear() for ev in _UME_DEBOUNCE_WAKE.values(): try: ev.clear() except Exception: pass def _set_runtime_task(task: str, *, status: str, last_run_at: datetime | None = None, last_error: str = "") -> None: with _UME_RUNTIME_LOCK: item = _UME_RUNTIME_TASKS.get(task, {"task": task, "status": "init", "last_run_at": None, "last_error": ""}) item["status"] = str(status or "unknown") if last_run_at is not None: item["last_run_at"] = last_run_at item["last_error"] = str(last_error or "") _UME_RUNTIME_TASKS[task] = item def _runtime_is_paused(task: str) -> bool: with _UME_RUNTIME_LOCK: return bool(_UME_RUNTIME_PAUSED.get(str(task or "").strip())) def _runtime_pause_task(task: str) -> None: tid = str(task or "").strip() with _UME_RUNTIME_LOCK: if tid not in _UME_RUNTIME_TASKS: raise KeyError(tid) _UME_RUNTIME_PAUSED[tid] = True def _runtime_resume_task(task: str) -> None: tid = str(task or "").strip() with _UME_RUNTIME_LOCK: _UME_RUNTIME_PAUSED[tid] = False def _format_runtime_interval_label(seconds: int) -> str: s = max(1, int(seconds)) if s >= 3600 and s % 3600 == 0: h = s // 3600 return f"{h} h" if s >= 60 and s % 60 == 0: m = s // 60 return f"{m} min" return f"{s}s" def _runtime_task_interval_fields(task_id: str) -> tuple[int | None, str]: """Effective loop interval as configured at process start (matches startup clamps).""" if task_id == "token_keepalive": if not bool(getattr(settings, "ume_keepalive_enabled", True)): return None, "disabled" interval_s = int(getattr(settings, "ume_keepalive_interval_s", 600) or 600) eff = max(30, min(interval_s, 3600)) return eff, _format_runtime_interval_label(eff) if task_id == "alarms_current_auto_sync": if not bool(getattr(settings, "ume_sync_alarms_current_enabled", True)): return None, "disabled" interval_s = int(getattr(settings, "ume_sync_alarms_current_interval_s", 18000) or 18000) eff = max(30, min(interval_s, 86400)) return eff, _format_runtime_interval_label(eff) if task_id == "alarms_current_ws_consumer": if not bool(getattr(settings, "ume_alarm_ws_enabled", True)): return None, "disabled" return None, "realtime" if task_id == "inventory_auto_sync": if not bool(getattr(settings, "ume_sync_inventory_auto_enabled", True)): return None, "disabled" hours = int(getattr(settings, "ume_sync_inventory_every_hours", 48) or 48) hours = max(1, min(hours, 168)) eff = int(hours * 3600) return eff, _format_runtime_interval_label(eff) return None, "—" def _list_runtime_tasks() -> list[dict[str, Any]]: with _UME_RUNTIME_LOCK: out: list[dict[str, Any]] = [] for v in _UME_RUNTIME_TASKS.values(): task_id = str(v.get("task") or "") paused = bool(_UME_RUNTIME_PAUSED.get(task_id)) eff_status = "paused" if paused else str(v.get("status") or "unknown") ts = _ensure_utc(v.get("last_run_at")) if isinstance(v.get("last_run_at"), datetime) else None interval_s, interval_label = _runtime_task_interval_fields(task_id) out.append( { "task": task_id, "status": eff_status, "paused": paused, "last_run_at": ts.isoformat() if ts else None, "last_error": str(v.get("last_error") or ""), "interval_s": interval_s, "interval_label": interval_label, } ) return out 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 _reset_runtime_pause_flags() -> None: """Ensure no task is stuck paused in memory after process boot (pause is not persisted).""" with _UME_RUNTIME_LOCK: for tid in UME_KNOWN_RUNTIME_TASKS: _UME_RUNTIME_PAUSED[tid] = False _reset_debounce_wakeup() def _fail_stale_running_sync_jobs_on_startup() -> None: """Orphan running rows (crashed mid-sync) confuse scheduling; close them so interval uses real ended_at.""" db = SessionLocal() try: rows = ( db.query(UmeSyncJob) .filter(UmeSyncJob.status == "running", UmeSyncJob.ended_at.is_(None)) .all() ) if not rows: return now_naive = datetime.utcnow() for row in rows: row.status = "failed" row.ended_at = now_naive msg = str(row.error_message or "").strip() suffix = "stale_running_reset_on_startup" row.error_message = (msg + ("; " if msg else "") + suffix)[:1024] db.commit() _schedule_log.warning("startup: closed %s orphaned running ume_sync_jobs", len(rows)) except Exception: _schedule_log.exception("startup: stale sync job cleanup failed") finally: db.close() def _needs_startup_alarm_sync_before_ws() -> bool: ume_url = str(getattr(settings, "ume_base_url", "") or "").strip() return bool( getattr(settings, "ume_startup_sync_alarms_before_ws", True) and getattr(settings, "ume_alarm_ws_enabled", True) and getattr(settings, "ume_sync_alarms_current_enabled", True) and ume_url ) def _startup_alarm_pull_delay_s() -> int: return max(0, min(3600, int(getattr(settings, "ume_startup_alarm_sync_delay_s", 60) or 60))) def _wait_until_startup_alarm_pull_allowed(label: str) -> None: delay_s = _startup_alarm_pull_delay_s() if delay_s <= 0: return remaining = float(delay_s) - (time.monotonic() - _BOOT_MONO) if remaining <= 0: return _schedule_log.info("%s: defer alarm pull %.0fs after process start", label, remaining) time.sleep(remaining) def _run_startup_alarm_sync_before_ws() -> None: """REST-sync current alarms once on boot; WSS gate must already be closed in on_startup.""" if not _needs_startup_alarm_sync_before_ws(): complete_startup_alarm_sync_gate() return _wait_until_startup_alarm_pull_allowed("startup_alarm_sync") try: _schedule_log.info("startup: REST current-alarm snapshot (WSS blocked until finished)") _set_runtime_task( "alarms_current_auto_sync", status="running", last_run_at=datetime.now(timezone.utc), last_error="启动:正在同步当前告警(完成后连接 WSS)…", ) db = SessionLocal() try: client = _ume_client() sync_alarms_current(db, client, trigger_mode="schedule", wss_active=False) _schedule_log.info("startup: current alarms sync completed, WSS may connect") _set_runtime_task( "alarms_current_auto_sync", status="running", last_run_at=datetime.now(timezone.utc), last_error="", ) finally: db.close() except RuntimeError as exc: if str(exc) != "alarms_current_sync_busy": raise _schedule_log.warning("startup: skip REST before WSS — sync already in progress") except Exception as exc: _schedule_log.exception("startup: current alarms sync before WSS failed: %s", exc) _set_runtime_task( "alarms_current_auto_sync", status="error", last_run_at=datetime.now(timezone.utc), last_error=str(exc)[:240], ) finally: complete_startup_alarm_sync_gate() def _sleep_or_until_paused(task_id: str, total_s: float) -> None: """Sleep up to total_s wall seconds; honor pause; wake early on resume (debounce interrupt).""" deadline = time.time() + max(0.0, float(total_s)) ev = _debounce_wake_event(task_id) ev.clear() while time.time() < deadline: if _runtime_is_paused(task_id): time.sleep(1) continue remaining = deadline - time.time() if remaining <= 0: break timeout = min(2.0, remaining) if ev.wait(timeout=timeout): ev.clear() _schedule_log.info("%s: debounce wait interrupted (resume)", task_id) with _UME_DEBOUNCE_MUTEX: _UME_SYNC_SKIP_DEBOUNCE.discard(task_id) return if ev.is_set(): ev.clear() def _last_finished_job_ended_at(db: Session, domain: str) -> datetime | None: """Latest finished sync job end time for domain (success or failed).""" row = ( db.query(UmeSyncJob) .filter( UmeSyncJob.domain == domain, UmeSyncJob.ended_at.isnot(None), ) .order_by(UmeSyncJob.ended_at.desc()) .limit(1) .first() ) if not row or row.ended_at is None: return None return _ensure_utc(row.ended_at) def _seconds_since_last_finished_job(db: Session, domain: str) -> float | None: """Seconds since latest job with ended_at for domain (done or failed). None if none.""" end = _last_finished_job_ended_at(db, domain) if end is None: return None return max(0.0, (datetime.now(timezone.utc) - end).total_seconds()) def _refresh_runtime_task_idle(task_id: str, domain: str, *, last_error: str | None = None) -> None: """Mark scheduled sync task running; last_run_at = last finished job time (idle / debounce).""" with _UME_RUNTIME_LOCK: prev_error = str((_UME_RUNTIME_TASKS.get(task_id) or {}).get("last_error") or "") db = SessionLocal() try: ended = _last_finished_job_ended_at(db, domain) finally: db.close() _set_runtime_task( task_id, status="idle", last_run_at=ended, last_error=prev_error if last_error is None else last_error, ) def _maybe_wait_for_sync_interval( *, task_id: str, domain: str, interval_s: int, label: str, ) -> None: """Sleep until interval elapsed since last finished job (ended_at), if any.""" with _UME_DEBOUNCE_MUTEX: if task_id in _UME_SYNC_SKIP_DEBOUNCE: _UME_SYNC_SKIP_DEBOUNCE.discard(task_id) _schedule_log.info("%s: debounce skipped (resume/kick)", label) return db = SessionLocal() try: elapsed = _seconds_since_last_finished_job(db, domain) finally: db.close() _refresh_runtime_task_idle(task_id, domain) if elapsed is None: _schedule_log.info("%s: no prior finished job for %s, sync now", label, domain) return if elapsed >= float(interval_s): _schedule_log.info("%s: last finished %.0fs ago (>= %ss), sync now", label, elapsed, interval_s) return wait_s = float(interval_s) - elapsed _schedule_log.info("%s: last finished %.0fs ago, wait %.0fs before sync", label, elapsed, wait_s) _sleep_or_until_paused(task_id, wait_s) def _parse_time(text: str | None) -> datetime | None: s = str(text or "").strip() if not s: return None s2 = s.replace("Z", "+00:00") try: dt = datetime.fromisoformat(s2) return _ensure_utc(dt) except Exception: return None def _aggregate_rows(items: list[Any], key_fn) -> list[dict[str, Any]]: bucket: dict[str, int] = {} for item in items: key = str(key_fn(item) or "").strip() if not key: key = "unknown" bucket[key] = int(bucket.get(key, 0)) + 1 return [{"key": k, "count": v} for k, v in sorted(bucket.items(), key=lambda kv: kv[1], reverse=True)] def _ume_alarm_host_name( alarm: UmeAlarmCurrent | UmeAlarmHistory, ne: UmeInventoryNE | None = None, ) -> str: hn = str(getattr(alarm, "host_name", "") or "").strip() if hn: return hn if ne is not None: return str(getattr(ne, "host_name", "") or "").strip() return "" def _ume_alarm_ne_group_key( alarm: UmeAlarmCurrent | UmeAlarmHistory, ne: UmeInventoryNE | None, ) -> str: return ( _ume_alarm_host_name(alarm, ne) or (str(ne.user_label if ne else "") or "").strip() or (str(ne.ne_name if ne else "") or "").strip() or str(alarm.ne_id or "").strip() or "unknown" ) _PROTOCOL_BUCKET_ZH: dict[str, str] = { "IP/MPLS": "IP/MPLS", "ETH": "ETH", "OTN/Optical": "OTN/光", "Clock": "时钟", "Power": "电源", "Other": "其他", } def _classify_protocol_bucket(text: str) -> str: """Canonical English protocol/technology bucket id.""" t = (text or "").upper() if any(x in t for x in ("BGP", "OSPF", "ISIS", "LDP", "MPLS", "L3VPN", "VPN")): return "IP/MPLS" if any(x in t for x in ("ETH", "GE", "10GE", "25GE", "40GE", "100GE", "XGE")): return "ETH" if any(x in t for x in ("OTN", "ODU", "OCH", "OMS", "OSC", "DWDM", "WDM", "ROADM")): return "OTN/Optical" if any(x in t for x in ("CLOCK", "SYNC", "PTP", "1588", "BITS", "TOD")): return "Clock" if any(x in t for x in ("PWR", "POWER", "PSU", "BAT", "BATT")): return "Power" return "Other" def _protocol_bucket_label(text: str, *, lang: str = "zh") -> str: key = _classify_protocol_bucket(text) if str(lang or "").strip().lower().startswith("en"): return key return _PROTOCOL_BUCKET_ZH.get(key, key) def _normalize_netx_lang(lang: str | None) -> str: return "en" if str(lang or "").strip().lower().startswith("en") else "zh" def _ume_client() -> UMEClient: return _UME_CLIENT_SINGLETON def _ume_error_kind(err: str) -> str: low = str(err or "").lower() if "401" in low or "403" in low or "password" in low or "auth" in low: return "auth_failed" if "timeout" in low: return "timeout" if "tls" in low or "certificate" in low or "ssl" in low: return "tls_failed" if "connect" in low or "name or service not known" in low: return "connect_failed" if "handshake" in low: return "handshake_failed" return "other" @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.post("/v1/sql/ume_query") def sql_ume_query(payload: dict[str, Any] | None = None, db: Session = Depends(get_db)) -> dict: """ Read-only SQL query endpoint for UME current alarms/inventory. Safety constraints: - SELECT only, single statement (no ';') - forbid DDL/DML keywords - enforce max rows (server-side LIMIT wrapper) - only allow FROM/JOIN on ume_alarms_current and ume_inventory_ne """ payload = payload or {} sql = str(payload.get("sql") or "").strip() limit = int(payload.get("limit") or 200) limit = max(1, min(limit, 2000)) statement_timeout_ms = int(payload.get("statement_timeout_ms") or 0) statement_timeout_ms = max(0, min(statement_timeout_ms, 30000)) 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") allowed_tables = {"ume_alarms_current", "ume_inventory_ne"} refs = re.findall(r"\b(?:from|join)\s+([a-zA-Z0-9_\"\.]+)", sql, flags=re.IGNORECASE) for ref in refs: normalized = str(ref).strip().strip('"') if "." in normalized: normalized = normalized.split(".")[-1] if normalized.lower() not in allowed_tables: raise HTTPException(status_code=400, detail=f"ume_table_not_allowed:{normalized}") wrapped = f"select * from ({sql}) as q limit {limit}" try: if statement_timeout_ms > 0: try: if str(getattr(getattr(db, "bind", None), "dialect", None).name).lower().startswith("postgres"): db.execute(sql_text("SET LOCAL statement_timeout = :ms"), {"ms": int(statement_timeout_ms)}) except Exception: pass res = db.execute(sql_text(wrapped)) 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 def _configure_ume_diag_logging() -> None: """Emit netx.ume.* INFO to stderr so background scripts/.run/*.log and consoles show scheduler lines.""" fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") for name in ("netx.ume.schedule", "netx.ume.sync"): lg = logging.getLogger(name) if lg.handlers: continue h = logging.StreamHandler() h.setFormatter(fmt) lg.addHandler(h) lg.setLevel(logging.INFO) lg.propagate = False @app.on_event("startup") def on_startup() -> None: _configure_ume_diag_logging() Base.metadata.create_all(bind=engine) _reset_runtime_pause_flags() _fail_stale_running_sync_jobs_on_startup() if _needs_startup_alarm_sync_before_ws(): begin_startup_alarm_sync_gate() _schedule_log.info( "startup: WSS blocked until initial REST current-alarm sync completes (delay=%ss)", _startup_alarm_pull_delay_s(), ) else: complete_startup_alarm_sync_gate() 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). # Safe for Postgres (IF NOT EXISTS); ignored on failure. try: with engine.begin() as conn: # Removed from ORM: drop legacy holder table if present (was optional nested UME data). conn.exec_driver_sql("DROP TABLE IF EXISTS ume_inventory_equipment_holder") 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 ''") conn.exec_driver_sql("ALTER TABLE ume_token_cache ADD COLUMN IF NOT EXISTS lock_owner VARCHAR(128) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE ume_token_cache ADD COLUMN IF NOT EXISTS lock_expires_at_epoch_s INTEGER DEFAULT 0") conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS device_level VARCHAR(64) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS host_name VARCHAR(256) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS location VARCHAR(512) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS ipv6_address VARCHAR(128) DEFAULT ''") conn.exec_driver_sql( "ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS hardware_version VARCHAR(128) DEFAULT ''" ) conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS loopback VARCHAR(128) DEFAULT ''") conn.exec_driver_sql( "ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS consistent_state VARCHAR(64) DEFAULT ''" ) conn.exec_driver_sql( "ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS interface_version VARCHAR(128) DEFAULT ''" ) conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS mac VARCHAR(128) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS admin_status VARCHAR(64) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS address_type VARCHAR(64) DEFAULT ''") conn.exec_driver_sql( "ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS connection_status VARCHAR(64) DEFAULT ''" ) conn.exec_driver_sql( "ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS maintain_status VARCHAR(64) DEFAULT ''" ) conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS net_mask VARCHAR(128) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS create_time VARCHAR(64) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS creator VARCHAR(128) DEFAULT ''") # Allow long UME alarm fields; avoid StringDataRightTruncation on large payloads. conn.exec_driver_sql("ALTER TABLE ume_alarms_current ALTER COLUMN alarm_key TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_current ALTER COLUMN object_name TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_current ALTER COLUMN event_type TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_current ALTER COLUMN native_probable_cause TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_current ALTER COLUMN perceived_severity TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_current ALTER COLUMN is_cleared TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_current ALTER COLUMN time_created TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_current ALTER COLUMN root_cause_alarm_indication TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_current ADD COLUMN IF NOT EXISTS host_name VARCHAR(256) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE ume_alarms_history ADD COLUMN IF NOT EXISTS host_name VARCHAR(256) DEFAULT ''") conn.exec_driver_sql( "ALTER TABLE ume_alarms_current ADD COLUMN IF NOT EXISTS notification_id VARCHAR(128) DEFAULT ''" ) conn.exec_driver_sql( "ALTER TABLE ume_alarms_history ADD COLUMN IF NOT EXISTS notification_id VARCHAR(128) DEFAULT ''" ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS ix_ume_alarms_current_notification_id ON ume_alarms_current (notification_id)" ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS ix_ume_alarms_history_notification_id ON ume_alarms_history (notification_id)" ) conn.exec_driver_sql( "ALTER TABLE ume_key_alert_rule ADD COLUMN IF NOT EXISTS match_type VARCHAR(32) DEFAULT 'notification_id'" ) conn.exec_driver_sql( "ALTER TABLE ume_key_alert_rule ADD COLUMN IF NOT EXISTS match_value VARCHAR(256) DEFAULT ''" ) conn.exec_driver_sql( "UPDATE ume_key_alert_rule SET match_value = notification_id " "WHERE (match_value IS NULL OR match_value = '') AND notification_id NOT LIKE 'kw:%'" ) conn.exec_driver_sql( "UPDATE ume_key_alert_rule SET match_type = 'keyword', match_value = SUBSTRING(notification_id FROM 4) " "WHERE notification_id LIKE 'kw:%' AND (match_type IS NULL OR match_type = '' OR match_type = 'notification_id')" ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS ix_ume_alarms_current_host_name ON ume_alarms_current (host_name)" ) conn.exec_driver_sql( "CREATE INDEX IF NOT EXISTS ix_ume_alarms_history_host_name ON ume_alarms_history (host_name)" ) conn.exec_driver_sql("ALTER TABLE ume_alarms_history ALTER COLUMN alarm_key TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_history ALTER COLUMN object_name TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_history ALTER COLUMN event_type TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_history ALTER COLUMN native_probable_cause TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_history ALTER COLUMN perceived_severity TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_history ALTER COLUMN is_cleared TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_history ALTER COLUMN time_created TYPE TEXT") conn.exec_driver_sql("ALTER TABLE ume_alarms_history ALTER COLUMN root_cause_alarm_indication TYPE TEXT") # Simplify alarm tables: display fields come from runtime join with inventory table. conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS ne_name") 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 user_label") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_enabled BOOLEAN DEFAULT FALSE") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vendor VARCHAR(32) DEFAULT 'zte'") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_host VARCHAR(128) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_port INTEGER DEFAULT 22") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_protocol VARCHAR(16) DEFAULT 'ssh'") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_username VARCHAR(128) DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_password_enc TEXT DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_command_template TEXT DEFAULT ''") conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_vrf VARCHAR(128) DEFAULT ''") conn.exec_driver_sql( "ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS hop_target_auth_mode VARCHAR(32) DEFAULT 'bastion_managed'" ) conn.exec_driver_sql("ALTER TABLE managed_ne ADD COLUMN IF NOT EXISTS connect_detail TEXT DEFAULT ''") 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 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_type IS '网元类型'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.user_label IS '用户标签'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.address_type IS '管理地址类型(1:IPv4,2:IPv6)'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ip_address IS '网元IPv4地址'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.net_mask IS '管理IPv4掩码(点分十进制)'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ipv6_address IS 'IPv6地址'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.admin_status IS '管理状态(0-离线,1-在线)'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.connection_status IS '连接状态(0-断链,1-正常)'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.consistent_state IS '数据一致性状态(1一致,2不一致,3冲突)'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.maintain_status IS '工程状态(0普通,1调测,2新建)'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.vendor IS '网元提供商'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.interface_version IS '网元接口版本号'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.hardware_version IS '硬件版本'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.mac IS '设备机架MAC地址'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.loopback IS '业务环回IP(IPv4)'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.device_level IS '网元层次'") conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.host_name IS '主机名称'") except Exception: pass try: if bool(getattr(settings, "ume_keepalive_enabled", True)): interval_keepalive_s = int(getattr(settings, "ume_keepalive_interval_s", 600) or 600) interval_keepalive_s = max(30, min(interval_keepalive_s, 3600)) renew_before_s = int(getattr(settings, "ume_keepalive_renew_before_s", 900) or 900) renew_before_s = max(30, min(renew_before_s, 86400)) def _keepalive_loop() -> None: # Best-effort keepalive: if token exists, periodically handshake to extend TTL. while True: try: if _runtime_is_paused("token_keepalive"): time.sleep(1) continue client = _ume_client() st = client.token_status() expires_in = int(st.get("expires_in_s") or 0) # Renew when missing/invalid TTL (0) or nearing expiry — previously 0 skipped renew forever. if bool(st.get("has_token")) and (expires_in <= 0 or expires_in < renew_before_s): client.renew_token() _set_runtime_task("token_keepalive", status="running", last_run_at=datetime.now(timezone.utc), last_error="") except Exception: _set_runtime_task("token_keepalive", status="error", last_run_at=datetime.now(timezone.utc), last_error="keepalive_failed") time.sleep(interval_keepalive_s) t = threading.Thread(target=_keepalive_loop, name="ume-token-keepalive", daemon=True) t.start() except Exception as exc: _schedule_log.exception("startup: token_keepalive thread init failed: %s", exc) _set_runtime_task( "token_keepalive", status="error", last_run_at=datetime.now(timezone.utc), last_error=f"startup_thread_init_failed: {str(exc)[:180]}", ) try: def _startup_alarm_sync_worker() -> None: try: _run_startup_alarm_sync_before_ws() except Exception as exc: _schedule_log.exception("startup: alarm sync before WSS failed: %s", exc) complete_startup_alarm_sync_gate() # Do not block HTTP /health on slow UME REST pull; WSS waits on startup_alarm_sync_gate. t_startup_sync = threading.Thread( target=_startup_alarm_sync_worker, name="ume-startup-alarm-sync", daemon=True, ) t_startup_sync.start() except Exception as exc: _schedule_log.exception("startup: alarm sync thread init failed: %s", exc) complete_startup_alarm_sync_gate() try: if bool(getattr(settings, "ume_sync_alarms_current_enabled", True)): alarms_interval_s = int(getattr(settings, "ume_sync_alarms_current_interval_s", 18000) or 18000) alarms_interval_s = max(30, min(alarms_interval_s, 86400)) def _alarms_current_sync_loop() -> None: _refresh_runtime_task_idle("alarms_current_auto_sync", "alarms_current") _wait_until_startup_alarm_pull_allowed("alarms_current_auto_sync") while True: try: _schedule_log.info( "alarms_current_auto_sync: loop tick paused=%s", _runtime_is_paused("alarms_current_auto_sync"), ) if _runtime_is_paused("alarms_current_auto_sync"): time.sleep(1) continue if is_startup_alarm_sync_pending(): _refresh_runtime_task_idle( "alarms_current_auto_sync", "alarms_current", last_error="启动 REST 全量同步未完成,定时 REST 与 WSS 均待命", ) time.sleep(10) continue if ( bool(getattr(settings, "ume_sync_alarms_current_skip_when_ws", True)) and is_wss_active_for_current_alarms() ): _refresh_runtime_task_idle( "alarms_current_auto_sync", "alarms_current", last_error="WSS 实时接收中,已跳过 REST 同步", ) time.sleep(max(30, min(alarms_interval_s, 300))) continue _maybe_wait_for_sync_interval( task_id="alarms_current_auto_sync", domain="alarms_current", interval_s=alarms_interval_s, label="alarms_current_auto_sync", ) _schedule_log.info( "alarms_current_auto_sync: iteration start (interval=%ss)", alarms_interval_s, ) _set_runtime_task( "alarms_current_auto_sync", status="running", last_run_at=datetime.now(timezone.utc), last_error="正在拉取 UME 当前告警…", ) db = SessionLocal() try: client = _ume_client() sync_alarms_current(db, client, trigger_mode="schedule") _schedule_log.info("alarms_current_auto_sync: sync finished ok") _set_runtime_task( "alarms_current_auto_sync", status="running", last_run_at=datetime.now(timezone.utc), last_error="", ) finally: db.close() except RuntimeError as exc: if str(exc) == "alarms_current_sync_busy": _refresh_runtime_task_idle( "alarms_current_auto_sync", "alarms_current", last_error="另一条当前告警 REST 同步进行中,已跳过", ) time.sleep(30) else: raise except Exception as exc: _schedule_log.exception("alarms_current_auto_sync: sync failed: %s", exc) _set_runtime_task( "alarms_current_auto_sync", status="error", last_run_at=datetime.now(timezone.utc), last_error=str(exc)[:240], ) t2 = threading.Thread(target=_alarms_current_sync_loop, name="ume-alarms-current-sync", daemon=True) t2.start() _schedule_log.info("started thread %s alive=%s", t2.name, t2.is_alive()) if not t2.is_alive(): _schedule_log.error("ume-alarms-current-sync thread exited immediately (check uncaught errors above)") except Exception as exc: _schedule_log.exception("startup: alarms_current_auto_sync thread init failed: %s", exc) _set_runtime_task( "alarms_current_auto_sync", status="error", last_run_at=datetime.now(timezone.utc), last_error=f"startup_thread_init_failed: {str(exc)[:180]}", ) try: if bool(getattr(settings, "ume_sync_inventory_auto_enabled", True)): hours = int(getattr(settings, "ume_sync_inventory_every_hours", 48) or 48) hours = max(1, min(hours, 168)) inventory_interval_s = int(hours * 3600) _refresh_runtime_task_idle("inventory_auto_sync", "inventory") def _inventory_auto_sync_loop() -> None: _refresh_runtime_task_idle("inventory_auto_sync", "inventory") while True: try: _schedule_log.info( "inventory_auto_sync: loop tick paused=%s", _runtime_is_paused("inventory_auto_sync"), ) if _runtime_is_paused("inventory_auto_sync"): time.sleep(1) continue _maybe_wait_for_sync_interval( task_id="inventory_auto_sync", domain="inventory", interval_s=inventory_interval_s, label="inventory_auto_sync", ) _schedule_log.info( "inventory_auto_sync: iteration start (interval=%ss)", inventory_interval_s, ) _set_runtime_task( "inventory_auto_sync", status="running", last_run_at=datetime.now(timezone.utc), last_error="正在拉取 UME 网元清单…", ) db = SessionLocal() try: client = _ume_client() sync_inventory_full(db, client, trigger_mode="schedule") _schedule_log.info("inventory_auto_sync: sync finished ok") _set_runtime_task( "inventory_auto_sync", status="running", last_run_at=datetime.now(timezone.utc), last_error="", ) finally: db.close() except Exception as exc: _schedule_log.exception("inventory_auto_sync: sync failed: %s", exc) _set_runtime_task( "inventory_auto_sync", status="error", last_run_at=datetime.now(timezone.utc), last_error=str(exc)[:240], ) t3 = threading.Thread(target=_inventory_auto_sync_loop, name="ume-inventory-auto-sync", daemon=True) t3.start() _schedule_log.info("started thread %s alive=%s", t3.name, t3.is_alive()) if not t3.is_alive(): _schedule_log.error("ume-inventory-auto-sync thread exited immediately (check uncaught errors above)") except Exception as exc: _schedule_log.exception("startup: inventory_auto_sync thread init failed: %s", exc) _set_runtime_task( "inventory_auto_sync", status="error", last_run_at=datetime.now(timezone.utc), last_error=f"startup_thread_init_failed: {str(exc)[:180]}", ) global _UME_WS_STOP_EVENT try: if bool(getattr(settings, "ume_alarm_ws_enabled", True)) and str(getattr(settings, "ume_base_url", "") or "").strip(): if load_persisted_subscription(): _schedule_log.info("startup: loaded persisted UME alarm subscription") _UME_WS_STOP_EVENT = threading.Event() def _ws_on_status(msg: str) -> None: _set_runtime_task( "alarms_current_ws_consumer", status="running", last_run_at=datetime.now(timezone.utc), last_error=str(msg or "")[:240], ) t_ws = start_ume_alarm_ws_consumer( _ume_client(), on_status=_ws_on_status, stop_event=_UME_WS_STOP_EVENT, is_paused=lambda: _runtime_is_paused("alarms_current_ws_consumer"), ) _schedule_log.info("started thread %s alive=%s", t_ws.name, t_ws.is_alive()) else: _set_runtime_task("alarms_current_ws_consumer", status="paused", last_error="未启用或未配置 UME_BASE_URL") except Exception as exc: _schedule_log.exception("startup: alarms_current_ws_consumer thread init failed: %s", exc) _set_runtime_task( "alarms_current_ws_consumer", status="error", last_run_at=datetime.now(timezone.utc), last_error=f"startup_thread_init_failed: {str(exc)[:180]}", ) try: t_fwd = start_oclaw_alarm_forwarder() if t_fwd is not None: _schedule_log.info("started thread %s alive=%s", t_fwd.name, t_fwd.is_alive()) except Exception as exc: _schedule_log.exception("startup: oclaw_alarm_forwarder thread init failed: %s", exc) @app.on_event("shutdown") def on_shutdown() -> None: global _UME_WS_STOP_EVENT shutdown_oclaw_alarm_forwarder() if _UME_WS_STOP_EVENT is not None: _UME_WS_STOP_EVENT.set() shutdown_ws_consumer() @app.get("/health", status_code=200) def health() -> dict[str, str]: return {"status": "ok"} @app.get("/v1/ume/token/status") def ume_token_status() -> dict[str, Any]: client = _ume_client() st = client.token_status() return {"ok": True, **st} @app.post("/v1/ume/token/refresh") def ume_token_refresh() -> dict[str, Any]: client = _ume_client() try: before = client.token_status() token = client.refresh_if_needed() after = client.token_status() return { "ok": True, "token": token, "changed": bool(before.get("token_preview") != after.get("token_preview")), **after, } except Exception as exc: msg = str(exc)[:240] return {"ok": False, "error_kind": _ume_error_kind(msg), "error": msg} @app.post("/v1/ume/token/disconnect") def ume_token_disconnect() -> dict[str, Any]: client = _ume_client() ok = bool(client.logout_token()) st = client.token_status() return {"ok": ok, **st} @app.get("/v1/ume/alarm-subscription/status") def ume_alarm_subscription_status(limit: int = 80) -> dict[str, Any]: st = get_subscription_status() ws_task = _UME_RUNTIME_TASKS.get("alarms_current_ws_consumer") or {} log_limit = max(10, min(int(limit or 80), 100)) return { "ok": True, **st, **get_alarms_coordination_status(), "ws_connection": get_ws_connection_status(), "ws_consumer_status": str(ws_task.get("status") or ""), "ws_consumer_last_error": str(ws_task.get("last_error") or ""), "ws_consumer_last_run_at": ws_task.get("last_run_at"), "ws_logs": get_ws_logs(limit=log_limit), } @app.post("/v1/ume/alarm-subscription/establish") def ume_alarm_subscription_establish( payload: dict[str, Any] | None = None, db: Session = Depends(get_db), ) -> dict[str, Any]: client = _ume_client() body = payload or {} force_reestablish = bool(body.get("force_reestablish")) try: st = establish_alarm_subscription_manual(client, db, force_reestablish=force_reestablish) return {"ok": True, "created": not bool(st.get("already_exists")), **st} except Exception as exc: msg = str(exc)[:240] raise HTTPException(status_code=502, detail=msg) from exc @app.post("/v1/ume/alarm-subscription/cancel") def ume_alarm_subscription_cancel( payload: dict[str, Any] | None = None, db: Session = Depends(get_db), ) -> dict[str, Any]: client = _ume_client() body = payload or {} force_clear_local = bool(body.get("force_clear_local")) try: st = cancel_alarm_subscription_manual(client, db, force_clear_local=force_clear_local) if st.get("needs_local_cleanup"): return st return {"ok": True, **st} except Exception as exc: msg = str(exc)[:240] raise HTTPException(status_code=502, detail=msg) from exc @app.post("/v1/ume/alarm-subscription/clear-local") def ume_alarm_subscription_clear_local(db: Session = Depends(get_db)) -> dict[str, Any]: try: st = clear_local_alarm_subscription_manual(db) return {"ok": True, "cleared_local": True, **st} except Exception as exc: msg = str(exc)[:240] raise HTTPException(status_code=502, detail=msg) from exc @app.get("/v1/ume/key-alert-rules") def ume_list_key_alert_rules(db: Session = Depends(get_db)) -> dict[str, Any]: from sqlalchemy import func rows = db.query(UmeKeyAlertRule).order_by(UmeKeyAlertRule.notification_id.asc()).all() stat_rows = ( db.query( UmeKeyAlertForwardLog.notification_id, func.count(UmeKeyAlertForwardLog.id).label("attempts"), func.sum(UmeKeyAlertForwardLog.oclaw_ok).label("published_ok"), func.max(UmeKeyAlertForwardLog.forwarded_at).label("last_forwarded_at"), ) .group_by(UmeKeyAlertForwardLog.notification_id) .all() ) stat_map = { str(nid or ""): { "attempts": int(attempts or 0), "published_ok": int(published_ok or 0), "last_forwarded_at": (_ensure_utc(last_at) or datetime.now(timezone.utc)).isoformat() if last_at else "", } for nid, attempts, published_ok, last_at in stat_rows if str(nid or "").strip() } items = [ { "notification_id": str(row.notification_id or ""), "match_type": rule_match_type(row), "match_value": rule_match_value(row), "enabled": bool(int(row.enabled or 0)), "forward_on_clear": bool(int(row.forward_on_clear or 0)), "label": str(row.label or ""), "created_at": (_ensure_utc(row.created_at) or datetime.now(timezone.utc)).isoformat(), "updated_at": (_ensure_utc(row.updated_at) or datetime.now(timezone.utc)).isoformat(), "forward_stats": stat_map.get(str(row.notification_id or ""), { "attempts": 0, "published_ok": 0, "last_forwarded_at": "", }), } for row in rows ] fwd = forwarder_status() return {"items": items, "total": len(items), "forwarder": fwd} @app.get("/v1/ume/key-alert-monitor") def ume_key_alert_monitor(db: Session = Depends(get_db)) -> dict[str, Any]: base = ume_list_key_alert_rules(db) return { "ok": True, "rules": base.get("items") or [], "forwarder": base.get("forwarder") or forwarder_status(), } @app.post("/v1/ume/key-alert-rules") def ume_upsert_key_alert_rule(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]: match_type = normalize_match_type(str(payload.get("match_type") or "notification_id")) match_value = str(payload.get("match_value") or payload.get("notification_id") or "").strip() if not match_value: raise HTTPException(status_code=400, detail="match_value_required") label = str(payload.get("label") or "").strip() if not label: raise HTTPException(status_code=400, detail="label_required") forward_on_clear = 1 if bool(payload.get("forward_on_clear", False)) else 0 enabled = 1 if bool(payload.get("enabled", True)) else 0 now = datetime.now(timezone.utc).replace(tzinfo=None) try: storage_key = rule_storage_key(match_type=match_type, value=match_value) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc row = db.get(UmeKeyAlertRule, storage_key) if row is None: row = UmeKeyAlertRule(notification_id=storage_key, created_at=now, updated_at=now) db.add(row) row.match_type = match_type row.match_value = match_value row.enabled = enabled row.forward_on_clear = forward_on_clear row.label = label row.updated_at = now saved = { "notification_id": storage_key, "match_type": match_type, "match_value": match_value, "enabled": bool(enabled), "forward_on_clear": bool(forward_on_clear), "label": label, } db.commit() invalidate_key_alert_rule_cache() return {"ok": True, "item": saved} @app.delete("/v1/ume/key-alert-rules/{rule_key:path}") def ume_delete_key_alert_rule(rule_key: str, db: Session = Depends(get_db)) -> dict[str, Any]: key = str(rule_key or "").strip() row = db.get(UmeKeyAlertRule, key) if row is None: raise HTTPException(status_code=404, detail="rule_not_found") db.delete(row) db.commit() invalidate_key_alert_rule_cache() return {"ok": True, "deleted": key} @app.get("/v1/ume/alarm-keywords") def ume_list_alarm_keywords( limit: int = Query(default=200, ge=1, le=2000), db: Session = Depends(get_db), ) -> dict[str, Any]: from sqlalchemy import func rows = ( db.query( UmeAlarmCurrent.native_probable_cause, func.count(UmeAlarmCurrent.alarm_key).label("cnt"), ) .filter(UmeAlarmCurrent.native_probable_cause != "") .group_by(UmeAlarmCurrent.native_probable_cause) .order_by(func.count(UmeAlarmCurrent.alarm_key).desc(), UmeAlarmCurrent.native_probable_cause.asc()) .limit(limit) .all() ) items = [ { "keyword": str(cause or ""), "alarm_count": int(cnt or 0), } for cause, cnt in rows if str(cause or "").strip() ] return {"items": items, "total": len(items)} @app.get("/v1/ume/notification-ids") def ume_list_notification_ids( limit: int = Query(default=200, ge=1, le=2000), db: Session = Depends(get_db), ) -> dict[str, Any]: from sqlalchemy import func rows = ( db.query( UmeAlarmCurrent.notification_id, func.max(UmeAlarmCurrent.native_probable_cause).label("cause_sample"), ) .filter(UmeAlarmCurrent.notification_id != "") .group_by(UmeAlarmCurrent.notification_id) .order_by(UmeAlarmCurrent.notification_id.asc()) .limit(limit) .all() ) items = [ { "notification_id": str(nid or ""), "native_probable_cause_sample": str(cause or ""), } for nid, cause in rows if str(nid or "").strip() ] return {"items": items, "total": len(items), "forwarder": forwarder_status()} @app.post("/v1/ume/sync") def ume_sync(payload: dict[str, Any] | None = None, db: Session = Depends(get_db)) -> dict[str, Any]: body = payload or {} domains = body.get("domains") if not isinstance(domains, list) or not domains: domains = ["inventory", "alarms_current", "alarms_history"] domain_set = {str(x).strip().lower() for x in domains if str(x).strip()} trigger_mode = str(body.get("trigger_mode") or "manual").strip().lower() if trigger_mode not in {"manual", "schedule"}: trigger_mode = "manual" client = _ume_client() out: dict[str, Any] = {"ok": True, "jobs": []} try: if "inventory" in domain_set: job = sync_inventory_full(db, client, trigger_mode=trigger_mode) out["jobs"].append( { "domain": "inventory", "status": job.status, "pulled_count": int(job.pulled_count or 0), "inserted_count": int(job.inserted_count or 0), "updated_count": int(job.updated_count or 0), "error_message": str(job.error_message or ""), } ) if "alarms" in domain_set or "alarms_current" in domain_set: paused_ws_for_sync = False if is_wss_active_for_current_alarms() and trigger_mode == "manual": _runtime_pause_task("alarms_current_ws_consumer") request_ws_reconnect() paused_ws_for_sync = True try: job, batch = sync_alarms_current( db, client, trigger_mode=trigger_mode, wss_active=is_wss_active_for_current_alarms(), ) finally: if paused_ws_for_sync: _runtime_resume_task("alarms_current_ws_consumer") request_ws_reconnect() out["jobs"].append( { "domain": "alarms_current", "status": job.status, "batch_id": str(batch.batch_id), "pulled_count": int(job.pulled_count or 0), "inserted_count": int(job.inserted_count or 0), "updated_count": int(job.updated_count or 0), "error_message": str(job.error_message or ""), } ) if "alarms_history" in domain_set: job, batch = sync_alarms_history_full(db, client, trigger_mode=trigger_mode) out["jobs"].append( { "domain": "alarms_history", "status": job.status, "batch_id": str(batch.batch_id), "pulled_count": int(job.pulled_count or 0), "inserted_count": int(job.inserted_count or 0), "updated_count": int(job.updated_count or 0), "error_message": str(job.error_message or ""), } ) except Exception as exc: out["ok"] = False out["error"] = str(exc)[:240] return out def _ume_sync_job_deleted_count(row: UmeSyncJob) -> int: """Single reconcile delete count: inventory uses deleted_inventory_ne; current alarms uses deleted_stale_current_alarms.""" raw = str(getattr(row, "details_json", "") or "").strip() if not raw: return 0 try: obj = json.loads(raw) except Exception: return 0 if not isinstance(obj, dict): return 0 inv = cur = 0 try: inv = max(0, int(obj.get("deleted_inventory_ne") or 0)) except Exception: pass try: cur = max(0, int(obj.get("deleted_stale_current_alarms") or 0)) except Exception: pass return int(inv + cur) @app.get("/v1/ume/sync/status") def ume_sync_status( page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=200), db: Session = Depends(get_db), ) -> dict[str, Any]: q = db.query(UmeSyncJob) total = int(q.count()) rows = ( q.order_by(UmeSyncJob.id.desc()) .offset((int(page) - 1) * int(page_size)) .limit(int(page_size)) .all() ) items = [] latest_by_domain: dict[str, dict[str, Any]] = {} for r in rows: item = { "id": int(r.id), "domain": str(r.domain or ""), "status": str(r.status or ""), "trigger_mode": str(r.trigger_mode or ""), "pulled_count": int(r.pulled_count or 0), "inserted_count": int(r.inserted_count or 0), "updated_count": int(r.updated_count or 0), "deleted": int(_ume_sync_job_deleted_count(r)), "error_message": str(r.error_message or ""), "started_at": (_ensure_utc(r.started_at) or datetime.now(timezone.utc)).isoformat(), "ended_at": (_ensure_utc(r.ended_at).isoformat() if r.ended_at else None), } items.append(item) if item["domain"] and item["domain"] not in latest_by_domain: latest_by_domain[item["domain"]] = item return { "total": total, "page": page, "page_size": page_size, "items": items, "latest_by_domain": latest_by_domain, "runtime_tasks": _list_runtime_tasks(), "alarm_subscription": get_subscription_status(), } @app.post("/v1/ume/runtime/tasks/{task}/pause") def ume_runtime_task_pause(task: str) -> dict[str, Any]: tid = str(task or "").strip() if tid not in UME_KNOWN_RUNTIME_TASKS: raise HTTPException(status_code=404, detail="unknown_runtime_task") _runtime_pause_task(tid) if tid in ("alarms_current_auto_sync", "inventory_auto_sync"): _clear_force_resume_hints(tid) if tid == "alarms_current_ws_consumer": request_ws_reconnect() _set_runtime_task(tid, status="paused", last_error="") return {"ok": True, "task": tid, "runtime_tasks": _list_runtime_tasks()} @app.post("/v1/ume/runtime/tasks/{task}/resume") def ume_runtime_task_resume(task: str) -> dict[str, Any]: tid = str(task or "").strip() if tid not in UME_KNOWN_RUNTIME_TASKS: raise HTTPException(status_code=404, detail="unknown_runtime_task") _runtime_resume_task(tid) if tid in ("alarms_current_auto_sync", "inventory_auto_sync"): _request_force_sync_after_resume(tid) resume_hint = "已恢复:将跳过本轮周期等待并尽快同步" elif tid == "alarms_current_ws_consumer": request_ws_reconnect() resume_hint = "已恢复:将尽快重连 WSS" else: resume_hint = "已恢复" _set_runtime_task(tid, status="running", last_error=resume_hint) return {"ok": True, "task": tid, "runtime_tasks": _list_runtime_tasks()} @app.get("/v1/ume/inventory/ne") def ume_list_ne( keyword: 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), ) -> dict[str, Any]: stmt = db.query(UmeInventoryNE) kw = str(keyword or "").strip() if kw: stmt = stmt.filter( UmeInventoryNE.ne_id.contains(kw) | UmeInventoryNE.ne_name.contains(kw) | UmeInventoryNE.user_label.contains(kw) | UmeInventoryNE.ip_address.contains(kw) | UmeInventoryNE.host_name.contains(kw) ) total = int(stmt.count()) rows = stmt.order_by(UmeInventoryNE.ne_id.asc()).offset((page - 1) * page_size).limit(page_size).all() items = [ { "ne_id": str(x.ne_id or ""), "ne_name": str(x.ne_name or ""), "user_label": str(x.user_label or ""), "ip_address": str(x.ip_address or ""), "ipv6_address": str(x.ipv6_address or ""), "ne_type": str(x.ne_type or ""), "device_level": str(x.device_level or ""), "host_name": str(x.host_name or ""), "location": str(x.location or ""), "hardware_version": str(x.hardware_version or ""), "loopback": str(x.loopback or ""), "consistent_state": str(x.consistent_state or ""), "interface_version": str(x.interface_version or ""), "mac": str(x.mac or ""), "admin_status": str(x.admin_status or ""), "address_type": str(x.address_type or ""), "connection_status": str(x.connection_status or ""), "maintain_status": str(x.maintain_status or ""), "net_mask": str(x.net_mask or ""), "create_time": str(x.create_time or ""), "creator": str(x.creator or ""), "last_seen_at": (_ensure_utc(x.last_seen_at) or datetime.now(timezone.utc)).isoformat(), } for x in rows ] return {"total": total, "page": page, "page_size": page_size, "items": items} @app.get("/v1/ume/inventory/ne/{ne_id}") def ume_get_ne(ne_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: row = db.get(UmeInventoryNE, ne_id) if not row: raise HTTPException(status_code=404, detail="ume_ne_not_found") return { "ne_id": str(row.ne_id or ""), "ne_name": str(row.ne_name or ""), "user_label": str(row.user_label or ""), "ip_address": str(row.ip_address or ""), "ipv6_address": str(row.ipv6_address or ""), "ne_type": str(row.ne_type or ""), "device_level": str(row.device_level or ""), "host_name": str(row.host_name or ""), "location": str(row.location or ""), "hardware_version": str(row.hardware_version or ""), "loopback": str(row.loopback or ""), "consistent_state": str(row.consistent_state or ""), "interface_version": str(row.interface_version or ""), "mac": str(row.mac or ""), "admin_status": str(row.admin_status or ""), "address_type": str(row.address_type or ""), "connection_status": str(row.connection_status or ""), "maintain_status": str(row.maintain_status or ""), "net_mask": str(row.net_mask or ""), "create_time": str(row.create_time or ""), "creator": str(row.creator or ""), "vendor": str(row.vendor or ""), "source_type": str(row.source_type or ""), "first_seen_at": (_ensure_utc(row.first_seen_at) or datetime.now(timezone.utc)).isoformat(), "last_seen_at": (_ensure_utc(row.last_seen_at) or datetime.now(timezone.utc)).isoformat(), "raw_json": str(row.raw_json or "{}"), } @app.get("/v1/ume/alarms") def ume_list_alarms( severity: str | None = Query(default=None), is_cleared: str | None = Query(default=None), ne_id: str | None = Query(default=None), host_name: str | None = Query(default=None), keyword: 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), ) -> dict[str, Any]: stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin( UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id ) if severity and str(severity).strip(): stmt = stmt.filter(UmeAlarmCurrent.perceived_severity == str(severity).strip()) if is_cleared and str(is_cleared).strip(): stmt = stmt.filter(UmeAlarmCurrent.is_cleared == str(is_cleared).strip()) if ne_id and str(ne_id).strip(): stmt = stmt.filter(UmeAlarmCurrent.ne_id == str(ne_id).strip()) hn = str(host_name or "").strip() if hn: stmt = stmt.filter( UmeAlarmCurrent.host_name.contains(hn) | UmeInventoryNE.host_name.contains(hn) ) kw = str(keyword or "").strip() if kw: stmt = stmt.filter( UmeAlarmCurrent.alarm_key.contains(kw) | UmeAlarmCurrent.object_name.contains(kw) | UmeAlarmCurrent.native_probable_cause.contains(kw) | UmeAlarmCurrent.notification_id.contains(kw) | UmeAlarmCurrent.host_name.contains(kw) | UmeInventoryNE.ne_name.contains(kw) | UmeInventoryNE.user_label.contains(kw) | UmeInventoryNE.ip_address.contains(kw) | UmeInventoryNE.host_name.contains(kw) ) total = int(stmt.count()) rows = ( stmt.order_by( UmeAlarmCurrent.time_created.desc(), UmeAlarmCurrent.last_seen_at.desc(), UmeAlarmCurrent.alarm_key.desc(), ) .offset((page - 1) * page_size) .limit(page_size) .all() ) items = [ { "alarm_key": str(alarm.alarm_key or ""), "ne_id": str(alarm.ne_id or ""), "ne_name": str((ne.ne_name if ne else "") or ""), "user_label": str((ne.user_label if ne else "") or ""), "host_name": _ume_alarm_host_name(alarm, ne), "ne_type": str((ne.ne_type if ne else "") or ""), "object_name": str(alarm.object_name or ""), "event_type": str(alarm.event_type or ""), "native_probable_cause": str(alarm.native_probable_cause or ""), "notification_id": str(alarm.notification_id or ""), "perceived_severity": str(alarm.perceived_severity or ""), "is_cleared": str(alarm.is_cleared or ""), "time_created": str(alarm.time_created or ""), "last_seen_at": (_ensure_utc(alarm.last_seen_at) or datetime.now(timezone.utc)).isoformat(), } for alarm, ne in rows ] return {"total": total, "page": page, "page_size": page_size, "items": items} @app.get("/v1/ume/alarms/fields") def ume_alarms_fields() -> dict[str, Any]: """List all queryable field names for UME raw alarm query.""" alarm_cols = [str(c.name) for c in UmeAlarmCurrent.__table__.columns] # type: ignore[attr-defined] ne_cols = [str(c.name) for c in UmeInventoryNE.__table__.columns] # type: ignore[attr-defined] selectable_fields = [f"alarm_{x}" for x in alarm_cols] + [f"ne_{x}" for x in ne_cols] + ["ne_exists"] order_by_allowed = ["last_seen_at", "time_created", "perceived_severity", "event_type", "ne_id"] return { "alarm_fields": alarm_cols, "ne_fields": ne_cols, "selectable_fields": selectable_fields, "order_by_allowed": order_by_allowed, } def _serialize_ume_alarm_raw_row( alarm: UmeAlarmCurrent, ne: UmeInventoryNE | None, selected_fields: set[str] | None = None ) -> dict[str, Any]: selected = selected_fields or set() use_all = len(selected) == 0 out: dict[str, Any] = {} for c in UmeAlarmCurrent.__table__.columns: # type: ignore[attr-defined] name = str(c.name) v = getattr(alarm, name, None) key = f"alarm_{name}" if not use_all and key not in selected: continue if hasattr(v, "isoformat"): try: if isinstance(v, datetime): out[key] = (_ensure_utc(v) or v).isoformat() else: out[key] = v.isoformat() continue except Exception: pass out[key] = v if ne is None: if use_all or "ne_exists" in selected: out["ne_exists"] = False return out if use_all or "ne_exists" in selected: out["ne_exists"] = True for c in UmeInventoryNE.__table__.columns: # type: ignore[attr-defined] name = str(c.name) v = getattr(ne, name, None) key = f"ne_{name}" if not use_all and key not in selected: continue if hasattr(v, "isoformat"): try: if isinstance(v, datetime): out[key] = (_ensure_utc(v) or v).isoformat() else: out[key] = v.isoformat() continue except Exception: pass out[key] = v return out def _extract_ume_raw_group_field(alarm: UmeAlarmCurrent, ne: UmeInventoryNE | None, field: str) -> str: key = str(field or "").strip() if not key: return "" if key.startswith("alarm_"): attr = key[len("alarm_") :] return str(getattr(alarm, attr, "") or "") if key.startswith("ne_"): attr = key[len("ne_") :] if key == "ne_exists": return "1" if ne is not None else "0" if key == "ne_host_name": hn = str(getattr(alarm, "host_name", "") or "").strip() if hn: return hn if ne is None: return "" return str(getattr(ne, attr, "") or "") return "" @app.get("/v1/ume/alarms/raw") def ume_alarms_raw( severity: str | None = Query(default=None), is_cleared: str | None = Query(default=None), ne_id: str | None = Query(default=None), event_type: str | None = Query(default=None), keyword: str | None = Query(default=None), time_from: str | None = Query(default=None), time_to: str | None = Query(default=None), order_by: str = Query(default="last_seen_at"), order: str = Query(default="desc"), select_fields: str | None = Query(default=None, description="comma-separated alarm_*/ne_* fields"), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=500), db: Session = Depends(get_db), ) -> dict[str, Any]: stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin( UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id ) if severity and str(severity).strip(): stmt = stmt.filter(UmeAlarmCurrent.perceived_severity == str(severity).strip()) if is_cleared and str(is_cleared).strip(): stmt = stmt.filter(UmeAlarmCurrent.is_cleared == str(is_cleared).strip()) if ne_id and str(ne_id).strip(): stmt = stmt.filter(UmeAlarmCurrent.ne_id == str(ne_id).strip()) if event_type and str(event_type).strip(): stmt = stmt.filter(UmeAlarmCurrent.event_type.contains(str(event_type).strip())) kw = str(keyword or "").strip() if kw: stmt = stmt.filter( UmeAlarmCurrent.alarm_key.contains(kw) | UmeAlarmCurrent.object_name.contains(kw) | UmeAlarmCurrent.native_probable_cause.contains(kw) | UmeAlarmCurrent.event_type.contains(kw) | UmeInventoryNE.ne_name.contains(kw) | UmeInventoryNE.user_label.contains(kw) | UmeInventoryNE.ip_address.contains(kw) ) dt_from = _parse_time(time_from) dt_to = _parse_time(time_to) if dt_from: stmt = stmt.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None)) if dt_to: stmt = stmt.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None)) allowed_order_by = { "last_seen_at": UmeAlarmCurrent.last_seen_at, "time_created": UmeAlarmCurrent.time_created, "perceived_severity": UmeAlarmCurrent.perceived_severity, "event_type": UmeAlarmCurrent.event_type, "ne_id": UmeAlarmCurrent.ne_id, } col = allowed_order_by.get(str(order_by or "").strip(), UmeAlarmCurrent.last_seen_at) if str(order or "").strip().lower() == "asc": stmt = stmt.order_by(col.asc()) else: stmt = stmt.order_by(col.desc()) selected_fields: set[str] = set() fields_meta = ume_alarms_fields() selectable_fields = set(str(x) for x in (fields_meta.get("selectable_fields") or [])) order_by_allowed = [str(x) for x in (fields_meta.get("order_by_allowed") or [])] if select_fields and str(select_fields).strip(): selected_fields = {x.strip() for x in str(select_fields).split(",") if x.strip()} invalid = [x for x in selected_fields if x not in selectable_fields] if invalid: raise HTTPException(status_code=400, detail=f"invalid_select_fields:{','.join(sorted(invalid)[:20])}") 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), "select_fields": sorted(selected_fields) if selected_fields else [], "meta": { "available_fields": sorted(selectable_fields), "order_by_allowed": order_by_allowed, "time_filter_field": "last_seen_at", }, "items": [_serialize_ume_alarm_raw_row(alarm, ne, selected_fields) for alarm, ne in rows], } @app.get("/v1/ume/alarms/aggregate/raw") def ume_alarms_aggregate_raw( group_by: str = Query(default="alarm_perceived_severity"), group_by2: str | None = Query(default=None), severity: str | None = Query(default=None), is_cleared: str | None = Query(default=None), ne_id: str | None = Query(default=None), event_type: str | None = Query(default=None), keyword: str | None = Query(default=None), time_from: str | None = Query(default=None), time_to: str | None = Query(default=None), limit: int = Query(default=200, ge=1, le=2000), db: Session = Depends(get_db), ) -> dict[str, Any]: fields_meta = ume_alarms_fields() selectable_fields = set(str(x) for x in (fields_meta.get("selectable_fields") or [])) g1 = str(group_by or "").strip() g2 = str(group_by2 or "").strip() if g1 not in selectable_fields: raise HTTPException(status_code=400, detail=f"invalid_group_by:{g1}") if g2 and g2 not in selectable_fields: raise HTTPException(status_code=400, detail=f"invalid_group_by2:{g2}") stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin( UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id ) if severity and str(severity).strip(): stmt = stmt.filter(UmeAlarmCurrent.perceived_severity == str(severity).strip()) if is_cleared and str(is_cleared).strip(): stmt = stmt.filter(UmeAlarmCurrent.is_cleared == str(is_cleared).strip()) if ne_id and str(ne_id).strip(): stmt = stmt.filter(UmeAlarmCurrent.ne_id == str(ne_id).strip()) if event_type and str(event_type).strip(): stmt = stmt.filter(UmeAlarmCurrent.event_type.contains(str(event_type).strip())) kw = str(keyword or "").strip() if kw: stmt = stmt.filter( UmeAlarmCurrent.alarm_key.contains(kw) | UmeAlarmCurrent.object_name.contains(kw) | UmeAlarmCurrent.native_probable_cause.contains(kw) | UmeAlarmCurrent.event_type.contains(kw) | UmeInventoryNE.ne_name.contains(kw) | UmeInventoryNE.user_label.contains(kw) | UmeInventoryNE.ip_address.contains(kw) ) dt_from = _parse_time(time_from) dt_to = _parse_time(time_to) if dt_from: stmt = stmt.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None)) if dt_to: stmt = stmt.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None)) rows = stmt.order_by(UmeAlarmCurrent.last_seen_at.desc()).all() counts: dict[tuple[str, str], int] = {} for alarm, ne in rows: k1 = _extract_ume_raw_group_field(alarm, ne, g1) k2 = _extract_ume_raw_group_field(alarm, ne, g2) if g2 else "" kk = (k1, k2) counts[kk] = int(counts.get(kk, 0)) + 1 buckets = sorted(counts.items(), key=lambda x: x[1], reverse=True)[: int(limit)] return { "total": len(rows), "group_by": g1, "group_by2": g2 or None, "meta": { "available_fields": sorted(selectable_fields), "group_by_allowed": sorted(selectable_fields), "applied_filters": { "severity": str(severity or "").strip() or None, "is_cleared": str(is_cleared or "").strip() or None, "ne_id": str(ne_id or "").strip() or None, "event_type": str(event_type or "").strip() or None, "keyword": str(keyword or "").strip() or None, "time_from": str(time_from or "").strip() or None, "time_to": str(time_to or "").strip() or None, }, "time_filter_field": "last_seen_at", "limit": int(limit), }, "buckets": [ {"key": k1, "key2": (k2 if g2 else None), "count": int(v)} for (k1, k2), v in buckets ], } @app.get("/v1/ume/alarms/aggregate") def ume_alarms_aggregate(db: Session = Depends(get_db)) -> dict[str, Any]: rows = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin( UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id ).all() by_severity = _aggregate_rows(rows, lambda x: x[0].perceived_severity) by_ne = _aggregate_rows(rows, lambda x: _ume_alarm_ne_group_key(x[0], x[1])) return {"total": len(rows), "by_severity": by_severity, "by_ne": by_ne} @app.get("/v1/ume/diagnostics") def ume_diagnostics( lang: str | None = Query(default=None), db: Session = Depends(get_db), ) -> dict[str, Any]: rows = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin( UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id ).all() by_severity = _aggregate_rows(rows, lambda x: x[0].perceived_severity) by_alarm_code = _aggregate_rows(rows, lambda x: x[0].event_type)[:10] by_ne = _aggregate_rows(rows, lambda x: _ume_alarm_ne_group_key(x[0], x[1]))[:10] lang_norm = _normalize_netx_lang(lang) proto_counts: dict[str, int] = {} for alarm, ne in rows: blob = " | ".join( [ str(alarm.event_type or ""), str(alarm.native_probable_cause or ""), str(alarm.object_name or ""), str(ne.ne_name if ne else ""), str(ne.user_label if ne else ""), str(ne.ip_address if ne else ""), ] ) bucket = _protocol_bucket_label(blob, lang=lang_norm) proto_counts[bucket] = int(proto_counts.get(bucket, 0)) + 1 protocol_summary = sorted(proto_counts.items(), key=lambda x: x[1], reverse=True)[:10] return { "source": "ume_alarms_current", "total_alarms": len(rows), "severity_summary": [{"key": k, "count": v} for k, v in by_severity], "top_alarm_codes": [{"key": k, "count": v} for k, v in by_alarm_code], "top_ne": [{"key": k, "count": v} for k, v in by_ne], "protocol_summary": [{"key": k, "count": v} for k, v in protocol_summary], } @app.get("/v1/ume/alarms/history") def ume_list_alarms_history( severity: str | None = Query(default=None), ne_id: str | None = Query(default=None), keyword: str | None = Query(default=None), time_from: str | None = Query(default=None), time_to: 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), ) -> dict[str, Any]: stmt = db.query(UmeAlarmHistory, UmeInventoryNE).outerjoin( UmeInventoryNE, UmeAlarmHistory.ne_id == UmeInventoryNE.ne_id ) if severity and str(severity).strip(): stmt = stmt.filter(UmeAlarmHistory.perceived_severity == str(severity).strip()) if ne_id and str(ne_id).strip(): stmt = stmt.filter(UmeAlarmHistory.ne_id == str(ne_id).strip()) kw = str(keyword or "").strip() if kw: stmt = stmt.filter( UmeAlarmHistory.alarm_key.contains(kw) | UmeAlarmHistory.object_name.contains(kw) | UmeAlarmHistory.native_probable_cause.contains(kw) | UmeInventoryNE.ne_name.contains(kw) | UmeInventoryNE.user_label.contains(kw) | UmeInventoryNE.ip_address.contains(kw) ) dt_from = _parse_time(time_from) dt_to = _parse_time(time_to) if dt_from: stmt = stmt.filter(UmeAlarmHistory.last_seen_at >= dt_from.replace(tzinfo=None)) if dt_to: stmt = stmt.filter(UmeAlarmHistory.last_seen_at <= dt_to.replace(tzinfo=None)) total = int(stmt.count()) rows = stmt.order_by(UmeAlarmHistory.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size).all() items = [ { "alarm_key": str(alarm.alarm_key or ""), "ne_id": str(alarm.ne_id or ""), "ne_name": str((ne.ne_name if ne else "") or ""), "user_label": str((ne.user_label if ne else "") or ""), "object_name": str(alarm.object_name or ""), "event_type": str(alarm.event_type or ""), "native_probable_cause": str(alarm.native_probable_cause or ""), "perceived_severity": str(alarm.perceived_severity or ""), "is_cleared": str(alarm.is_cleared or ""), "time_created": str(alarm.time_created or ""), "last_seen_at": (_ensure_utc(alarm.last_seen_at) or datetime.now(timezone.utc)).isoformat(), } for alarm, ne in rows ] return {"total": total, "page": page, "page_size": page_size, "items": items} @app.get("/v1/ume/alarms/history/aggregate") def ume_alarms_history_aggregate(db: Session = Depends(get_db)) -> dict[str, Any]: rows = db.query(UmeAlarmHistory, UmeInventoryNE).outerjoin( UmeInventoryNE, UmeAlarmHistory.ne_id == UmeInventoryNE.ne_id ).all() by_severity = _aggregate_rows(rows, lambda x: x[0].perceived_severity) by_ne = _aggregate_rows(rows, lambda x: (x[1].user_label if x[1] else "") or (x[1].ne_name if x[1] else "") or x[0].ne_id) by_date = _aggregate_rows(rows, lambda x: str(x[0].time_created or "")[:10]) return {"total": len(rows), "by_severity": by_severity, "by_ne": by_ne, "by_date": by_date} @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"} fwd = forwarder_status() if not bool(fwd.get("enabled")): oclaw_status = { "status": "unknown", "mode": "ws", "enabled": False, "connected": False, "error_kind": "disabled", "error": "NETX_OCLAW_ALARM_WS_ENABLED=false or missing token/url", "forwarder": fwd, } elif bool(fwd.get("connected")): oclaw_status = { "status": "up", "mode": "ws", "enabled": True, "connected": True, "queue_size": int(fwd.get("queue_size") or 0), "published_ok": int(fwd.get("published_ok") or 0), "published_fail": int(fwd.get("published_fail") or 0), "url": str(fwd.get("url") or ""), "forwarder": fwd, } else: oclaw_status = { "status": "down", "mode": "ws", "enabled": True, "connected": False, "error_kind": "ws_disconnected", "error": "oclaw netx-bridge WebSocket not connected", "queue_size": int(fwd.get("queue_size") or 0), "url": str(fwd.get("url") or ""), "forwarder": fwd, } 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(...), lang: str | None = Query(default=None), 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) lang_norm = _normalize_netx_lang(lang) 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_label(blob, lang=lang_norm) 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)