mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
feat(UME): 增加RESTCONF对接与可视化运维页
- 支持 token 获取/续约/断开、DB共享缓存与跨进程单飞锁、keepalive 保活 - 新增 inventory/current/history 告警同步入库与查询接口,前端增加 UME 对接页面与分页 - 修复 stop_netx.ps1 在部分 PowerShell 版本下 Stop-Job -Force 报错 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ab1b76dd47
commit
7f2e4ec393
15 changed files with 2577 additions and 15 deletions
12
.env.example
12
.env.example
|
|
@ -7,3 +7,15 @@ NETX_PARSER_CONFIG=netx_api/config/parsers/zte_alarm_monitor_v1.yaml
|
||||||
NETX_OCLAW_ANALYZE_URL=http://127.0.0.1:8787/admin/api/ops-ai/analyze-sync
|
NETX_OCLAW_ANALYZE_URL=http://127.0.0.1:8787/admin/api/ops-ai/analyze-sync
|
||||||
NETX_OCLAW_ANALYZE_TOKEN=
|
NETX_OCLAW_ANALYZE_TOKEN=
|
||||||
NETX_OCLAW_HEALTH_URL=http://127.0.0.1:8787/admin/api/ops-ai/health
|
NETX_OCLAW_HEALTH_URL=http://127.0.0.1:8787/admin/api/ops-ai/health
|
||||||
|
NETX_UME_BASE_URL=https://10.227.157.143:18014
|
||||||
|
NETX_UME_USERNAME=
|
||||||
|
NETX_UME_PASSWORD=
|
||||||
|
NETX_UME_AUTH_HEADER=accessToken
|
||||||
|
NETX_UME_CONTENT_TYPE=application/yang-data+json;charset=UTF-8
|
||||||
|
NETX_UME_TOKEN_TTL_S=1800
|
||||||
|
NETX_UME_TOKEN_REFRESH_SKEW_S=60
|
||||||
|
NETX_UME_TOKEN_PATH=/restconf/operations/zte-security:oauth_token
|
||||||
|
NETX_UME_TOKEN_HANDSHAKE_PATH=/restconf/operations/zte-security:oauth_handshake
|
||||||
|
NETX_UME_TOKEN_LOGOUT_PATH=/restconf/operations/zte-security:oauth_token
|
||||||
|
NETX_UME_NE_PATH=/restconf/data/zte-resources-module:network-elements
|
||||||
|
NETX_UME_ALARMS_PATH=/restconf/data/zte-alarms:alarms/alarm-list
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,30 @@ class Settings(BaseSettings):
|
||||||
oclaw_connect_timeout_sec: float = 15.0
|
oclaw_connect_timeout_sec: float = 15.0
|
||||||
oclaw_analyze_read_timeout_sec: float = 180.0
|
oclaw_analyze_read_timeout_sec: float = 180.0
|
||||||
oclaw_health_timeout_sec: float = 8.0
|
oclaw_health_timeout_sec: float = 8.0
|
||||||
|
# UME RESTCONF integration
|
||||||
|
ume_base_url: str = ""
|
||||||
|
ume_username: str = ""
|
||||||
|
ume_password: str = ""
|
||||||
|
ume_verify_tls: bool = False
|
||||||
|
ume_timeout_s: float = 20.0
|
||||||
|
ume_page_size: int = 1000
|
||||||
|
ume_max_pages: int = 2000
|
||||||
|
ume_limit_max: int = 5000
|
||||||
|
ume_limit_only_page_size: int = 5000
|
||||||
|
ume_auth_header: str = "accessToken"
|
||||||
|
ume_content_type: str = "application/yang-data+json;charset=UTF-8"
|
||||||
|
ume_token_ttl_s: int = 1800
|
||||||
|
ume_token_refresh_skew_s: int = 60
|
||||||
|
ume_keepalive_enabled: bool = True
|
||||||
|
ume_keepalive_interval_s: int = 600
|
||||||
|
ume_keepalive_renew_before_s: int = 900
|
||||||
|
ume_token_path: str = "/restconf/operations/zte-security:oauth_token"
|
||||||
|
ume_token_handshake_path: str = "/restconf/operations/zte-security:oauth_handshake"
|
||||||
|
ume_token_logout_path: str = "/restconf/operations/zte-security:oauth_token"
|
||||||
|
ume_ne_path: str = "/restconf/data/zte-resources-module:network-elements"
|
||||||
|
ume_alarms_path: str = "/restconf/data/zte-alarms:alarms/alarm-list"
|
||||||
|
ume_sync_inventory_every_hours: int = 24
|
||||||
|
ume_sync_alarms_history_every_hours: int = 24
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
390
netx_api/main.py
390
netx_api/main.py
|
|
@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile
|
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import text as sql_text
|
from sqlalchemy import text as sql_text
|
||||||
|
|
@ -17,9 +18,28 @@ from .ap_client import analyze_with_oclaw, health_with_oclaw
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import Base, SessionLocal, engine
|
from .db import Base, SessionLocal, engine
|
||||||
from .importer import aggregate_alarms, import_alarm_excel, query_alarms
|
from .importer import aggregate_alarms, import_alarm_excel, query_alarms
|
||||||
from .models import AiAnalyzeHistory, AlarmBatch, AlarmNorm, ImportErrorRow
|
from .models import (
|
||||||
|
AiAnalyzeHistory,
|
||||||
|
AlarmBatch,
|
||||||
|
AlarmNorm,
|
||||||
|
ImportErrorRow,
|
||||||
|
UmeAlarmCurrent,
|
||||||
|
UmeAlarmHistory,
|
||||||
|
UmeInventoryNE,
|
||||||
|
UmeSyncJob,
|
||||||
|
)
|
||||||
from .models import ImportJob
|
from .models import ImportJob
|
||||||
from .parser_config import load_parser_config
|
from .parser_config import load_parser_config
|
||||||
|
from .ume_client import UMEClient
|
||||||
|
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full
|
||||||
|
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 (
|
from .schemas import (
|
||||||
AlarmAggregateBucket,
|
AlarmAggregateBucket,
|
||||||
AlarmAggregateResponse,
|
AlarmAggregateResponse,
|
||||||
|
|
@ -34,6 +54,14 @@ from .schemas import (
|
||||||
|
|
||||||
app = FastAPI(title="netx ops tool", version="0.1.0")
|
app = FastAPI(title="netx ops tool", version="0.1.0")
|
||||||
parser_cfg = load_parser_config()
|
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(
|
_SQL_FORBIDDEN_RE = re.compile(
|
||||||
r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|call|copy|vacuum|analyze)\b",
|
r"\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|call|copy|vacuum|analyze)\b",
|
||||||
|
|
@ -61,6 +89,47 @@ def get_db():
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
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_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")
|
@app.post("/v1/sql/query")
|
||||||
def sql_query(payload: dict[str, Any] | None = None, db: Session = Depends(get_db)) -> dict:
|
def sql_query(payload: dict[str, Any] | None = None, db: Session = Depends(get_db)) -> dict:
|
||||||
"""
|
"""
|
||||||
|
|
@ -124,6 +193,32 @@ def on_startup() -> None:
|
||||||
)
|
)
|
||||||
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 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 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")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if bool(getattr(settings, "ume_keepalive_enabled", True)):
|
||||||
|
interval_s = int(getattr(settings, "ume_keepalive_interval_s", 600) or 600)
|
||||||
|
interval_s = max(30, min(interval_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:
|
||||||
|
client = _ume_client()
|
||||||
|
st = client.token_status()
|
||||||
|
expires_in = int(st.get("expires_in_s") or 0)
|
||||||
|
if bool(st.get("has_token")) and expires_in > 0 and expires_in < renew_before_s:
|
||||||
|
client.renew_token()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(interval_s)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_keepalive_loop, name="ume-token-keepalive", daemon=True)
|
||||||
|
t.start()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -133,6 +228,299 @@ def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
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.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:
|
||||||
|
job, batch = sync_alarms_current(db, client, trigger_mode=trigger_mode)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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),
|
||||||
|
"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}
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
)
|
||||||
|
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 ""),
|
||||||
|
"ne_type": str(x.ne_type 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 ""),
|
||||||
|
"ne_type": str(row.ne_type 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),
|
||||||
|
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)
|
||||||
|
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())
|
||||||
|
kw = str(keyword or "").strip()
|
||||||
|
if kw:
|
||||||
|
stmt = stmt.filter(
|
||||||
|
UmeAlarmCurrent.alarm_key.contains(kw)
|
||||||
|
| UmeAlarmCurrent.object_name.contains(kw)
|
||||||
|
| UmeAlarmCurrent.ne_name.contains(kw)
|
||||||
|
| UmeAlarmCurrent.user_label.contains(kw)
|
||||||
|
| UmeAlarmCurrent.native_probable_cause.contains(kw)
|
||||||
|
)
|
||||||
|
total = int(stmt.count())
|
||||||
|
rows = stmt.order_by(UmeAlarmCurrent.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"alarm_key": str(x.alarm_key or ""),
|
||||||
|
"ne_id": str(x.ne_id or ""),
|
||||||
|
"ne_name": str(x.ne_name or ""),
|
||||||
|
"user_label": str(x.user_label or ""),
|
||||||
|
"object_name": str(x.object_name or ""),
|
||||||
|
"event_type": str(x.event_type or ""),
|
||||||
|
"native_probable_cause": str(x.native_probable_cause or ""),
|
||||||
|
"perceived_severity": str(x.perceived_severity or ""),
|
||||||
|
"is_cleared": str(x.is_cleared or ""),
|
||||||
|
"time_created": str(x.time_created 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/alarms/aggregate")
|
||||||
|
def ume_alarms_aggregate(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
rows = db.query(UmeAlarmCurrent).all()
|
||||||
|
by_severity = _aggregate_rows(rows, lambda x: x.perceived_severity)
|
||||||
|
by_ne = _aggregate_rows(rows, lambda x: x.user_label or x.ne_name or x.ne_id)
|
||||||
|
return {"total": len(rows), "by_severity": by_severity, "by_ne": by_ne}
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
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.ne_name.contains(kw)
|
||||||
|
| UmeAlarmHistory.user_label.contains(kw)
|
||||||
|
| UmeAlarmHistory.native_probable_cause.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(x.alarm_key or ""),
|
||||||
|
"ne_id": str(x.ne_id or ""),
|
||||||
|
"ne_name": str(x.ne_name or ""),
|
||||||
|
"user_label": str(x.user_label or ""),
|
||||||
|
"object_name": str(x.object_name or ""),
|
||||||
|
"event_type": str(x.event_type or ""),
|
||||||
|
"native_probable_cause": str(x.native_probable_cause or ""),
|
||||||
|
"perceived_severity": str(x.perceived_severity or ""),
|
||||||
|
"is_cleared": str(x.is_cleared or ""),
|
||||||
|
"time_created": str(x.time_created 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/alarms/history/aggregate")
|
||||||
|
def ume_alarms_history_aggregate(db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
rows = db.query(UmeAlarmHistory).all()
|
||||||
|
by_severity = _aggregate_rows(rows, lambda x: x.perceived_severity)
|
||||||
|
by_ne = _aggregate_rows(rows, lambda x: x.user_label or x.ne_name or x.ne_id)
|
||||||
|
by_date = _aggregate_rows(rows, lambda x: str(x.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")
|
@app.get("/v1/integrations/status")
|
||||||
def integrations_status(db: Session = Depends(get_db)) -> dict:
|
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 is up if this handler executes; still verify DB + oclaw bridge separately.
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,28 @@ import json
|
||||||
import sys
|
import sys
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .db import SessionLocal
|
from .db import Base, SessionLocal, engine
|
||||||
from .importer import aggregate_alarms, query_alarms
|
from .importer import aggregate_alarms, query_alarms
|
||||||
from .models import AlarmBatch
|
from .models import AlarmBatch, UmeAlarmCurrent, UmeAlarmHistory, UmeInventoryNE
|
||||||
|
from .ume_client import UMEClient
|
||||||
|
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
_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)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _ok(rid: Any, result: dict[str, Any]) -> None:
|
def _ok(rid: Any, result: dict[str, Any]) -> None:
|
||||||
|
|
@ -76,6 +95,75 @@ def _tool_list() -> list[dict[str, Any]]:
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "umeSync",
|
||||||
|
"description": "Trigger UME sync for inventory/current/history domains.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"domains": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string", "enum": ["inventory", "alarms_current", "alarms_history"]},
|
||||||
|
},
|
||||||
|
"trigger_mode": {"type": "string", "enum": ["manual", "schedule"]},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "umeListNE",
|
||||||
|
"description": "List UME network elements from inventory table.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"keyword": {"type": "string"},
|
||||||
|
"page": {"type": "integer", "minimum": 1},
|
||||||
|
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "umeGetNE",
|
||||||
|
"description": "Get UME network element by ne_id.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"ne_id": {"type": "string"}},
|
||||||
|
"required": ["ne_id"],
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "umeListCurrentAlarms",
|
||||||
|
"description": "List current UME alarms from current table.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"severity": {"type": "string"},
|
||||||
|
"is_cleared": {"type": "string"},
|
||||||
|
"ne_id": {"type": "string"},
|
||||||
|
"keyword": {"type": "string"},
|
||||||
|
"page": {"type": "integer", "minimum": 1},
|
||||||
|
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "umeListHistoryAlarms",
|
||||||
|
"description": "List historical UME alarms from history table.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"severity": {"type": "string"},
|
||||||
|
"ne_id": {"type": "string"},
|
||||||
|
"keyword": {"type": "string"},
|
||||||
|
"page": {"type": "integer", "minimum": 1},
|
||||||
|
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
|
||||||
|
},
|
||||||
|
"additionalProperties": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -161,10 +249,191 @@ def _call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||||
"actions": actions,
|
"actions": actions,
|
||||||
}
|
}
|
||||||
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||||
|
if name == "umeSync":
|
||||||
|
domains_raw = args.get("domains")
|
||||||
|
domains = []
|
||||||
|
if isinstance(domains_raw, list):
|
||||||
|
domains = [str(x).strip() for x in domains_raw if str(x).strip()]
|
||||||
|
if not domains:
|
||||||
|
domains = ["inventory", "alarms_current", "alarms_history"]
|
||||||
|
trigger_mode = str(args.get("trigger_mode") or "manual").strip().lower()
|
||||||
|
if trigger_mode not in {"manual", "schedule"}:
|
||||||
|
trigger_mode = "manual"
|
||||||
|
client = _UME_CLIENT_SINGLETON
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
if "inventory" in domains:
|
||||||
|
j = sync_inventory_full(db, client, trigger_mode=trigger_mode)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"domain": "inventory",
|
||||||
|
"status": j.status,
|
||||||
|
"pulled_count": int(j.pulled_count or 0),
|
||||||
|
"inserted_count": int(j.inserted_count or 0),
|
||||||
|
"updated_count": int(j.updated_count or 0),
|
||||||
|
"error_message": str(j.error_message or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "alarms_current" in domains:
|
||||||
|
j, b = sync_alarms_current(db, client, trigger_mode=trigger_mode)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"domain": "alarms_current",
|
||||||
|
"status": j.status,
|
||||||
|
"batch_id": str(b.batch_id),
|
||||||
|
"pulled_count": int(j.pulled_count or 0),
|
||||||
|
"inserted_count": int(j.inserted_count or 0),
|
||||||
|
"updated_count": int(j.updated_count or 0),
|
||||||
|
"error_message": str(j.error_message or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "alarms_history" in domains:
|
||||||
|
j, b = sync_alarms_history_full(db, client, trigger_mode=trigger_mode)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"domain": "alarms_history",
|
||||||
|
"status": j.status,
|
||||||
|
"batch_id": str(b.batch_id),
|
||||||
|
"pulled_count": int(j.pulled_count or 0),
|
||||||
|
"inserted_count": int(j.inserted_count or 0),
|
||||||
|
"updated_count": int(j.updated_count or 0),
|
||||||
|
"error_message": str(j.error_message or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({"ok": True, "jobs": results}, ensure_ascii=False)}]}
|
||||||
|
if name == "umeListNE":
|
||||||
|
stmt = db.query(UmeInventoryNE)
|
||||||
|
keyword = str(args.get("keyword") or "").strip()
|
||||||
|
if keyword:
|
||||||
|
stmt = stmt.filter(
|
||||||
|
UmeInventoryNE.ne_id.contains(keyword)
|
||||||
|
| UmeInventoryNE.ne_name.contains(keyword)
|
||||||
|
| UmeInventoryNE.user_label.contains(keyword)
|
||||||
|
| UmeInventoryNE.ip_address.contains(keyword)
|
||||||
|
)
|
||||||
|
page = max(1, int(args.get("page") or 1))
|
||||||
|
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||||
|
total = int(stmt.count())
|
||||||
|
rows = stmt.order_by(UmeInventoryNE.ne_id.asc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
payload = {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"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 ""),
|
||||||
|
"ne_type": str(x.ne_type or ""),
|
||||||
|
}
|
||||||
|
for x in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||||
|
if name == "umeGetNE":
|
||||||
|
ne_id = str(args.get("ne_id") or "").strip()
|
||||||
|
row = db.get(UmeInventoryNE, ne_id)
|
||||||
|
if not row:
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps({"error": "ume_ne_not_found"})}], "isError": True}
|
||||||
|
payload = {
|
||||||
|
"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 ""),
|
||||||
|
"ne_type": str(row.ne_type or ""),
|
||||||
|
"vendor": str(row.vendor or ""),
|
||||||
|
"raw_json": str(row.raw_json or "{}"),
|
||||||
|
}
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||||
|
if name == "umeListCurrentAlarms":
|
||||||
|
stmt = db.query(UmeAlarmCurrent)
|
||||||
|
severity = str(args.get("severity") or "").strip()
|
||||||
|
is_cleared = str(args.get("is_cleared") or "").strip()
|
||||||
|
ne_id = str(args.get("ne_id") or "").strip()
|
||||||
|
keyword = str(args.get("keyword") or "").strip()
|
||||||
|
if severity:
|
||||||
|
stmt = stmt.filter(UmeAlarmCurrent.perceived_severity == severity)
|
||||||
|
if is_cleared:
|
||||||
|
stmt = stmt.filter(UmeAlarmCurrent.is_cleared == is_cleared)
|
||||||
|
if ne_id:
|
||||||
|
stmt = stmt.filter(UmeAlarmCurrent.ne_id == ne_id)
|
||||||
|
if keyword:
|
||||||
|
stmt = stmt.filter(
|
||||||
|
UmeAlarmCurrent.alarm_key.contains(keyword)
|
||||||
|
| UmeAlarmCurrent.object_name.contains(keyword)
|
||||||
|
| UmeAlarmCurrent.ne_name.contains(keyword)
|
||||||
|
| UmeAlarmCurrent.user_label.contains(keyword)
|
||||||
|
)
|
||||||
|
page = max(1, int(args.get("page") or 1))
|
||||||
|
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||||
|
total = int(stmt.count())
|
||||||
|
rows = stmt.order_by(UmeAlarmCurrent.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
payload = {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"alarm_key": str(x.alarm_key or ""),
|
||||||
|
"ne_id": str(x.ne_id or ""),
|
||||||
|
"ne_name": str(x.ne_name or ""),
|
||||||
|
"user_label": str(x.user_label or ""),
|
||||||
|
"object_name": str(x.object_name or ""),
|
||||||
|
"perceived_severity": str(x.perceived_severity or ""),
|
||||||
|
"is_cleared": str(x.is_cleared or ""),
|
||||||
|
"time_created": str(x.time_created or ""),
|
||||||
|
}
|
||||||
|
for x in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||||
|
if name == "umeListHistoryAlarms":
|
||||||
|
stmt = db.query(UmeAlarmHistory)
|
||||||
|
severity = str(args.get("severity") or "").strip()
|
||||||
|
ne_id = str(args.get("ne_id") or "").strip()
|
||||||
|
keyword = str(args.get("keyword") or "").strip()
|
||||||
|
if severity:
|
||||||
|
stmt = stmt.filter(UmeAlarmHistory.perceived_severity == severity)
|
||||||
|
if ne_id:
|
||||||
|
stmt = stmt.filter(UmeAlarmHistory.ne_id == ne_id)
|
||||||
|
if keyword:
|
||||||
|
stmt = stmt.filter(
|
||||||
|
UmeAlarmHistory.alarm_key.contains(keyword)
|
||||||
|
| UmeAlarmHistory.object_name.contains(keyword)
|
||||||
|
| UmeAlarmHistory.ne_name.contains(keyword)
|
||||||
|
| UmeAlarmHistory.user_label.contains(keyword)
|
||||||
|
)
|
||||||
|
page = max(1, int(args.get("page") or 1))
|
||||||
|
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||||
|
total = int(stmt.count())
|
||||||
|
rows = stmt.order_by(UmeAlarmHistory.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
payload = {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"alarm_key": str(x.alarm_key or ""),
|
||||||
|
"ne_id": str(x.ne_id or ""),
|
||||||
|
"ne_name": str(x.ne_name or ""),
|
||||||
|
"user_label": str(x.user_label or ""),
|
||||||
|
"object_name": str(x.object_name or ""),
|
||||||
|
"perceived_severity": str(x.perceived_severity or ""),
|
||||||
|
"is_cleared": str(x.is_cleared or ""),
|
||||||
|
"time_created": str(x.time_created or ""),
|
||||||
|
}
|
||||||
|
for x in rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
|
||||||
raise ValueError(f"unknown tool: {name}")
|
raise ValueError(f"unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
try:
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
for line in sys.stdin:
|
for line in sys.stdin:
|
||||||
raw = line.strip()
|
raw = line.strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
|
|
|
||||||
|
|
@ -99,3 +99,111 @@ class AiAnalyzeHistory(Base):
|
||||||
error: Mapped[str] = mapped_column(Text, default="")
|
error: Mapped[str] = mapped_column(Text, default="")
|
||||||
evidence_json: 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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UmeSyncJob(Base):
|
||||||
|
__tablename__ = "ume_sync_jobs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
domain: Mapped[str] = mapped_column(String(32), index=True, default="inventory")
|
||||||
|
status: Mapped[str] = mapped_column(String(32), index=True, default="running")
|
||||||
|
trigger_mode: Mapped[str] = mapped_column(String(32), default="manual")
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
pulled_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
inserted_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
updated_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
error_message: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
|
details_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
|
||||||
|
|
||||||
|
class UmeAlarmBatch(Base):
|
||||||
|
__tablename__ = "ume_alarm_batches"
|
||||||
|
|
||||||
|
batch_id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), index=True, default="current") # current/history
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="done")
|
||||||
|
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)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
error_message: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
|
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
|
||||||
|
|
||||||
|
class UmeInventoryNE(Base):
|
||||||
|
__tablename__ = "ume_inventory_ne"
|
||||||
|
|
||||||
|
ne_id: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
ne_name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||||
|
user_label: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||||
|
ip_address: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||||
|
ne_type: 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="ume_restconf")
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
|
||||||
|
|
||||||
|
class UmeInventoryEquipmentHolder(Base):
|
||||||
|
__tablename__ = "ume_inventory_equipment_holder"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
ne_id: Mapped[str] = mapped_column(ForeignKey("ume_inventory_ne.ne_id"), index=True)
|
||||||
|
holder_name: Mapped[str] = mapped_column(String(256), index=True, default="")
|
||||||
|
holder_type: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
holder_state: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
|
||||||
|
|
||||||
|
class UmeAlarmCurrent(Base):
|
||||||
|
__tablename__ = "ume_alarms_current"
|
||||||
|
|
||||||
|
alarm_key: Mapped[str] = mapped_column(String(256), primary_key=True)
|
||||||
|
ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||||
|
ne_name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||||
|
user_label: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||||
|
object_name: Mapped[str] = mapped_column(String(512), default="", index=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
native_probable_cause: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
perceived_severity: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
is_cleared: Mapped[str] = mapped_column(String(16), default="", index=True)
|
||||||
|
time_created: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
root_cause_alarm_indication: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
|
||||||
|
|
||||||
|
class UmeAlarmHistory(Base):
|
||||||
|
__tablename__ = "ume_alarms_history"
|
||||||
|
|
||||||
|
alarm_key: Mapped[str] = mapped_column(String(256), primary_key=True)
|
||||||
|
ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||||
|
ne_name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||||
|
user_label: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||||
|
object_name: Mapped[str] = mapped_column(String(512), default="", index=True)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
native_probable_cause: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
perceived_severity: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
is_cleared: Mapped[str] = mapped_column(String(16), default="", index=True)
|
||||||
|
time_created: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
root_cause_alarm_indication: Mapped[str] = mapped_column(String(32), default="")
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
||||||
|
|
||||||
|
|
||||||
|
class UmeTokenCache(Base):
|
||||||
|
__tablename__ = "ume_token_cache"
|
||||||
|
|
||||||
|
cache_key: Mapped[str] = mapped_column(String(256), primary_key=True)
|
||||||
|
token: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
expires_at_epoch_s: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
lock_owner: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||||
|
lock_expires_at_epoch_s: Mapped[int] = mapped_column(Integer, default=0, index=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
||||||
|
|
|
||||||
458
netx_api/ume_client.py
Normal file
458
netx_api/ume_client.py
Normal file
|
|
@ -0,0 +1,458 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import json
|
||||||
|
from threading import Lock
|
||||||
|
from time import time
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _rstrip_slash(url: str) -> str:
|
||||||
|
return str(url or "").strip().rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_dict(value: Any) -> dict[str, Any]:
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_list(value: Any) -> list[Any]:
|
||||||
|
return value if isinstance(value, list) else []
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RequestDiagnostics:
|
||||||
|
method: str
|
||||||
|
path: str
|
||||||
|
status_code: int
|
||||||
|
latency_ms: int
|
||||||
|
retry_count: int = 0
|
||||||
|
error_code: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class UMEClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
base_url: str | None = None,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
verify_tls: bool | None = None,
|
||||||
|
timeout_s: float | None = None,
|
||||||
|
auth_header: str | None = None,
|
||||||
|
content_type: str | None = None,
|
||||||
|
token_ttl_s: int | None = None,
|
||||||
|
token_refresh_skew_s: int | None = None,
|
||||||
|
token_path: str | None = None,
|
||||||
|
token_handshake_path: str | None = None,
|
||||||
|
token_logout_path: str | None = None,
|
||||||
|
ne_path: str | None = None,
|
||||||
|
alarms_path: str | None = None,
|
||||||
|
token_loader: Callable[[], tuple[str, float] | None] | None = None,
|
||||||
|
token_saver: Callable[[str, float], None] | None = None,
|
||||||
|
token_clearer: Callable[[], None] | None = None,
|
||||||
|
lock_acquirer: Callable[[], bool] | None = None,
|
||||||
|
lock_releaser: Callable[[], None] | None = None,
|
||||||
|
token_waiter: Callable[[float], tuple[str, float] | None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = _rstrip_slash(base_url if base_url is not None else settings.ume_base_url)
|
||||||
|
self.username = str(username if username is not None else settings.ume_username)
|
||||||
|
self.password = str(password if password is not None else settings.ume_password)
|
||||||
|
self.verify_tls = bool(settings.ume_verify_tls if verify_tls is None else verify_tls)
|
||||||
|
self.timeout_s = max(3.0, float(settings.ume_timeout_s if timeout_s is None else timeout_s))
|
||||||
|
self.auth_header = str(auth_header if auth_header is not None else settings.ume_auth_header).strip() or "accessToken"
|
||||||
|
self.content_type = str(content_type if content_type is not None else settings.ume_content_type).strip()
|
||||||
|
self.token_ttl_s = max(60, int(settings.ume_token_ttl_s if token_ttl_s is None else token_ttl_s))
|
||||||
|
self.token_refresh_skew_s = max(
|
||||||
|
5, int(settings.ume_token_refresh_skew_s if token_refresh_skew_s is None else token_refresh_skew_s)
|
||||||
|
)
|
||||||
|
self.token_path = str(token_path if token_path is not None else settings.ume_token_path).strip()
|
||||||
|
self.token_handshake_path = str(
|
||||||
|
token_handshake_path if token_handshake_path is not None else settings.ume_token_handshake_path
|
||||||
|
).strip()
|
||||||
|
self.token_logout_path = str(token_logout_path if token_logout_path is not None else settings.ume_token_logout_path).strip()
|
||||||
|
self.ne_path = str(ne_path if ne_path is not None else settings.ume_ne_path).strip()
|
||||||
|
self.alarms_path = str(alarms_path if alarms_path is not None else settings.ume_alarms_path).strip()
|
||||||
|
self._token_loader = token_loader
|
||||||
|
self._token_saver = token_saver
|
||||||
|
self._token_clearer = token_clearer
|
||||||
|
self._lock_acquirer = lock_acquirer
|
||||||
|
self._lock_releaser = lock_releaser
|
||||||
|
self._token_waiter = token_waiter
|
||||||
|
|
||||||
|
self._token_lock = Lock()
|
||||||
|
self._token_value: str = ""
|
||||||
|
self._token_expires_at: float = 0.0
|
||||||
|
self._last_token_source: str = "memory"
|
||||||
|
self._last_store_sync_changed: bool = False
|
||||||
|
|
||||||
|
def _sync_token_from_store(self) -> None:
|
||||||
|
self._last_store_sync_changed = False
|
||||||
|
if self._token_loader is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loaded = self._token_loader()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if not loaded:
|
||||||
|
return
|
||||||
|
token, exp = loaded
|
||||||
|
token = str(token or "").strip()
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
if exp > float(self._token_expires_at):
|
||||||
|
self._token_value = token
|
||||||
|
self._token_expires_at = float(exp)
|
||||||
|
self._last_token_source = "db"
|
||||||
|
self._last_store_sync_changed = True
|
||||||
|
|
||||||
|
def _persist_token_to_store(self) -> None:
|
||||||
|
if self._token_saver is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._token_saver(self._token_value, self._token_expires_at)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _clear_token_in_store(self) -> None:
|
||||||
|
if self._token_clearer is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._token_clearer()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def token_status(self) -> dict[str, Any]:
|
||||||
|
self._sync_token_from_store()
|
||||||
|
now = time()
|
||||||
|
token = self._token_value.strip()
|
||||||
|
has_token = bool(token)
|
||||||
|
expires_in_s = int(max(0, self._token_expires_at - now)) if has_token else 0
|
||||||
|
token_preview = ""
|
||||||
|
if token:
|
||||||
|
token_preview = f"{token[:8]}...{token[-4:]}" if len(token) > 16 else token
|
||||||
|
return {
|
||||||
|
"has_token": has_token,
|
||||||
|
"expires_in_s": expires_in_s,
|
||||||
|
"expires_at_epoch_s": int(self._token_expires_at) if has_token else 0,
|
||||||
|
"auth_header": self.auth_header,
|
||||||
|
"token_preview": token_preview,
|
||||||
|
"source": str(self._last_token_source or "memory"),
|
||||||
|
"store_synced": bool(self._last_store_sync_changed),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _assert_ready(self) -> None:
|
||||||
|
if not self.base_url:
|
||||||
|
raise RuntimeError("ume_base_url_required")
|
||||||
|
if not self.username:
|
||||||
|
raise RuntimeError("ume_username_required")
|
||||||
|
if not self.password:
|
||||||
|
raise RuntimeError("ume_password_required")
|
||||||
|
|
||||||
|
def _build_url(self, path: str) -> str:
|
||||||
|
p = str(path or "").strip()
|
||||||
|
if not p:
|
||||||
|
raise RuntimeError("ume_path_required")
|
||||||
|
if p.startswith("http://") or p.startswith("https://"):
|
||||||
|
return p
|
||||||
|
if not p.startswith("/"):
|
||||||
|
p = "/" + p
|
||||||
|
return f"{self.base_url}{p}"
|
||||||
|
|
||||||
|
def _headers(self, *, include_token: bool = True) -> dict[str, str]:
|
||||||
|
headers = {
|
||||||
|
"accept": "application/yang-data+json",
|
||||||
|
"content-type": self.content_type,
|
||||||
|
}
|
||||||
|
if include_token:
|
||||||
|
token = self._token_value.strip()
|
||||||
|
if token:
|
||||||
|
headers[self.auth_header] = token
|
||||||
|
return headers
|
||||||
|
|
||||||
|
def _extract_token_and_ttl(self, payload: dict[str, Any]) -> tuple[str, int | None]:
|
||||||
|
token = ""
|
||||||
|
ttl: int | None = None
|
||||||
|
|
||||||
|
def walk(node: Any) -> None:
|
||||||
|
nonlocal token, ttl
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for k, v in node.items():
|
||||||
|
key = str(k).lower()
|
||||||
|
if not token and key in {"accesstoken", "access_token", "token"} and isinstance(v, str):
|
||||||
|
token = v.strip()
|
||||||
|
if ttl is None and key in {"expires", "expiresin", "expires_in", "ttl"}:
|
||||||
|
try:
|
||||||
|
ttl = int(v)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
walk(v)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
walk(item)
|
||||||
|
|
||||||
|
walk(payload)
|
||||||
|
return token, ttl
|
||||||
|
|
||||||
|
def login(self, *, force: bool = False) -> str:
|
||||||
|
self._assert_ready()
|
||||||
|
self._sync_token_from_store()
|
||||||
|
now = time()
|
||||||
|
if not force and self._token_value and now < (self._token_expires_at - self.token_refresh_skew_s):
|
||||||
|
return self._token_value
|
||||||
|
|
||||||
|
with self._token_lock:
|
||||||
|
now = time()
|
||||||
|
if not force and self._token_value and now < (self._token_expires_at - self.token_refresh_skew_s):
|
||||||
|
return self._token_value
|
||||||
|
|
||||||
|
# Cross-process singleflight: if another process is refreshing, wait for DB update.
|
||||||
|
min_exp = float(self._token_expires_at)
|
||||||
|
if self._lock_acquirer is not None:
|
||||||
|
acquired = False
|
||||||
|
try:
|
||||||
|
acquired = bool(self._lock_acquirer())
|
||||||
|
except Exception:
|
||||||
|
acquired = False
|
||||||
|
if not acquired:
|
||||||
|
if self._token_waiter is not None:
|
||||||
|
waited = self._token_waiter(min_exp)
|
||||||
|
if waited:
|
||||||
|
self._sync_token_from_store()
|
||||||
|
now = time()
|
||||||
|
if self._token_value and now < (self._token_expires_at - self.token_refresh_skew_s):
|
||||||
|
return self._token_value
|
||||||
|
|
||||||
|
url = self._build_url(self.token_path)
|
||||||
|
payload = {"login-info": {"user-name": self.username, "value": self.password}}
|
||||||
|
try:
|
||||||
|
with httpx.Client(verify=self.verify_tls, timeout=self.timeout_s) as client:
|
||||||
|
resp = client.post(url, json=payload, headers=self._headers(include_token=False))
|
||||||
|
if not resp.is_success:
|
||||||
|
raise RuntimeError(f"ume_login_failed:{resp.status_code}:{resp.text[:240]}")
|
||||||
|
data = _coerce_dict(resp.json())
|
||||||
|
except Exception as exc:
|
||||||
|
if self._lock_releaser is not None:
|
||||||
|
try:
|
||||||
|
self._lock_releaser()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError(f"ume_login_failed:{str(exc)[:240]}") from exc
|
||||||
|
|
||||||
|
token, ttl = self._extract_token_and_ttl(data)
|
||||||
|
if not token:
|
||||||
|
if self._lock_releaser is not None:
|
||||||
|
try:
|
||||||
|
self._lock_releaser()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise RuntimeError("ume_login_failed:missing_access_token")
|
||||||
|
use_ttl = max(60, int(ttl)) if ttl is not None else self.token_ttl_s
|
||||||
|
self._token_value = token
|
||||||
|
self._token_expires_at = time() + use_ttl
|
||||||
|
self._last_token_source = "memory"
|
||||||
|
self._persist_token_to_store()
|
||||||
|
if self._lock_releaser is not None:
|
||||||
|
try:
|
||||||
|
self._lock_releaser()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self._token_value
|
||||||
|
|
||||||
|
def renew_token(self) -> str:
|
||||||
|
self._assert_ready()
|
||||||
|
token = self._token_value.strip()
|
||||||
|
if not token:
|
||||||
|
return self.login(force=True)
|
||||||
|
with self._token_lock:
|
||||||
|
token = self._token_value.strip()
|
||||||
|
if not token:
|
||||||
|
return self.login(force=True)
|
||||||
|
url = self._build_url(self.token_handshake_path)
|
||||||
|
try:
|
||||||
|
with httpx.Client(verify=self.verify_tls, timeout=self.timeout_s) as client:
|
||||||
|
resp = client.post(url, headers=self._headers(include_token=True))
|
||||||
|
if not resp.is_success:
|
||||||
|
# handshake may fail if token expired; fallback to full login
|
||||||
|
return self.login(force=True)
|
||||||
|
# Per UME guide, oauth_handshake may return no body; treat HTTP 2xx as success.
|
||||||
|
next_token = ""
|
||||||
|
ttl: int | None = None
|
||||||
|
try:
|
||||||
|
text = (resp.text or "").strip()
|
||||||
|
if text:
|
||||||
|
data = _coerce_dict(resp.json())
|
||||||
|
next_token, ttl = self._extract_token_and_ttl(data)
|
||||||
|
except Exception:
|
||||||
|
# ignore json parse errors, success is based on status code
|
||||||
|
next_token = ""
|
||||||
|
ttl = None
|
||||||
|
if next_token:
|
||||||
|
self._token_value = next_token
|
||||||
|
# If handshake doesn't provide ttl, fall back to configured ttl.
|
||||||
|
use_ttl = max(60, int(ttl)) if ttl is not None else self.token_ttl_s
|
||||||
|
self._token_expires_at = time() + use_ttl
|
||||||
|
self._last_token_source = "memory"
|
||||||
|
self._persist_token_to_store()
|
||||||
|
return self._token_value
|
||||||
|
except Exception:
|
||||||
|
return self.login(force=True)
|
||||||
|
|
||||||
|
def logout_token(self) -> bool:
|
||||||
|
token = self._token_value.strip()
|
||||||
|
if not token:
|
||||||
|
return True
|
||||||
|
url = self._build_url(self.token_logout_path)
|
||||||
|
try:
|
||||||
|
with httpx.Client(verify=self.verify_tls, timeout=self.timeout_s) as client:
|
||||||
|
resp = client.delete(url, headers=self._headers(include_token=True))
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
# Token already invalid/expired on server side can be treated as logged out.
|
||||||
|
self._token_value = ""
|
||||||
|
self._token_expires_at = 0.0
|
||||||
|
self._last_token_source = "memory"
|
||||||
|
self._clear_token_in_store()
|
||||||
|
return True
|
||||||
|
if not resp.is_success:
|
||||||
|
return False
|
||||||
|
self._token_value = ""
|
||||||
|
self._token_expires_at = 0.0
|
||||||
|
self._last_token_source = "memory"
|
||||||
|
self._clear_token_in_store()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def refresh_if_needed(self) -> str:
|
||||||
|
self._sync_token_from_store()
|
||||||
|
now = time()
|
||||||
|
if self._token_value and now < (self._token_expires_at - self.token_refresh_skew_s):
|
||||||
|
return self._token_value
|
||||||
|
if self._token_value:
|
||||||
|
return self.renew_token()
|
||||||
|
return self.login(force=False)
|
||||||
|
|
||||||
|
def request_json(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
body: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[dict[str, Any], RequestDiagnostics]:
|
||||||
|
self.refresh_if_needed()
|
||||||
|
url = self._build_url(path)
|
||||||
|
m = str(method or "GET").upper()
|
||||||
|
retry_count = 0
|
||||||
|
t0 = time()
|
||||||
|
try:
|
||||||
|
with httpx.Client(verify=self.verify_tls, timeout=self.timeout_s) as client:
|
||||||
|
resp = client.request(m, url, params=params, json=body, headers=self._headers(include_token=True))
|
||||||
|
if resp.status_code in (401, 403):
|
||||||
|
retry_count = 1
|
||||||
|
self.login(force=True)
|
||||||
|
with httpx.Client(verify=self.verify_tls, timeout=self.timeout_s) as client:
|
||||||
|
resp = client.request(m, url, params=params, json=body, headers=self._headers(include_token=True))
|
||||||
|
if not resp.is_success:
|
||||||
|
diag = RequestDiagnostics(
|
||||||
|
method=m,
|
||||||
|
path=path,
|
||||||
|
status_code=int(resp.status_code),
|
||||||
|
latency_ms=int((time() - t0) * 1000),
|
||||||
|
retry_count=retry_count,
|
||||||
|
error_code=f"http_{int(resp.status_code)}",
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"ume_request_failed:{resp.status_code}:{resp.text[:240]}")
|
||||||
|
data = _coerce_dict(resp.json())
|
||||||
|
diag = RequestDiagnostics(
|
||||||
|
method=m,
|
||||||
|
path=path,
|
||||||
|
status_code=int(resp.status_code),
|
||||||
|
latency_ms=int((time() - t0) * 1000),
|
||||||
|
retry_count=retry_count,
|
||||||
|
)
|
||||||
|
return data, diag
|
||||||
|
except Exception as exc:
|
||||||
|
if isinstance(exc, RuntimeError):
|
||||||
|
raise
|
||||||
|
raise RuntimeError(f"ume_request_failed:{str(exc)[:240]}") from exc
|
||||||
|
|
||||||
|
def _extract_named_list(self, payload: dict[str, Any], keys: list[str]) -> list[dict[str, Any]]:
|
||||||
|
target_keys = {str(k).lower() for k in keys}
|
||||||
|
found: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def add_unique(item: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
mark = json.dumps(item, ensure_ascii=False, sort_keys=True, default=str)
|
||||||
|
except Exception:
|
||||||
|
mark = str(item)
|
||||||
|
if mark in seen:
|
||||||
|
return
|
||||||
|
seen.add(mark)
|
||||||
|
found.append(item)
|
||||||
|
|
||||||
|
def walk(node: Any) -> None:
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for k, v in node.items():
|
||||||
|
if str(k).lower() in target_keys:
|
||||||
|
if isinstance(v, list):
|
||||||
|
for it in v:
|
||||||
|
if isinstance(it, dict):
|
||||||
|
add_unique(it)
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
# Common RESTCONF wrappers are container dicts, e.g.
|
||||||
|
# network-elements -> network-element[] / alarm-list -> alarm[].
|
||||||
|
# Prefer unwrapping nested list payloads before falling back.
|
||||||
|
nested_collected = False
|
||||||
|
for nested_v in v.values():
|
||||||
|
if isinstance(nested_v, list):
|
||||||
|
for it in nested_v:
|
||||||
|
if isinstance(it, dict):
|
||||||
|
add_unique(it)
|
||||||
|
nested_collected = True
|
||||||
|
if not nested_collected:
|
||||||
|
add_unique(v)
|
||||||
|
walk(v)
|
||||||
|
elif isinstance(node, list):
|
||||||
|
for item in node:
|
||||||
|
walk(item)
|
||||||
|
|
||||||
|
walk(payload)
|
||||||
|
return found
|
||||||
|
|
||||||
|
def get_network_elements(self) -> tuple[list[dict[str, Any]], RequestDiagnostics]:
|
||||||
|
data, diag = self.request_json("GET", self.ne_path)
|
||||||
|
rows = self._extract_named_list(data, ["network-elements", "network-element", "ne", "network_elements"])
|
||||||
|
if rows:
|
||||||
|
return rows, diag
|
||||||
|
# fallback: some responses may directly return list-like map at top-level
|
||||||
|
for v in data.values():
|
||||||
|
lst = _coerce_list(v)
|
||||||
|
if lst and isinstance(lst[0], dict):
|
||||||
|
return [x for x in lst if isinstance(x, dict)], diag
|
||||||
|
return [], diag
|
||||||
|
|
||||||
|
def get_alarms(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
is_uncleared: bool,
|
||||||
|
limit: int | None = None,
|
||||||
|
offset: int | None = None,
|
||||||
|
) -> tuple[list[dict[str, Any]], RequestDiagnostics]:
|
||||||
|
limit_max = int(getattr(settings, "ume_limit_max", 5000) or 5000)
|
||||||
|
limit_max = max(1, limit_max)
|
||||||
|
page_size = int(limit or settings.ume_page_size or 1000)
|
||||||
|
page_size = max(1, min(page_size, limit_max))
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"is-uncleared": "true" if is_uncleared else "false",
|
||||||
|
"limit": page_size,
|
||||||
|
}
|
||||||
|
if offset is not None and int(offset) >= 0:
|
||||||
|
params["offset"] = int(offset)
|
||||||
|
data, diag = self.request_json("GET", self.alarms_path, params=params)
|
||||||
|
rows = self._extract_named_list(data, ["alarm-list", "alarm"])
|
||||||
|
return rows, diag
|
||||||
299
netx_api/ume_sync_service.py
Normal file
299
netx_api/ume_sync_service.py
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .models import (
|
||||||
|
UmeAlarmBatch,
|
||||||
|
UmeAlarmCurrent,
|
||||||
|
UmeAlarmHistory,
|
||||||
|
UmeInventoryEquipmentHolder,
|
||||||
|
UmeInventoryNE,
|
||||||
|
UmeSyncJob,
|
||||||
|
)
|
||||||
|
from .ume_client import UMEClient
|
||||||
|
|
||||||
|
|
||||||
|
def _s(v: Any) -> str:
|
||||||
|
if v is None:
|
||||||
|
return ""
|
||||||
|
text = str(v).strip()
|
||||||
|
if text.lower() == "nan":
|
||||||
|
return ""
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now_naive() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(d: dict[str, Any], *keys: str) -> Any:
|
||||||
|
for key in keys:
|
||||||
|
if key in d:
|
||||||
|
return d.get(key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _alarm_key(alarm: dict[str, Any]) -> str:
|
||||||
|
key = _s(_pick(alarm, "alarmKey", "alarm-key", "id"))
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
parts = [
|
||||||
|
_s(_pick(alarm, "objectName", "object-name")),
|
||||||
|
_s(_pick(alarm, "eventType", "event-type")),
|
||||||
|
_s(_pick(alarm, "timeCreated", "time-created")),
|
||||||
|
_s(_pick(alarm, "nativeProbableCause", "native-probable-cause")),
|
||||||
|
]
|
||||||
|
merged = "|".join(x for x in parts if x)
|
||||||
|
return merged or f"fallback-{datetime.utcnow().timestamp()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sync_job(domain: str, trigger_mode: str) -> UmeSyncJob:
|
||||||
|
return UmeSyncJob(
|
||||||
|
domain=domain,
|
||||||
|
status="running",
|
||||||
|
trigger_mode=trigger_mode,
|
||||||
|
started_at=_utc_now_naive(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_inventory_full(db: Session, client: UMEClient, *, trigger_mode: str = "manual") -> UmeSyncJob:
|
||||||
|
job = _build_sync_job("inventory", trigger_mode)
|
||||||
|
db.add(job)
|
||||||
|
db.flush()
|
||||||
|
pulled = inserted = updated = 0
|
||||||
|
try:
|
||||||
|
ne_rows, _ = client.get_network_elements()
|
||||||
|
now = _utc_now_naive()
|
||||||
|
pulled = len(ne_rows)
|
||||||
|
for row in ne_rows:
|
||||||
|
ne_id = _s(_pick(row, "ne-id", "ne_id", "id"))
|
||||||
|
if not ne_id:
|
||||||
|
continue
|
||||||
|
existing = db.get(UmeInventoryNE, ne_id)
|
||||||
|
if existing is None:
|
||||||
|
existing = UmeInventoryNE(
|
||||||
|
ne_id=ne_id,
|
||||||
|
first_seen_at=now,
|
||||||
|
)
|
||||||
|
db.add(existing)
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
existing.ne_name = _s(_pick(row, "name", "ne-name"))
|
||||||
|
existing.user_label = _s(_pick(row, "user-label", "user_label"))
|
||||||
|
existing.ip_address = _s(_pick(row, "ip-Address", "ip-address", "ip"))
|
||||||
|
existing.ne_type = _s(_pick(row, "type", "ne-type"))
|
||||||
|
existing.last_seen_at = now
|
||||||
|
existing.raw_json = json.dumps(row, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
# Optional: holders may not exist in current UME deployment; keep best-effort.
|
||||||
|
# This pass keeps schema warm for later detailed holder endpoint integration.
|
||||||
|
holder_payload: list[dict[str, Any]] = []
|
||||||
|
for ne in ne_rows:
|
||||||
|
holders = _pick(ne, "equipment-holder", "equipment-holders")
|
||||||
|
if isinstance(holders, list):
|
||||||
|
for h in holders:
|
||||||
|
if isinstance(h, dict):
|
||||||
|
h2 = dict(h)
|
||||||
|
if "ne-id" not in h2:
|
||||||
|
h2["ne-id"] = _pick(ne, "ne-id", "ne_id", "id")
|
||||||
|
holder_payload.append(h2)
|
||||||
|
for h in holder_payload:
|
||||||
|
ne_id = _s(_pick(h, "ne-id", "ne_id"))
|
||||||
|
holder_name = _s(_pick(h, "name", "holder-name"))
|
||||||
|
if not ne_id or not holder_name:
|
||||||
|
continue
|
||||||
|
existing_holder = (
|
||||||
|
db.query(UmeInventoryEquipmentHolder)
|
||||||
|
.filter(
|
||||||
|
UmeInventoryEquipmentHolder.ne_id == ne_id,
|
||||||
|
UmeInventoryEquipmentHolder.holder_name == holder_name,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if existing_holder is None:
|
||||||
|
existing_holder = UmeInventoryEquipmentHolder(
|
||||||
|
ne_id=ne_id,
|
||||||
|
holder_name=holder_name,
|
||||||
|
first_seen_at=now,
|
||||||
|
)
|
||||||
|
db.add(existing_holder)
|
||||||
|
existing_holder.holder_type = _s(_pick(h, "type", "holder-type"))
|
||||||
|
existing_holder.holder_state = _s(_pick(h, "state", "holder-state"))
|
||||||
|
existing_holder.last_seen_at = now
|
||||||
|
existing_holder.raw_json = json.dumps(h, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
job.status = "done"
|
||||||
|
except Exception as exc:
|
||||||
|
job.status = "failed"
|
||||||
|
job.error_message = str(exc)[:1024]
|
||||||
|
finally:
|
||||||
|
job.pulled_count = int(pulled)
|
||||||
|
job.inserted_count = int(inserted)
|
||||||
|
job.updated_count = int(updated)
|
||||||
|
job.ended_at = _utc_now_naive()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_alarms_common(
|
||||||
|
db: Session,
|
||||||
|
client: UMEClient,
|
||||||
|
*,
|
||||||
|
is_uncleared: bool,
|
||||||
|
trigger_mode: str,
|
||||||
|
) -> tuple[UmeSyncJob, UmeAlarmBatch]:
|
||||||
|
domain = "alarms_history" if is_uncleared else "alarms_current"
|
||||||
|
job = _build_sync_job(domain, trigger_mode)
|
||||||
|
db.add(job)
|
||||||
|
batch = UmeAlarmBatch(
|
||||||
|
kind="history" if is_uncleared else "current",
|
||||||
|
status="running",
|
||||||
|
started_at=_utc_now_naive(),
|
||||||
|
)
|
||||||
|
db.add(batch)
|
||||||
|
db.flush()
|
||||||
|
now = _utc_now_naive()
|
||||||
|
pulled = inserted = updated = 0
|
||||||
|
paging_mode = "offset"
|
||||||
|
paging_note = ""
|
||||||
|
try:
|
||||||
|
limit_max = int(getattr(settings, "ume_limit_max", 5000) or 5000)
|
||||||
|
limit_max = max(1, limit_max)
|
||||||
|
page_size = int(getattr(settings, "ume_page_size", 1000) or 1000)
|
||||||
|
page_size = max(1, min(page_size, limit_max))
|
||||||
|
max_pages = int(getattr(settings, "ume_max_pages", 2000) or 2000)
|
||||||
|
max_pages = max(1, min(max_pages, 20000))
|
||||||
|
offset = 0
|
||||||
|
page_no = 0
|
||||||
|
|
||||||
|
def upsert_alarm(alarm: dict[str, Any]) -> None:
|
||||||
|
nonlocal inserted, updated
|
||||||
|
key = _alarm_key(alarm)
|
||||||
|
if is_uncleared:
|
||||||
|
existing = db.get(UmeAlarmHistory, key)
|
||||||
|
if existing is None:
|
||||||
|
existing = UmeAlarmHistory(alarm_key=key, first_seen_at=now)
|
||||||
|
db.add(existing)
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
existing = db.get(UmeAlarmCurrent, key)
|
||||||
|
if existing is None:
|
||||||
|
existing = UmeAlarmCurrent(alarm_key=key, first_seen_at=now)
|
||||||
|
db.add(existing)
|
||||||
|
inserted += 1
|
||||||
|
else:
|
||||||
|
updated += 1
|
||||||
|
existing.ne_id = _s(_pick(alarm, "ne-id", "neId", "ne_id"))
|
||||||
|
existing.ne_name = _s(_pick(alarm, "ne-name", "neName", "ne_name"))
|
||||||
|
existing.user_label = _s(_pick(alarm, "user-label", "userLabel", "user_label"))
|
||||||
|
existing.object_name = _s(_pick(alarm, "objectName", "object-name"))
|
||||||
|
existing.event_type = _s(_pick(alarm, "eventType", "event-type"))
|
||||||
|
existing.native_probable_cause = _s(_pick(alarm, "nativeProbableCause", "native-probable-cause"))
|
||||||
|
existing.perceived_severity = _s(_pick(alarm, "perceivedSeverity", "perceived-severity"))
|
||||||
|
existing.is_cleared = _s(_pick(alarm, "isCleared", "is-cleared"))
|
||||||
|
existing.time_created = _s(_pick(alarm, "timeCreated", "time-created"))
|
||||||
|
existing.root_cause_alarm_indication = _s(
|
||||||
|
_pick(alarm, "rootCauseAlarmIndication", "root-cause-alarm-indication")
|
||||||
|
)
|
||||||
|
existing.last_seen_at = now
|
||||||
|
existing.raw_json = json.dumps(alarm, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
def is_offset_unsupported_error(exc: Exception) -> bool:
|
||||||
|
msg = str(exc or "")
|
||||||
|
if "ume_request_failed:400" not in msg:
|
||||||
|
return False
|
||||||
|
low = msg.lower()
|
||||||
|
return ("offset" in low) or ("unknown" in low and "param" in low) or ("illegal" in low and "param" in low)
|
||||||
|
|
||||||
|
# Try offset pagination first (best-effort). If server rejects offset, fall back to single-page.
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
page_no += 1
|
||||||
|
if page_no > max_pages:
|
||||||
|
raise RuntimeError(f"ume_alarms_pagination_exceeded:max_pages={max_pages}")
|
||||||
|
rows, _ = client.get_alarms(is_uncleared=is_uncleared, limit=page_size, offset=offset)
|
||||||
|
pulled += len(rows)
|
||||||
|
for alarm in rows:
|
||||||
|
upsert_alarm(alarm)
|
||||||
|
if len(rows) < page_size:
|
||||||
|
break
|
||||||
|
offset += page_size
|
||||||
|
except Exception as exc:
|
||||||
|
if is_offset_unsupported_error(exc):
|
||||||
|
paging_mode = "limit_only"
|
||||||
|
paging_note = str(exc)[:200]
|
||||||
|
limit_only_size = int(getattr(settings, "ume_limit_only_page_size", limit_max) or limit_max)
|
||||||
|
limit_only_size = max(1, min(limit_only_size, limit_max))
|
||||||
|
# Re-run as single page without offset param.
|
||||||
|
pulled = inserted = updated = 0
|
||||||
|
rows, _ = client.get_alarms(is_uncleared=is_uncleared, limit=limit_only_size, offset=None)
|
||||||
|
pulled = len(rows)
|
||||||
|
for alarm in rows:
|
||||||
|
upsert_alarm(alarm)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not is_uncleared:
|
||||||
|
expiry = now - timedelta(hours=48)
|
||||||
|
(
|
||||||
|
db.query(UmeAlarmCurrent)
|
||||||
|
.filter(UmeAlarmCurrent.last_seen_at < expiry)
|
||||||
|
.delete(synchronize_session=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
batch.total_rows = int(pulled)
|
||||||
|
batch.success_rows = int(inserted + updated)
|
||||||
|
batch.failed_rows = max(0, int(pulled) - int(inserted + updated))
|
||||||
|
batch.status = "done"
|
||||||
|
batch.ended_at = _utc_now_naive()
|
||||||
|
batch.raw_json = json.dumps(
|
||||||
|
{"pulled": pulled, "inserted": inserted, "updated": updated, "paging_mode": paging_mode},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
job.status = "done"
|
||||||
|
except Exception as exc:
|
||||||
|
msg = str(exc)[:1024]
|
||||||
|
batch.status = "failed"
|
||||||
|
batch.error_message = msg
|
||||||
|
batch.ended_at = _utc_now_naive()
|
||||||
|
job.status = "failed"
|
||||||
|
job.error_message = msg
|
||||||
|
finally:
|
||||||
|
job.pulled_count = int(pulled)
|
||||||
|
job.inserted_count = int(inserted)
|
||||||
|
job.updated_count = int(updated)
|
||||||
|
job.ended_at = _utc_now_naive()
|
||||||
|
job.details_json = json.dumps(
|
||||||
|
{
|
||||||
|
"batch_id": batch.batch_id,
|
||||||
|
"kind": batch.kind,
|
||||||
|
"status": batch.status,
|
||||||
|
"paging_mode": paging_mode,
|
||||||
|
"paging_note": paging_note,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(job)
|
||||||
|
db.refresh(batch)
|
||||||
|
return job, batch
|
||||||
|
|
||||||
|
|
||||||
|
def sync_alarms_current(db: Session, client: UMEClient, *, trigger_mode: str = "manual") -> tuple[UmeSyncJob, UmeAlarmBatch]:
|
||||||
|
return _sync_alarms_common(db, client, is_uncleared=False, trigger_mode=trigger_mode)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_alarms_history_full(
|
||||||
|
db: Session, client: UMEClient, *, trigger_mode: str = "manual"
|
||||||
|
) -> tuple[UmeSyncJob, UmeAlarmBatch]:
|
||||||
|
return _sync_alarms_common(db, client, is_uncleared=True, trigger_mode=trigger_mode)
|
||||||
143
netx_api/ume_token_store.py
Normal file
143
netx_api/ume_token_store.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from time import sleep, time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import text as sql_text
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .db import SessionLocal
|
||||||
|
from .models import UmeTokenCache
|
||||||
|
|
||||||
|
|
||||||
|
def build_cache_key() -> str:
|
||||||
|
base = str(settings.ume_base_url or "").strip().rstrip("/")
|
||||||
|
user = str(settings.ume_username or "").strip()
|
||||||
|
return f"{base}|{user}" if base or user else "default"
|
||||||
|
|
||||||
|
def build_owner_id() -> str:
|
||||||
|
host = ""
|
||||||
|
try:
|
||||||
|
host = socket.gethostname()
|
||||||
|
except Exception:
|
||||||
|
host = "host"
|
||||||
|
pid = os.getpid()
|
||||||
|
return f"{host}:{pid}"
|
||||||
|
|
||||||
|
|
||||||
|
def load_shared_token(cache_key: str | None = None) -> tuple[str, float] | None:
|
||||||
|
key = str(cache_key or build_cache_key())
|
||||||
|
with SessionLocal() as db:
|
||||||
|
row = db.get(UmeTokenCache, key)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
token = str(row.token or "").strip()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return token, float(int(row.expires_at_epoch_s or 0))
|
||||||
|
|
||||||
|
def try_acquire_refresh_lock(
|
||||||
|
*,
|
||||||
|
cache_key: str | None = None,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
lock_ttl_s: int = 30,
|
||||||
|
) -> bool:
|
||||||
|
key = str(cache_key or build_cache_key())
|
||||||
|
owner = str(owner_id or build_owner_id())
|
||||||
|
now = int(time())
|
||||||
|
ttl = max(5, int(lock_ttl_s or 30))
|
||||||
|
lock_until = now + ttl
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(
|
||||||
|
sql_text(
|
||||||
|
"""
|
||||||
|
UPDATE ume_token_cache
|
||||||
|
SET lock_owner = :owner,
|
||||||
|
lock_expires_at_epoch_s = :lock_until,
|
||||||
|
updated_at = updated_at
|
||||||
|
WHERE cache_key = :key
|
||||||
|
AND (
|
||||||
|
lock_expires_at_epoch_s IS NULL
|
||||||
|
OR lock_expires_at_epoch_s < :now
|
||||||
|
OR lock_owner = :owner
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"owner": owner, "lock_until": lock_until, "key": key, "now": now},
|
||||||
|
)
|
||||||
|
if db.get(UmeTokenCache, key) is None:
|
||||||
|
row = UmeTokenCache(cache_key=key)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
row = db.get(UmeTokenCache, key)
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
ok = (str(row.lock_owner or "") == owner) and int(row.lock_expires_at_epoch_s or 0) >= now
|
||||||
|
if ok:
|
||||||
|
db.commit()
|
||||||
|
return bool(ok)
|
||||||
|
|
||||||
|
|
||||||
|
def release_refresh_lock(*, cache_key: str | None = None, owner_id: str | None = None) -> None:
|
||||||
|
key = str(cache_key or build_cache_key())
|
||||||
|
owner = str(owner_id or build_owner_id())
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(
|
||||||
|
sql_text(
|
||||||
|
"""
|
||||||
|
UPDATE ume_token_cache
|
||||||
|
SET lock_owner = '',
|
||||||
|
lock_expires_at_epoch_s = 0
|
||||||
|
WHERE cache_key = :key AND lock_owner = :owner
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"key": key, "owner": owner},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_token_update(
|
||||||
|
*,
|
||||||
|
cache_key: str | None = None,
|
||||||
|
min_expires_at_epoch_s: float = 0.0,
|
||||||
|
timeout_s: float = 10.0,
|
||||||
|
poll_interval_s: float = 0.4,
|
||||||
|
) -> tuple[str, float] | None:
|
||||||
|
key = str(cache_key or build_cache_key())
|
||||||
|
deadline = time() + max(0.1, float(timeout_s or 10.0))
|
||||||
|
poll = max(0.1, float(poll_interval_s or 0.4))
|
||||||
|
while time() < deadline:
|
||||||
|
cur = load_shared_token(key)
|
||||||
|
if cur:
|
||||||
|
token, exp = cur
|
||||||
|
if float(exp) > float(min_expires_at_epoch_s) and str(token or "").strip():
|
||||||
|
return token, float(exp)
|
||||||
|
sleep(poll)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_shared_token(token: str, expires_at_epoch_s: float, cache_key: str | None = None) -> None:
|
||||||
|
key = str(cache_key or build_cache_key())
|
||||||
|
with SessionLocal() as db:
|
||||||
|
row = db.get(UmeTokenCache, key)
|
||||||
|
if row is None:
|
||||||
|
row = UmeTokenCache(cache_key=key)
|
||||||
|
db.add(row)
|
||||||
|
row.token = str(token or "")
|
||||||
|
row.expires_at_epoch_s = int(expires_at_epoch_s or 0)
|
||||||
|
row.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_shared_token(cache_key: str | None = None) -> None:
|
||||||
|
key = str(cache_key or build_cache_key())
|
||||||
|
with SessionLocal() as db:
|
||||||
|
row = db.get(UmeTokenCache, key)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
row.token = ""
|
||||||
|
row.expires_at_epoch_s = 0
|
||||||
|
row.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
@ -31,8 +31,9 @@ function Get-ListenPids {
|
||||||
Write-Host "[WARN] port query failed for $LocalPort : $($_.Exception.Message)"
|
Write-Host "[WARN] port query failed for $LocalPort : $($_.Exception.Message)"
|
||||||
} finally {
|
} finally {
|
||||||
if ($job) {
|
if ($job) {
|
||||||
Stop-Job $job -Force -ErrorAction SilentlyContinue
|
# Windows PowerShell 5.1 does not support -Force on Stop-Job/Remove-Job.
|
||||||
Remove-Job $job -Force -ErrorAction SilentlyContinue
|
Stop-Job $job -ErrorAction SilentlyContinue
|
||||||
|
Remove-Job $job -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@($ids)
|
@($ids)
|
||||||
|
|
|
||||||
255
tests/test_ume_sync.py
Normal file
255
tests/test_ume_sync.py
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from netx_api.db import Base
|
||||||
|
from netx_api.models import UmeAlarmCurrent, UmeInventoryNE
|
||||||
|
from netx_api.ume_client import UMEClient
|
||||||
|
from netx_api.ume_sync_service import sync_alarms_current, sync_inventory_full
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, status_code: int, payload: dict):
|
||||||
|
self.status_code = int(status_code)
|
||||||
|
self._payload = payload
|
||||||
|
self.text = str(payload)
|
||||||
|
self.is_success = 200 <= self.status_code < 300
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, responses):
|
||||||
|
self._responses = list(responses)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def post(self, url, json=None, headers=None):
|
||||||
|
if not self._responses:
|
||||||
|
raise RuntimeError("no_more_fake_responses")
|
||||||
|
return self._responses.pop(0)
|
||||||
|
|
||||||
|
def request(self, method, url, params=None, json=None, headers=None):
|
||||||
|
if not self._responses:
|
||||||
|
raise RuntimeError("no_more_fake_responses")
|
||||||
|
return self._responses.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
class UMEClientTests(unittest.TestCase):
|
||||||
|
def test_login_and_cached_token(self):
|
||||||
|
responses = [
|
||||||
|
_FakeResponse(200, {"output": {"accessToken": "token-1", "expires": 1800}}),
|
||||||
|
]
|
||||||
|
with patch("netx_api.ume_client.httpx.Client", return_value=_FakeClient(responses)):
|
||||||
|
client = UMEClient(
|
||||||
|
base_url="https://ume.local:18014",
|
||||||
|
username="u",
|
||||||
|
password="p",
|
||||||
|
verify_tls=False,
|
||||||
|
)
|
||||||
|
t1 = client.login()
|
||||||
|
t2 = client.login()
|
||||||
|
self.assertEqual(t1, "token-1")
|
||||||
|
self.assertEqual(t2, "token-1")
|
||||||
|
|
||||||
|
def test_request_retry_after_401(self):
|
||||||
|
responses = [
|
||||||
|
_FakeResponse(200, {"output": {"accessToken": "token-1", "expires": 1800}}),
|
||||||
|
_FakeResponse(401, {"error": "expired"}),
|
||||||
|
_FakeResponse(200, {"output": {"accessToken": "token-2", "expires": 1800}}),
|
||||||
|
_FakeResponse(200, {"alarm-list": []}),
|
||||||
|
]
|
||||||
|
with patch("netx_api.ume_client.httpx.Client", return_value=_FakeClient(responses)):
|
||||||
|
client = UMEClient(
|
||||||
|
base_url="https://ume.local:18014",
|
||||||
|
username="u",
|
||||||
|
password="p",
|
||||||
|
verify_tls=False,
|
||||||
|
)
|
||||||
|
payload, diag = client.request_json("GET", "/restconf/data/zte-alarms:alarms/alarm-list")
|
||||||
|
self.assertIsInstance(payload, dict)
|
||||||
|
self.assertEqual(diag.retry_count, 1)
|
||||||
|
self.assertEqual(client.refresh_if_needed(), "token-2")
|
||||||
|
|
||||||
|
def test_extract_network_elements_from_wrapped_container(self):
|
||||||
|
client = UMEClient(base_url="https://ume.local:18014", username="u", password="p", verify_tls=False)
|
||||||
|
|
||||||
|
def _fake_request_json(method: str, path: str, **kwargs):
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"zte-resources-module:network-elements": {
|
||||||
|
"network-element": [
|
||||||
|
{"ne-id": "NE-1", "name": "ne1"},
|
||||||
|
{"ne-id": "NE-2", "name": "ne2"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
client.request_json = _fake_request_json # type: ignore[method-assign]
|
||||||
|
rows, _ = client.get_network_elements()
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(rows[0].get("ne-id"), "NE-1")
|
||||||
|
|
||||||
|
def test_extract_alarm_rows_from_wrapped_alarm_list(self):
|
||||||
|
client = UMEClient(base_url="https://ume.local:18014", username="u", password="p", verify_tls=False)
|
||||||
|
|
||||||
|
def _fake_request_json(method: str, path: str, **kwargs):
|
||||||
|
return (
|
||||||
|
{
|
||||||
|
"zte-alarms:alarms": {
|
||||||
|
"alarm-list": {
|
||||||
|
"alarm": [
|
||||||
|
{"alarmKey": "AK-1", "ne-id": "NE-1"},
|
||||||
|
{"alarmKey": "AK-2", "ne-id": "NE-2"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
client.request_json = _fake_request_json # type: ignore[method-assign]
|
||||||
|
rows, _ = client.get_alarms(is_uncleared=False)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(rows[1].get("alarmKey"), "AK-2")
|
||||||
|
|
||||||
|
|
||||||
|
class UmeSyncServiceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||||
|
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
self.db = TestingSessionLocal()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def test_sync_inventory_upsert(self):
|
||||||
|
class _C:
|
||||||
|
def get_network_elements(self):
|
||||||
|
rows = [
|
||||||
|
{"ne-id": "NE-1", "name": "ne1", "user-label": "网元1", "ip-Address": "10.0.0.1", "type": "A"},
|
||||||
|
]
|
||||||
|
return rows, None
|
||||||
|
|
||||||
|
job1 = sync_inventory_full(self.db, _C(), trigger_mode="manual")
|
||||||
|
self.assertEqual(job1.status, "done")
|
||||||
|
self.assertEqual(job1.inserted_count, 1)
|
||||||
|
|
||||||
|
job2 = sync_inventory_full(self.db, _C(), trigger_mode="manual")
|
||||||
|
self.assertEqual(job2.status, "done")
|
||||||
|
self.assertEqual(job2.updated_count, 1)
|
||||||
|
|
||||||
|
ne = self.db.get(UmeInventoryNE, "NE-1")
|
||||||
|
self.assertIsNotNone(ne)
|
||||||
|
self.assertEqual(ne.user_label, "网元1")
|
||||||
|
|
||||||
|
def test_sync_current_alarms_upsert(self):
|
||||||
|
class _C:
|
||||||
|
def get_alarms(self, *, is_uncleared: bool, limit=None, offset=None):
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"alarmKey": "AK-1",
|
||||||
|
"ne-id": "NE-1",
|
||||||
|
"ne-name": "ne1",
|
||||||
|
"user-label": "网元1",
|
||||||
|
"objectName": "port-1",
|
||||||
|
"eventType": "COMMUNICATION",
|
||||||
|
"nativeProbableCause": "LOS",
|
||||||
|
"perceivedSeverity": "critical",
|
||||||
|
"isCleared": "false",
|
||||||
|
"timeCreated": "2026-01-01T00:00:00Z",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return rows, None
|
||||||
|
|
||||||
|
job1, _ = sync_alarms_current(self.db, _C(), trigger_mode="manual")
|
||||||
|
self.assertEqual(job1.status, "done")
|
||||||
|
self.assertEqual(job1.inserted_count, 1)
|
||||||
|
|
||||||
|
job2, _ = sync_alarms_current(self.db, _C(), trigger_mode="manual")
|
||||||
|
self.assertEqual(job2.status, "done")
|
||||||
|
self.assertEqual(job2.updated_count, 1)
|
||||||
|
|
||||||
|
row = self.db.get(UmeAlarmCurrent, "AK-1")
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
self.assertEqual(row.perceived_severity, "critical")
|
||||||
|
|
||||||
|
def test_sync_current_alarms_pagination(self):
|
||||||
|
class _C:
|
||||||
|
def get_alarms(self, *, is_uncleared: bool, limit=None, offset=None):
|
||||||
|
# Return 2 pages with page_size=2 then stop.
|
||||||
|
off = int(offset or 0)
|
||||||
|
if off == 0:
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{"alarmKey": "AK-1", "ne-id": "NE-1", "perceivedSeverity": "major", "isCleared": "false"},
|
||||||
|
{"alarmKey": "AK-2", "ne-id": "NE-2", "perceivedSeverity": "major", "isCleared": "false"},
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if off == 2:
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{"alarmKey": "AK-3", "ne-id": "NE-3", "perceivedSeverity": "minor", "isCleared": "false"},
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return ([], None)
|
||||||
|
|
||||||
|
from netx_api import ume_sync_service as svc
|
||||||
|
old_page_size = svc.settings.ume_page_size
|
||||||
|
old_max_pages = svc.settings.ume_max_pages
|
||||||
|
svc.settings.ume_page_size = 2
|
||||||
|
svc.settings.ume_max_pages = 10
|
||||||
|
try:
|
||||||
|
job, _ = sync_alarms_current(self.db, _C(), trigger_mode="manual")
|
||||||
|
finally:
|
||||||
|
svc.settings.ume_page_size = old_page_size
|
||||||
|
svc.settings.ume_max_pages = old_max_pages
|
||||||
|
self.assertEqual(job.status, "done")
|
||||||
|
self.assertEqual(job.pulled_count, 3)
|
||||||
|
self.assertIsNotNone(self.db.get(UmeAlarmCurrent, "AK-3"))
|
||||||
|
|
||||||
|
def test_sync_current_alarms_offset_unsupported_fallback(self):
|
||||||
|
class _C:
|
||||||
|
def get_alarms(self, *, is_uncleared: bool, limit=None, offset=None):
|
||||||
|
if offset is not None:
|
||||||
|
raise RuntimeError("ume_request_failed:400:unknown_param_offset")
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
{"alarmKey": "AK-1", "ne-id": "NE-1", "perceivedSeverity": "major", "isCleared": "false"},
|
||||||
|
],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
from netx_api import ume_sync_service as svc
|
||||||
|
|
||||||
|
old_page_size = svc.settings.ume_page_size
|
||||||
|
old_max_pages = svc.settings.ume_max_pages
|
||||||
|
svc.settings.ume_page_size = 1000
|
||||||
|
svc.settings.ume_max_pages = 10
|
||||||
|
try:
|
||||||
|
job, _ = sync_alarms_current(self.db, _C(), trigger_mode="manual")
|
||||||
|
finally:
|
||||||
|
svc.settings.ume_page_size = old_page_size
|
||||||
|
svc.settings.ume_max_pages = old_max_pages
|
||||||
|
|
||||||
|
self.assertEqual(job.status, "done")
|
||||||
|
self.assertEqual(job.pulled_count, 1)
|
||||||
|
self.assertIsNotNone(self.db.get(UmeAlarmCurrent, "AK-1"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -5,8 +5,8 @@ import { AppLayout } from "./layout/AppLayout";
|
||||||
import { AlarmsPage } from "./pages/AlarmsPage";
|
import { AlarmsPage } from "./pages/AlarmsPage";
|
||||||
import { DiagnosticsPage } from "./pages/DiagnosticsPage";
|
import { DiagnosticsPage } from "./pages/DiagnosticsPage";
|
||||||
import { AiAnalysisPage } from "./pages/AiAnalysisPage";
|
import { AiAnalysisPage } from "./pages/AiAnalysisPage";
|
||||||
import { JobsPage } from "./pages/JobsPage";
|
|
||||||
import { IngestPage } from "./pages/IngestPage";
|
import { IngestPage } from "./pages/IngestPage";
|
||||||
|
import { UmePage } from "./pages/UmePage";
|
||||||
import type { Alarm, Batch, Diagnostics, ImportHistoryItem } from "./types";
|
import type { Alarm, Batch, Diagnostics, ImportHistoryItem } from "./types";
|
||||||
import { apiDelete, apiPost, apiPostForm, fetchAlarms, fetchBatches, fetchDiagnostics, fetchIntegrationStatus } from "./services/api";
|
import { apiDelete, apiPost, apiPostForm, fetchAlarms, fetchBatches, fetchDiagnostics, fetchIntegrationStatus } from "./services/api";
|
||||||
import { fetchJobs } from "./services/api";
|
import { fetchJobs } from "./services/api";
|
||||||
|
|
@ -17,7 +17,6 @@ function App() {
|
||||||
alarms: (import.meta.env.VITE_FEATURE_ALARMS ?? "1") !== "0",
|
alarms: (import.meta.env.VITE_FEATURE_ALARMS ?? "1") !== "0",
|
||||||
diagnostics: (import.meta.env.VITE_FEATURE_DIAGNOSTICS ?? "1") !== "0",
|
diagnostics: (import.meta.env.VITE_FEATURE_DIAGNOSTICS ?? "1") !== "0",
|
||||||
aiAnalysis: (import.meta.env.VITE_FEATURE_AI_ANALYSIS ?? "1") !== "0",
|
aiAnalysis: (import.meta.env.VITE_FEATURE_AI_ANALYSIS ?? "1") !== "0",
|
||||||
jobs: (import.meta.env.VITE_FEATURE_JOBS ?? "0") === "1",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -336,7 +335,7 @@ function App() {
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/jobs" element={<JobsPage />} />
|
<Route path="/ume" element={<UmePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
{toast && <div className={`toast toast--${toast.type}`}>{toast.text}</div>}
|
{toast && <div className={`toast toast--${toast.type}`}>{toast.text}</div>}
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ type Props = {
|
||||||
alarms: boolean;
|
alarms: boolean;
|
||||||
diagnostics: boolean;
|
diagnostics: boolean;
|
||||||
aiAnalysis: boolean;
|
aiAnalysis: boolean;
|
||||||
jobs: boolean;
|
|
||||||
};
|
};
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
@ -52,11 +51,8 @@ export function AppLayout({ status, connections, onRefreshBatches, onRefreshAlar
|
||||||
>
|
>
|
||||||
AI 分析
|
AI 分析
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink
|
<NavLink to="/ume" className={({ isActive }) => `nav-item${isActive ? " active" : ""}`}>
|
||||||
to="/jobs"
|
UME 对接
|
||||||
className={({ isActive }) => `nav-item${isActive ? " active" : ""}${!capabilities.jobs ? " disabled" : ""}`}
|
|
||||||
>
|
|
||||||
作业中心
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
|
||||||
486
web/src/pages/UmePage.tsx
Normal file
486
web/src/pages/UmePage.tsx
Normal file
|
|
@ -0,0 +1,486 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
apiPost,
|
||||||
|
disconnectUmeToken,
|
||||||
|
fetchUmeCurrentAlarms,
|
||||||
|
fetchUmeHistoryAlarms,
|
||||||
|
fetchUmeNe,
|
||||||
|
fetchUmeSyncStatus,
|
||||||
|
fetchUmeTokenStatus,
|
||||||
|
refreshUmeToken,
|
||||||
|
} from "../services/api";
|
||||||
|
import { formatSystemTime } from "../utils/time";
|
||||||
|
|
||||||
|
function toIsoNoMs(d: Date): string {
|
||||||
|
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UmePage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [syncPage, setSyncPage] = useState(1);
|
||||||
|
const [syncPageSize, setSyncPageSize] = useState(20);
|
||||||
|
const [neKeyword, setNeKeyword] = useState("");
|
||||||
|
const [nePage, setNePage] = useState(1);
|
||||||
|
const [nePageSize, setNePageSize] = useState(50);
|
||||||
|
|
||||||
|
const [curSeverity, setCurSeverity] = useState("");
|
||||||
|
const [curCleared, setCurCleared] = useState("");
|
||||||
|
const [curNeId, setCurNeId] = useState("");
|
||||||
|
const [curKeyword, setCurKeyword] = useState("");
|
||||||
|
const [curPage, setCurPage] = useState(1);
|
||||||
|
const [curPageSize, setCurPageSize] = useState(50);
|
||||||
|
|
||||||
|
const [hisSeverity, setHisSeverity] = useState("");
|
||||||
|
const [hisNeId, setHisNeId] = useState("");
|
||||||
|
const [hisKeyword, setHisKeyword] = useState("");
|
||||||
|
const [hisTimeFrom, setHisTimeFrom] = useState("");
|
||||||
|
const [hisTimeTo, setHisTimeTo] = useState("");
|
||||||
|
const [hisPage, setHisPage] = useState(1);
|
||||||
|
const [hisPageSize, setHisPageSize] = useState(50);
|
||||||
|
|
||||||
|
const applyQuickRange = (hours: number) => {
|
||||||
|
const now = new Date();
|
||||||
|
const from = new Date(now.getTime() - hours * 3600 * 1000);
|
||||||
|
setHisTimeFrom(toIsoNoMs(from));
|
||||||
|
setHisTimeTo(toIsoNoMs(now));
|
||||||
|
setHisPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncMutation = useMutation({
|
||||||
|
mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["umeNE"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["umeCurrentAlarms"] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["umeHistoryAlarms"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const syncStatusQuery = useQuery({
|
||||||
|
queryKey: ["umeSyncStatus", syncPage, syncPageSize],
|
||||||
|
queryFn: () => fetchUmeSyncStatus({ page: syncPage, pageSize: syncPageSize }),
|
||||||
|
staleTime: 5000,
|
||||||
|
});
|
||||||
|
const tokenStatusQuery = useQuery({
|
||||||
|
queryKey: ["umeTokenStatus"],
|
||||||
|
queryFn: fetchUmeTokenStatus,
|
||||||
|
staleTime: 3000,
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
const tokenRefreshMutation = useMutation({
|
||||||
|
mutationFn: refreshUmeToken,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const tokenDisconnectMutation = useMutation({
|
||||||
|
mutationFn: disconnectUmeToken,
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenExpiresIn = Number(tokenStatusQuery.data?.expires_in_s || 0);
|
||||||
|
const tokenLevel = !tokenStatusQuery.data?.has_token
|
||||||
|
? "down"
|
||||||
|
: tokenExpiresIn < 15
|
||||||
|
? "down"
|
||||||
|
: tokenExpiresIn < 60
|
||||||
|
? "unknown"
|
||||||
|
: "up";
|
||||||
|
const neQuery = useQuery({
|
||||||
|
queryKey: ["umeNE", neKeyword, nePage, nePageSize],
|
||||||
|
queryFn: () => fetchUmeNe({ keyword: neKeyword, page: nePage, pageSize: nePageSize }),
|
||||||
|
staleTime: 5000,
|
||||||
|
});
|
||||||
|
const currentQuery = useQuery({
|
||||||
|
queryKey: ["umeCurrentAlarms", curSeverity, curCleared, curNeId, curKeyword, curPage, curPageSize],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchUmeCurrentAlarms({
|
||||||
|
severity: curSeverity,
|
||||||
|
isCleared: curCleared,
|
||||||
|
neId: curNeId,
|
||||||
|
keyword: curKeyword,
|
||||||
|
page: curPage,
|
||||||
|
pageSize: curPageSize,
|
||||||
|
}),
|
||||||
|
staleTime: 5000,
|
||||||
|
});
|
||||||
|
const historyQuery = useQuery({
|
||||||
|
queryKey: ["umeHistoryAlarms", hisSeverity, hisNeId, hisKeyword, hisTimeFrom, hisTimeTo, hisPage, hisPageSize],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchUmeHistoryAlarms({
|
||||||
|
severity: hisSeverity,
|
||||||
|
neId: hisNeId,
|
||||||
|
keyword: hisKeyword,
|
||||||
|
timeFrom: hisTimeFrom,
|
||||||
|
timeTo: hisTimeTo,
|
||||||
|
page: hisPage,
|
||||||
|
pageSize: hisPageSize,
|
||||||
|
}),
|
||||||
|
staleTime: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section className="cards">
|
||||||
|
<article className="card card--full">
|
||||||
|
<h3>UME Token 状态</h3>
|
||||||
|
<div className="actions-row actions-row--inline">
|
||||||
|
<span className={`conn-pill conn-pill--${tokenStatusQuery.data?.has_token ? "up" : "down"}`}>
|
||||||
|
token: {tokenStatusQuery.data?.has_token ? "connected" : "disconnected"}
|
||||||
|
</span>
|
||||||
|
<span className={`conn-pill conn-pill--${tokenLevel}`}>
|
||||||
|
expires_in: {typeof tokenStatusQuery.data?.expires_in_s === "number" ? `${tokenStatusQuery.data.expires_in_s}s` : "-"}
|
||||||
|
</span>
|
||||||
|
{tokenStatusQuery.data?.token_preview ? <span className="conn-pill">preview: {tokenStatusQuery.data.token_preview}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="actions-row actions-row--inline">
|
||||||
|
<button
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ["umeTokenStatus"] })}
|
||||||
|
disabled={tokenStatusQuery.isFetching}
|
||||||
|
>
|
||||||
|
刷新状态
|
||||||
|
</button>
|
||||||
|
<button onClick={() => tokenRefreshMutation.mutate()} disabled={tokenRefreshMutation.isPending}>
|
||||||
|
手动续期/登录
|
||||||
|
</button>
|
||||||
|
<button onClick={() => tokenDisconnectMutation.mutate()} disabled={tokenDisconnectMutation.isPending}>
|
||||||
|
断开 token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(tokenRefreshMutation.error || tokenDisconnectMutation.error) && (
|
||||||
|
<div className="pill pill--high">
|
||||||
|
操作失败: {String(tokenRefreshMutation.error || tokenDisconnectMutation.error)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
<article className="card card--full">
|
||||||
|
<h3>UME 同步</h3>
|
||||||
|
<div className="actions-row actions-row--inline">
|
||||||
|
<button onClick={() => syncMutation.mutate(["inventory"])} disabled={syncMutation.isPending}>
|
||||||
|
同步 Inventory
|
||||||
|
</button>
|
||||||
|
<button onClick={() => syncMutation.mutate(["alarms_current"])} disabled={syncMutation.isPending}>
|
||||||
|
同步当前告警
|
||||||
|
</button>
|
||||||
|
<button onClick={() => syncMutation.mutate(["alarms_history"])} disabled={syncMutation.isPending}>
|
||||||
|
同步历史告警
|
||||||
|
</button>
|
||||||
|
<button onClick={() => syncMutation.mutate(["inventory", "alarms_current", "alarms_history"])} disabled={syncMutation.isPending}>
|
||||||
|
全量同步
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{syncMutation.error && <div className="pill pill--high">同步失败: {String(syncMutation.error)}</div>}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>同步状态</h2>
|
||||||
|
<div className="actions-row actions-row--inline">
|
||||||
|
<button onClick={() => queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] })} disabled={syncStatusQuery.isFetching}>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>domain</th>
|
||||||
|
<th>status</th>
|
||||||
|
<th>pulled</th>
|
||||||
|
<th>inserted</th>
|
||||||
|
<th>updated</th>
|
||||||
|
<th>started_at</th>
|
||||||
|
<th>ended_at</th>
|
||||||
|
<th>error</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(syncStatusQuery.data?.items || []).map((x) => (
|
||||||
|
<tr key={x.id}>
|
||||||
|
<td>{x.id}</td>
|
||||||
|
<td>{x.domain}</td>
|
||||||
|
<td>{x.status}</td>
|
||||||
|
<td>{x.pulled_count}</td>
|
||||||
|
<td>{x.inserted_count}</td>
|
||||||
|
<td>{x.updated_count}</td>
|
||||||
|
<td>{formatSystemTime(x.started_at)}</td>
|
||||||
|
<td>{x.ended_at ? formatSystemTime(x.ended_at) : "-"}</td>
|
||||||
|
<td>{x.error_message || "-"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!syncStatusQuery.isLoading && (syncStatusQuery.data?.items || []).length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9}>暂无同步记录</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="pager">
|
||||||
|
<div className="pager__meta">
|
||||||
|
共 {syncStatusQuery.data?.total || 0} 条 · 第 {syncPage}/
|
||||||
|
{Math.max(1, Math.ceil(Math.max(0, Number(syncStatusQuery.data?.total || 0)) / Math.max(1, syncPageSize)))} 页
|
||||||
|
</div>
|
||||||
|
<div className="pager__controls">
|
||||||
|
<button className="pager__btn" onClick={() => setSyncPage(Math.max(1, syncPage - 1))} disabled={syncPage <= 1}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pager__btn"
|
||||||
|
onClick={() => setSyncPage(syncPage + 1)}
|
||||||
|
disabled={syncPage >= Math.max(1, Math.ceil(Math.max(0, Number(syncStatusQuery.data?.total || 0)) / Math.max(1, syncPageSize)))}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
className="pager__size"
|
||||||
|
value={String(syncPageSize)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSyncPageSize(Number(e.target.value) || 20);
|
||||||
|
setSyncPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="20">20/页</option>
|
||||||
|
<option value="50">50/页</option>
|
||||||
|
<option value="100">100/页</option>
|
||||||
|
<option value="200">200/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>网元清单</h2>
|
||||||
|
<div className="filter-inline">
|
||||||
|
<input value={neKeyword} placeholder="keyword(ne_id/ne_name/user_label/ip)" onChange={(e) => setNeKeyword(e.target.value)} />
|
||||||
|
<button onClick={() => queryClient.invalidateQueries({ queryKey: ["umeNE"] })}>查询</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ne_id</th>
|
||||||
|
<th>user_label</th>
|
||||||
|
<th>ne_name</th>
|
||||||
|
<th>ip</th>
|
||||||
|
<th>type</th>
|
||||||
|
<th>last_seen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(neQuery.data?.items || []).map((x) => (
|
||||||
|
<tr key={x.ne_id}>
|
||||||
|
<td>{x.ne_id}</td>
|
||||||
|
<td>{x.user_label}</td>
|
||||||
|
<td>{x.ne_name}</td>
|
||||||
|
<td>{x.ip_address}</td>
|
||||||
|
<td>{x.ne_type}</td>
|
||||||
|
<td>{x.last_seen_at ? formatSystemTime(x.last_seen_at) : "-"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="pager">
|
||||||
|
<div className="pager__meta">
|
||||||
|
共 {neQuery.data?.total || 0} 条 · 第 {nePage}/
|
||||||
|
{Math.max(1, Math.ceil(Math.max(0, Number(neQuery.data?.total || 0)) / Math.max(1, nePageSize)))} 页
|
||||||
|
</div>
|
||||||
|
<div className="pager__controls">
|
||||||
|
<button className="pager__btn" onClick={() => setNePage(Math.max(1, nePage - 1))} disabled={nePage <= 1}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="pager__btn"
|
||||||
|
onClick={() => setNePage(nePage + 1)}
|
||||||
|
disabled={nePage >= Math.max(1, Math.ceil(Math.max(0, Number(neQuery.data?.total || 0)) / Math.max(1, nePageSize)))}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
className="pager__size"
|
||||||
|
value={String(nePageSize)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNePageSize(Number(e.target.value) || 50);
|
||||||
|
setNePage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="20">20/页</option>
|
||||||
|
<option value="50">50/页</option>
|
||||||
|
<option value="100">100/页</option>
|
||||||
|
<option value="200">200/页</option>
|
||||||
|
<option value="500">500/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>当前告警</h2>
|
||||||
|
<div className="filter-inline">
|
||||||
|
<input value={curKeyword} placeholder="keyword" onChange={(e) => setCurKeyword(e.target.value)} />
|
||||||
|
<input value={curNeId} placeholder="ne_id" onChange={(e) => setCurNeId(e.target.value)} />
|
||||||
|
<select value={curSeverity} onChange={(e) => setCurSeverity(e.target.value)}>
|
||||||
|
<option value="">全部级别</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
<option value="major">major</option>
|
||||||
|
<option value="minor">minor</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
</select>
|
||||||
|
<select value={curCleared} onChange={(e) => setCurCleared(e.target.value)}>
|
||||||
|
<option value="">is_cleared: all</option>
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>time_created</th>
|
||||||
|
<th>severity</th>
|
||||||
|
<th>user_label</th>
|
||||||
|
<th>object_name</th>
|
||||||
|
<th>cause</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(currentQuery.data?.items || []).map((x) => (
|
||||||
|
<tr key={x.alarm_key}>
|
||||||
|
<td>{x.time_created}</td>
|
||||||
|
<td>{x.perceived_severity}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="link-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setCurNeId(x.ne_id || "");
|
||||||
|
setCurKeyword(x.user_label || x.ne_name || x.ne_id || "");
|
||||||
|
setCurPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{x.user_label || x.ne_name || x.ne_id}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>{x.object_name}</td>
|
||||||
|
<td>{x.native_probable_cause}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="pager">
|
||||||
|
<div className="pager__meta">共 {currentQuery.data?.total || 0} 条 · 第 {curPage} 页</div>
|
||||||
|
<div className="pager__controls">
|
||||||
|
<button className="pager__btn" onClick={() => setCurPage(Math.max(1, curPage - 1))} disabled={curPage <= 1}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button className="pager__btn" onClick={() => setCurPage(curPage + 1)} disabled={(currentQuery.data?.items || []).length < curPageSize}>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
className="pager__size"
|
||||||
|
value={String(curPageSize)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCurPageSize(Number(e.target.value) || 50);
|
||||||
|
setCurPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="50">50/页</option>
|
||||||
|
<option value="100">100/页</option>
|
||||||
|
<option value="200">200/页</option>
|
||||||
|
<option value="500">500/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="panel">
|
||||||
|
<h2>历史告警</h2>
|
||||||
|
<div className="filter-inline">
|
||||||
|
<input value={hisKeyword} placeholder="keyword" onChange={(e) => setHisKeyword(e.target.value)} />
|
||||||
|
<input value={hisNeId} placeholder="ne_id" onChange={(e) => setHisNeId(e.target.value)} />
|
||||||
|
<input value={hisTimeFrom} placeholder="time_from(ISO8601)" onChange={(e) => setHisTimeFrom(e.target.value)} />
|
||||||
|
<input value={hisTimeTo} placeholder="time_to(ISO8601)" onChange={(e) => setHisTimeTo(e.target.value)} />
|
||||||
|
<select value={hisSeverity} onChange={(e) => setHisSeverity(e.target.value)}>
|
||||||
|
<option value="">全部级别</option>
|
||||||
|
<option value="critical">critical</option>
|
||||||
|
<option value="major">major</option>
|
||||||
|
<option value="minor">minor</option>
|
||||||
|
<option value="warning">warning</option>
|
||||||
|
<option value="info">info</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="actions-row actions-row--inline">
|
||||||
|
<button onClick={() => applyQuickRange(1)}>最近1小时</button>
|
||||||
|
<button onClick={() => applyQuickRange(24)}>最近24小时</button>
|
||||||
|
<button onClick={() => applyQuickRange(24 * 7)}>最近7天</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setHisTimeFrom("");
|
||||||
|
setHisTimeTo("");
|
||||||
|
setHisPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
清空时间
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>time_created</th>
|
||||||
|
<th>severity</th>
|
||||||
|
<th>user_label</th>
|
||||||
|
<th>object_name</th>
|
||||||
|
<th>cause</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(historyQuery.data?.items || []).map((x) => (
|
||||||
|
<tr key={x.alarm_key}>
|
||||||
|
<td>{x.time_created}</td>
|
||||||
|
<td>{x.perceived_severity}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="link-btn"
|
||||||
|
onClick={() => {
|
||||||
|
setCurNeId(x.ne_id || "");
|
||||||
|
setCurKeyword(x.user_label || x.ne_name || x.ne_id || "");
|
||||||
|
setCurPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{x.user_label || x.ne_name || x.ne_id}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>{x.object_name}</td>
|
||||||
|
<td>{x.native_probable_cause}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div className="pager">
|
||||||
|
<div className="pager__meta">共 {historyQuery.data?.total || 0} 条 · 第 {hisPage} 页</div>
|
||||||
|
<div className="pager__controls">
|
||||||
|
<button className="pager__btn" onClick={() => setHisPage(Math.max(1, hisPage - 1))} disabled={hisPage <= 1}>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<button className="pager__btn" onClick={() => setHisPage(hisPage + 1)} disabled={(historyQuery.data?.items || []).length < hisPageSize}>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
className="pager__size"
|
||||||
|
value={String(hisPageSize)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setHisPageSize(Number(e.target.value) || 50);
|
||||||
|
setHisPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="50">50/页</option>
|
||||||
|
<option value="100">100/页</option>
|
||||||
|
<option value="200">200/页</option>
|
||||||
|
<option value="500">500/页</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,15 @@
|
||||||
import type { AiAnalyzeHistoryResponse, AlarmQueryResponse, Batch, Diagnostics, ImportHistoryItem, IntegrationStatus } from "../types";
|
import type {
|
||||||
|
AiAnalyzeHistoryResponse,
|
||||||
|
AlarmQueryResponse,
|
||||||
|
Batch,
|
||||||
|
Diagnostics,
|
||||||
|
ImportHistoryItem,
|
||||||
|
IntegrationStatus,
|
||||||
|
UmeAlarmItem,
|
||||||
|
UmeNeItem,
|
||||||
|
UmeSyncStatusResponse,
|
||||||
|
UmeTokenStatus,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
export const apiGet = async <T,>(path: string): Promise<T> => {
|
export const apiGet = async <T,>(path: string): Promise<T> => {
|
||||||
const res = await fetch(path, { headers: { accept: "application/json" } });
|
const res = await fetch(path, { headers: { accept: "application/json" } });
|
||||||
|
|
@ -89,3 +100,61 @@ export const fetchAlarms = (params: {
|
||||||
return apiGet<AlarmQueryResponse>(`/v1/alarms?${p.toString()}`);
|
return apiGet<AlarmQueryResponse>(`/v1/alarms?${p.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchUmeSyncStatus = (params: { page: number; pageSize: number }) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||||
|
p.set("page_size", String(Math.max(1, Math.min(200, Number(params.pageSize || 20)))));
|
||||||
|
return apiGet<UmeSyncStatusResponse>(`/v1/ume/sync/status?${p.toString()}`);
|
||||||
|
};
|
||||||
|
export const fetchUmeTokenStatus = () => apiGet<UmeTokenStatus>("/v1/ume/token/status");
|
||||||
|
export const refreshUmeToken = () => apiPost<UmeTokenStatus>("/v1/ume/token/refresh", {});
|
||||||
|
export const disconnectUmeToken = () => apiPost<UmeTokenStatus>("/v1/ume/token/disconnect", {});
|
||||||
|
|
||||||
|
export const fetchUmeNe = (params: { keyword: string; page: number; pageSize: number }) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (params.keyword) p.set("keyword", params.keyword);
|
||||||
|
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||||
|
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
|
||||||
|
return apiGet<{ total: number; page: number; page_size: number; items: UmeNeItem[] }>(`/v1/ume/inventory/ne?${p.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchUmeCurrentAlarms = (params: {
|
||||||
|
severity: string;
|
||||||
|
isCleared: string;
|
||||||
|
neId: string;
|
||||||
|
keyword: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (params.severity) p.set("severity", params.severity);
|
||||||
|
if (params.isCleared) p.set("is_cleared", params.isCleared);
|
||||||
|
if (params.neId) p.set("ne_id", params.neId);
|
||||||
|
if (params.keyword) p.set("keyword", params.keyword);
|
||||||
|
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||||
|
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
|
||||||
|
return apiGet<{ total: number; page: number; page_size: number; items: UmeAlarmItem[] }>(`/v1/ume/alarms?${p.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchUmeHistoryAlarms = (params: {
|
||||||
|
severity: string;
|
||||||
|
neId: string;
|
||||||
|
keyword: string;
|
||||||
|
timeFrom: string;
|
||||||
|
timeTo: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (params.severity) p.set("severity", params.severity);
|
||||||
|
if (params.neId) p.set("ne_id", params.neId);
|
||||||
|
if (params.keyword) p.set("keyword", params.keyword);
|
||||||
|
if (params.timeFrom) p.set("time_from", params.timeFrom);
|
||||||
|
if (params.timeTo) p.set("time_to", params.timeTo);
|
||||||
|
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||||
|
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
|
||||||
|
return apiGet<{ total: number; page: number; page_size: number; items: UmeAlarmItem[] }>(
|
||||||
|
`/v1/ume/alarms/history?${p.toString()}`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,3 +73,58 @@ export type AiAnalyzeHistoryResponse = {
|
||||||
page_size: number;
|
page_size: number;
|
||||||
items: AiAnalyzeHistoryItem[];
|
items: AiAnalyzeHistoryItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UmeSyncJobItem = {
|
||||||
|
id: number;
|
||||||
|
domain: string;
|
||||||
|
status: string;
|
||||||
|
trigger_mode: string;
|
||||||
|
pulled_count: number;
|
||||||
|
inserted_count: number;
|
||||||
|
updated_count: number;
|
||||||
|
error_message?: string;
|
||||||
|
started_at: string;
|
||||||
|
ended_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UmeSyncStatusResponse = {
|
||||||
|
total?: number;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
items: UmeSyncJobItem[];
|
||||||
|
latest_by_domain?: Record<string, UmeSyncJobItem>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UmeNeItem = {
|
||||||
|
ne_id: string;
|
||||||
|
ne_name: string;
|
||||||
|
user_label: string;
|
||||||
|
ip_address: string;
|
||||||
|
ne_type: string;
|
||||||
|
last_seen_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UmeAlarmItem = {
|
||||||
|
alarm_key: string;
|
||||||
|
ne_id: string;
|
||||||
|
ne_name: string;
|
||||||
|
user_label: string;
|
||||||
|
object_name: string;
|
||||||
|
event_type: string;
|
||||||
|
native_probable_cause: string;
|
||||||
|
perceived_severity: string;
|
||||||
|
is_cleared: string;
|
||||||
|
time_created: string;
|
||||||
|
last_seen_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UmeTokenStatus = {
|
||||||
|
ok: boolean;
|
||||||
|
has_token: boolean;
|
||||||
|
expires_in_s: number;
|
||||||
|
expires_at_epoch_s: number;
|
||||||
|
auth_header: string;
|
||||||
|
token_preview?: string;
|
||||||
|
error_kind?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue