From 17270a26b65e27a64b9b562daf6218a9cec8bd46 Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 23 Jun 2026 14:36:13 +0800 Subject: [PATCH] fix(ume): forward stats by rule_key, rule toggle, and list filters Aggregate push counts per monitor rule, add enabled PATCH and paginated filtered list API/UI so keyword rules show correct stats without deleting. Co-authored-by: Cursor --- netx_api/key_alert_forward.py | 15 +++- netx_api/main.py | 105 +++++++++++++++++++++--- netx_api/models.py | 1 + netx_api/oclaw_alarm_forwarder.py | 1 + web/src/constants/queryKeys.ts | 9 +- web/src/i18n/en.ts | 8 ++ web/src/i18n/zh.ts | 8 ++ web/src/pages/UmePage.tsx | 132 ++++++++++++++++++++++++++++-- web/src/services/api.ts | 25 +++++- web/src/types.ts | 3 + 10 files changed, 286 insertions(+), 21 deletions(-) diff --git a/netx_api/key_alert_forward.py b/netx_api/key_alert_forward.py index 9bed9cd..3132895 100644 --- a/netx_api/key_alert_forward.py +++ b/netx_api/key_alert_forward.py @@ -94,6 +94,7 @@ def maybe_forward_key_alert( rule_label=str(rule.label or ""), ) payload["ne"] = _ne_payload(db, ne_id) + payload["rule_key"] = str(rule.notification_id or "") queued = enqueue_alarm_forward(payload) if not queued: @@ -104,6 +105,7 @@ def maybe_forward_key_alert( row = UmeKeyAlertForwardLog( alarm_key=str(alarm_key or ""), action=act, + rule_key=str(rule.notification_id or ""), notification_id=notification_id_from_norm(norm), forwarded_at=_utc_now_naive(), oclaw_ok=0, @@ -112,6 +114,7 @@ def maybe_forward_key_alert( db.add(row) else: row.notification_id = notification_id_from_norm(norm) + row.rule_key = str(rule.notification_id or "") row.forwarded_at = _utc_now_naive() row.oclaw_ok = 0 row.error = "queued" @@ -122,7 +125,14 @@ def maybe_forward_key_alert( return True -def record_forward_result(*, alarm_key: str, action: str, ok: bool, error: str = "") -> None: +def record_forward_result( + *, + alarm_key: str, + action: str, + ok: bool, + error: str = "", + rule_key: str = "", +) -> None: from .db import SessionLocal key = str(alarm_key or "").strip() @@ -140,12 +150,15 @@ def record_forward_result(*, alarm_key: str, action: str, ok: bool, error: str = row = UmeKeyAlertForwardLog( alarm_key=key, action=act, + rule_key=str(rule_key or "").strip(), forwarded_at=_utc_now_naive(), oclaw_ok=1 if ok else 0, error="" if ok else str(error or "forward_failed")[:240], ) db.add(row) else: + if rule_key and not str(row.rule_key or "").strip(): + row.rule_key = str(rule_key or "").strip() row.forwarded_at = _utc_now_naive() row.oclaw_ok = 1 if ok else 0 row.error = "" if ok else str(error or "forward_failed")[:240] diff --git a/netx_api/main.py b/netx_api/main.py index a86bd68..a546e5c 100644 --- a/netx_api/main.py +++ b/netx_api/main.py @@ -735,6 +735,10 @@ def _migrate_key_alert_rule_schema() -> None: "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)", ), + ( + "add forward_log rule_key", + "ALTER TABLE ume_key_alert_forward_log ADD COLUMN IF NOT EXISTS rule_key VARCHAR(128) DEFAULT ''", + ), ] for label, sql in steps: try: @@ -1266,28 +1270,63 @@ def ume_alarm_subscription_clear_local(db: Session = Depends(get_db)) -> dict[st @app.get("/v1/ume/key-alert-rules") -def ume_list_key_alert_rules(db: Session = Depends(get_db)) -> dict[str, Any]: - from sqlalchemy import func +def ume_list_key_alert_rules( + db: Session = Depends(get_db), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + keyword: str = Query(default=""), + enabled: str | None = Query(default=None), + match_type: str | None = Query(default=None), +) -> dict[str, Any]: + from sqlalchemy import func, or_ - rows = db.query(UmeKeyAlertRule).order_by(UmeKeyAlertRule.notification_id.asc()).all() + q = db.query(UmeKeyAlertRule) + kw = str(keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter( + or_( + UmeKeyAlertRule.notification_id.ilike(like), + UmeKeyAlertRule.match_value.ilike(like), + UmeKeyAlertRule.label.ilike(like), + ) + ) + if enabled is not None: + en = str(enabled).strip().lower() + if en in {"1", "true", "yes", "on"}: + q = q.filter(UmeKeyAlertRule.enabled == 1) + elif en in {"0", "false", "no", "off"}: + q = q.filter(UmeKeyAlertRule.enabled == 0) + if match_type: + mt = normalize_match_type(str(match_type)) + q = q.filter(UmeKeyAlertRule.match_type == mt) + + total = int(q.count()) + rows = ( + q.order_by(UmeKeyAlertRule.notification_id.asc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) stat_rows = ( db.query( - UmeKeyAlertForwardLog.notification_id, + UmeKeyAlertForwardLog.rule_key, func.count(UmeKeyAlertForwardLog.id).label("attempts"), func.sum(UmeKeyAlertForwardLog.oclaw_ok).label("published_ok"), func.max(UmeKeyAlertForwardLog.forwarded_at).label("last_forwarded_at"), ) - .group_by(UmeKeyAlertForwardLog.notification_id) + .filter(UmeKeyAlertForwardLog.rule_key != "") + .group_by(UmeKeyAlertForwardLog.rule_key) .all() ) stat_map = { - str(nid or ""): { + str(rk or ""): { "attempts": int(attempts or 0), "published_ok": int(published_ok or 0), "last_forwarded_at": (_ensure_utc(last_at) or datetime.now(timezone.utc)).isoformat() if last_at else "", } - for nid, attempts, published_ok, last_at in stat_rows - if str(nid or "").strip() + for rk, attempts, published_ok, last_at in stat_rows + if str(rk or "").strip() } items = [ { @@ -1307,15 +1346,32 @@ def ume_list_key_alert_rules(db: Session = Depends(get_db)) -> dict[str, Any]: for row in rows ] fwd = forwarder_status() - return {"items": items, "total": len(items), "forwarder": fwd} + return {"items": items, "total": total, "page": page, "page_size": page_size, "forwarder": fwd} @app.get("/v1/ume/key-alert-monitor") -def ume_key_alert_monitor(db: Session = Depends(get_db)) -> dict[str, Any]: - base = ume_list_key_alert_rules(db) +def ume_key_alert_monitor( + db: Session = Depends(get_db), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=50, ge=1, le=200), + keyword: str = Query(default=""), + enabled: str | None = Query(default=None), + match_type: str | None = Query(default=None), +) -> dict[str, Any]: + base = ume_list_key_alert_rules( + db=db, + page=page, + page_size=page_size, + keyword=keyword, + enabled=enabled, + match_type=match_type, + ) return { "ok": True, "rules": base.get("items") or [], + "total": int(base.get("total") or 0), + "page": int(base.get("page") or page), + "page_size": int(base.get("page_size") or page_size), "config": get_key_alert_monitor_config(db), "forwarder": base.get("forwarder") or forwarder_status(), } @@ -1375,6 +1431,33 @@ def ume_upsert_key_alert_rule(payload: dict[str, Any], db: Session = Depends(get return {"ok": True, "item": saved} +@app.patch("/v1/ume/key-alert-rules/{rule_key:path}") +def ume_patch_key_alert_rule(rule_key: str, payload: dict[str, Any], db: Session = Depends(get_db)) -> dict[str, Any]: + key = str(rule_key or "").strip() + if not key: + raise HTTPException(status_code=400, detail="rule_key_required") + 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") + now = datetime.now(timezone.utc).replace(tzinfo=None) + row.enabled = 1 if bool(payload.get("enabled")) else 0 + row.updated_at = now + db.commit() + invalidate_key_alert_rule_cache() + return { + "ok": True, + "item": { + "notification_id": key, + "match_type": rule_match_type(row), + "match_value": rule_match_value(row), + "enabled": bool(int(row.enabled or 0)), + "label": str(row.label or ""), + }, + } + + @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() diff --git a/netx_api/models.py b/netx_api/models.py index 40d931c..fd76a09 100644 --- a/netx_api/models.py +++ b/netx_api/models.py @@ -232,6 +232,7 @@ class UmeKeyAlertForwardLog(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) alarm_key: Mapped[str] = mapped_column(Text, index=True) action: Mapped[str] = mapped_column(String(32), default="", index=True) + rule_key: Mapped[str] = mapped_column(String(128), default="", index=True) notification_id: Mapped[str] = mapped_column(String(128), default="", index=True) forwarded_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) oclaw_ok: Mapped[int] = mapped_column(Integer, default=0) diff --git a/netx_api/oclaw_alarm_forwarder.py b/netx_api/oclaw_alarm_forwarder.py index fdbe15b..71eae75 100644 --- a/netx_api/oclaw_alarm_forwarder.py +++ b/netx_api/oclaw_alarm_forwarder.py @@ -153,6 +153,7 @@ def _run_loop() -> None: action=str(payload.get("action") or ""), ok=ok, error=err, + rule_key=str(payload.get("rule_key") or ""), ) except Exception as rec_exc: _log.warning("forward result record failed: %s", str(rec_exc)[:120]) diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index c46cb21..750326b 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -4,7 +4,14 @@ export const queryKeys = { integrationsStatus: ["integrationsStatus"] as const, umeTokenStatus: ["umeTokenStatus"] as const, umeAlarmSubscription: ["umeAlarmSubscription"] as const, - umeKeyAlertMonitor: ["umeKeyAlertMonitor"] as const, + umeKeyAlertMonitorAll: ["umeKeyAlertMonitor"] as const, + umeKeyAlertMonitor: ( + page: number, + pageSize: number, + keyword: string, + enabled: string, + matchType: string, + ) => ["umeKeyAlertMonitor", page, pageSize, keyword, enabled, matchType] as const, /** Prefix keys for invalidating all pages/variants of a domain. */ umeSyncStatusAll: ["umeSyncStatus"] as const, umeNEAll: ["umeNE"] as const, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index b8a3c48..6330070 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -367,6 +367,14 @@ const en = { colAttempts: "attempts", colLast: "last push", colActions: "actions", + colMonitor: "monitor", + monitorOn: "on", + monitorOff: "off", + filterKeywordPh: "Filter match / label", + filterMatchAll: "All types", + filterEnabledAll: "All states", + filterEnabledOn: "Monitoring on", + filterEnabledOff: "Monitoring off", addOk: "Monitor rule added", deleteOk: "Deleted", confirmDelete: "Delete this monitor rule?", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 3d9f5d0..421f95e 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -365,6 +365,14 @@ const zh = { colAttempts: "尝试次数", colLast: "最近推送", colActions: "操作", + colMonitor: "是否监控", + monitorOn: "开启", + monitorOff: "关闭", + filterKeywordPh: "筛选匹配内容 / 描述", + filterMatchAll: "全部类型", + filterEnabledAll: "全部状态", + filterEnabledOn: "监控中", + filterEnabledOff: "已暂停", addOk: "已添加监控规则", deleteOk: "已删除", confirmDelete: "确定删除该监控规则?", diff --git a/web/src/pages/UmePage.tsx b/web/src/pages/UmePage.tsx index 72c90a9..e0d49be 100644 --- a/web/src/pages/UmePage.tsx +++ b/web/src/pages/UmePage.tsx @@ -17,6 +17,7 @@ import { refreshUmeToken, upsertUmeKeyAlertRule, deleteUmeKeyAlertRule, + patchUmeKeyAlertRule, updateUmeKeyAlertMonitorConfig, } from "../services/api"; import { HelpHint } from "../components/HelpHint"; @@ -56,6 +57,11 @@ export function UmePage() { const [keyAlertIdHints, setKeyAlertIdHints] = useState< Array<{ notification_id: string; native_probable_cause_sample: string }> >([]); + const [keyAlertPage, setKeyAlertPage] = useState(1); + const [keyAlertPageSize, setKeyAlertPageSize] = useState(20); + const [keyAlertFilterKeyword, setKeyAlertFilterKeyword] = useState(""); + const [keyAlertFilterEnabled, setKeyAlertFilterEnabled] = useState<"" | "true" | "false">(""); + const [keyAlertFilterMatchType, setKeyAlertFilterMatchType] = useState<"" | "notification_id" | "keyword">(""); const syncMutation = useMutation({ mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }), @@ -106,8 +112,21 @@ export function UmePage() { refetchInterval: 5000, }); const keyAlertMonitorQuery = useQuery({ - queryKey: queryKeys.umeKeyAlertMonitor, - queryFn: fetchUmeKeyAlertMonitor, + queryKey: queryKeys.umeKeyAlertMonitor( + keyAlertPage, + keyAlertPageSize, + keyAlertFilterKeyword, + keyAlertFilterEnabled, + keyAlertFilterMatchType, + ), + queryFn: () => + fetchUmeKeyAlertMonitor({ + page: keyAlertPage, + pageSize: keyAlertPageSize, + keyword: keyAlertFilterKeyword, + enabled: keyAlertFilterEnabled, + matchType: keyAlertFilterMatchType, + }), staleTime: 3000, refetchInterval: 5000, }); @@ -306,7 +325,7 @@ export function UmePage() { setKeyAlertMatchValue(""); setKeyAlertLabel(""); showOk(t("ume.keyAlert.addOk")); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor }); + await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); await queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus }); }, onError: (err) => { @@ -321,7 +340,7 @@ export function UmePage() { onMutate: () => setKeyAlertOpError(""), onSuccess: async () => { showOk(t("ume.keyAlert.deleteOk")); - await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor }); + await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); await queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus }); }, onError: (err) => { @@ -331,11 +350,25 @@ export function UmePage() { }, }); + const keyAlertToggleMutation = useMutation({ + mutationFn: (vars: { ruleKey: string; enabled: boolean }) => + patchUmeKeyAlertRule(vars.ruleKey, { enabled: vars.enabled }), + onMutate: () => setKeyAlertOpError(""), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); + }, + onError: (err) => { + const msg = String(err); + setKeyAlertOpError(msg); + showError(msg); + }, + }); + const keyAlertConfigMutation = useMutation({ mutationFn: (forwardOnClear: boolean) => updateUmeKeyAlertMonitorConfig({ forward_on_clear: forwardOnClear }), onMutate: () => setKeyAlertOpError(""), onSuccess: async () => { - await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor }); + await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll }); }, onError: (err) => { const msg = String(err); @@ -353,6 +386,8 @@ export function UmePage() { const keyAlertForwarder = keyAlertMonitorQuery.data?.forwarder; const keyAlertRules = keyAlertMonitorQuery.data?.rules || []; + const keyAlertTotal = Number(keyAlertMonitorQuery.data?.total || keyAlertRules.length); + const keyAlertPages = pageCount(keyAlertTotal, keyAlertPageSize); const keyAlertForwardOnClear = Boolean(keyAlertMonitorQuery.data?.config?.forward_on_clear); const oclawWsPill = !keyAlertForwarder?.enabled @@ -740,9 +775,43 @@ export function UmePage() { {t("common.opFailed")}: {keyAlertOpError} ) : null} +
+ { + setKeyAlertFilterKeyword(e.target.value); + setKeyAlertPage(1); + }} + /> + + +
+ @@ -754,7 +823,23 @@ export function UmePage() { {keyAlertRules.map((rule) => ( - + + - + ) : null}
{t("ume.keyAlert.colMonitor")} {t("ume.keyAlert.colType")} {t("ume.keyAlert.colMatch")} {t("ume.keyAlert.colLabel")}
+ + {rule.match_type === "keyword" ? t("ume.keyAlert.matchKeyword") @@ -786,11 +871,44 @@ export function UmePage() { ))} {!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? (
{t("ume.keyAlert.emptyRules")}{t("ume.keyAlert.emptyRules")}
+
+
+ {t("common.pagerMeta", { total: keyAlertTotal, page: keyAlertPage, pages: keyAlertPages })} +
+
+ + + +
+
diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 3aa0403..2ca7889 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -80,7 +80,24 @@ export const cancelUmeAlarmSubscription = (opts?: { forceClearLocal?: boolean }) export const clearLocalUmeAlarmSubscription = () => apiPost("/v1/ume/alarm-subscription/clear-local", {}); -export const fetchUmeKeyAlertMonitor = () => apiGet("/v1/ume/key-alert-monitor"); +export const fetchUmeKeyAlertMonitor = (params?: { + page?: number; + pageSize?: number; + keyword?: string; + enabled?: "" | "true" | "false"; + matchType?: "" | "notification_id" | "keyword"; +}) => { + const p = new URLSearchParams(); + p.set("page", String(Math.max(1, Number(params?.page || 1)))); + p.set("page_size", String(Math.max(1, Math.min(200, Number(params?.pageSize || 50))))); + const kw = String(params?.keyword || "").trim(); + if (kw) p.set("keyword", kw); + const en = String(params?.enabled || "").trim(); + if (en) p.set("enabled", en); + const mt = String(params?.matchType || "").trim(); + if (mt) p.set("match_type", mt); + return apiGet(`/v1/ume/key-alert-monitor?${p.toString()}`); +}; export const upsertUmeKeyAlertRule = (payload: { match_type: "notification_id" | "keyword"; @@ -95,6 +112,12 @@ export const updateUmeKeyAlertMonitorConfig = (payload: { forward_on_clear: bool export const deleteUmeKeyAlertRule = (ruleKey: string) => apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`); +export const patchUmeKeyAlertRule = (ruleKey: string, payload: { enabled: boolean }) => + apiPatch<{ ok: boolean; item?: unknown }>( + `/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`, + payload, + ); + export const fetchUmeNotificationIds = (limit = 200) => apiGet<{ items: Array<{ notification_id: string; native_probable_cause_sample: string }> }>( `/v1/ume/notification-ids?limit=${encodeURIComponent(String(limit))}`, diff --git a/web/src/types.ts b/web/src/types.ts index a962fbe..93a61b3 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -44,6 +44,9 @@ export type UmeKeyAlertForwarderStatus = { export type UmeKeyAlertMonitorResponse = { ok: boolean; rules: UmeKeyAlertRuleItem[]; + total?: number; + page?: number; + page_size?: number; config?: { forward_on_clear: boolean; };