feat(ume): keyword key-alert rules with label and case-insensitive match

Add description keyword matching alongside notificationId rules, require
per-rule labels, and improve the AI monitor form layout and clear-on-push UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-22 16:48:01 +08:00
parent 99e7b1557f
commit 34db62c8d2
11 changed files with 449 additions and 134 deletions

View file

@ -1,5 +1,6 @@
from __future__ import annotations
import re
import threading
import time
from typing import Any
@ -7,13 +8,72 @@ from typing import Any
from sqlalchemy.orm import Session
from .models import UmeKeyAlertRule
from .ume_sync_service import _is_alarm_cleared, notification_id_from_norm
from .ume_sync_service import _is_alarm_cleared, _pick, _s, notification_id_from_norm
_RULE_CACHE_LOCK = threading.Lock()
_RULE_CACHE: dict[str, UmeKeyAlertRule] = {}
_RULE_CACHE: list[UmeKeyAlertRule] = []
_RULE_CACHE_LOADED_AT = 0.0
_RULE_CACHE_TTL_S = 30.0
_ITEM_SEP_RE = re.compile(r"[,,;;|\n]+")
def _fold(text: str) -> str:
"""Case-insensitive compare key for keyword matching."""
return str(text or "").strip().casefold()
def parse_rule_items(text: str) -> list[str]:
"""Split batch rule input (comma/semicolon/newline separated)."""
raw = str(text or "").strip()
if not raw:
return []
parts = [p.strip() for p in _ITEM_SEP_RE.split(raw) if p.strip()]
seen: set[str] = set()
out: list[str] = []
for part in parts:
key = _fold(part)
if key in seen:
continue
seen.add(key)
out.append(part)
return out
def normalize_match_type(value: str) -> str:
mt = str(value or "").strip().lower()
if mt in {"keyword", "keywords", "desc", "description", "cause"}:
return "keyword"
return "notification_id"
def rule_storage_key(*, match_type: str, value: str) -> str:
mt = normalize_match_type(match_type)
v = str(value or "").strip()
if not v:
raise ValueError("match value is required")
if mt == "keyword":
return f"kw:{_fold(v)[:120]}"
return v[:128]
def rule_match_value(row: UmeKeyAlertRule) -> str:
mv = str(getattr(row, "match_value", "") or "").strip()
if mv:
return mv
pk = str(row.notification_id or "").strip()
if pk.startswith("kw:"):
return pk[3:]
return pk
def rule_match_type(row: UmeKeyAlertRule) -> str:
mt = str(getattr(row, "match_type", "") or "").strip().lower()
if mt in {"keyword", "notification_id"}:
return mt
pk = str(row.notification_id or "").strip()
return "keyword" if pk.startswith("kw:") else "notification_id"
def invalidate_key_alert_rule_cache() -> None:
global _RULE_CACHE_LOADED_AT
@ -22,23 +82,48 @@ def invalidate_key_alert_rule_cache() -> None:
_RULE_CACHE_LOADED_AT = 0.0
def _load_enabled_rules(db: Session) -> dict[str, UmeKeyAlertRule]:
def _load_enabled_rules(db: Session) -> list[UmeKeyAlertRule]:
global _RULE_CACHE_LOADED_AT
now = time.time()
with _RULE_CACHE_LOCK:
if _RULE_CACHE and (now - _RULE_CACHE_LOADED_AT) < _RULE_CACHE_TTL_S:
return dict(_RULE_CACHE)
return list(_RULE_CACHE)
rows = (
db.query(UmeKeyAlertRule)
.filter(UmeKeyAlertRule.enabled == 1)
.all()
)
loaded = {str(row.notification_id or "").strip(): row for row in rows if str(row.notification_id or "").strip()}
with _RULE_CACHE_LOCK:
_RULE_CACHE.clear()
_RULE_CACHE.update(loaded)
_RULE_CACHE.extend(rows)
_RULE_CACHE_LOADED_AT = now
return dict(loaded)
return list(rows)
def _alarm_search_text(norm: dict[str, Any]) -> str:
parts = [
notification_id_from_norm(norm),
_s(_pick(norm, "nativeProbableCause", "native-probable-cause")),
_s(_pick(norm, "objectName", "object-name")),
_s(_pick(norm, "eventType", "event-type")),
]
return " ".join(p for p in parts if p).casefold()
def _keyword_matches(norm: dict[str, Any], keyword: str) -> bool:
kw = _fold(keyword)
if not kw:
return False
return kw in _alarm_search_text(norm)
def _rule_matches_norm(rule: UmeKeyAlertRule, norm: dict[str, Any]) -> bool:
mt = rule_match_type(rule)
mv = rule_match_value(rule)
if mt == "keyword":
return _keyword_matches(norm, mv)
nid = notification_id_from_norm(norm)
return bool(nid) and nid == mv
def match_key_alert_rule(
@ -47,19 +132,15 @@ def match_key_alert_rule(
norm: dict[str, Any],
action: str,
) -> UmeKeyAlertRule | None:
notification_id = notification_id_from_norm(norm)
if not notification_id:
return None
rules = _load_enabled_rules(db)
rule = rules.get(notification_id)
if rule is None:
return None
act = str(action or "").strip().lower()
if act in {"inserted", "updated"}:
if _is_alarm_cleared(norm):
return None
return rule
if act == "deleted":
if int(getattr(rule, "forward_on_clear", 0) or 0) == 1:
for rule in _load_enabled_rules(db):
if not _rule_matches_norm(rule, norm):
continue
if act in {"inserted", "updated"}:
if _is_alarm_cleared(norm):
continue
return rule
if act == "deleted":
if int(getattr(rule, "forward_on_clear", 0) or 0) == 1:
return rule
return None

View file

@ -60,7 +60,13 @@ from .ume_alarm_ws import (
start_ume_alarm_ws_consumer,
)
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full
from .key_alert_matcher import invalidate_key_alert_rule_cache
from .key_alert_matcher import (
invalidate_key_alert_rule_cache,
normalize_match_type,
rule_match_type,
rule_match_value,
rule_storage_key,
)
from .oclaw_alarm_forwarder import forwarder_status, shutdown_oclaw_alarm_forwarder, start_oclaw_alarm_forwarder
from .ume_token_store import (
clear_shared_token,
@ -771,6 +777,20 @@ def on_startup() -> None:
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_ume_alarms_history_notification_id ON ume_alarms_history (notification_id)"
)
conn.exec_driver_sql(
"ALTER TABLE ume_key_alert_rule ADD COLUMN IF NOT EXISTS match_type VARCHAR(32) DEFAULT 'notification_id'"
)
conn.exec_driver_sql(
"ALTER TABLE ume_key_alert_rule ADD COLUMN IF NOT EXISTS match_value VARCHAR(256) DEFAULT ''"
)
conn.exec_driver_sql(
"UPDATE ume_key_alert_rule SET match_value = notification_id "
"WHERE (match_value IS NULL OR match_value = '') AND notification_id NOT LIKE 'kw:%'"
)
conn.exec_driver_sql(
"UPDATE ume_key_alert_rule SET match_type = 'keyword', match_value = SUBSTRING(notification_id FROM 4) "
"WHERE notification_id LIKE 'kw:%' AND (match_type IS NULL OR match_type = '' OR match_type = 'notification_id')"
)
conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_ume_alarms_current_host_name ON ume_alarms_current (host_name)"
)
@ -1227,6 +1247,8 @@ def ume_list_key_alert_rules(db: Session = Depends(get_db)) -> dict[str, Any]:
items = [
{
"notification_id": str(row.notification_id or ""),
"match_type": rule_match_type(row),
"match_value": rule_match_value(row),
"enabled": bool(int(row.enabled or 0)),
"forward_on_clear": bool(int(row.forward_on_clear or 0)),
"label": str(row.label or ""),
@ -1256,39 +1278,82 @@ def ume_key_alert_monitor(db: Session = Depends(get_db)) -> dict[str, Any]:
@app.post("/v1/ume/key-alert-rules")
def ume_upsert_key_alert_rule(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]:
notification_id = str(payload.get("notification_id") or "").strip()
if not notification_id:
raise HTTPException(status_code=400, detail="notification_id_required")
match_type = normalize_match_type(str(payload.get("match_type") or "notification_id"))
match_value = str(payload.get("match_value") or payload.get("notification_id") or "").strip()
if not match_value:
raise HTTPException(status_code=400, detail="match_value_required")
label = str(payload.get("label") or "").strip()
if not label:
raise HTTPException(status_code=400, detail="label_required")
forward_on_clear = 1 if bool(payload.get("forward_on_clear", False)) else 0
enabled = 1 if bool(payload.get("enabled", True)) else 0
now = datetime.now(timezone.utc).replace(tzinfo=None)
row = db.get(UmeKeyAlertRule, notification_id)
try:
storage_key = rule_storage_key(match_type=match_type, value=match_value)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
row = db.get(UmeKeyAlertRule, storage_key)
if row is None:
row = UmeKeyAlertRule(notification_id=notification_id, created_at=now, updated_at=now)
row = UmeKeyAlertRule(notification_id=storage_key, created_at=now, updated_at=now)
db.add(row)
row.enabled = 1 if bool(payload.get("enabled", True)) else 0
row.forward_on_clear = 1 if bool(payload.get("forward_on_clear", False)) else 0
row.label = str(payload.get("label") or "").strip()
row.match_type = match_type
row.match_value = match_value
row.enabled = enabled
row.forward_on_clear = forward_on_clear
row.label = label
row.updated_at = now
saved = {
"notification_id": storage_key,
"match_type": match_type,
"match_value": match_value,
"enabled": bool(enabled),
"forward_on_clear": bool(forward_on_clear),
"label": label,
}
db.commit()
invalidate_key_alert_rule_cache()
return {
"ok": True,
"notification_id": notification_id,
"enabled": bool(row.enabled),
"forward_on_clear": bool(row.forward_on_clear),
"label": row.label,
}
return {"ok": True, "item": saved}
@app.delete("/v1/ume/key-alert-rules/{notification_id}")
def ume_delete_key_alert_rule(notification_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
nid = str(notification_id or "").strip()
row = db.get(UmeKeyAlertRule, nid)
@app.delete("/v1/ume/key-alert-rules/{rule_key:path}")
def ume_delete_key_alert_rule(rule_key: str, db: Session = Depends(get_db)) -> dict[str, Any]:
key = str(rule_key or "").strip()
row = db.get(UmeKeyAlertRule, key)
if row is None:
raise HTTPException(status_code=404, detail="rule_not_found")
db.delete(row)
db.commit()
invalidate_key_alert_rule_cache()
return {"ok": True, "deleted": nid}
return {"ok": True, "deleted": key}
@app.get("/v1/ume/alarm-keywords")
def ume_list_alarm_keywords(
limit: int = Query(default=200, ge=1, le=2000),
db: Session = Depends(get_db),
) -> dict[str, Any]:
from sqlalchemy import func
rows = (
db.query(
UmeAlarmCurrent.native_probable_cause,
func.count(UmeAlarmCurrent.alarm_key).label("cnt"),
)
.filter(UmeAlarmCurrent.native_probable_cause != "")
.group_by(UmeAlarmCurrent.native_probable_cause)
.order_by(func.count(UmeAlarmCurrent.alarm_key).desc(), UmeAlarmCurrent.native_probable_cause.asc())
.limit(limit)
.all()
)
items = [
{
"keyword": str(cause or ""),
"alarm_count": int(cnt or 0),
}
for cause, cnt in rows
if str(cause or "").strip()
]
return {"items": items, "total": len(items)}
@app.get("/v1/ume/notification-ids")

View file

@ -202,11 +202,13 @@ class UmeAlarmHistory(Base):
class UmeKeyAlertRule(Base):
"""Key alert rule matched by UME notificationId."""
"""Key alert rule matched by UME notificationId or alarm description keyword."""
__tablename__ = "ume_key_alert_rule"
notification_id: Mapped[str] = mapped_column(String(128), primary_key=True)
match_type: Mapped[str] = mapped_column(String(32), default="notification_id", index=True)
match_value: Mapped[str] = mapped_column(String(256), default="", index=True)
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="")