mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
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:
parent
99e7b1557f
commit
34db62c8d2
11 changed files with 449 additions and 134 deletions
|
|
@ -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
|
||||
|
|
|
|||
107
netx_api/main.py
107
netx_api/main.py
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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="")
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ from sqlalchemy import create_engine
|
|||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from netx_api.db import Base
|
||||
from netx_api.key_alert_matcher import invalidate_key_alert_rule_cache, match_key_alert_rule
|
||||
from netx_api.key_alert_matcher import (
|
||||
invalidate_key_alert_rule_cache,
|
||||
match_key_alert_rule,
|
||||
parse_rule_items,
|
||||
rule_storage_key,
|
||||
)
|
||||
from netx_api.models import UmeKeyAlertRule
|
||||
from netx_api.ume_sync_service import apply_alarm_to_current, notification_id_from_norm
|
||||
|
||||
|
|
@ -70,6 +75,62 @@ class KeyAlertMatcherTests(unittest.TestCase):
|
|||
invalidate_key_alert_rule_cache()
|
||||
self.assertIsNotNone(match_key_alert_rule(self.db, norm=cleared, action="deleted"))
|
||||
|
||||
def test_parse_rule_items(self) -> None:
|
||||
self.assertEqual(parse_rule_items("a, b;c\n d"), ["a", "b", "c", "d"])
|
||||
self.assertEqual(parse_rule_items("A, a"), ["A"])
|
||||
self.assertEqual(parse_rule_items(""), [])
|
||||
|
||||
def test_rule_storage_key_keyword(self) -> None:
|
||||
self.assertEqual(rule_storage_key(match_type="keyword", value="BGP Down"), "kw:bgp down")
|
||||
|
||||
def test_keyword_match_insert(self) -> None:
|
||||
pk = rule_storage_key(match_type="keyword", value="链路中断")
|
||||
self.db.add(
|
||||
UmeKeyAlertRule(
|
||||
notification_id=pk,
|
||||
match_type="keyword",
|
||||
match_value="链路中断",
|
||||
enabled=1,
|
||||
forward_on_clear=0,
|
||||
label="链路",
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
invalidate_key_alert_rule_cache()
|
||||
norm = {
|
||||
"notificationId": "NID-999",
|
||||
"nativeProbableCause": "核心链路中断告警",
|
||||
"is-cleared": False,
|
||||
}
|
||||
rule = match_key_alert_rule(self.db, norm=norm, action="inserted")
|
||||
self.assertIsNotNone(rule)
|
||||
assert rule is not None
|
||||
self.assertEqual(rule.match_value, "链路中断")
|
||||
|
||||
norm_miss = {"notificationId": "NID-1", "nativeProbableCause": "CPU high", "is-cleared": False}
|
||||
self.assertIsNone(match_key_alert_rule(self.db, norm=norm_miss, action="inserted"))
|
||||
|
||||
def test_keyword_match_case_insensitive(self) -> None:
|
||||
pk = rule_storage_key(match_type="keyword", value="bgp down")
|
||||
self.db.add(
|
||||
UmeKeyAlertRule(
|
||||
notification_id=pk,
|
||||
match_type="keyword",
|
||||
match_value="BGP DOWN",
|
||||
enabled=1,
|
||||
forward_on_clear=0,
|
||||
label="BGP",
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
invalidate_key_alert_rule_cache()
|
||||
for cause in ("BGP DOWN alarm", "bgp down on port", "Link BGP DOWN detected"):
|
||||
norm = {"notificationId": "NID-x", "nativeProbableCause": cause, "is-cleared": False}
|
||||
self.assertIsNotNone(
|
||||
match_key_alert_rule(self.db, norm=norm, action="inserted"),
|
||||
msg=f"expected match for cause={cause!r}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -4,9 +4,10 @@ type Props = {
|
|||
text: string;
|
||||
ariaLabel: string;
|
||||
align?: "start" | "end";
|
||||
nowrap?: boolean;
|
||||
};
|
||||
|
||||
export function HelpHint({ text, ariaLabel, align = "start" }: Props) {
|
||||
export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
|
|
@ -38,7 +39,10 @@ export function HelpHint({ text, ariaLabel, align = "start" }: Props) {
|
|||
?
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="help-hint__popover" role="tooltip">
|
||||
<div
|
||||
className={`help-hint__popover${nowrap ? " help-hint__popover--nowrap" : ""}`}
|
||||
role="tooltip"
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ const en = {
|
|||
},
|
||||
keyAlert: {
|
||||
title: "AI alarm monitor (OClaw WhatsApp)",
|
||||
help: "Match UME key alarms by notificationId and push via OClaw WebSocket. Auth shares NETX_OCLAW_ANALYZE_TOKEN / OCLAW_OPS_AI_SHARED_TOKEN; set NETX_OCLAW_ALARM_WS_URL separately.",
|
||||
help: "Exact match by notificationId, or substring match on alarm description (case-insensitive; nativeProbableCause, etc.), pushed via OClaw WebSocket. Shares NETX_OCLAW_ANALYZE_TOKEN.",
|
||||
ws: "OClaw WSS",
|
||||
wsConnected: "connected",
|
||||
wsDisconnected: "disconnected",
|
||||
|
|
@ -345,25 +345,33 @@ const en = {
|
|||
publishedOk: "published ok",
|
||||
publishedFail: "published fail",
|
||||
queue: "queue",
|
||||
notificationId: "notificationId",
|
||||
notificationIdPh: "Enter or pick notificationId",
|
||||
label: "label",
|
||||
labelPh: "optional, e.g. BGP Down",
|
||||
forwardOnClear: "forward clear events",
|
||||
add: "Add monitor ID",
|
||||
matchType: "Match by",
|
||||
matchNotificationId: "notificationId",
|
||||
matchKeyword: "description keyword",
|
||||
matchValuePhNotificationId: "Enter notificationId",
|
||||
matchValuePhKeyword: "Enter keyword (case-insensitive)",
|
||||
label: "Rule description",
|
||||
labelPh: "Required, e.g. BGP Down",
|
||||
forwardOnClear: "Push on alarm clear",
|
||||
forwardOnClearHelp: "By default only new/updated alarms are pushed. When enabled, a clear notification is also sent when UME reports the alarm cleared.",
|
||||
add: "Add rules",
|
||||
adding: "Adding…",
|
||||
delete: "Delete",
|
||||
pickFromAlarms: "Pick from current alarms",
|
||||
emptyRules: "No notificationId rules yet",
|
||||
colId: "notificationId",
|
||||
colLabel: "label",
|
||||
pickFromAlarms: "Load alarm hints",
|
||||
emptyRules: "No monitor rules yet",
|
||||
colType: "type",
|
||||
colMatch: "match",
|
||||
colLabel: "description",
|
||||
colForwardClear: "on clear",
|
||||
colPublished: "published",
|
||||
colAttempts: "attempts",
|
||||
colLast: "last push",
|
||||
colActions: "actions",
|
||||
addOk: "Rule added",
|
||||
addOk: "Monitor rule added",
|
||||
deleteOk: "Deleted",
|
||||
confirmDelete: "Delete this notificationId monitor rule?",
|
||||
confirmDelete: "Delete this monitor rule?",
|
||||
yes: "yes",
|
||||
no: "no",
|
||||
},
|
||||
sync: {
|
||||
title: "UME sync",
|
||||
|
|
|
|||
|
|
@ -335,7 +335,7 @@ const zh = {
|
|||
},
|
||||
keyAlert: {
|
||||
title: "AI 告警监控(OClaw WhatsApp)",
|
||||
help: "按 notificationId 匹配 UME 关键告警,经 OClaw WebSocket 推送到 WhatsApp 群。鉴权与 NETX_OCLAW_ANALYZE_TOKEN / OCLAW_OPS_AI_SHARED_TOKEN 共用;另需配置 NETX_OCLAW_ALARM_WS_URL。",
|
||||
help: "按 notificationId 精确匹配,或按告警描述关键字模糊匹配(不区分大小写,匹配 nativeProbableCause 等字段),经 OClaw WebSocket 推送到 WhatsApp 群。鉴权与 NETX_OCLAW_ANALYZE_TOKEN 共用。",
|
||||
ws: "OClaw WSS",
|
||||
wsConnected: "已连接",
|
||||
wsDisconnected: "未连接",
|
||||
|
|
@ -343,25 +343,33 @@ const zh = {
|
|||
publishedOk: "发布成功",
|
||||
publishedFail: "发布失败",
|
||||
queue: "待发队列",
|
||||
notificationId: "notificationId",
|
||||
notificationIdPh: "输入或从下方选择 notificationId",
|
||||
matchType: "匹配方式",
|
||||
matchNotificationId: "告警码 (notificationId)",
|
||||
matchKeyword: "告警描述关键字",
|
||||
matchValuePhNotificationId: "输入 notificationId",
|
||||
matchValuePhKeyword: "输入匹配关键字(不区分大小写)",
|
||||
label: "规则描述",
|
||||
labelPh: "可选,如 BGP Down",
|
||||
forwardOnClear: "转发清除告警",
|
||||
add: "添加监控 ID",
|
||||
labelPh: "必填,如 BGP Down",
|
||||
forwardOnClear: "告警清除时也推送",
|
||||
forwardOnClearHelp: "默认只在告警新增/更新时推送。勾选后,当 UME 上报告警已清除(从当前告警表删除)时,也会向 WhatsApp 发送一条清除通知。",
|
||||
add: "添加规则",
|
||||
adding: "添加中…",
|
||||
delete: "删除",
|
||||
pickFromAlarms: "从当前告警选择",
|
||||
emptyRules: "尚未配置监控 notificationId",
|
||||
colId: "notificationId",
|
||||
pickFromAlarms: "加载当前告警参考",
|
||||
emptyRules: "尚未配置监控规则",
|
||||
colType: "类型",
|
||||
colMatch: "匹配内容",
|
||||
colLabel: "描述",
|
||||
colForwardClear: "清除推送",
|
||||
colPublished: "发布次数",
|
||||
colAttempts: "尝试次数",
|
||||
colLast: "最近推送",
|
||||
colActions: "操作",
|
||||
addOk: "已添加监控规则",
|
||||
deleteOk: "已删除",
|
||||
confirmDelete: "确定删除该 notificationId 监控规则?",
|
||||
confirmDelete: "确定删除该监控规则?",
|
||||
yes: "是",
|
||||
no: "否",
|
||||
},
|
||||
sync: {
|
||||
title: "UME 同步",
|
||||
|
|
|
|||
|
|
@ -708,6 +708,12 @@ pre {
|
|||
right: 0;
|
||||
}
|
||||
|
||||
.help-hint__popover--nowrap {
|
||||
width: max-content;
|
||||
max-width: min(90vw, 720px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel__toolbar-end {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
clearLocalUmeAlarmSubscription,
|
||||
establishUmeAlarmSubscription,
|
||||
fetchUmeAlarmSubscriptionStatus,
|
||||
fetchUmeAlarmKeywords,
|
||||
fetchUmeKeyAlertMonitor,
|
||||
fetchUmeNotificationIds,
|
||||
fetchUmeSyncStatus,
|
||||
|
|
@ -46,10 +47,15 @@ export function UmePage() {
|
|||
const [curPage, setCurPage] = useState(1);
|
||||
const [curPageSize, setCurPageSize] = useState(50);
|
||||
const [alarmsPanelOpen, setAlarmsPanelOpen] = useState(false);
|
||||
const [keyAlertNotificationId, setKeyAlertNotificationId] = useState("");
|
||||
const [keyAlertMatchType, setKeyAlertMatchType] = useState<"notification_id" | "keyword">("keyword");
|
||||
const [keyAlertMatchValue, setKeyAlertMatchValue] = useState("");
|
||||
const [keyAlertLabel, setKeyAlertLabel] = useState("");
|
||||
const [keyAlertForwardOnClear, setKeyAlertForwardOnClear] = useState(false);
|
||||
const [keyAlertOpError, setKeyAlertOpError] = useState("");
|
||||
const [keyAlertKeywordHints, setKeyAlertKeywordHints] = useState<string[]>([]);
|
||||
const [keyAlertIdHints, setKeyAlertIdHints] = useState<
|
||||
Array<{ notification_id: string; native_probable_cause_sample: string }>
|
||||
>([]);
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }),
|
||||
|
|
@ -105,12 +111,6 @@ export function UmePage() {
|
|||
staleTime: 3000,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const notificationIdsQuery = useQuery({
|
||||
queryKey: ["umeNotificationIds"],
|
||||
queryFn: () => fetchUmeNotificationIds(200),
|
||||
staleTime: 10000,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const confirmClearLocalSubscription = (hint?: string) =>
|
||||
window.confirm(hint || t("ume.subscription.confirmClearDefault"));
|
||||
|
|
@ -296,14 +296,15 @@ export function UmePage() {
|
|||
const keyAlertAddMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
upsertUmeKeyAlertRule({
|
||||
notification_id: keyAlertNotificationId.trim(),
|
||||
match_type: keyAlertMatchType,
|
||||
match_value: keyAlertMatchValue.trim(),
|
||||
label: keyAlertLabel.trim(),
|
||||
enabled: true,
|
||||
forward_on_clear: keyAlertForwardOnClear,
|
||||
}),
|
||||
onMutate: () => setKeyAlertOpError(""),
|
||||
onSuccess: async () => {
|
||||
setKeyAlertNotificationId("");
|
||||
setKeyAlertMatchValue("");
|
||||
setKeyAlertLabel("");
|
||||
showOk(t("ume.keyAlert.addOk"));
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor });
|
||||
|
|
@ -606,58 +607,121 @@ export function UmePage() {
|
|||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="actions-row actions-row--inline" style={{ marginTop: 10, flexWrap: "wrap" }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ minWidth: 180 }}
|
||||
placeholder={t("ume.keyAlert.notificationIdPh")}
|
||||
value={keyAlertNotificationId}
|
||||
onChange={(e) => setKeyAlertNotificationId(e.target.value)}
|
||||
list="ume-notification-id-options"
|
||||
/>
|
||||
<datalist id="ume-notification-id-options">
|
||||
{(notificationIdsQuery.data?.items || []).map((x) => (
|
||||
<option key={x.notification_id} value={x.notification_id}>
|
||||
{x.native_probable_cause_sample || x.notification_id}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<input
|
||||
className="input"
|
||||
style={{ minWidth: 140 }}
|
||||
placeholder={t("ume.keyAlert.labelPh")}
|
||||
value={keyAlertLabel}
|
||||
onChange={(e) => setKeyAlertLabel(e.target.value)}
|
||||
/>
|
||||
<label className="muted" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div
|
||||
className="actions-row actions-row--inline"
|
||||
style={{ flexWrap: "wrap", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<label className="muted">{t("ume.keyAlert.matchType")}</label>
|
||||
<select
|
||||
className="input"
|
||||
value={keyAlertMatchType}
|
||||
onChange={(e) => {
|
||||
setKeyAlertMatchType(e.target.value === "keyword" ? "keyword" : "notification_id");
|
||||
setKeyAlertKeywordHints([]);
|
||||
setKeyAlertIdHints([]);
|
||||
}}
|
||||
>
|
||||
<option value="notification_id">{t("ume.keyAlert.matchNotificationId")}</option>
|
||||
<option value="keyword">{t("ume.keyAlert.matchKeyword")}</option>
|
||||
</select>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keyAlertForwardOnClear}
|
||||
onChange={(e) => setKeyAlertForwardOnClear(e.target.checked)}
|
||||
className="input"
|
||||
style={{ flex: "1 1 180px", minWidth: 160 }}
|
||||
placeholder={
|
||||
keyAlertMatchType === "keyword"
|
||||
? t("ume.keyAlert.matchValuePhKeyword")
|
||||
: t("ume.keyAlert.matchValuePhNotificationId")
|
||||
}
|
||||
value={keyAlertMatchValue}
|
||||
onChange={(e) => setKeyAlertMatchValue(e.target.value)}
|
||||
list="ume-key-alert-match-options"
|
||||
/>
|
||||
{t("ume.keyAlert.forwardOnClear")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => notificationIdsQuery.refetch()}
|
||||
disabled={notificationIdsQuery.isFetching}
|
||||
<input
|
||||
className="input"
|
||||
style={{ flex: "1 1 140px", minWidth: 120 }}
|
||||
placeholder={t("ume.keyAlert.labelPh")}
|
||||
value={keyAlertLabel}
|
||||
onChange={(e) => setKeyAlertLabel(e.target.value)}
|
||||
/>
|
||||
<datalist id="ume-key-alert-match-options">
|
||||
{keyAlertMatchType === "keyword"
|
||||
? keyAlertKeywordHints.map((kw) => <option key={kw} value={kw} />)
|
||||
: keyAlertIdHints.map((x) => (
|
||||
<option key={x.notification_id} value={x.notification_id}>
|
||||
{x.native_probable_cause_sample || x.notification_id}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (keyAlertMatchType === "keyword") {
|
||||
const resp = await fetchUmeAlarmKeywords(100);
|
||||
const hints = (resp.items || []).map((x) => x.keyword).filter(Boolean);
|
||||
setKeyAlertKeywordHints(hints);
|
||||
if (hints.length && !keyAlertMatchValue.trim()) {
|
||||
setKeyAlertMatchValue(hints[0]);
|
||||
}
|
||||
} else {
|
||||
const resp = await fetchUmeNotificationIds(100);
|
||||
const items = resp.items || [];
|
||||
setKeyAlertIdHints(items);
|
||||
if (items.length && !keyAlertMatchValue.trim()) {
|
||||
const first = items[0];
|
||||
setKeyAlertMatchValue(first.notification_id);
|
||||
if (!keyAlertLabel.trim() && first.native_probable_cause_sample) {
|
||||
setKeyAlertLabel(first.native_probable_cause_sample);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("ume.keyAlert.pickFromAlarms")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => keyAlertAddMutation.mutate()}
|
||||
disabled={
|
||||
keyAlertAddMutation.isPending || !keyAlertMatchValue.trim() || !keyAlertLabel.trim()
|
||||
}
|
||||
>
|
||||
{keyAlertAddMutation.isPending ? t("ume.keyAlert.adding") : t("ume.keyAlert.add")}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="actions-row actions-row--inline"
|
||||
style={{ marginTop: 8, flexWrap: "wrap", alignItems: "center", justifyContent: "space-between" }}
|
||||
>
|
||||
{t("ume.keyAlert.pickFromAlarms")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => keyAlertAddMutation.mutate()}
|
||||
disabled={keyAlertAddMutation.isPending || !keyAlertNotificationId.trim()}
|
||||
>
|
||||
{keyAlertAddMutation.isPending ? t("ume.keyAlert.adding") : t("ume.keyAlert.add")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor })}
|
||||
disabled={keyAlertMonitorQuery.isFetching}
|
||||
>
|
||||
{keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
<label
|
||||
className="muted"
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keyAlertForwardOnClear}
|
||||
onChange={(e) => setKeyAlertForwardOnClear(e.target.checked)}
|
||||
/>
|
||||
{t("ume.keyAlert.forwardOnClear")}
|
||||
<HelpHint
|
||||
text={t("ume.keyAlert.forwardOnClearHelp")}
|
||||
ariaLabel={t("common.help")}
|
||||
align="end"
|
||||
nowrap
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor })}
|
||||
disabled={keyAlertMonitorQuery.isFetching}
|
||||
>
|
||||
{keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{keyAlertOpError ? (
|
||||
<div className="pill pill--high" style={{ marginTop: 8 }}>
|
||||
|
|
@ -667,8 +731,10 @@ export function UmePage() {
|
|||
<table style={{ marginTop: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("ume.keyAlert.colId")}</th>
|
||||
<th>{t("ume.keyAlert.colType")}</th>
|
||||
<th>{t("ume.keyAlert.colMatch")}</th>
|
||||
<th>{t("ume.keyAlert.colLabel")}</th>
|
||||
<th>{t("ume.keyAlert.colForwardClear")}</th>
|
||||
<th>{t("ume.keyAlert.colPublished")}</th>
|
||||
<th>{t("ume.keyAlert.colAttempts")}</th>
|
||||
<th>{t("ume.keyAlert.colLast")}</th>
|
||||
|
|
@ -678,8 +744,14 @@ export function UmePage() {
|
|||
<tbody>
|
||||
{keyAlertRules.map((rule) => (
|
||||
<tr key={rule.notification_id}>
|
||||
<td>{rule.notification_id}</td>
|
||||
<td>
|
||||
{rule.match_type === "keyword"
|
||||
? t("ume.keyAlert.matchKeyword")
|
||||
: t("ume.keyAlert.matchNotificationId")}
|
||||
</td>
|
||||
<td>{rule.match_value || rule.notification_id}</td>
|
||||
<td>{rule.label || t("common.empty")}</td>
|
||||
<td>{rule.forward_on_clear ? t("ume.keyAlert.yes") : t("ume.keyAlert.no")}</td>
|
||||
<td>{Number(rule.forward_stats?.published_ok || 0)}</td>
|
||||
<td>{Number(rule.forward_stats?.attempts || 0)}</td>
|
||||
<td>
|
||||
|
|
@ -704,7 +776,7 @@ export function UmePage() {
|
|||
))}
|
||||
{!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6}>{t("ume.keyAlert.emptyRules")}</td>
|
||||
<td colSpan={8}>{t("ume.keyAlert.emptyRules")}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
|
|
|
|||
|
|
@ -75,20 +75,26 @@ export const clearLocalUmeAlarmSubscription = () =>
|
|||
export const fetchUmeKeyAlertMonitor = () => apiGet<UmeKeyAlertMonitorResponse>("/v1/ume/key-alert-monitor");
|
||||
|
||||
export const upsertUmeKeyAlertRule = (payload: {
|
||||
notification_id: string;
|
||||
label?: string;
|
||||
match_type: "notification_id" | "keyword";
|
||||
match_value: string;
|
||||
label: string;
|
||||
enabled?: boolean;
|
||||
forward_on_clear?: boolean;
|
||||
}) => apiPost<{ ok: boolean }>("/v1/ume/key-alert-rules", payload);
|
||||
}) => apiPost<{ ok: boolean; item?: unknown }>("/v1/ume/key-alert-rules", payload);
|
||||
|
||||
export const deleteUmeKeyAlertRule = (notificationId: string) =>
|
||||
apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(notificationId)}`);
|
||||
export const deleteUmeKeyAlertRule = (ruleKey: string) =>
|
||||
apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`);
|
||||
|
||||
export const fetchUmeNotificationIds = (limit = 200) =>
|
||||
apiGet<{ items: Array<{ notification_id: string; native_probable_cause_sample: string }> }>(
|
||||
`/v1/ume/notification-ids?limit=${encodeURIComponent(String(limit))}`,
|
||||
);
|
||||
|
||||
export const fetchUmeAlarmKeywords = (limit = 200) =>
|
||||
apiGet<{ items: Array<{ keyword: string; alarm_count: number }> }>(
|
||||
`/v1/ume/alarm-keywords?limit=${encodeURIComponent(String(limit))}`,
|
||||
);
|
||||
|
||||
export const fetchUmeSyncStatus = (params: { page: number; pageSize: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("page", String(Math.max(1, Number(params.page || 1))));
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export type IntegrationStatus = {
|
|||
|
||||
export type UmeKeyAlertRuleItem = {
|
||||
notification_id: string;
|
||||
match_type: "notification_id" | "keyword";
|
||||
match_value: string;
|
||||
enabled: boolean;
|
||||
forward_on_clear: boolean;
|
||||
label: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue