From b54a6e5ad46838774c9bd7a50369d8a6b8c74f72 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 22 Jun 2026 17:26:49 +0800 Subject: [PATCH] fix(ume): global clear-push setting, schema migration, and help hints Make forward-on-clear a global monitor toggle, run key-alert DDL in an isolated startup transaction, and fix HelpHint popovers plus API errors when the server returns non-JSON responses. Co-authored-by: Cursor --- netx_api/key_alert_config.py | 63 +++++++++++++++++++++++++ netx_api/key_alert_matcher.py | 4 +- netx_api/main.py | 74 ++++++++++++++++++++++------- netx_api/models.py | 10 ++++ tests/test_key_alert_matcher.py | 11 ++--- web/src/components/HelpHint.tsx | 83 ++++++++++++++++++++++++++++----- web/src/i18n/en.ts | 2 +- web/src/i18n/zh.ts | 2 +- web/src/index.css | 5 ++ web/src/pages/UmePage.tsx | 46 +++++++++++------- web/src/services/api.ts | 30 ++++++++---- web/src/types.ts | 4 +- 12 files changed, 266 insertions(+), 68 deletions(-) create mode 100644 netx_api/key_alert_config.py diff --git a/netx_api/key_alert_config.py b/netx_api/key_alert_config.py new file mode 100644 index 0000000..684165f --- /dev/null +++ b/netx_api/key_alert_config.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import threading +import time +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from .models import UmeKeyAlertMonitorConfig + +_CONFIG_LOCK = threading.Lock() +_FORWARD_ON_CLEAR_CACHE: bool | None = None +_CONFIG_CACHE_LOADED_AT = 0.0 +_CONFIG_CACHE_TTL_S = 10.0 +_CONFIG_ROW_ID = 1 + + +def _utc_now_naive() -> datetime: + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def invalidate_key_alert_config_cache() -> None: + global _CONFIG_CACHE_LOADED_AT, _FORWARD_ON_CLEAR_CACHE + with _CONFIG_LOCK: + _FORWARD_ON_CLEAR_CACHE = None + _CONFIG_CACHE_LOADED_AT = 0.0 + + +def _get_or_create_config_row(db: Session) -> UmeKeyAlertMonitorConfig: + row = db.get(UmeKeyAlertMonitorConfig, _CONFIG_ROW_ID) + if row is None: + now = _utc_now_naive() + row = UmeKeyAlertMonitorConfig(id=_CONFIG_ROW_ID, forward_on_clear=0, updated_at=now) + db.add(row) + db.flush() + return row + + +def is_forward_on_clear_enabled(db: Session) -> bool: + global _CONFIG_CACHE_LOADED_AT, _FORWARD_ON_CLEAR_CACHE + now = time.time() + with _CONFIG_LOCK: + if _FORWARD_ON_CLEAR_CACHE is not None and (now - _CONFIG_CACHE_LOADED_AT) < _CONFIG_CACHE_TTL_S: + return _FORWARD_ON_CLEAR_CACHE + row = _get_or_create_config_row(db) + enabled = bool(int(row.forward_on_clear or 0)) + with _CONFIG_LOCK: + _FORWARD_ON_CLEAR_CACHE = enabled + _CONFIG_CACHE_LOADED_AT = now + return enabled + + +def get_key_alert_monitor_config(db: Session) -> dict[str, bool]: + return {"forward_on_clear": is_forward_on_clear_enabled(db)} + + +def set_key_alert_monitor_config(db: Session, *, forward_on_clear: bool) -> dict[str, bool]: + row = _get_or_create_config_row(db) + row.forward_on_clear = 1 if forward_on_clear else 0 + row.updated_at = _utc_now_naive() + db.commit() + invalidate_key_alert_config_cache() + return {"forward_on_clear": bool(forward_on_clear)} diff --git a/netx_api/key_alert_matcher.py b/netx_api/key_alert_matcher.py index 9bcb120..63bfda9 100644 --- a/netx_api/key_alert_matcher.py +++ b/netx_api/key_alert_matcher.py @@ -141,6 +141,8 @@ def match_key_alert_rule( continue return rule if act == "deleted": - if int(getattr(rule, "forward_on_clear", 0) or 0) == 1: + from .key_alert_config import is_forward_on_clear_enabled + + if is_forward_on_clear_enabled(db): return rule return None diff --git a/netx_api/main.py b/netx_api/main.py index a18a14e..c470961 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -60,6 +60,11 @@ 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_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, @@ -687,10 +692,52 @@ def _configure_ume_diag_logging() -> None: lg.propagate = False +def _migrate_key_alert_rule_schema() -> None: + """Evolve ume_key_alert_rule in its own transaction (avoid rollback with bulk startup DDL).""" + try: + with engine.begin() as conn: + 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 TABLE IF NOT EXISTS ume_key_alert_monitor_config ( + id INTEGER PRIMARY KEY, + forward_on_clear INTEGER DEFAULT 0, + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() + ) + """ + ) + conn.exec_driver_sql( + "INSERT INTO ume_key_alert_monitor_config (id, forward_on_clear, updated_at) " + "VALUES (1, 0, NOW()) ON CONFLICT (id) DO NOTHING" + ) + conn.exec_driver_sql( + "UPDATE ume_key_alert_monitor_config SET forward_on_clear = 1, updated_at = NOW() " + "WHERE id = 1 AND EXISTS (SELECT 1 FROM ume_key_alert_rule WHERE forward_on_clear = 1)" + ) + except Exception: + _schedule_log.exception("startup: ume_key_alert_rule schema migration failed") + invalidate_key_alert_config_cache() + + @app.on_event("startup") def on_startup() -> None: _configure_ume_diag_logging() Base.metadata.create_all(bind=engine) + _migrate_key_alert_rule_schema() _reset_runtime_pause_flags() _fail_stale_running_sync_jobs_on_startup() if _needs_startup_alarm_sync_before_ws(): @@ -777,20 +824,6 @@ 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)" ) @@ -1250,7 +1283,6 @@ def ume_list_key_alert_rules(db: Session = Depends(get_db)) -> dict[str, Any]: "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 ""), "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(), @@ -1272,10 +1304,19 @@ def ume_key_alert_monitor(db: Session = Depends(get_db)) -> dict[str, Any]: return { "ok": True, "rules": base.get("items") or [], + "config": get_key_alert_monitor_config(db), "forwarder": base.get("forwarder") or forwarder_status(), } +@app.patch("/v1/ume/key-alert-monitor/config") +def ume_update_key_alert_monitor_config(payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]: + if "forward_on_clear" not in payload: + raise HTTPException(status_code=400, detail="forward_on_clear_required") + config = set_key_alert_monitor_config(db, forward_on_clear=bool(payload.get("forward_on_clear"))) + return {"ok": True, "config": config} + + @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]: match_type = normalize_match_type(str(payload.get("match_type") or "notification_id")) @@ -1285,7 +1326,6 @@ def ume_upsert_key_alert_rule(payload: dict[str, Any], db: Session = Depends(get 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) try: @@ -1299,7 +1339,6 @@ def ume_upsert_key_alert_rule(payload: dict[str, Any], db: Session = Depends(get 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 = { @@ -1307,7 +1346,6 @@ def ume_upsert_key_alert_rule(payload: dict[str, Any], db: Session = Depends(get "match_type": match_type, "match_value": match_value, "enabled": bool(enabled), - "forward_on_clear": bool(forward_on_clear), "label": label, } db.commit() diff --git a/netx_api/models.py b/netx_api/models.py index 5309aad..40d931c 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -201,6 +201,16 @@ class UmeAlarmHistory(Base): raw_json: Mapped[str] = mapped_column(Text, default="{}") +class UmeKeyAlertMonitorConfig(Base): + """Global monitor options for key alert forwarding (singleton row id=1).""" + + __tablename__ = "ume_key_alert_monitor_config" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + forward_on_clear: Mapped[int] = mapped_column(Integer, default=0) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + class UmeKeyAlertRule(Base): """Key alert rule matched by UME notificationId or alarm description keyword.""" diff --git a/tests/test_key_alert_matcher.py b/tests/test_key_alert_matcher.py index 61a0f52..f395b22 100644 --- a/tests/test_key_alert_matcher.py +++ b/tests/test_key_alert_matcher.py @@ -7,6 +7,7 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from netx_api.db import Base +from netx_api.key_alert_config import invalidate_key_alert_config_cache, set_key_alert_monitor_config from netx_api.key_alert_matcher import ( invalidate_key_alert_rule_cache, match_key_alert_rule, @@ -24,9 +25,11 @@ class KeyAlertMatcherTests(unittest.TestCase): Base.metadata.create_all(bind=engine) self.db = self.SessionLocal() invalidate_key_alert_rule_cache() + invalidate_key_alert_config_cache() def tearDown(self) -> None: self.db.close() + invalidate_key_alert_config_cache() def test_notification_id_from_norm(self) -> None: norm = {"notificationId": "NID-1001", "is-cleared": False} @@ -57,7 +60,6 @@ class KeyAlertMatcherTests(unittest.TestCase): UmeKeyAlertRule( notification_id="NID-42", enabled=1, - forward_on_clear=0, label="test", ) ) @@ -68,10 +70,7 @@ class KeyAlertMatcherTests(unittest.TestCase): self.assertIsNotNone(rule) cleared = {"notificationId": "NID-42", "is-cleared": True} self.assertIsNone(match_key_alert_rule(self.db, norm=cleared, action="deleted")) - row = self.db.get(UmeKeyAlertRule, "NID-42") - assert row is not None - row.forward_on_clear = 1 - self.db.commit() + set_key_alert_monitor_config(self.db, forward_on_clear=True) invalidate_key_alert_rule_cache() self.assertIsNotNone(match_key_alert_rule(self.db, norm=cleared, action="deleted")) @@ -91,7 +90,6 @@ class KeyAlertMatcherTests(unittest.TestCase): match_type="keyword", match_value="链路中断", enabled=1, - forward_on_clear=0, label="链路", ) ) @@ -118,7 +116,6 @@ class KeyAlertMatcherTests(unittest.TestCase): match_type="keyword", match_value="BGP DOWN", enabled=1, - forward_on_clear=0, label="BGP", ) ) diff --git a/web/src/components/HelpHint.tsx b/web/src/components/HelpHint.tsx index 9e9b203..11c5c8c 100644 --- a/web/src/components/HelpHint.tsx +++ b/web/src/components/HelpHint.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; type Props = { text: string; @@ -7,14 +8,63 @@ type Props = { nowrap?: boolean; }; +const VIEWPORT_MARGIN = 8; +const POPOVER_MAX_WIDTH = 360; + export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: Props) { const [open, setOpen] = useState(false); + const [popoverStyle, setPopoverStyle] = useState({}); const rootRef = useRef(null); + const popoverRef = useRef(null); + + const updatePopoverPosition = () => { + const el = rootRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const popover = popoverRef.current; + const maxWidth = Math.min(POPOVER_MAX_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2); + const popWidth = popover?.offsetWidth || maxWidth; + const popHeight = popover?.offsetHeight || 0; + + let left = align === "end" ? rect.right - popWidth : rect.left; + left = Math.max(VIEWPORT_MARGIN, Math.min(left, window.innerWidth - popWidth - VIEWPORT_MARGIN)); + + let top = rect.bottom + 6; + if (popHeight > 0 && top + popHeight > window.innerHeight - VIEWPORT_MARGIN) { + top = Math.max(VIEWPORT_MARGIN, rect.top - popHeight - 6); + } + + setPopoverStyle({ + position: "fixed", + top, + left, + right: "auto", + maxWidth, + }); + }; + + useLayoutEffect(() => { + if (!open) return; + updatePopoverPosition(); + const raf = window.requestAnimationFrame(updatePopoverPosition); + const onReflow = () => updatePopoverPosition(); + window.addEventListener("resize", onReflow); + window.addEventListener("scroll", onReflow, true); + return () => { + window.cancelAnimationFrame(raf); + window.removeEventListener("resize", onReflow); + window.removeEventListener("scroll", onReflow, true); + }; + }, [open, align, text, nowrap]); useEffect(() => { if (!open) return; const onDocClick = (ev: MouseEvent) => { - if (!rootRef.current?.contains(ev.target as Node)) setOpen(false); + const root = rootRef.current; + const target = ev.target as Node; + if (root?.contains(target)) return; + if (target instanceof Element && target.closest(".help-hint__popover")) return; + setOpen(false); }; const onKey = (ev: KeyboardEvent) => { if (ev.key === "Escape") setOpen(false); @@ -34,18 +84,29 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P className="help-hint__trigger" aria-label={ariaLabel} aria-expanded={open} - onClick={() => setOpen((v) => !v)} + onMouseDown={(e) => e.preventDefault()} + onClick={(e) => { + e.stopPropagation(); + setOpen((v) => !v); + }} > ? - {open ? ( -
- {text} -
- ) : null} + {open + ? createPortal( +
+ {text} +
, + document.body, + ) + : null} ); } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 350e24f..b8a3c48 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -353,7 +353,7 @@ const en = { 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.", + forwardOnClearHelp: "Global switch for all monitor rules. By default only new/updated alarms are pushed; when enabled, clear notifications are also sent when UME reports alarms cleared.", add: "Add rules", adding: "Adding…", delete: "Delete", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index de34a3a..3d9f5d0 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -351,7 +351,7 @@ const zh = { label: "规则描述", labelPh: "必填,如 BGP Down", forwardOnClear: "告警清除时也推送", - forwardOnClearHelp: "默认只在告警新增/更新时推送。勾选后,当 UME 上报告警已清除(从当前告警表删除)时,也会向 WhatsApp 发送一条清除通知。", + forwardOnClearHelp: "全局开关:对所有监控规则生效。默认只在告警新增/更新时推送;勾选后,UME 上报告警已清除时也会向 WhatsApp 发送清除通知。", add: "添加规则", adding: "添加中…", delete: "删除", diff --git a/web/src/index.css b/web/src/index.css index bffc9ba..aec801d 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -708,6 +708,11 @@ pre { right: 0; } +.help-hint__popover--portal { + z-index: 2000; + width: auto; +} + .help-hint__popover--nowrap { width: max-content; max-width: min(90vw, 720px); diff --git a/web/src/pages/UmePage.tsx b/web/src/pages/UmePage.tsx index 2fddcdd..72c90a9 100644 --- a/web/src/pages/UmePage.tsx +++ b/web/src/pages/UmePage.tsx @@ -17,6 +17,7 @@ import { refreshUmeToken, upsertUmeKeyAlertRule, deleteUmeKeyAlertRule, + updateUmeKeyAlertMonitorConfig, } from "../services/api"; import { HelpHint } from "../components/HelpHint"; import { queryKeys } from "../constants/queryKeys"; @@ -50,7 +51,6 @@ export function UmePage() { 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([]); const [keyAlertIdHints, setKeyAlertIdHints] = useState< @@ -300,7 +300,6 @@ export function UmePage() { match_value: keyAlertMatchValue.trim(), label: keyAlertLabel.trim(), enabled: true, - forward_on_clear: keyAlertForwardOnClear, }), onMutate: () => setKeyAlertOpError(""), onSuccess: async () => { @@ -332,6 +331,19 @@ export function UmePage() { }, }); + const keyAlertConfigMutation = useMutation({ + mutationFn: (forwardOnClear: boolean) => updateUmeKeyAlertMonitorConfig({ forward_on_clear: forwardOnClear }), + onMutate: () => setKeyAlertOpError(""), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor }); + }, + onError: (err) => { + const msg = String(err); + setKeyAlertOpError(msg); + showError(msg); + }, + }); + const syncTotal = Number(syncStatusQuery.data?.total || 0); const syncPages = pageCount(syncTotal, syncPageSize); const neTotal = Number(neQuery.data?.total || 0); @@ -341,6 +353,7 @@ export function UmePage() { const keyAlertForwarder = keyAlertMonitorQuery.data?.forwarder; const keyAlertRules = keyAlertMonitorQuery.data?.rules || []; + const keyAlertForwardOnClear = Boolean(keyAlertMonitorQuery.data?.config?.forward_on_clear); const oclawWsPill = !keyAlertForwarder?.enabled ? "unknown" @@ -697,23 +710,22 @@ export function UmePage() { className="actions-row actions-row--inline" style={{ marginTop: 8, flexWrap: "wrap", alignItems: "center", justifyContent: "space-between" }} > - +