mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 22:20:58 +08:00
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:
parent
d5d6d4eada
commit
e474c0a431
11 changed files with 383 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from netx_api.key_alert_matcher import (
|
|||
parse_rule_items,
|
||||
rule_storage_key,
|
||||
)
|
||||
from netx_api.models import UmeKeyAlertRule
|
||||
from netx_api.models import UmeInventoryNE, UmeKeyAlertRule
|
||||
from netx_api.ume_sync_service import apply_alarm_to_current, notification_id_from_norm
|
||||
|
||||
|
||||
|
|
@ -128,6 +128,49 @@ class KeyAlertMatcherTests(unittest.TestCase):
|
|||
msg=f"expected match for cause={cause!r}",
|
||||
)
|
||||
|
||||
def test_ne_type_filter(self) -> None:
|
||||
from netx_api.key_alert_matcher import serialize_rule_ne_types
|
||||
|
||||
self.db.add(
|
||||
UmeInventoryNE(
|
||||
ne_id="NE-PTN-1",
|
||||
ne_name="PTN-A",
|
||||
ne_type="PTN 9700",
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
UmeInventoryNE(
|
||||
ne_id="NE-ROUTER-1",
|
||||
ne_name="Router-A",
|
||||
ne_type="ZXR10 M6000",
|
||||
)
|
||||
)
|
||||
self.db.add(
|
||||
UmeKeyAlertRule(
|
||||
notification_id="NID-NE-TYPE",
|
||||
match_type="notification_id",
|
||||
match_value="NID-NE-TYPE",
|
||||
enabled=1,
|
||||
label="PTN only",
|
||||
ne_types=serialize_rule_ne_types(["PTN 9700"]),
|
||||
)
|
||||
)
|
||||
self.db.commit()
|
||||
invalidate_key_alert_rule_cache()
|
||||
|
||||
ptn_norm = {
|
||||
"notificationId": "NID-NE-TYPE",
|
||||
"is-cleared": False,
|
||||
"ne-id": "NE-PTN-1",
|
||||
}
|
||||
router_norm = {
|
||||
"notificationId": "NID-NE-TYPE",
|
||||
"is-cleared": False,
|
||||
"ne-id": "NE-ROUTER-1",
|
||||
}
|
||||
self.assertIsNotNone(match_key_alert_rule(self.db, norm=ptn_norm, action="inserted"))
|
||||
self.assertIsNone(match_key_alert_rule(self.db, norm=router_norm, action="inserted"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const queryKeys = {
|
|||
umeTokenStatus: ["umeTokenStatus"] as const,
|
||||
umeAlarmSubscription: ["umeAlarmSubscription"] as const,
|
||||
umeKeyAlertMonitorAll: ["umeKeyAlertMonitor"] as const,
|
||||
umeInventoryNeTypes: ["umeInventoryNeTypes"] as const,
|
||||
umeKeyAlertMonitor: (
|
||||
page: number,
|
||||
pageSize: number,
|
||||
|
|
|
|||
|
|
@ -337,7 +337,7 @@ const en = {
|
|||
},
|
||||
keyAlert: {
|
||||
title: "AI alarm monitor (OClaw WhatsApp)",
|
||||
help: "Exact match by notificationId, or substring match on alarm description (case-insensitive; nativeProbableCause, etc.), pushed via OClaw WebSocket. Shares NETX_OCLAW_ANALYZE_TOKEN.",
|
||||
help: "Exact match by notificationId, or substring match on alarm description (case-insensitive; nativeProbableCause, etc.). Optionally restrict to UME inventory ne_type values (multi-select; empty = all devices). Pushed via OClaw WebSocket. Shares NETX_OCLAW_ANALYZE_TOKEN.",
|
||||
ws: "OClaw WSS",
|
||||
wsConnected: "connected",
|
||||
wsDisconnected: "disconnected",
|
||||
|
|
@ -352,16 +352,27 @@ const en = {
|
|||
matchValuePhKeyword: "Enter keyword (case-insensitive)",
|
||||
label: "Rule description",
|
||||
labelPh: "Required, e.g. BGP Down",
|
||||
neTypesLabel: "Device types (optional, multi-select)",
|
||||
neTypesHint: "Leave empty to match all device types. Options come from synced UME Inventory ne_type.",
|
||||
neTypesEmpty: "No device types yet — sync Inventory first",
|
||||
neTypesAll: "All",
|
||||
forwardOnClear: "Push on alarm clear",
|
||||
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",
|
||||
edit: "Edit",
|
||||
editTitle: "Edit monitor rule",
|
||||
save: "Save",
|
||||
saving: "Saving…",
|
||||
cancel: "Cancel",
|
||||
editOk: "Rule updated",
|
||||
pickFromAlarms: "Load alarm hints",
|
||||
emptyRules: "No monitor rules yet",
|
||||
colType: "type",
|
||||
colMatch: "match",
|
||||
colLabel: "description",
|
||||
colNeTypes: "device types",
|
||||
colForwardClear: "on clear",
|
||||
colPublished: "published",
|
||||
colAttempts: "attempts",
|
||||
|
|
|
|||
|
|
@ -335,7 +335,7 @@ const zh = {
|
|||
},
|
||||
keyAlert: {
|
||||
title: "AI 告警监控(OClaw WhatsApp)",
|
||||
help: "按 notificationId 精确匹配,或按告警描述关键字模糊匹配(不区分大小写,匹配 nativeProbableCause 等字段),经 OClaw WebSocket 推送到 WhatsApp 群。鉴权与 NETX_OCLAW_ANALYZE_TOKEN 共用。",
|
||||
help: "按 notificationId 精确匹配,或按告警描述关键字模糊匹配(不区分大小写,匹配 nativeProbableCause 等字段);可选限定 UME Inventory 中的网元类型 (ne_type),不选则匹配全部设备。经 OClaw WebSocket 推送到 WhatsApp 群。鉴权与 NETX_OCLAW_ANALYZE_TOKEN 共用。",
|
||||
ws: "OClaw WSS",
|
||||
wsConnected: "已连接",
|
||||
wsDisconnected: "未连接",
|
||||
|
|
@ -350,16 +350,27 @@ const zh = {
|
|||
matchValuePhKeyword: "输入匹配关键字(不区分大小写)",
|
||||
label: "规则描述",
|
||||
labelPh: "必填,如 BGP Down",
|
||||
neTypesLabel: "设备类型(可选,多选)",
|
||||
neTypesHint: "不选表示全部设备类型;选项来自 UME Inventory 同步后的 ne_type。",
|
||||
neTypesEmpty: "暂无设备类型,请先同步 Inventory",
|
||||
neTypesAll: "全部",
|
||||
forwardOnClear: "告警清除时也推送",
|
||||
forwardOnClearHelp: "全局开关:对所有监控规则生效。默认只在告警新增/更新时推送;勾选后,UME 上报告警已清除时也会向 WhatsApp 发送清除通知。",
|
||||
add: "添加规则",
|
||||
adding: "添加中…",
|
||||
delete: "删除",
|
||||
edit: "修改",
|
||||
editTitle: "修改监控规则",
|
||||
save: "保存",
|
||||
saving: "保存中…",
|
||||
cancel: "取消",
|
||||
editOk: "规则已更新",
|
||||
pickFromAlarms: "加载当前告警参考",
|
||||
emptyRules: "尚未配置监控规则",
|
||||
colType: "类型",
|
||||
colMatch: "匹配内容",
|
||||
colLabel: "描述",
|
||||
colNeTypes: "设备类型",
|
||||
colForwardClear: "清除推送",
|
||||
colPublished: "发布次数",
|
||||
colAttempts: "尝试次数",
|
||||
|
|
|
|||
|
|
@ -362,6 +362,12 @@ button {
|
|||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pager input,
|
||||
.pager select {
|
||||
width: auto;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.actions-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
|
@ -864,7 +870,7 @@ pre {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.pager__btn,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
establishUmeAlarmSubscription,
|
||||
fetchUmeAlarmSubscriptionStatus,
|
||||
fetchUmeAlarmKeywords,
|
||||
fetchUmeInventoryNeTypes,
|
||||
fetchUmeKeyAlertMonitor,
|
||||
fetchUmeNotificationIds,
|
||||
fetchUmeSyncStatus,
|
||||
|
|
@ -24,7 +25,7 @@ import { HelpHint } from "../components/HelpHint";
|
|||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import type { UmeAlarmSubscriptionStatus } from "../types";
|
||||
import type { UmeAlarmSubscriptionStatus, UmeKeyAlertRuleItem } from "../types";
|
||||
import { pageCount, runtimeIntervalLabel } from "../utils/display";
|
||||
import { formatSystemTime } from "../utils/time";
|
||||
|
||||
|
|
@ -62,6 +63,9 @@ export function UmePage() {
|
|||
const [keyAlertFilterKeyword, setKeyAlertFilterKeyword] = useState("");
|
||||
const [keyAlertFilterEnabled, setKeyAlertFilterEnabled] = useState<"" | "true" | "false">("");
|
||||
const [keyAlertFilterMatchType, setKeyAlertFilterMatchType] = useState<"" | "notification_id" | "keyword">("");
|
||||
const [keyAlertNeTypes, setKeyAlertNeTypes] = useState<string[]>([]);
|
||||
const [keyAlertEditRule, setKeyAlertEditRule] = useState<UmeKeyAlertRuleItem | null>(null);
|
||||
const [keyAlertEditNeTypes, setKeyAlertEditNeTypes] = useState<string[]>([]);
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }),
|
||||
|
|
@ -69,6 +73,7 @@ export function UmePage() {
|
|||
await queryClient.invalidateQueries({ queryKey: queryKeys.umeSyncStatusAll });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.umeNEAll });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.umeCurrentAlarmsAll });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.umeInventoryNeTypes });
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -130,6 +135,11 @@ export function UmePage() {
|
|||
staleTime: 3000,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const keyAlertNeTypesQuery = useQuery({
|
||||
queryKey: queryKeys.umeInventoryNeTypes,
|
||||
queryFn: () => fetchUmeInventoryNeTypes(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const confirmClearLocalSubscription = (hint?: string) =>
|
||||
window.confirm(hint || t("ume.subscription.confirmClearDefault"));
|
||||
|
|
@ -319,11 +329,13 @@ export function UmePage() {
|
|||
match_value: keyAlertMatchValue.trim(),
|
||||
label: keyAlertLabel.trim(),
|
||||
enabled: true,
|
||||
ne_types: keyAlertNeTypes,
|
||||
}),
|
||||
onMutate: () => setKeyAlertOpError(""),
|
||||
onSuccess: async () => {
|
||||
setKeyAlertMatchValue("");
|
||||
setKeyAlertLabel("");
|
||||
setKeyAlertNeTypes([]);
|
||||
showOk(t("ume.keyAlert.addOk"));
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitorAll });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus });
|
||||
|
|
@ -364,6 +376,22 @@ export function UmePage() {
|
|||
},
|
||||
});
|
||||
|
||||
const keyAlertEditMutation = useMutation({
|
||||
mutationFn: (vars: { ruleKey: string; ne_types: string[] }) =>
|
||||
patchUmeKeyAlertRule(vars.ruleKey, { ne_types: vars.ne_types }),
|
||||
onMutate: () => setKeyAlertOpError(""),
|
||||
onSuccess: async () => {
|
||||
setKeyAlertEditRule(null);
|
||||
showOk(t("ume.keyAlert.editOk"));
|
||||
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(""),
|
||||
|
|
@ -741,6 +769,53 @@ export function UmePage() {
|
|||
{keyAlertAddMutation.isPending ? t("ume.keyAlert.adding") : t("ume.keyAlert.add")}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div className="muted" style={{ marginBottom: 4 }}>
|
||||
{t("ume.keyAlert.neTypesLabel")}
|
||||
</div>
|
||||
{keyAlertNeTypesQuery.isLoading ? (
|
||||
<span className="muted">{t("common.refreshing")}</span>
|
||||
) : (keyAlertNeTypesQuery.data?.items || []).length === 0 ? (
|
||||
<span className="muted">{t("ume.keyAlert.neTypesEmpty")}</span>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "8px 16px",
|
||||
maxHeight: 120,
|
||||
overflowY: "auto",
|
||||
padding: "6px 8px",
|
||||
border: "1px solid var(--border, #ddd)",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{(keyAlertNeTypesQuery.data?.items || []).map((item) => (
|
||||
<label
|
||||
key={item.ne_type}
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keyAlertNeTypes.includes(item.ne_type)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setKeyAlertNeTypes((prev) => [...prev, item.ne_type]);
|
||||
} else {
|
||||
setKeyAlertNeTypes((prev) => prev.filter((x) => x !== item.ne_type));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{item.ne_type}
|
||||
<span className="muted">({item.ne_count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
|
||||
{t("ume.keyAlert.neTypesHint")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="actions-row actions-row--inline"
|
||||
style={{ marginTop: 8, flexWrap: "wrap", alignItems: "center", justifyContent: "space-between" }}
|
||||
|
|
@ -815,6 +890,7 @@ export function UmePage() {
|
|||
<th>{t("ume.keyAlert.colType")}</th>
|
||||
<th>{t("ume.keyAlert.colMatch")}</th>
|
||||
<th>{t("ume.keyAlert.colLabel")}</th>
|
||||
<th>{t("ume.keyAlert.colNeTypes")}</th>
|
||||
<th>{t("ume.keyAlert.colPublished")}</th>
|
||||
<th>{t("ume.keyAlert.colAttempts")}</th>
|
||||
<th>{t("ume.keyAlert.colLast")}</th>
|
||||
|
|
@ -847,6 +923,11 @@ export function UmePage() {
|
|||
</td>
|
||||
<td>{rule.match_value || rule.notification_id}</td>
|
||||
<td>{rule.label || t("common.empty")}</td>
|
||||
<td>
|
||||
{rule.ne_types?.length
|
||||
? rule.ne_types.join(", ")
|
||||
: t("ume.keyAlert.neTypesAll")}
|
||||
</td>
|
||||
<td>{Number(rule.forward_stats?.published_ok || 0)}</td>
|
||||
<td>{Number(rule.forward_stats?.attempts || 0)}</td>
|
||||
<td>
|
||||
|
|
@ -855,23 +936,35 @@ export function UmePage() {
|
|||
: t("common.empty")}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(t("ume.keyAlert.confirmDelete"))) {
|
||||
keyAlertDeleteMutation.mutate(rule.notification_id);
|
||||
}
|
||||
}}
|
||||
disabled={keyAlertDeleteMutation.isPending}
|
||||
>
|
||||
{t("ume.keyAlert.delete")}
|
||||
</button>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setKeyAlertEditRule(rule);
|
||||
setKeyAlertEditNeTypes(rule.ne_types || []);
|
||||
}}
|
||||
disabled={keyAlertEditMutation.isPending}
|
||||
>
|
||||
{t("ume.keyAlert.edit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(t("ume.keyAlert.confirmDelete"))) {
|
||||
keyAlertDeleteMutation.mutate(rule.notification_id);
|
||||
}
|
||||
}}
|
||||
disabled={keyAlertDeleteMutation.isPending}
|
||||
>
|
||||
{t("ume.keyAlert.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8}>{t("ume.keyAlert.emptyRules")}</td>
|
||||
<td colSpan={9}>{t("ume.keyAlert.emptyRules")}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
|
|
@ -1324,6 +1417,83 @@ export function UmePage() {
|
|||
</>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{keyAlertEditRule ? (
|
||||
<div className="modal-backdrop" role="presentation" onClick={() => setKeyAlertEditRule(null)}>
|
||||
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{t("ume.keyAlert.editTitle")}</h3>
|
||||
<p className="form-hint muted">
|
||||
{keyAlertEditRule.match_type === "keyword"
|
||||
? t("ume.keyAlert.matchKeyword")
|
||||
: t("ume.keyAlert.matchNotificationId")}
|
||||
: {keyAlertEditRule.match_value || keyAlertEditRule.notification_id}
|
||||
{keyAlertEditRule.label ? ` · ${keyAlertEditRule.label}` : ""}
|
||||
</p>
|
||||
<div className="muted" style={{ marginBottom: 4 }}>
|
||||
{t("ume.keyAlert.neTypesLabel")}
|
||||
</div>
|
||||
{keyAlertNeTypesQuery.isLoading ? (
|
||||
<span className="muted">{t("common.refreshing")}</span>
|
||||
) : (keyAlertNeTypesQuery.data?.items || []).length === 0 ? (
|
||||
<span className="muted">{t("ume.keyAlert.neTypesEmpty")}</span>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "8px 16px",
|
||||
maxHeight: 240,
|
||||
overflowY: "auto",
|
||||
padding: "6px 8px",
|
||||
border: "1px solid var(--border, #ddd)",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{(keyAlertNeTypesQuery.data?.items || []).map((item) => (
|
||||
<label
|
||||
key={item.ne_type}
|
||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keyAlertEditNeTypes.includes(item.ne_type)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setKeyAlertEditNeTypes((prev) => [...prev, item.ne_type]);
|
||||
} else {
|
||||
setKeyAlertEditNeTypes((prev) => prev.filter((x) => x !== item.ne_type));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{item.ne_type}
|
||||
<span className="muted">({item.ne_count})</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="muted" style={{ marginTop: 4, fontSize: 12 }}>
|
||||
{t("ume.keyAlert.neTypesHint")}
|
||||
</div>
|
||||
<div className="actions-row actions-row--inline" style={{ marginTop: 16 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
keyAlertEditMutation.mutate({
|
||||
ruleKey: keyAlertEditRule.notification_id,
|
||||
ne_types: keyAlertEditNeTypes,
|
||||
})
|
||||
}
|
||||
disabled={keyAlertEditMutation.isPending}
|
||||
>
|
||||
{keyAlertEditMutation.isPending ? t("ume.keyAlert.saving") : t("ume.keyAlert.save")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setKeyAlertEditRule(null)} disabled={keyAlertEditMutation.isPending}>
|
||||
{t("ume.keyAlert.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@ export const upsertUmeKeyAlertRule = (payload: {
|
|||
match_value: string;
|
||||
label: string;
|
||||
enabled?: boolean;
|
||||
ne_types?: string[];
|
||||
}) => apiPost<{ ok: boolean; item?: unknown }>("/v1/ume/key-alert-rules", payload);
|
||||
|
||||
export const updateUmeKeyAlertMonitorConfig = (payload: { forward_on_clear: boolean }) =>
|
||||
|
|
@ -112,7 +113,10 @@ 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 }) =>
|
||||
export const patchUmeKeyAlertRule = (
|
||||
ruleKey: string,
|
||||
payload: { enabled?: boolean; ne_types?: string[] },
|
||||
) =>
|
||||
apiPatch<{ ok: boolean; item?: unknown }>(
|
||||
`/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`,
|
||||
payload,
|
||||
|
|
@ -128,6 +132,11 @@ export const fetchUmeAlarmKeywords = (limit = 200) =>
|
|||
`/v1/ume/alarm-keywords?limit=${encodeURIComponent(String(limit))}`,
|
||||
);
|
||||
|
||||
export const fetchUmeInventoryNeTypes = (limit = 500) =>
|
||||
apiGet<{ items: Array<{ ne_type: string; ne_count: number }>; total: number }>(
|
||||
`/v1/ume/inventory/ne-types?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))));
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export type UmeKeyAlertRuleItem = {
|
|||
match_value: string;
|
||||
enabled: boolean;
|
||||
label: string;
|
||||
ne_types?: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
forward_stats?: {
|
||||
|
|
@ -31,6 +32,11 @@ export type UmeKeyAlertRuleItem = {
|
|||
};
|
||||
};
|
||||
|
||||
export type UmeInventoryNeTypeItem = {
|
||||
ne_type: string;
|
||||
ne_count: number;
|
||||
};
|
||||
|
||||
export type UmeKeyAlertForwarderStatus = {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue