feat(ume): ne_type filter, rule edit, and pager layout

Match key alerts by optional inventory ne_types, add edit dialog to update device types on existing rules, and keep card pager controls on one row.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-23 20:44:33 +08:00
parent d5d6d4eada
commit e474c0a431
11 changed files with 383 additions and 24 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import json
import re
import threading
import time
@ -7,8 +8,8 @@ from typing import Any
from sqlalchemy.orm import Session
from .models import UmeKeyAlertRule
from .ume_sync_service import _is_alarm_cleared, _pick, _s, notification_id_from_norm
from .models import UmeInventoryNE, UmeKeyAlertRule
from .ume_sync_service import _derive_ne_id_from_alarm, _is_alarm_cleared, _pick, _s, notification_id_from_norm
_RULE_CACHE_LOCK = threading.Lock()
_RULE_CACHE: list[UmeKeyAlertRule] = []
@ -75,6 +76,65 @@ def rule_match_type(row: UmeKeyAlertRule) -> str:
return "keyword" if pk.startswith("kw:") else "notification_id"
def serialize_rule_ne_types(ne_types: list[str]) -> str:
cleaned = parse_rule_items(",".join(str(x or "").strip() for x in (ne_types or []) if str(x or "").strip()))
return json.dumps(cleaned, ensure_ascii=False)
def rule_ne_types(row: UmeKeyAlertRule) -> list[str]:
raw = str(getattr(row, "ne_types", "") or "").strip()
if not raw or raw == "[]":
return []
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return parse_rule_items(",".join(str(x or "").strip() for x in parsed if str(x or "").strip()))
except json.JSONDecodeError:
pass
return parse_rule_items(raw)
def parse_rule_ne_types_payload(raw: Any) -> list[str]:
if raw is None:
return []
if isinstance(raw, list):
return parse_rule_items(",".join(str(x or "").strip() for x in raw if str(x or "").strip()))
if isinstance(raw, str):
text = raw.strip()
if not text:
return []
if text.startswith("["):
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return parse_rule_items(",".join(str(x or "").strip() for x in parsed if str(x or "").strip()))
except json.JSONDecodeError:
pass
return parse_rule_items(text)
return []
def _alarm_ne_type(db: Session, norm: dict[str, Any]) -> str:
ne_id = _s(_derive_ne_id_from_alarm(norm))
if not ne_id:
return ""
row = db.get(UmeInventoryNE, ne_id)
if row is None:
return ""
return str(row.ne_type or "").strip()
def _ne_type_matches_rule(db: Session, rule: UmeKeyAlertRule, norm: dict[str, Any]) -> bool:
allowed = rule_ne_types(rule)
if not allowed:
return True
ne_type = _alarm_ne_type(db, norm)
if not ne_type:
return False
allowed_fold = {_fold(x) for x in allowed}
return _fold(ne_type) in allowed_fold
def invalidate_key_alert_rule_cache() -> None:
global _RULE_CACHE_LOADED_AT
with _RULE_CACHE_LOCK:
@ -136,6 +196,8 @@ def match_key_alert_rule(
for rule in _load_enabled_rules(db):
if not _rule_matches_norm(rule, norm):
continue
if not _ne_type_matches_rule(db, rule, norm):
continue
if act in {"inserted", "updated"}:
if _is_alarm_cleared(norm):
continue

View file

@ -68,9 +68,12 @@ from .key_alert_config import (
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 .oclaw_alarm_forwarder import forwarder_status, shutdown_oclaw_alarm_forwarder, start_oclaw_alarm_forwarder
from .ume_token_store import (
@ -739,6 +742,10 @@ def _migrate_key_alert_rule_schema() -> None:
"add forward_log rule_key",
"ALTER TABLE ume_key_alert_forward_log ADD COLUMN IF NOT EXISTS rule_key VARCHAR(128) DEFAULT ''",
),
(
"add rule ne_types",
"ALTER TABLE ume_key_alert_rule ADD COLUMN IF NOT EXISTS ne_types TEXT DEFAULT '[]'",
),
]
for label, sql in steps:
try:
@ -1335,6 +1342,7 @@ def ume_list_key_alert_rules(
"match_value": rule_match_value(row),
"enabled": bool(int(row.enabled or 0)),
"label": str(row.label or ""),
"ne_types": rule_ne_types(row),
"created_at": (_ensure_utc(row.created_at) or datetime.now(timezone.utc)).isoformat(),
"updated_at": (_ensure_utc(row.updated_at) or datetime.now(timezone.utc)).isoformat(),
"forward_stats": stat_map.get(str(row.notification_id or ""), {
@ -1395,6 +1403,7 @@ def ume_upsert_key_alert_rule(payload: dict[str, Any], db: Session = Depends(get
if not label:
raise HTTPException(status_code=400, detail="label_required")
enabled = 1 if bool(payload.get("enabled", True)) else 0
ne_types_list = parse_rule_ne_types_payload(payload.get("ne_types"))
now = datetime.now(timezone.utc).replace(tzinfo=None)
try:
storage_key = rule_storage_key(match_type=match_type, value=match_value)
@ -1408,6 +1417,7 @@ def ume_upsert_key_alert_rule(payload: dict[str, Any], db: Session = Depends(get
row.match_value = match_value
row.enabled = enabled
row.label = label
row.ne_types = serialize_rule_ne_types(ne_types_list)
row.updated_at = now
saved = {
"notification_id": storage_key,
@ -1415,13 +1425,14 @@ def ume_upsert_key_alert_rule(payload: dict[str, Any], db: Session = Depends(get
"match_value": match_value,
"enabled": bool(enabled),
"label": label,
"ne_types": ne_types_list,
}
try:
db.commit()
except Exception as exc:
db.rollback()
msg = str(exc).lower()
if "match_type" in msg or "match_value" in msg or "undefinedcolumn" in msg:
if "match_type" in msg or "match_value" in msg or "ne_types" in msg or "undefinedcolumn" in msg:
raise HTTPException(
status_code=503,
detail="key_alert_schema_outdated: restart netx API to apply database migration",
@ -1439,10 +1450,15 @@ def ume_patch_key_alert_rule(rule_key: str, payload: dict[str, Any], db: Session
row = db.get(UmeKeyAlertRule, key)
if row is None:
raise HTTPException(status_code=404, detail="rule_not_found")
if "enabled" not in payload:
raise HTTPException(status_code=400, detail="enabled_required")
has_enabled = "enabled" in payload
has_ne_types = "ne_types" in payload
if not has_enabled and not has_ne_types:
raise HTTPException(status_code=400, detail="patch_fields_required")
now = datetime.now(timezone.utc).replace(tzinfo=None)
row.enabled = 1 if bool(payload.get("enabled")) else 0
if has_enabled:
row.enabled = 1 if bool(payload.get("enabled")) else 0
if has_ne_types:
row.ne_types = serialize_rule_ne_types(parse_rule_ne_types_payload(payload.get("ne_types")))
row.updated_at = now
db.commit()
invalidate_key_alert_rule_cache()
@ -1454,6 +1470,7 @@ def ume_patch_key_alert_rule(rule_key: str, payload: dict[str, Any], db: Session
"match_value": rule_match_value(row),
"enabled": bool(int(row.enabled or 0)),
"label": str(row.label or ""),
"ne_types": rule_ne_types(row),
},
}
@ -1700,6 +1717,28 @@ def ume_runtime_task_resume(task: str) -> dict[str, Any]:
return {"ok": True, "task": tid, "runtime_tasks": _list_runtime_tasks()}
@app.get("/v1/ume/inventory/ne-types")
def ume_list_inventory_ne_types(
limit: int = Query(default=500, ge=1, le=2000),
db: Session = Depends(get_db),
) -> dict[str, Any]:
from sqlalchemy import func
rows = (
db.query(
UmeInventoryNE.ne_type,
func.count(UmeInventoryNE.ne_id).label("ne_count"),
)
.filter(UmeInventoryNE.ne_type != "")
.group_by(UmeInventoryNE.ne_type)
.order_by(func.count(UmeInventoryNE.ne_id).desc(), UmeInventoryNE.ne_type.asc())
.limit(limit)
.all()
)
items = [{"ne_type": str(ne_type or ""), "ne_count": int(ne_count or 0)} for ne_type, ne_count in rows if str(ne_type or "").strip()]
return {"items": items, "total": len(items)}
@app.get("/v1/ume/inventory/ne")
def ume_list_ne(
keyword: str | None = Query(default=None),

View file

@ -222,6 +222,7 @@ class UmeKeyAlertRule(Base):
enabled: Mapped[int] = mapped_column(Integer, default=1)
forward_on_clear: Mapped[int] = mapped_column(Integer, default=0)
label: Mapped[str] = mapped_column(String(256), default="")
ne_types: Mapped[str] = mapped_column(Text, default="[]")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)