mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Key alerts now deliver only via the DSH hub; Modal Body no longer flex-shrinks the rules rows to empty. Co-authored-by: Cursor <cursoragent@cursor.com>
776 lines
30 KiB
Python
776 lines
30 KiB
Python
"""UME current/history alarms, aggregates, diagnostics."""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy import func, or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from .config import settings
|
|
from .db import get_db
|
|
from .key_alert_config import (
|
|
get_key_alert_monitor_config,
|
|
invalidate_key_alert_config_cache,
|
|
set_key_alert_monitor_config,
|
|
)
|
|
from .key_alert_matcher import (
|
|
invalidate_key_alert_rule_cache,
|
|
normalize_match_type,
|
|
parse_rule_ne_types_payload,
|
|
rule_match_type,
|
|
rule_match_value,
|
|
rule_ne_types,
|
|
rule_storage_key,
|
|
serialize_rule_ne_types,
|
|
)
|
|
from .models import (
|
|
UmeAlarmCurrent,
|
|
UmeAlarmHistory,
|
|
UmeInventoryNE,
|
|
UmeKeyAlertForwardLog,
|
|
UmeKeyAlertRule,
|
|
UmeSyncJob,
|
|
)
|
|
from .ume_alarm_ws import (
|
|
cancel_alarm_subscription_manual,
|
|
clear_local_alarm_subscription_manual,
|
|
establish_alarm_subscription_manual,
|
|
get_alarms_coordination_status,
|
|
get_subscription_status,
|
|
get_ws_connection_status,
|
|
get_ws_logs,
|
|
request_ws_reconnect,
|
|
)
|
|
from .ume_support import (
|
|
UME_KNOWN_RUNTIME_TASKS,
|
|
_aggregate_rows,
|
|
_ensure_utc,
|
|
_list_runtime_tasks,
|
|
_normalize_netx_lang,
|
|
_parse_time,
|
|
_protocol_bucket_label,
|
|
_request_force_sync_after_resume,
|
|
_runtime_pause_task,
|
|
_runtime_resume_task,
|
|
_ume_alarm_host_name,
|
|
_ume_alarm_ne_group_key,
|
|
_ume_client,
|
|
_ume_error_kind,
|
|
_clear_force_resume_hints,
|
|
)
|
|
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full
|
|
from .ume_token_store import clear_shared_token
|
|
|
|
_log = logging.getLogger("netx.ume.router")
|
|
router = APIRouter(tags=["ume"])
|
|
|
|
@router.get("/v1/ume/alarms")
|
|
def ume_list_alarms(
|
|
severity: str | None = Query(default=None),
|
|
is_cleared: str | None = Query(default=None),
|
|
ne_id: str | None = Query(default=None),
|
|
host_name: str | None = Query(default=None),
|
|
keyword: str | None = Query(default=None),
|
|
time_from: str | None = Query(default=None, description="Filter by last_seen_at >="),
|
|
time_to: str | None = Query(default=None, description="Filter by last_seen_at <="),
|
|
page: int = Query(default=1, ge=1),
|
|
page_size: int = Query(default=50, ge=1, le=500),
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, Any]:
|
|
stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin(
|
|
UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id
|
|
)
|
|
if severity and str(severity).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.perceived_severity == str(severity).strip())
|
|
if is_cleared and str(is_cleared).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.is_cleared == str(is_cleared).strip())
|
|
if ne_id and str(ne_id).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.ne_id == str(ne_id).strip())
|
|
hn = str(host_name or "").strip()
|
|
if hn:
|
|
stmt = stmt.filter(
|
|
UmeAlarmCurrent.host_name.contains(hn) | UmeInventoryNE.host_name.contains(hn)
|
|
)
|
|
kw = str(keyword or "").strip()
|
|
if kw:
|
|
stmt = stmt.filter(
|
|
UmeAlarmCurrent.alarm_key.contains(kw)
|
|
| UmeAlarmCurrent.object_name.contains(kw)
|
|
| UmeAlarmCurrent.native_probable_cause.contains(kw)
|
|
| UmeAlarmCurrent.notification_id.contains(kw)
|
|
| UmeAlarmCurrent.host_name.contains(kw)
|
|
| UmeInventoryNE.ne_name.contains(kw)
|
|
| UmeInventoryNE.user_label.contains(kw)
|
|
| UmeInventoryNE.ip_address.contains(kw)
|
|
| UmeInventoryNE.host_name.contains(kw)
|
|
)
|
|
dt_from = _parse_time(time_from)
|
|
dt_to = _parse_time(time_to)
|
|
if dt_from:
|
|
stmt = stmt.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None))
|
|
if dt_to:
|
|
stmt = stmt.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None))
|
|
total = int(stmt.count())
|
|
rows = (
|
|
stmt.order_by(
|
|
UmeAlarmCurrent.time_created.desc(),
|
|
UmeAlarmCurrent.last_seen_at.desc(),
|
|
UmeAlarmCurrent.alarm_key.desc(),
|
|
)
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
.all()
|
|
)
|
|
items = [
|
|
{
|
|
"alarm_key": str(alarm.alarm_key or ""),
|
|
"ne_id": str(alarm.ne_id or ""),
|
|
"ne_name": str((ne.ne_name if ne else "") or ""),
|
|
"user_label": str((ne.user_label if ne else "") or ""),
|
|
"host_name": _ume_alarm_host_name(alarm, ne),
|
|
"ne_type": str((ne.ne_type if ne else "") or ""),
|
|
"object_name": str(alarm.object_name or ""),
|
|
"event_type": str(alarm.event_type or ""),
|
|
"native_probable_cause": str(alarm.native_probable_cause or ""),
|
|
"notification_id": str(alarm.notification_id or ""),
|
|
"perceived_severity": str(alarm.perceived_severity or ""),
|
|
"is_cleared": str(alarm.is_cleared or ""),
|
|
"time_created": str(alarm.time_created or ""),
|
|
"last_seen_at": (_ensure_utc(alarm.last_seen_at) or datetime.now(timezone.utc)).isoformat(),
|
|
}
|
|
for alarm, ne in rows
|
|
]
|
|
return {
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"meta": {"time_filter_field": "last_seen_at"},
|
|
"items": items,
|
|
}
|
|
|
|
|
|
@router.get("/v1/ume/alarms/fields")
|
|
def ume_alarms_fields() -> dict[str, Any]:
|
|
"""List all queryable field names for UME raw alarm query."""
|
|
alarm_cols = [str(c.name) for c in UmeAlarmCurrent.__table__.columns] # type: ignore[attr-defined]
|
|
ne_cols = [str(c.name) for c in UmeInventoryNE.__table__.columns] # type: ignore[attr-defined]
|
|
selectable_fields = [f"alarm_{x}" for x in alarm_cols] + [f"ne_{x}" for x in ne_cols] + ["ne_exists"]
|
|
order_by_allowed = ["last_seen_at", "time_created", "perceived_severity", "event_type", "ne_id"]
|
|
return {
|
|
"alarm_fields": alarm_cols,
|
|
"ne_fields": ne_cols,
|
|
"selectable_fields": selectable_fields,
|
|
"order_by_allowed": order_by_allowed,
|
|
}
|
|
|
|
|
|
def _serialize_ume_alarm_raw_row(
|
|
alarm: UmeAlarmCurrent, ne: UmeInventoryNE | None, selected_fields: set[str] | None = None
|
|
) -> dict[str, Any]:
|
|
selected = selected_fields or set()
|
|
use_all = len(selected) == 0
|
|
out: dict[str, Any] = {}
|
|
for c in UmeAlarmCurrent.__table__.columns: # type: ignore[attr-defined]
|
|
name = str(c.name)
|
|
v = getattr(alarm, name, None)
|
|
key = f"alarm_{name}"
|
|
if not use_all and key not in selected:
|
|
continue
|
|
if hasattr(v, "isoformat"):
|
|
try:
|
|
if isinstance(v, datetime):
|
|
out[key] = (_ensure_utc(v) or v).isoformat()
|
|
else:
|
|
out[key] = v.isoformat()
|
|
continue
|
|
except Exception:
|
|
pass
|
|
out[key] = v
|
|
if ne is None:
|
|
if use_all or "ne_exists" in selected:
|
|
out["ne_exists"] = False
|
|
return out
|
|
if use_all or "ne_exists" in selected:
|
|
out["ne_exists"] = True
|
|
for c in UmeInventoryNE.__table__.columns: # type: ignore[attr-defined]
|
|
name = str(c.name)
|
|
v = getattr(ne, name, None)
|
|
key = f"ne_{name}"
|
|
if not use_all and key not in selected:
|
|
continue
|
|
if hasattr(v, "isoformat"):
|
|
try:
|
|
if isinstance(v, datetime):
|
|
out[key] = (_ensure_utc(v) or v).isoformat()
|
|
else:
|
|
out[key] = v.isoformat()
|
|
continue
|
|
except Exception:
|
|
pass
|
|
out[key] = v
|
|
return out
|
|
|
|
|
|
def _extract_ume_raw_group_field(alarm: UmeAlarmCurrent, ne: UmeInventoryNE | None, field: str) -> str:
|
|
key = str(field or "").strip()
|
|
if not key:
|
|
return ""
|
|
if key.startswith("alarm_"):
|
|
attr = key[len("alarm_") :]
|
|
return str(getattr(alarm, attr, "") or "")
|
|
if key.startswith("ne_"):
|
|
attr = key[len("ne_") :]
|
|
if key == "ne_exists":
|
|
return "1" if ne is not None else "0"
|
|
if key == "ne_host_name":
|
|
hn = str(getattr(alarm, "host_name", "") or "").strip()
|
|
if hn:
|
|
return hn
|
|
if ne is None:
|
|
return ""
|
|
return str(getattr(ne, attr, "") or "")
|
|
return ""
|
|
|
|
|
|
@router.get("/v1/ume/alarms/raw")
|
|
def ume_alarms_raw(
|
|
severity: str | None = Query(default=None),
|
|
is_cleared: str | None = Query(default=None),
|
|
ne_id: str | None = Query(default=None),
|
|
event_type: str | None = Query(default=None),
|
|
keyword: str | None = Query(default=None),
|
|
time_from: str | None = Query(default=None),
|
|
time_to: str | None = Query(default=None),
|
|
order_by: str = Query(default="last_seen_at"),
|
|
order: str = Query(default="desc"),
|
|
select_fields: str | None = Query(default=None, description="comma-separated alarm_*/ne_* fields"),
|
|
page: int = Query(default=1, ge=1),
|
|
page_size: int = Query(default=50, ge=1, le=500),
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, Any]:
|
|
stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin(
|
|
UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id
|
|
)
|
|
if severity and str(severity).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.perceived_severity == str(severity).strip())
|
|
if is_cleared and str(is_cleared).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.is_cleared == str(is_cleared).strip())
|
|
if ne_id and str(ne_id).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.ne_id == str(ne_id).strip())
|
|
if event_type and str(event_type).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.event_type.contains(str(event_type).strip()))
|
|
kw = str(keyword or "").strip()
|
|
if kw:
|
|
stmt = stmt.filter(
|
|
UmeAlarmCurrent.alarm_key.contains(kw)
|
|
| UmeAlarmCurrent.object_name.contains(kw)
|
|
| UmeAlarmCurrent.native_probable_cause.contains(kw)
|
|
| UmeAlarmCurrent.event_type.contains(kw)
|
|
| UmeInventoryNE.ne_name.contains(kw)
|
|
| UmeInventoryNE.user_label.contains(kw)
|
|
| UmeInventoryNE.ip_address.contains(kw)
|
|
)
|
|
dt_from = _parse_time(time_from)
|
|
dt_to = _parse_time(time_to)
|
|
if dt_from:
|
|
stmt = stmt.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None))
|
|
if dt_to:
|
|
stmt = stmt.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None))
|
|
|
|
allowed_order_by = {
|
|
"last_seen_at": UmeAlarmCurrent.last_seen_at,
|
|
"time_created": UmeAlarmCurrent.time_created,
|
|
"perceived_severity": UmeAlarmCurrent.perceived_severity,
|
|
"event_type": UmeAlarmCurrent.event_type,
|
|
"ne_id": UmeAlarmCurrent.ne_id,
|
|
}
|
|
col = allowed_order_by.get(str(order_by or "").strip(), UmeAlarmCurrent.last_seen_at)
|
|
if str(order or "").strip().lower() == "asc":
|
|
stmt = stmt.order_by(col.asc())
|
|
else:
|
|
stmt = stmt.order_by(col.desc())
|
|
|
|
selected_fields: set[str] = set()
|
|
fields_meta = ume_alarms_fields()
|
|
selectable_fields = set(str(x) for x in (fields_meta.get("selectable_fields") or []))
|
|
order_by_allowed = [str(x) for x in (fields_meta.get("order_by_allowed") or [])]
|
|
if select_fields and str(select_fields).strip():
|
|
selected_fields = {x.strip() for x in str(select_fields).split(",") if x.strip()}
|
|
invalid = [x for x in selected_fields if x not in selectable_fields]
|
|
if invalid:
|
|
raise HTTPException(status_code=400, detail=f"invalid_select_fields:{','.join(sorted(invalid)[:20])}")
|
|
|
|
total = int(stmt.count())
|
|
rows = stmt.offset((int(page) - 1) * int(page_size)).limit(int(page_size)).all()
|
|
return {
|
|
"total": total,
|
|
"page": int(page),
|
|
"page_size": int(page_size),
|
|
"select_fields": sorted(selected_fields) if selected_fields else [],
|
|
"meta": {
|
|
"available_fields": sorted(selectable_fields),
|
|
"order_by_allowed": order_by_allowed,
|
|
"time_filter_field": "last_seen_at",
|
|
},
|
|
"items": [_serialize_ume_alarm_raw_row(alarm, ne, selected_fields) for alarm, ne in rows],
|
|
}
|
|
|
|
|
|
_HOST_MISSING_LABEL = "(host_name missing)"
|
|
_HOST_GROUP_FIELDS = frozenset({"alarm_host_name", "ne_host_name", "ne_user_label"})
|
|
|
|
|
|
def _normalize_ne_bucket_key(raw: Any) -> str:
|
|
s = str(raw or "").strip()
|
|
if not s or s.lower() in {"unknown", "none", "null"}:
|
|
return _HOST_MISSING_LABEL
|
|
# Bare UUID or ME{uuid}
|
|
if len(s) >= 32 and s.count("-") >= 4 and " " not in s:
|
|
return _HOST_MISSING_LABEL
|
|
if s.startswith("ME{") and s.endswith("}"):
|
|
return _HOST_MISSING_LABEL
|
|
return s
|
|
|
|
|
|
def _raw_group_bucket_key(field: str, value: str) -> str:
|
|
if field in _HOST_GROUP_FIELDS:
|
|
return _normalize_ne_bucket_key(value)
|
|
s = str(value or "").strip()
|
|
return s if s else "(empty)"
|
|
|
|
|
|
@router.get("/v1/ume/alarms/aggregate/raw")
|
|
def ume_alarms_aggregate_raw(
|
|
group_by: str = Query(default="alarm_perceived_severity"),
|
|
group_by2: str | None = Query(default=None),
|
|
severity: str | None = Query(default=None),
|
|
is_cleared: str | None = Query(default=None),
|
|
ne_id: str | None = Query(default=None),
|
|
event_type: str | None = Query(default=None),
|
|
keyword: str | None = Query(default=None),
|
|
time_from: str | None = Query(default=None),
|
|
time_to: str | None = Query(default=None),
|
|
exclude_missing_host: bool = Query(
|
|
default=True,
|
|
description="When grouping by host/user_label fields, omit (host_name missing) from buckets.",
|
|
),
|
|
limit: int = Query(default=200, ge=1, le=2000),
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, Any]:
|
|
fields_meta = ume_alarms_fields()
|
|
selectable_fields = set(str(x) for x in (fields_meta.get("selectable_fields") or []))
|
|
g1 = str(group_by or "").strip()
|
|
g2 = str(group_by2 or "").strip()
|
|
if g1 not in selectable_fields:
|
|
raise HTTPException(status_code=400, detail=f"invalid_group_by:{g1}")
|
|
if g2 and g2 not in selectable_fields:
|
|
raise HTTPException(status_code=400, detail=f"invalid_group_by2:{g2}")
|
|
|
|
stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin(
|
|
UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id
|
|
)
|
|
if severity and str(severity).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.perceived_severity == str(severity).strip())
|
|
if is_cleared and str(is_cleared).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.is_cleared == str(is_cleared).strip())
|
|
if ne_id and str(ne_id).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.ne_id == str(ne_id).strip())
|
|
if event_type and str(event_type).strip():
|
|
stmt = stmt.filter(UmeAlarmCurrent.event_type.contains(str(event_type).strip()))
|
|
kw = str(keyword or "").strip()
|
|
if kw:
|
|
stmt = stmt.filter(
|
|
UmeAlarmCurrent.alarm_key.contains(kw)
|
|
| UmeAlarmCurrent.object_name.contains(kw)
|
|
| UmeAlarmCurrent.native_probable_cause.contains(kw)
|
|
| UmeAlarmCurrent.event_type.contains(kw)
|
|
| UmeInventoryNE.ne_name.contains(kw)
|
|
| UmeInventoryNE.user_label.contains(kw)
|
|
| UmeInventoryNE.ip_address.contains(kw)
|
|
)
|
|
dt_from = _parse_time(time_from)
|
|
dt_to = _parse_time(time_to)
|
|
if dt_from:
|
|
stmt = stmt.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None))
|
|
if dt_to:
|
|
stmt = stmt.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None))
|
|
|
|
rows = stmt.order_by(UmeAlarmCurrent.last_seen_at.desc()).all()
|
|
merged: dict[tuple[str, str], int] = {}
|
|
by_ne_missing = 0
|
|
for alarm, ne in rows:
|
|
nk1 = _raw_group_bucket_key(g1, _extract_ume_raw_group_field(alarm, ne, g1))
|
|
nk2 = _raw_group_bucket_key(g2, _extract_ume_raw_group_field(alarm, ne, g2)) if g2 else ""
|
|
missing_hit = False
|
|
if g1 in _HOST_GROUP_FIELDS and nk1 == _HOST_MISSING_LABEL:
|
|
missing_hit = True
|
|
if g2 and g2 in _HOST_GROUP_FIELDS and nk2 == _HOST_MISSING_LABEL:
|
|
missing_hit = True
|
|
if missing_hit:
|
|
by_ne_missing += 1
|
|
if exclude_missing_host:
|
|
continue
|
|
kk = (nk1, nk2)
|
|
merged[kk] = int(merged.get(kk, 0)) + 1
|
|
buckets = sorted(merged.items(), key=lambda x: x[1], reverse=True)[: int(limit)]
|
|
|
|
return {
|
|
"total": len(rows),
|
|
"group_by": g1,
|
|
"group_by2": g2 or None,
|
|
"by_ne_missing": by_ne_missing,
|
|
"exclude_missing_host": bool(exclude_missing_host),
|
|
"meta": {
|
|
"available_fields": sorted(selectable_fields),
|
|
"group_by_allowed": sorted(selectable_fields),
|
|
"applied_filters": {
|
|
"severity": str(severity or "").strip() or None,
|
|
"is_cleared": str(is_cleared or "").strip() or None,
|
|
"ne_id": str(ne_id or "").strip() or None,
|
|
"event_type": str(event_type or "").strip() or None,
|
|
"keyword": str(keyword or "").strip() or None,
|
|
"time_from": str(time_from or "").strip() or None,
|
|
"time_to": str(time_to or "").strip() or None,
|
|
},
|
|
"time_filter_field": "last_seen_at",
|
|
"limit": int(limit),
|
|
"host_missing_label": _HOST_MISSING_LABEL,
|
|
},
|
|
"buckets": [
|
|
{
|
|
"key": k1,
|
|
"key2": (k2 if g2 else None),
|
|
"count": int(v),
|
|
}
|
|
for (k1, k2), v in buckets
|
|
],
|
|
}
|
|
|
|
|
|
@router.get("/v1/ume/alarms/aggregate")
|
|
def ume_alarms_aggregate(
|
|
top_ne: int = Query(
|
|
default=50,
|
|
ge=0,
|
|
le=5000,
|
|
description="Max NE buckets to return (0 = all). Severity buckets are always complete.",
|
|
),
|
|
exclude_missing_host: bool = Query(
|
|
default=True,
|
|
description="When true, omit (host_name missing) from by_ne ranking (count still in by_ne_missing).",
|
|
),
|
|
severity: str | None = Query(
|
|
default=None,
|
|
description="Optional perceived_severity filter (e.g. critical) for top-NE ranking.",
|
|
),
|
|
time_from: str | None = Query(default=None, description="Filter by last_seen_at >="),
|
|
time_to: str | None = Query(default=None, description="Filter by last_seen_at <="),
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, Any]:
|
|
"""Aggregate current alarms by severity and NE (SQL group-by; top_ne capped)."""
|
|
dt_from = _parse_time(time_from)
|
|
dt_to = _parse_time(time_to)
|
|
sev = str(severity or "").strip() or None
|
|
|
|
base = db.query(UmeAlarmCurrent)
|
|
if sev:
|
|
base = base.filter(UmeAlarmCurrent.perceived_severity == sev)
|
|
if dt_from:
|
|
base = base.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None))
|
|
if dt_to:
|
|
base = base.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None))
|
|
# Prefer .count() — with_entities(func.count()) without select_from can return 1.
|
|
total = int(base.count())
|
|
|
|
sev_q = db.query(UmeAlarmCurrent.perceived_severity, func.count())
|
|
if sev:
|
|
sev_q = sev_q.filter(UmeAlarmCurrent.perceived_severity == sev)
|
|
if dt_from:
|
|
sev_q = sev_q.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None))
|
|
if dt_to:
|
|
sev_q = sev_q.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None))
|
|
sev_rows = (
|
|
sev_q.group_by(UmeAlarmCurrent.perceived_severity).order_by(func.count().desc()).all()
|
|
)
|
|
by_severity = [
|
|
{"key": (str(k).strip() if k is not None and str(k).strip() else "unknown"), "count": int(v)}
|
|
for k, v in sev_rows
|
|
]
|
|
|
|
# Display key: host_name / inventory host / user_label only (never bare UUID).
|
|
ne_key = func.coalesce(
|
|
func.nullif(func.trim(UmeAlarmCurrent.host_name), ""),
|
|
func.nullif(func.trim(UmeInventoryNE.host_name), ""),
|
|
func.nullif(func.trim(UmeInventoryNE.user_label), ""),
|
|
_HOST_MISSING_LABEL,
|
|
)
|
|
ne_q = (
|
|
db.query(ne_key.label("ne_key"), func.count().label("cnt"))
|
|
.select_from(UmeAlarmCurrent)
|
|
.outerjoin(UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id)
|
|
)
|
|
if sev:
|
|
ne_q = ne_q.filter(UmeAlarmCurrent.perceived_severity == sev)
|
|
if dt_from:
|
|
ne_q = ne_q.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None))
|
|
if dt_to:
|
|
ne_q = ne_q.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None))
|
|
ne_q = ne_q.group_by(ne_key).order_by(func.count().desc())
|
|
|
|
all_ne_rows = ne_q.all()
|
|
by_ne_missing = 0
|
|
named_counts: dict[str, int] = {}
|
|
for k, v in all_ne_rows:
|
|
label = _normalize_ne_bucket_key(k)
|
|
cnt = int(v)
|
|
if label == _HOST_MISSING_LABEL:
|
|
by_ne_missing += cnt
|
|
else:
|
|
named_counts[label] = int(named_counts.get(label, 0)) + cnt
|
|
named_rows = sorted(named_counts.items(), key=lambda kv: kv[1], reverse=True)
|
|
by_ne_total = len(named_rows) + (1 if by_ne_missing else 0)
|
|
ranked = list(named_rows)
|
|
if not exclude_missing_host and by_ne_missing:
|
|
ranked.append((_HOST_MISSING_LABEL, by_ne_missing))
|
|
ranked.sort(key=lambda kv: int(kv[1]), reverse=True)
|
|
if top_ne > 0:
|
|
ranked = ranked[: int(top_ne)]
|
|
by_ne = [{"key": str(k), "count": int(v)} for k, v in ranked]
|
|
|
|
seen_bounds = db.query(
|
|
func.min(UmeAlarmCurrent.last_seen_at),
|
|
func.max(UmeAlarmCurrent.last_seen_at),
|
|
)
|
|
if sev:
|
|
seen_bounds = seen_bounds.filter(UmeAlarmCurrent.perceived_severity == sev)
|
|
if dt_from:
|
|
seen_bounds = seen_bounds.filter(
|
|
UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None)
|
|
)
|
|
if dt_to:
|
|
seen_bounds = seen_bounds.filter(
|
|
UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None)
|
|
)
|
|
min_seen, max_seen = seen_bounds.one()
|
|
|
|
return {
|
|
"total": total,
|
|
"by_severity": by_severity,
|
|
"by_ne": by_ne,
|
|
"by_ne_total": by_ne_total,
|
|
"by_ne_missing": by_ne_missing,
|
|
"top_ne": int(top_ne),
|
|
"exclude_missing_host": bool(exclude_missing_host),
|
|
"severity": sev,
|
|
"meta": {
|
|
"time_filter_field": "last_seen_at",
|
|
"last_seen_min": (_ensure_utc(min_seen).isoformat() if min_seen else None),
|
|
"last_seen_max": (_ensure_utc(max_seen).isoformat() if max_seen else None),
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/v1/ume/diagnostics")
|
|
def ume_diagnostics(
|
|
lang: str | None = Query(default=None),
|
|
top_n: int = Query(default=10, ge=1, le=50),
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, Any]:
|
|
"""Fast SQL diagnostics; top_ne excludes missing host_name; includes data freshness."""
|
|
total = int(db.query(func.count()).select_from(UmeAlarmCurrent).scalar() or 0)
|
|
min_seen, max_seen = db.query(
|
|
func.min(UmeAlarmCurrent.last_seen_at),
|
|
func.max(UmeAlarmCurrent.last_seen_at),
|
|
).one()
|
|
|
|
sev_rows = (
|
|
db.query(UmeAlarmCurrent.perceived_severity, func.count())
|
|
.group_by(UmeAlarmCurrent.perceived_severity)
|
|
.order_by(func.count().desc())
|
|
.all()
|
|
)
|
|
by_severity = [
|
|
{"key": (str(k).strip() if k is not None and str(k).strip() else "unknown"), "count": int(v)}
|
|
for k, v in sev_rows
|
|
]
|
|
|
|
event_rows = (
|
|
db.query(UmeAlarmCurrent.event_type, func.count())
|
|
.group_by(UmeAlarmCurrent.event_type)
|
|
.order_by(func.count().desc())
|
|
.limit(top_n)
|
|
.all()
|
|
)
|
|
top_event_types = [
|
|
{"key": (str(k).strip() if k is not None and str(k).strip() else "unknown"), "count": int(v)}
|
|
for k, v in event_rows
|
|
]
|
|
|
|
# Prefer real UME alarmCode from raw_json when present (Postgres JSON text).
|
|
top_alarm_codes: list[dict[str, Any]] = []
|
|
try:
|
|
from sqlalchemy import cast
|
|
from sqlalchemy.dialects.postgresql import JSON
|
|
|
|
code_expr = func.coalesce(
|
|
func.nullif(
|
|
func.json_extract_path_text(cast(UmeAlarmCurrent.raw_json, JSON), "alarmCode"),
|
|
"",
|
|
),
|
|
"(none)",
|
|
)
|
|
code_rows = (
|
|
db.query(code_expr.label("code"), func.count())
|
|
.group_by(code_expr)
|
|
.order_by(func.count().desc())
|
|
.limit(top_n)
|
|
.all()
|
|
)
|
|
top_alarm_codes = [{"key": str(k), "count": int(v)} for k, v in code_rows]
|
|
except Exception:
|
|
# SQLite / non-JSON: fall back to native_probable_cause tops.
|
|
cause_rows = (
|
|
db.query(UmeAlarmCurrent.native_probable_cause, func.count())
|
|
.group_by(UmeAlarmCurrent.native_probable_cause)
|
|
.order_by(func.count().desc())
|
|
.limit(top_n)
|
|
.all()
|
|
)
|
|
top_alarm_codes = [
|
|
{
|
|
"key": (str(k).strip() if k is not None and str(k).strip() else "unknown"),
|
|
"count": int(v),
|
|
}
|
|
for k, v in cause_rows
|
|
]
|
|
|
|
ne_key = func.coalesce(
|
|
func.nullif(func.trim(UmeAlarmCurrent.host_name), ""),
|
|
func.nullif(func.trim(UmeInventoryNE.host_name), ""),
|
|
func.nullif(func.trim(UmeInventoryNE.user_label), ""),
|
|
_HOST_MISSING_LABEL,
|
|
)
|
|
ne_rows = (
|
|
db.query(ne_key.label("ne_key"), func.count().label("cnt"))
|
|
.select_from(UmeAlarmCurrent)
|
|
.outerjoin(UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id)
|
|
.group_by(ne_key)
|
|
.order_by(func.count().desc())
|
|
.all()
|
|
)
|
|
by_ne_missing = 0
|
|
named_counts: dict[str, int] = {}
|
|
for k, v in ne_rows:
|
|
label = _normalize_ne_bucket_key(k)
|
|
cnt = int(v)
|
|
if label == _HOST_MISSING_LABEL:
|
|
by_ne_missing += cnt
|
|
continue
|
|
named_counts[label] = int(named_counts.get(label, 0)) + cnt
|
|
top_ne = [
|
|
{"key": label, "count": cnt}
|
|
for label, cnt in sorted(named_counts.items(), key=lambda kv: kv[1], reverse=True)[:top_n]
|
|
]
|
|
|
|
# Protocol buckets still need text classify; stream rows lightly (cause+event only).
|
|
lang_norm = _normalize_netx_lang(lang)
|
|
proto_counts: dict[str, int] = {}
|
|
light = db.query(
|
|
UmeAlarmCurrent.event_type,
|
|
UmeAlarmCurrent.native_probable_cause,
|
|
UmeAlarmCurrent.object_name,
|
|
).yield_per(2000)
|
|
for event_type, cause, obj in light:
|
|
blob = " | ".join([str(event_type or ""), str(cause or ""), str(obj or "")])
|
|
bucket = _protocol_bucket_label(blob, lang=lang_norm)
|
|
proto_counts[bucket] = int(proto_counts.get(bucket, 0)) + 1
|
|
protocol_summary = sorted(proto_counts.items(), key=lambda x: x[1], reverse=True)[:top_n]
|
|
|
|
return {
|
|
"source": "ume_alarms_current",
|
|
"total_alarms": total,
|
|
"severity_summary": by_severity,
|
|
"top_event_types": top_event_types,
|
|
# Backward-compatible alias — historically event_type, now real alarmCode when possible.
|
|
"top_alarm_codes": top_alarm_codes,
|
|
"top_ne": top_ne,
|
|
"by_ne_missing": by_ne_missing,
|
|
"protocol_summary": [{"key": k, "count": v} for k, v in protocol_summary],
|
|
"meta": {
|
|
"last_seen_min": (_ensure_utc(min_seen).isoformat() if min_seen else None),
|
|
"last_seen_max": (_ensure_utc(max_seen).isoformat() if max_seen else None),
|
|
"time_filter_field": "last_seen_at",
|
|
"host_missing_label": _HOST_MISSING_LABEL,
|
|
},
|
|
}
|
|
|
|
|
|
@router.get("/v1/ume/alarms/history")
|
|
def ume_list_alarms_history(
|
|
severity: str | None = Query(default=None),
|
|
ne_id: str | None = Query(default=None),
|
|
keyword: str | None = Query(default=None),
|
|
time_from: str | None = Query(default=None),
|
|
time_to: str | None = Query(default=None),
|
|
page: int = Query(default=1, ge=1),
|
|
page_size: int = Query(default=50, ge=1, le=500),
|
|
db: Session = Depends(get_db),
|
|
) -> dict[str, Any]:
|
|
stmt = db.query(UmeAlarmHistory, UmeInventoryNE).outerjoin(
|
|
UmeInventoryNE, UmeAlarmHistory.ne_id == UmeInventoryNE.ne_id
|
|
)
|
|
if severity and str(severity).strip():
|
|
stmt = stmt.filter(UmeAlarmHistory.perceived_severity == str(severity).strip())
|
|
if ne_id and str(ne_id).strip():
|
|
stmt = stmt.filter(UmeAlarmHistory.ne_id == str(ne_id).strip())
|
|
kw = str(keyword or "").strip()
|
|
if kw:
|
|
stmt = stmt.filter(
|
|
UmeAlarmHistory.alarm_key.contains(kw)
|
|
| UmeAlarmHistory.object_name.contains(kw)
|
|
| UmeAlarmHistory.native_probable_cause.contains(kw)
|
|
| UmeInventoryNE.ne_name.contains(kw)
|
|
| UmeInventoryNE.user_label.contains(kw)
|
|
| UmeInventoryNE.ip_address.contains(kw)
|
|
)
|
|
dt_from = _parse_time(time_from)
|
|
dt_to = _parse_time(time_to)
|
|
if dt_from:
|
|
stmt = stmt.filter(UmeAlarmHistory.last_seen_at >= dt_from.replace(tzinfo=None))
|
|
if dt_to:
|
|
stmt = stmt.filter(UmeAlarmHistory.last_seen_at <= dt_to.replace(tzinfo=None))
|
|
total = int(stmt.count())
|
|
rows = stmt.order_by(UmeAlarmHistory.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
|
|
items = [
|
|
{
|
|
"alarm_key": str(alarm.alarm_key or ""),
|
|
"ne_id": str(alarm.ne_id or ""),
|
|
"ne_name": str((ne.ne_name if ne else "") or ""),
|
|
"user_label": str((ne.user_label if ne else "") or ""),
|
|
"object_name": str(alarm.object_name or ""),
|
|
"event_type": str(alarm.event_type or ""),
|
|
"native_probable_cause": str(alarm.native_probable_cause or ""),
|
|
"perceived_severity": str(alarm.perceived_severity or ""),
|
|
"is_cleared": str(alarm.is_cleared or ""),
|
|
"time_created": str(alarm.time_created or ""),
|
|
"last_seen_at": (_ensure_utc(alarm.last_seen_at) or datetime.now(timezone.utc)).isoformat(),
|
|
}
|
|
for alarm, ne in rows
|
|
]
|
|
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
|
|
|
|
|
@router.get("/v1/ume/alarms/history/aggregate")
|
|
def ume_alarms_history_aggregate(db: Session = Depends(get_db)) -> dict[str, Any]:
|
|
rows = db.query(UmeAlarmHistory, UmeInventoryNE).outerjoin(
|
|
UmeInventoryNE, UmeAlarmHistory.ne_id == UmeInventoryNE.ne_id
|
|
).all()
|
|
by_severity = _aggregate_rows(rows, lambda x: x[0].perceived_severity)
|
|
by_ne = _aggregate_rows(rows, lambda x: (x[1].user_label if x[1] else "") or (x[1].ne_name if x[1] else "") or x[0].ne_id)
|
|
by_date = _aggregate_rows(rows, lambda x: str(x[0].time_created or "")[:10])
|
|
return {"total": len(rows), "by_severity": by_severity, "by_ne": by_ne, "by_date": by_date}
|
|
|
|
|